SLOPSHOPPER

tarefas

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

newpanebandguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-09kevinmedeiros/claude-mods/plugins/tarefas
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tarefas
│ ┃ Tarefas ✕ › fix the failing auth test and add an audit log call │ ┃ Sem TASKS.md em /work/app: a primeira tarefa │ ┃ cria o arquivo. ⏺ Read(src/auth.ts) │ ┃ Nova tarefa (título - contexto) ⏎ Adicionar ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Importar pendências ] ↻ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tarefas │ ⎿ tarefas: Ainda não há TASKS.md neste projeto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tarefas
Sem TASKS.md em /work/app: a primeira tarefa cria o arquivo. Nova tarefa (título - contexto) ⏎ Adicionar [ Importar pendências ] ↻
README

claude-mods

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.

License: MIT Claude Code plugins Mods Tests

English · Português

<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.

ModWhat it gives you
usage-limitsYour 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.
tarefasThe 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-toolsCompiler 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-previewA 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.

Install

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.


usage-limits

<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?

  • Status line and a band above the prompt with each limit's %, how many hours it lasts at the current pace, time to reset, and tokens and cost across all open sessions.
  • /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.
  • Every open session on the machine, with project, model, tokens, cost and share of the total, plus today's total.
  • Per-project spend for each window, so you know which project is burning the week.
  • Model advice. When the pace won't reach the reset, it recommends the least disruptive change, such as "use Opus to orchestrate and Haiku to execute", and shows how long the limit would last.
  • Economy mode (/economia on). The main thread keeps its model while new subagents run on Claude Haiku 5.5.
  • API credits. Optional, with a Console Admin API key: spend of the monthly credit by model, the daily pace, and when the credit runs out.
  • ↻ Refresh to pick up the newest reading any session on the machine has seen.
  • Toasts at 80%, at 95%, when a limit is exhausted, and when the projection says it ends before the reset.
OptionDefault
adminApiKey—sk-ant-admin… key of the Console organization that receives the plan's API credits. Kept in secure storage.
apiMonthlyCredit200Max 5x: 100 · Max 20x: 200
apiCycleDay1Day of the month the credit renews
  • 5-hour window: the pace of the last hour, or the window average when the window is young. Hours left = (100 − used) ÷ pace.
  • Weekly window: points the account spent in the last 24 hours, never less than one day of history, so a busy first morning isn't treated as the pace of the whole week. This includes other machines and overnight runs.
  • Hours of work (weekly, extra): the mod measures how many weekly points each 5-hour point costs, inside a single 5-hour window, and multiplies that by the 5-hour pace. It reacts within minutes when you switch models.
  • Model scenarios: spend is priced per model at API list prices from the last three active hours, and each scenario reprices it with a cheaper model. Subscription limits may weight models differently, so treat the numbers as estimates.
  • Freshness: a session learns the account's percentages only from its own API responses. Sessions on the same machine share the newest reading, and the band says "reading from 2h ago" when it is older than 10 minutes.

fluxo

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.
  • Automatic handoff (off by default). When autoHandoff is on, every turn that edits files refreshes the file list, so there's a record even if the session dies.
  • On the other machine, the next session in that project shows "↪ Handoff from MacBook (3h ago): …" above the prompt, with Continue from here, View and Dismiss buttons.
  • Long-turn alert: a toast, plus a chime on macOS, when a turn runs longer than 3 minutes. Optionally spoken.

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.

OptionDefault
longTurnMinutes3
sound / speaktrue / false
autoHandofffalse (turn on to refresh the file after every turn that edits)
handoffPath.claude/handoff.md

tarefas

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).

  • Above the prompt: "▶ Next: port the dodge to FishNet · 7 open" with Start (writes the prompt for you), ✓ Done and Tasks.
  • /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.
  • Claude keeps it current. It gets 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.
  • With fluxo, /handoff carries the open tasks to the other machine.
OptionDefault
tasksPathTASKS.md
showBandtrue
claudeCanEdittrue

unity-tools

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.

  • Compiler errors in Claude's context. After Claude edits a .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.
  • Guards refuse, with a reason Claude can act on:
  • editing Library/, Temp/, Logs/ or UserSettings/;
  • hand-writing .unity or .prefab files;
  • creating or deleting .meta files by hand;
  • UnityEngine inside an engine-free core folder.
OptionDefault
projectPathautoProject folder, if auto-detection misses
unityPathUnity Hub path for the project's version
coreDirAssets/Scripts/CoreEngine-free folder; empty turns the guard off
compileChecktrue
allowSceneEditsfalse
serverBuildPathBuilds/Server/Server.x86_64
buildMethod—e.g. MyGame.Editor.BuildServer.Build; empty uses -buildLinux64Player

Requirements:

  • Unity Hub with the project's editor version.
  • The .NET SDK (dotnet), for the compile check.
  • Generated project files: Preferences → External Tools → Regenerate project files.
  • Batch tests need the editor closed for that project.

blender-preview

Works with Blender connected to Claude Code through a Blender MCP server.

  • After every scene change Claude makes through Blender MCP, the mod renders a thumbnail and measures triangles per object, bones, materials, textures and actions against your budget. /blender opens the pane.
  • On every .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.
OptionDefault
maxTriangles20000
maxBones100
maxTextureSize2048
autoPreviewtrue
serverBlender

Privacy

Everything stays on your machine.

  • Local files. The mods write to ~/.claude/usage-limits/, to each plugin's own store, to .claude/handoff.md and TASKS.md in your project.
  • Network requests:
  • 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.
  • Nothing else leaves your machine.
  • Local processes: unity-tools runs dotnet and the Unity editor; blender-preview talks to your local Blender through MCP.

Development

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.

License

MIT © Kevin Medeiros. Not affiliated with Anthropic.

Source 3 files
hooks/register.tsx 329 lines
1import { 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}
329
hooks/tasks.ts 259 lines
1import 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)
259
types/index.d.ts 30 lines
1export 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