As tarefas do projeto num TASKS.md: painel, próxima tarefa acima do prompt, e o Claude criando e concluindo tarefas

Live mods for Claude Code: see how long your plan will last, hand work off between machines, and give Claude eyes on Unity and Blender.
<img src="docs/terminal.svg" alt="Claude Code in a terminal: a band above the prompt shows the 5-hour limit at 38% lasting ~4.7h and the weekly limit at 41% lasting ~52h, three open sessions with tokens and cost, a model recommendation, and a Unity status line" width="100%">
Claude Code mods are plugins of function hooks: TypeScript that runs inside Claude Code and can draw panes and bands, add slash commands, watch tool calls, and feed context back to the model. This repository is a marketplace of five of them, built for daily use on a Max plan and on game projects.
| Mod | What it gives you |
|---|---|
| usage-limits | Your 5-hour and weekly limits with how long they will last at your current pace, tokens and cost across every open session, a per-project breakdown, API-credit tracking, and a model recommendation that keeps you from running dry before the reset. |
| fluxo | /handoff writes where you stopped so the next session on another machine picks it up, plus a sound and notification when a long turn finishes. |
| tarefas | The project's tasks in a plain TASKS.md: a pane, the next task above the prompt with Start and Done buttons, and Claude creating and finishing tasks as it works. |
| unity-tools | Compiler errors handed to Claude seconds after it edits a .cs, EditMode/PlayMode tests it can run on its own, a dedicated-server build pane, and guards that keep it out of Library/, scenes and .meta files. |
| blender-preview | A live thumbnail of the Blender scene with triangle, bone and texture budgets, and a check of every .glb Claude exports. |
[!NOTE] The mods' interface is in Brazilian Portuguese. Translations are very welcome; see Contributing.
You need a recent Claude Code (tested on 2.1.286 – 2.1.295). In a terminal, start claude and install the mods you want:
/plugin install usage-limits --marketplace kevinmedeiros/claude-mods
/plugin install fluxo --marketplace kevinmedeiros/claude-mods
/plugin install tarefas --marketplace kevinmedeiros/claude-mods
/plugin install unity-tools --marketplace kevinmedeiros/claude-mods
/plugin install blender-preview --marketplace kevinmedeiros/claude-mods
Answer y to add the marketplace the first time and pick the user scope. From then on the mods run in every Claude Code session on that machine: the terminal, the Code tab of Claude Desktop, and the IDE extensions. Restart sessions that were already open.
Update with /plugin update <mod>; change options with /plugin → the mod → configure.
<img src="docs/desktop-pane.svg" alt="The /limites pane in Claude Desktop: a 5-hour card at 38% with a bar, the projection hatched to the reset and a pace marker; a weekly card at 41% in red warning that at the current pace it runs out about 80 hours before the reset; and the session's token mix" width="560" align="right">
The percentages come from Anthropic's own rate-limit headers, so they cover the whole account: every session, every machine, and claude.ai. On top of them the mod answers the question the usage page doesn't: will this last until the reset?
/limites pane with bars that show used, projected-to-reset and an even-pace marker, plus the 5-hour pace, the weekly pace in points per day, and how many hours of work are left at the last hour's pace./economia on). The main thread keeps its model while new subagents run on Claude Haiku 5.5.| Option | Default | |
|---|---|---|
adminApiKey | — | sk-ant-admin… key of the Console organization that receives the plan's API credits. Kept in secure storage. |
apiMonthlyCredit | 200 | Max 5x: 100 · Max 20x: 200 |
apiCycleDay | 1 | Day of the month the credit renews |
Continue on another machine where you stopped.
/handoff [note] (or /passagem, if another plugin already has /handoff) has Claude Haiku 5.5 summarize the session (where you stopped, what was done, next steps, gotchas) and writes it to .claude/handoff.md with the files you changed and the git state.autoHandoff is on, every turn that edits files refreshes the file list, so there's a record even if the session dies.The handoff file travels with your project, through a git push or a synced folder. If you'd rather keep it out of git, add .claude/handoff.md to .gitignore and sync the folder instead.
| Option | Default |
|---|---|
longTurnMinutes | 3 |
sound / speak | true / false |
autoHandoff | false (turn on to refresh the file after every turn that edits) |
handoffPath | .claude/handoff.md |
The project's to-do list, kept in a TASKS.md at the project root (the same format as Claude's task-management skill, so you, Claude and your editor can all edit it).
/tarefas opens the pane: Active, Waiting On, Someday and recently done, a field to add a task, Start, ✓ and ↑ (move from Someday to Active)./tarefa <title> [- context] adds one from the prompt.task_add, task_done and task_list tools, so it records work it leaves for later and checks tasks off when it finishes them, with a toast every time. Turn this off with claudeCanEdit./tarefas importar pulls pending items from your notes (sections such as Pendente, Próximos passos or Next steps) and open checkboxes in BACKLOG.md / ROADMAP.md into Someday, without duplicates./handoff carries the open tasks to the other machine.| Option | Default |
|---|---|
tasksPath | TASKS.md |
showBand | true |
claudeCanEdit | true |
Turns on in any session whose folder holds a Unity project. It finds ProjectSettings/ above or up to two levels below the session's folder.
.cs, the mod compiles that assembly with dotnet build from the .csproj Unity generates, out of tree under Temp/, in about a second. The errors come back with the tool result. New files Unity hasn't listed yet are included./testes [editmode|playmode|tudo] runs the Unity Test Runner in batch mode and shows the results in the /unity pane, with a send failures to Claude button. Claude can also call the unity_tests tool itself to check its own changes./build-servidor builds the Linux dedicated server and streams the log. It tells you if the Linux Dedicated Server module is missing.Library/, Temp/, Logs/ or UserSettings/;.unity or .prefab files;.meta files by hand;UnityEngine inside an engine-free core folder.| Option | Default | |
|---|---|---|
projectPath | auto | Project folder, if auto-detection misses |
unityPath | Unity Hub path for the project's version | |
coreDir | Assets/Scripts/Core | Engine-free folder; empty turns the guard off |
compileCheck | true | |
allowSceneEdits | false | |
serverBuildPath | Builds/Server/Server.x86_64 | |
buildMethod | — | e.g. MyGame.Editor.BuildServer.Build; empty uses -buildLinux64Player |
Requirements:
dotnet), for the compile check.Works with Blender connected to Claude Code through a Blender MCP server.
/blender opens the pane..glb export, it reads the file and hands Claude a report: size, triangles, skin and bones, animations, and texture sizes, with a warning for anything over budget./glb <file> checks any .glb.| Option | Default |
|---|---|
maxTriangles | 20000 |
maxBones | 100 |
maxTextureSize | 2048 |
autoPreview | true |
server | Blender |
Everything stays on your machine.
~/.claude/usage-limits/, to each plugin's own store, to .claude/handoff.md and TASKS.md in your project.usage-limits calls the Console cost API, and only when you set an Admin key.fluxo calls Claude Haiku 5.5 when you run /handoff.unity-tools runs dotnet and the Unity editor; blender-preview talks to your local Blender through MCP.plugins/<mod>/
.claude-plugin/plugin.json manifest and options
hooks/register.tsx the hooks module
hooks/*.ts pure logic (unit-tested)
tests/*.test.ts run by `claude plugin test`
types/index.d.ts the mod's state contract
claude plugin validate plugins/usage-limits
claude plugin test plugins/usage-limits
claude --plugin-dir plugins/usage-limits
With --plugin-dir, a mod hot-reloads as you save. To regenerate the images in this README, run bun docs/generate-previews.ts; they are drawn with the mods' own code. See CONTRIBUTING.md.
MIT © Kevin Medeiros. Not affiliated with Anthropic.
hooks/register.tsx 329 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Section, Task } from '../types'
5import { addTask, completeTask, extractPending, findTask, isImportable, moveTask, parseTasks } from './tasks'
6
7const PANE = 'tarefas'
8const PREFIX = 'mcp__tarefas__'
9
10const board = atom({ plugin: 'tarefas', key: 'board' } as const, null)
11const notice = atom({ plugin: 'tarefas', key: 'notice' } as const, null)
12
13/** Onde está o TASKS.md desta sessão. */
14const where = { root: '', file: '', project: '' }
15
16const join = (root: string, rel: string) => `${root.replace(/[\\/]+$/, '')}/${rel.replace(/^[\\/]+/, '')}`
17const norm = (p: string) => p.replaceAll('\\', '/').toLowerCase()
18
19const today = (now: number) => {
20 const d = new Date(now)
21 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
22}
23
24/** Relê o TASKS.md; `force` relê mesmo sem mudança na data do arquivo. */
25const load = async ($: EngineInterface, force = false) => {
26 if (!where.file) return
27 const exists = await $.fs.exists(where.file)
28 const mtimeMs = exists ? (await $.fs.stat(where.file)).mtimeMs : 0
29 const held = await read($, board)
30 if (!force && held && held.exists === exists && held.mtimeMs === mtimeMs) return
31 const tasks = exists ? parseTasks(await $.fs.read(where.file)) : []
32 const next: Board = { path: where.file, project: where.project, exists, tasks, mtimeMs }
33 await update($, board, () => next)
34}
35
36const readText = async ($: EngineInterface) => ((await $.fs.exists(where.file)) ? await $.fs.read(where.file) : undefined)
37
38const save = async ($: EngineInterface, text: string) => {
39 await $.fs.write(where.file, text)
40 await load($, true)
41}
42
43const say = async ($: EngineInterface, text: string) => {
44 await update($, notice, () => text)
45 $.ui.toast(text, { timeoutMs: 6000 })
46}
47
48const add = async ($: EngineInterface, title: string, context?: string, section?: Section, by?: string) => {
49 const result = addTask(await readText($), { title, context, section })
50 if (!result.added) return result.task ? `Já existe: ${result.task.title}` : 'Título vazio.'
51 await save($, result.text)
52 const where2 = section && section !== 'Active' ? ` (${section})` : ''
53 await say($, `${by ?? 'Nova tarefa'}: ${result.task?.title ?? title}${where2}`)
54
55 return `Tarefa criada: ${result.task?.title ?? title}${where2}`
56}
57
58const complete = async ($: EngineInterface, query: string, by?: string) => {
59 const text = await readText($)
60 if (!text) return 'Ainda não há TASKS.md neste projeto.'
61 const result = completeTask(text, query, today(await $.clock.now()))
62 if (!result.task) return `Não achei a tarefa "${query}".`
63 if (result.text === text) return `Já estava feita: ${result.task.title}`
64 await save($, result.text)
65 await say($, `${by ?? 'Feita'}: ${result.task.title}`)
66
67 return `Tarefa concluída: ${result.task.title}`
68}
69
70const activate = async ($: EngineInterface, query: string) => {
71 const text = await readText($)
72 if (!text) return
73 const result = moveTask(text, query, 'Active')
74 if (result.text !== text) await save($, result.text)
75}
76
77/** Varre os markdown da raiz e de docs/ atrás de pendências e põe as novas em Someday. */
78const importPending = async ($: EngineInterface) => {
79 const found: { title: string; context?: string; source: string }[] = []
80 for (const dir of [where.root, join(where.root, 'docs')]) {
81 let entries: { name: string; kind: string; size: number }[] = []
82 try {
83 entries = await $.fs.list(dir)
84 } catch {
85 continue
86 }
87 for (const e of entries) {
88 if (e.kind !== 'file' || !isImportable(e.name) || e.size > 1_000_000) continue
89 const file = join(dir, e.name)
90 if (norm(file) === norm(where.file)) continue
91 const rel = file.slice(where.root.replace(/[\\/]+$/, '').length + 1)
92 found.push(...extractPending(await $.fs.read(file), rel))
93 }
94 }
95 let text = await readText($)
96 let added = 0
97 for (const p of found.slice(0, 60)) {
98 const result = addTask(text, { title: p.title, context: [p.context, `de ${p.source}`].filter(Boolean).join(' · '), section: 'Someday' })
99 text = result.text
100 if (result.added) added++
101 }
102 if (added > 0 && text) await save($, text)
103 const skipped = found.length - added
104 const msg =
105 found.length === 0
106 ? 'Nenhuma pendência encontrada nos markdown da raiz e de docs/.'
107 : `Importei ${added} pendência${added === 1 ? '' : 's'} para "Someday"${skipped > 0 ? ` (${skipped} já existiam ou passaram do limite)` : ''}. Use ↑ para ativar.`
108 await say($, msg)
109
110 return msg
111}
112
113const startPrompt = (t: Task) =>
114 `Faça a tarefa "${t.title}" do TASKS.md${t.context ? ` (${t.context})` : ''}. Quando terminar, marque como feita.`
115
116const listText = (b: Board | null) => {
117 if (!b?.exists) return 'Ainda não há TASKS.md neste projeto.'
118 const open = b.tasks.filter(t => !t.done)
119 if (open.length === 0) return 'Nenhuma tarefa aberta.'
120 const bySection = (s: Section) => open.filter(t => t.section === s)
121
122 return (['Active', 'Waiting On', 'Someday'] as const)
123 .filter(s => bySection(s).length > 0)
124 .map(s => `${s}:\n${bySection(s).map(t => `- ${t.title}${t.context ? ` — ${t.context}` : ''}`).join('\n')}`)
125 .join('\n\n')
126}
127
128export const register: Register = (on, options) => {
129 const tasksPath = String(options.tasksPath ?? 'TASKS.md')
130 const showBand = options.showBand !== false
131 const claudeCanEdit = options.claudeCanEdit !== false
132
133 on('session.start', async ($, e, next) => {
134 const started = await next(e)
135 let root = started.cwd
136 try {
137 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: started.cwd, timeoutMs: 5000 })
138 if (r.exitCode === 0 && r.stdout.trim()) root = r.stdout.trim()
139 } catch {
140 // Sem git: a pasta da sessão é a raiz.
141 }
142 where.root = root
143 where.file = join(root, tasksPath)
144 where.project = root.split(/[\\/]/).filter(Boolean).pop() ?? root
145 await load($, true)
146
147 for (const spec of [
148 { name: 'tarefas', description: 'Lista as tarefas do projeto (TASKS.md); "importar" traz pendências das notas e backlogs', argumentHint: '[importar]' },
149 { name: 'tarefa', description: 'Cria uma tarefa no TASKS.md do projeto', argumentHint: '<título> [- contexto]' },
150 ]) {
151 try {
152 await $.command.register(spec)
153 } catch {
154 // Outro plugin já tem esse comando: o painel e as ferramentas continuam.
155 }
156 }
157 await $.tool.register({
158 name: 'task_list',
159 description: 'Lista as tarefas abertas do projeto (TASKS.md), por seção. Use para saber o que falta fazer.',
160 })
161 if (claudeCanEdit) {
162 await $.tool.register({
163 name: 'task_add',
164 description:
165 'Cria uma tarefa no TASKS.md do projeto. Use quando encontrar trabalho que fica para depois (uma pendência, ' +
166 'um bug visto de passagem, um passo que falta) em vez de só mencionar no texto. Não crie tarefa para o que vai ' +
167 'fazer agora.',
168 inputSchema: {
169 type: 'object',
170 properties: {
171 title: { type: 'string', description: 'Curto e acionável, ex.: "Portar a esquiva para o FishNet"' },
172 context: { type: 'string', description: 'Opcional: arquivo, motivo ou detalhe' },
173 section: { type: 'string', enum: ['Active', 'Waiting On', 'Someday'] },
174 },
175 required: ['title'],
176 },
177 })
178 await $.tool.register({
179 name: 'task_done',
180 description: 'Marca uma tarefa do TASKS.md como feita, pelo título (ou parte dele). Use quando terminar o trabalho dela.',
181 inputSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
182 })
183 }
184 $.clock.every(20_000, () => void load($))
185
186 return started
187 })
188
189 // Edições no TASKS.md feitas pelo Claude com Edit/Write: relê na hora.
190 on('tool.call', async ($, e, next) => {
191 const ran = await next(e)
192 if ((e.tool === 'Edit' || e.tool === 'Write') && where.file && norm(e.file_path) === norm(where.file)) await load($, true)
193 return ran
194 }).catch(($, e, next) => next(e))
195
196 on('tool.call', { tool: `${PREFIX}task_list` }, async $ => {
197 await load($)
198 return { result: listText(await read($, board)) }
199 }).catch(() => ({ result: 'Não consegui ler o TASKS.md.' }))
200
201 on('tool.call', { tool: `${PREFIX}task_add` }, async ($, e) => {
202 const input = e as unknown as { title?: string; context?: string; section?: Section }
203 return { result: await add($, String(input.title ?? ''), input.context, input.section, 'Claude criou a tarefa') }
204 }).catch(() => ({ result: 'Não consegui gravar o TASKS.md.' }))
205
206 on('tool.call', { tool: `${PREFIX}task_done` }, async ($, e) => {
207 const input = e as unknown as { title?: string }
208 return { result: await complete($, String(input.title ?? ''), 'Claude concluiu') }
209 }).catch(() => ({ result: 'Não consegui gravar o TASKS.md.' }))
210
211 on('command.run', { command: 'tarefa' }, async ($, e) => {
212 const raw = e.args.trim()
213 if (!raw) return { text: 'Uso: /tarefa <título> [- contexto]' }
214 const cut = raw.search(/\s[-–—]\s/)
215 return { text: await add($, cut > 0 ? raw.slice(0, cut) : raw, cut > 0 ? raw.slice(cut + 3) : undefined) }
216 })
217
218 on('command.run', { command: 'tarefas' }, async ($, e) => {
219 if (/^import/i.test(e.args.trim())) return { text: await importPending($) }
220 await load($, true)
221 await $.ui.open({ id: PANE, title: 'Tarefas' })
222 return { text: listText(await read($, board)) }
223 })
224
225 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226 const { Box, Text, Button } = $.ui.resolve(e)
227 const onNew = (value: string) => {
228 const cut = value.search(/\s[-–—]\s/)
229 void add($, cut > 0 ? value.slice(0, cut) : value, cut > 0 ? value.slice(cut + 3) : undefined)
230 }
231 // O app de celular ainda não desenha campos de texto.
232 const newField = (() => {
233 if (e.surface === 'mobile') return undefined
234 const { Input } = $.ui.resolve(e)
235 return <Input key="new" placeholder="Nova tarefa (título - contexto)" submitLabel="Adicionar" onSubmit={onNew} />
236 })()
237 const b = await read($, board)
238 const last = await read($, notice)
239 const open = (b?.tasks ?? []).filter(t => !t.done)
240 const done = (b?.tasks ?? []).filter(t => t.done).slice(0, 5)
241 const section = (name: Section, label: string) => {
242 const list = open.filter(t => t.section === name)
243 if (list.length === 0) return undefined
244 return (
245 <Box key={`s-${name}`} flexDirection="column" marginTop={1}>
246 <Text bold>
247 {label} ({list.length})
248 </Text>
249 {list.map(t => (
250 <Box key={`t-${t.id}`} flexDirection="row" columnGap={1} flexWrap="wrap">
251 <Text>
252 ○ {t.title}
253 {t.context ? <Text dimColor> — {t.context}</Text> : ''}
254 </Text>
255 {name === 'Someday' ? (
256 <Button key={`up-${t.id}`} label="↑" plain onPress={() => void activate($, t.id)} />
257 ) : (
258 <Button key={`go-${t.id}`} label="Começar" plain onPress={() => void $.prompt.fill({ text: startPrompt(t) })} />
259 )}
260 <Button key={`ok-${t.id}`} label="✓" plain onPress={() => void complete($, t.id)} />
261 </Box>
262 ))}
263 </Box>
264 )
265 }
266
267 return (
268 <Box flexDirection="column">
269 <Text dimColor>
270 {b?.exists ? `${b.path} · ${open.length} aberta${open.length === 1 ? '' : 's'}` : `Sem TASKS.md em ${where.root || 'esta pasta'}: a primeira tarefa cria o arquivo.`}
271 </Text>
272 {newField}
273 {section('Active', 'Ativas')}
274 {section('Waiting On', 'Aguardando')}
275 {section('Someday', 'Algum dia')}
276 {done.length > 0 && (
277 <Box key="done" flexDirection="column" marginTop={1}>
278 <Text dimColor>Feitas</Text>
279 {done.map(t => (
280 <Text key={`d-${t.id}`} dimColor>
281 ✓ {t.title}
282 {t.doneAt ? ` (${t.doneAt})` : ''}
283 </Text>
284 ))}
285 </Box>
286 )}
287 <Box flexDirection="row" columnGap={2} marginTop={1}>
288 <Button key="import" label="Importar pendências" onPress={() => void importPending($)} />
289 <Button key="reload" label="↻" plain onPress={() => void load($, true)} />
290 </Box>
291 {last && <Text dimColor>{last}</Text>}
292 </Box>
293 )
294 })
295
296 // Acima do prompt: a próxima tarefa ativa, empilhada com o que outros plugins desenham.
297 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298 const below = await next(e)
299 if (!showBand || e.props.hasSurvey) return below
300 const b = await read($, board)
301 const open = (b?.tasks ?? []).filter(t => !t.done)
302 const first = open.find(t => t.section === 'Active')
303 if (!b?.exists || open.length === 0) return below
304 const { Box, Text, Button } = $.ui.resolve(e)
305 const nextTask = findTask(open, first?.id ?? '') ?? first
306
307 return (
308 <Box flexDirection="column">
309 <Box flexDirection="row" columnGap={2} flexWrap="wrap" alignItems="center">
310 <Text>
311 {nextTask ? (
312 <Text>
313 <Text bold>▶ Próxima:</Text> {nextTask.title}
314 </Text>
315 ) : (
316 <Text dimColor>Sem tarefas ativas</Text>
317 )}
318 <Text dimColor> · {open.length} aberta{open.length === 1 ? '' : 's'}</Text>
319 </Text>
320 {nextTask && <Button key="tarefa-go" label="Começar" plain onPress={() => void $.prompt.fill({ text: startPrompt(nextTask) })} />}
321 {nextTask && <Button key="tarefa-ok" label="✓ Feita" plain onPress={() => void complete($, nextTask.id)} />}
322 <Button key="tarefa-list" label="Tarefas" plain onPress={() => void $.ui.open({ id: PANE, title: 'Tarefas' })} />
323 </Box>
324 {below}
325 </Box>
326 )
327 })
328}
329hooks/tasks.ts 259 lines1import type { Section, Task } from '../types'
2
3/**
4 * O formato do TASKS.md, o mesmo da skill de tarefas do Claude:
5 * ## Active / ## Waiting On / ## Someday / ## Done
6 * - [ ] **Título** - contexto
7 * - [x] ~~Título~~ (2026-10-09)
8 * O mod mexe só nas linhas das tarefas que muda; o resto do arquivo fica como está.
9 */
10export const TEMPLATE = '# Tasks\n\n## Active\n\n## Waiting On\n\n## Someday\n\n## Done\n'
11
12export const SECTIONS: Section[] = ['Active', 'Waiting On', 'Someday', 'Done']
13
14const SECTION_ALIASES: Record<string, Section> = {
15 active: 'Active',
16 ativas: 'Active',
17 'em andamento': 'Active',
18 'waiting on': 'Waiting On',
19 aguardando: 'Waiting On',
20 someday: 'Someday',
21 'algum dia': 'Someday',
22 backlog: 'Someday',
23 done: 'Done',
24 feitas: 'Done',
25 concluídas: 'Done',
26}
27
28const HEADING = /^(#{1,6})\s+(.+?)\s*$/
29const TASK = /^([-*])\s+\[( |x|X)\]\s+(.*)$/
30
31/** "Portar a esquiva!" → "portar a esquiva": a chave para achar e não duplicar. */
32export const taskId = (title: string) =>
33 title
34 .normalize('NFD')
35 .replace(/[̀-ͯ]/g, '')
36 .toLowerCase()
37 .replace(/[^a-z0-9]+/g, ' ')
38 .trim()
39
40const clean = (s: string) => s.replace(/~~/g, '').replace(/\*\*/g, '').replace(/`/g, '').trim()
41
42/** Título e contexto de uma linha: o negrito é o título; sem negrito, o que vem antes de " - ". */
43export const splitTitle = (raw: string): { title: string; context?: string; doneAt?: string } => {
44 let text = raw.trim()
45 const date = /\s*\((\d{4}-\d{2}-\d{2})\)\s*$/.exec(text)
46 if (date) text = text.slice(0, date.index).trim()
47 const bold = /^(?:~~)?\*\*(.+?)\*\*(?:~~)?\s*(?:[-–—:]\s*(.*))?$/.exec(text)
48 if (bold) return { title: clean(bold[1] ?? ''), context: bold[2] ? clean(bold[2]) : undefined, doneAt: date?.[1] }
49 const cut = text.search(/\s[-–—]\s/)
50 if (cut > 0) return { title: clean(text.slice(0, cut)), context: clean(text.slice(cut + 3)), doneAt: date?.[1] }
51
52 return { title: clean(text), doneAt: date?.[1] }
53}
54
55const sectionOf = (name: string): Section | undefined => SECTION_ALIASES[name.toLowerCase().trim()]
56
57export const parseTasks = (text: string): Task[] => {
58 const lines = text.split('\n')
59 const tasks: Task[] = []
60 let section: Section | undefined
61 lines.forEach((line, i) => {
62 const h = HEADING.exec(line)
63 if (h && (h[1] ?? '').length === 2) {
64 section = sectionOf(h[2] ?? '')
65 return
66 }
67 const m = TASK.exec(line)
68 if (!m || !section) return
69 const parts = splitTitle(m[3] ?? '')
70 if (!parts.title) return
71 tasks.push({
72 id: taskId(parts.title),
73 title: parts.title,
74 context: parts.context,
75 done: (m[2] ?? ' ').toLowerCase() === 'x',
76 section,
77 line: i,
78 doneAt: parts.doneAt,
79 })
80 })
81
82 return tasks
83}
84
85/** Onde termina o bloco da tarefa na linha `start` (ela e os sub-itens indentados). */
86const blockEnd = (lines: string[], start: number) => {
87 let end = start + 1
88 while (end < lines.length && /^\s{2,}\S/.test(lines[end] ?? '')) end++
89
90 return end
91}
92
93/** A linha onde entra uma tarefa nova no fim de uma seção (cria a seção se faltar). */
94const insertionPoint = (lines: string[], section: Section): { lines: string[]; at: number } => {
95 let header = lines.findIndex(l => {
96 const h = HEADING.exec(l)
97 return h !== null && (h[1] ?? '').length === 2 && sectionOf(h[2] ?? '') === section
98 })
99 let out = lines
100 if (header < 0) {
101 // Seção nova: antes de "## Done" se houver, senão no fim.
102 const done = lines.findIndex(l => /^##\s+(done|feitas|concluídas)\s*$/i.test(l))
103 const block = [`## ${section}`, '']
104 out = done >= 0 ? [...lines.slice(0, done), ...block, ...lines.slice(done)] : [...lines, '', ...block]
105 header = done >= 0 ? done : out.length - 2
106 }
107 let at = header + 1
108 let last = header
109 while (at < out.length && !/^#{1,2}\s/.test(out[at] ?? '')) {
110 if ((out[at] ?? '').trim()) last = at
111 at++
112 }
113
114 return { lines: out, at: last + 1 }
115}
116
117const taskLine = (title: string, context?: string) =>
118 `- [ ] **${title.replace(/\*\*/g, '')}**${context ? ` - ${context.replace(/\n/g, ' ')}` : ''}`
119
120export type AddResult = { text: string; added: boolean; task?: Task }
121
122/** Acrescenta uma tarefa aberta na seção; não duplica uma aberta com o mesmo título. */
123export const addTask = (
124 text: string | undefined,
125 input: { title: string; context?: string; section?: Section },
126): AddResult => {
127 const base = text && text.trim() ? text : TEMPLATE
128 const title = input.title.trim().replace(/\s+/g, ' ').slice(0, 160)
129 if (!title) return { text: base, added: false }
130 const existing = parseTasks(base).find(t => !t.done && t.id === taskId(title))
131 if (existing) return { text: base, added: false, task: existing }
132 const { lines, at } = insertionPoint(base.replace(/\n+$/, '').split('\n'), input.section ?? 'Active')
133 const next = [...lines.slice(0, at), taskLine(title, input.context), ...lines.slice(at)]
134 const out = `${next.join('\n')}\n`
135
136 return { text: out, added: true, task: parseTasks(out).find(t => !t.done && t.id === taskId(title)) }
137}
138
139/** Acha a tarefa pelo id ou por um pedaço do título (abertas primeiro). */
140export const findTask = (tasks: Task[], query: string): Task | undefined => {
141 const q = taskId(query)
142 if (!q) return undefined
143 const ordered = [...tasks.filter(t => !t.done), ...tasks.filter(t => t.done)]
144
145 const words = q.split(' ').filter(w => w.length > 1)
146 const hasAllWords = (t: Task) => words.length > 0 && words.every(w => t.id.split(' ').some(x => x.startsWith(w)))
147
148 return (
149 ordered.find(t => t.id === q) ??
150 ordered.find(t => t.id.includes(q)) ??
151 ordered.find(t => q.includes(t.id)) ??
152 ordered.find(hasAllWords)
153 )
154}
155
156const removeBlock = (lines: string[], start: number) => {
157 const end = blockEnd(lines, start)
158 return { rest: [...lines.slice(0, start), ...lines.slice(end)], block: lines.slice(start, end) }
159}
160
161/** Marca como feita: risca, põe a data e move para o topo de "## Done". */
162export const completeTask = (text: string, query: string, date: string): { text: string; task?: Task } => {
163 const task = findTask(parseTasks(text), query)
164 if (!task || task.done) return { text, task }
165 const { rest, block } = removeBlock(text.replace(/\n+$/, '').split('\n'), task.line)
166 const doneLine = `- [x] ~~${task.title}~~${task.context ? ` - ${task.context}` : ''} (${date})`
167 const doneBlock = [doneLine, ...block.slice(1)]
168 let lines = rest
169 let header = lines.findIndex(l => /^##\s+(done|feitas|concluídas)\s*$/i.test(l))
170 if (header < 0) {
171 lines = [...lines, '', '## Done']
172 header = lines.length - 1
173 }
174 const at = header + 1 < lines.length && !(lines[header + 1] ?? '').trim() ? header + 2 : header + 1
175 const out = [...lines.slice(0, at), ...doneBlock, ...lines.slice(at)]
176
177 return { text: `${out.join('\n')}\n`, task: { ...task, done: true, section: 'Done', doneAt: date } }
178}
179
180/** Move uma tarefa aberta para outra seção (ex.: de Someday para Active). */
181export const moveTask = (text: string, query: string, section: Section): { text: string; task?: Task } => {
182 const task = findTask(parseTasks(text), query)
183 if (!task || task.done || task.section === section) return { text, task }
184 const { rest, block } = removeBlock(text.replace(/\n+$/, '').split('\n'), task.line)
185 const { lines, at } = insertionPoint(rest, section)
186 const out = [...lines.slice(0, at), ...block, ...lines.slice(at)]
187
188 return { text: `${out.join('\n')}\n`, task: { ...task, section } }
189}
190
191// ---------- importar pendências de outros arquivos ----------
192
193export type Pending = { title: string; context?: string; source: string }
194
195const PENDING_HEADING =
196 /^(pendente|pendentes|pendências|pendencias|to ?do|a fazer|próximos passos|proximos passos|next steps|now|next|agora|em andamento|in progress|backlog)\b/i
197
198const toPending = (raw: string, source: string): Pending | undefined => {
199 const text = raw.replace(/^\[( |x|X)\]\s+/, '').trim()
200 if (!text) return undefined
201 const bold = /^\*\*(.+?)\*\*[:.]?\s*(.*)$/.exec(text)
202 let title: string
203 let context: string | undefined
204 if (bold) {
205 title = clean(bold[1] ?? '')
206 context = clean(bold[2] ?? '') || undefined
207 } else {
208 const cut = text.search(/[:.]\s|\s[-–—]\s/)
209 title = clean(cut > 0 ? text.slice(0, cut) : text)
210 context = cut > 0 ? clean(text.slice(cut + 2)) || undefined : undefined
211 }
212 if (!title) return undefined
213
214 return {
215 title: title.replace(/[.:]$/, '').slice(0, 120),
216 context: context && context.length > 160 ? `${context.slice(0, 157)}…` : context,
217 source,
218 }
219}
220
221/**
222 * Itens de primeiro nível sob títulos como "Pendente", "Próximos passos",
223 * "Backlog" ou "Now/Next", e caixas de seleção ainda abertas (`- [ ]`).
224 */
225export const extractPending = (text: string, source: string): Pending[] => {
226 const out: Pending[] = []
227 let level = 0
228 let inside = false
229 for (const line of text.split('\n')) {
230 const h = HEADING.exec(line)
231 if (h) {
232 const depth = (h[1] ?? '').length
233 if (inside && depth <= level) inside = false
234 if (PENDING_HEADING.test(clean(h[2] ?? ''))) {
235 inside = true
236 level = depth
237 }
238 continue
239 }
240 const open = /^[-*]\s+\[ \]\s+(.*)$/.exec(line)
241 if (open) {
242 const p = toPending(open[1] ?? '', source)
243 if (p) out.push(p)
244 continue
245 }
246 const bullet = /^[-*]\s+(?!\[[xX]\])(.*)$/.exec(line)
247 if (inside && bullet) {
248 const p = toPending(bullet[1] ?? '', source)
249 if (p) out.push(p)
250 }
251 }
252
253 return out
254}
255
256/** Os arquivos que valem para importar: markdown da raiz e de docs/, menos os de projeto. */
257export const isImportable = (name: string) =>
258 /\.md$/i.test(name) && !/^(tasks|readme|changelog|license|contributing|code_of_conduct|security)(\.|-)/i.test(name)
259types/index.d.ts 30 lines1export type Section = 'Active' | 'Waiting On' | 'Someday' | 'Done'
2
3export type Task = {
4 /** O título normalizado: a chave para achar e não duplicar. */
5 id: string
6 title: string
7 context?: string
8 done: boolean
9 section: Section
10 /** A linha do TASKS.md onde a tarefa está. */
11 line: number
12 doneAt?: string
13}
14
15/** O TASKS.md do projeto como o mod o leu por último. */
16export type Board = {
17 path: string
18 project: string
19 exists: boolean
20 tasks: Task[]
21 /** Quando o arquivo mudou por último (para notar edições feitas fora do mod). */
22 mtimeMs: number
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 tarefas: { board: Board | null; notice: string | null }
28 }
29}
30