O roadmap do projeto num quadro: trilha das fases, kanban do que anda, a fase e o % acima do prompt, e o Claude marcando o que fez

Where the project stands on its roadmap, at a glance: one line above the prompt with the current phase, its progress and the next task, and a /roadmap pane that draws the phases as a trail and the work as a small kanban board. Claude keeps it current with three tools, so nobody edits the JSON by hand.
See the main README · Português.
/plugin install roadmap --marketplace kevinmedeiros/claude-mods
The interface is in Brazilian Portuguese, like the other mods in this repository; it is shown here in English.
One line, in the terminal and in Claude Desktop, stacked with the other mods' bands:
Phase 2 ▰▰▱▱▱▱▱▱▱▱ 16% › Email sign-in (Claude) mine: 3 [ Do it ] [ Roadmap ] hide
Nothing is drawn in a project without a roadmap.
/roadmap opens it. This is the example at 100 columns:
Lighthouse ↻
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━◉ 16% ━━━━━━━━━━━━━━━━━━━━━━━━━━━○
1 Prototype 2 Closed beta 3 Launch
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Phase 2 · Closed beta ⚑ Gate 2 · pending │
│ ██████████████▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░ 16% │
│ 1 ✓ 1 ▶ 1 ‖ 3 ○ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
[ All ] [ Mine 3 ]
Now 1 Next 2 Waiting 2 Done 1
╭──────────────────────╮ ╭──────────────────────╮ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ╭──────────────────────╮
│ Email sign-in │ │ Dark mode │ ╎ Store account ╎ │ Sync notes │
│ Claude │ │ Claude │ ╎ ‖ card approval ╎ ╰──────────────────────╯
╰──────────────────────╯ ╰──────────────────────╯ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
╭──────────────────────╮ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
│ Pick the app icon │ ╎ Invite the testers ╎
│ you │ ╎ after Email sign-in ╎
╰──────────────────────╯ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
From top to bottom, with no lists of text:
goal, and the counts with their labels (done, in progress, waiting, to do, and with you when you have items on the board); underneath, the whole gate with its gateStatus.gateStatus up to its first punctuation; just Gate 2 when narrow), a bar split into done, in progress and waiting (▒), the %, and the counts (done ✓, in progress ▶, waiting ‖, to do ○).both ones.path order, from any phase. An item outside the path comes right after the path's items of its own phase.doneAt, and on the same day the one the mod saw finished last. ┏━━━━━━━━━━━━━━━━━━━━━━┓
┃ Email sign-in ┃
┃ ▶ in progress ┃
┃ Claude ┃
┃ [ Do it now ] ┃
┃ [ Close ] ┃
┗━━━━━━━━━━━━━━━━━━━━━━┛
Without a short, the label grows into the whole title (up to four lines); with one, the title shows underneath, minus what the label already says. It also shows the state, the owner, the phase when it is not the current one, Gate, what it waits on, the open dependencies, the date and evidence, and the notes. Do it now writes the request. On a blocked or waiting item the button is See what's missing: Claude finds out what holds it and with whom, without starting it. Close, or clicking the card again, closes it. When the open card leaves the board (its column folded back), the detail moves to a box above the board, with the whole title.
A roadmap of hundreds of items draws the same pane. In the tests, a 300-item roadmap went from 5,342 characters of text in the 0.1.0 pane to 825 in the terminal and 607 on Desktop.
/roadmap also answers in one line: "Phase 2 · 16% · next: Email sign-in".The mod counts the commits since the last commit that touched the roadmap (git rev-list --count <that commit>..HEAD). Above the limit (10 by default), the band and the pane show "14 commits without an update". There is no warning without git, for a roadmap never committed, or while the roadmap has uncommitted changes.
roadmap_status {query?, phase?, all?}short, and the phases whose label is over 18 characters.query finds items by words of the title, the short or the id: up to ten, with state, owner, phase, short label and notes. This gives Claude the id of any item.phase lists a whole phase. all: true drops the cuts.roadmap_update {id, status?, evidence?, doneAt?, note?, waitingOn?, short?} changes one item.doneAt for an item that is not done and is not being marked done.done without doneAt uses today's date. Leaving done drops the date. A call that only sets short, a note or waitingOn leaves the state and the date alone.short sets the card's label; "" removes it.done, doing or todo) and its short, and evidence becomes its gateStatus.roadmap_add {phase, title, short?, owner?, after?, status?, dependsOn?, ...} creates an item, with an id made from the title (never repeated).after puts it right after another item, in the file and in the path. Without it, the item goes to the end of its phase.after, a title the phase already has, and a short over 60 characters.git commit that Claude runs, it reads a short reminder that the project has a roadmap and that it should check off what the commit finished with roadmap_update. The reminder carries the out-of-date warning when there is one. There is no reminder when the commit itself touched the roadmap, or with claudeCanEdit off.Do it and Do it now write a different request for each kind of owner, with the item's notes:
both: Claude does its part, tells you yours, and marks it done only when both parts are.The mod reloads the file:
The commit count is refreshed after each turn (at most once a minute) and every 5 minutes.
docs/roadmap.json from the git root (or the session's folder without git); change it with the file option. Without it, a ROADMAP.md works too. Format version 1:
| Field | |
|---|---|
version | 1 |
project, goal | project is the pane's header. |
updated | YYYY-MM-DD. The tools set it on every change. |
owner | Who you are in the items: items owned by this name (any case) or by both are yours (the Mine filter, you on the cards). |
phases[] | id, name, short (the label on the trail), goal, status (done, doing or todo; todo when missing), gate, gateStatus, notes. |
items[] | id (unique), phase (a phase id), title, short (the card's label), status (done, doing, todo or blocked), owner (Claude, your name or both), waitingOn, dependsOn (item ids), doneAt, evidence, area, gate (true for an item of the phase's gate), notes. |
path | Item ids in the order you want them done. |
openDecisions[] | title, phase, ref. |
Fields the mod does not know are kept as they are.
Short labels. short is optional:
short that is empty, is not text, or is over 60 characters is an error, reported with its line.How it reads the file:
doing phase, otherwise the first one that is not done.path order and then in file order. The band shows the first one; the board's Next column shows those still to do.owner is the file's owner or both. Mine N (and mine: N on the band) counts yours in Now, Next and Waiting.blocked items, with their waitingOn (or the first dependency still open), then its todo items with a dependency still open.An invalid file is never rewritten. The band says the roadmap has an error, with the line and the reason, the pane shows it, and the tools refuse to write until it is fixed.
How it writes the file:
{
"version": 1,
"project": "Lighthouse",
"goal": "until the beta",
"updated": "2026-10-12",
"owner": "Sam",
"phases": [
{ "id": "1", "name": "Prototype", "status": "done" },
{
"id": "2",
"name": "Closed beta",
"goal": "ten people using it",
"status": "doing",
"gate": "ten people use it for a week",
"gateStatus": "pending"
},
{ "id": "3", "name": "Launch", "status": "todo" }
],
"items": [
{ "id": "home-screen", "phase": "1", "title": "Home screen", "status": "done", "owner": "Claude", "doneAt": "2026-09-20", "evidence": "commit abc123" },
{ "id": "sync-notes", "phase": "2", "title": "Sync notes", "status": "done", "owner": "Claude", "doneAt": "2026-10-02" },
{ "id": "email-sign-in", "phase": "2", "title": "Email sign-in", "status": "doing", "owner": "Claude", "dependsOn": ["sync-notes"] },
{ "id": "invite-testers", "phase": "2", "title": "Invite the testers", "status": "todo", "owner": "Sam", "dependsOn": ["email-sign-in"], "gate": true },
{ "id": "store-account", "phase": "2", "title": "Open the developer account on the app store with the company card", "short": "Store account", "status": "blocked", "owner": "both", "waitingOn": "card approval" },
{ "id": "dark-mode", "phase": "2", "title": "Dark mode", "status": "todo", "owner": "Claude", "area": "ui" },
{ "id": "pick-icon", "phase": "2", "title": "Pick the app icon", "status": "todo", "owner": "Sam" },
{ "id": "publish", "phase": "3", "title": "Publish on the store", "status": "todo", "owner": "both", "dependsOn": ["store-account"] }
],
"path": ["email-sign-in", "dark-mode", "invite-testers"],
"openDecisions": [{ "title": "Charge a subscription?", "phase": "3", "ref": "docs/pricing.md" }]
}
Here the current phase is 2 at 16% (1 of 6 items), and the board shows:
short, blocked on the card approval; then Invite the testers, after Email sign-in.Mine is 3: the icon, the store account and the testers. The decision belongs to phase 3, so the footer has no Decisions chip yet. When Claude marks Email sign-in done, only that line and updated change, and the items stay one per line; Invite the testers then moves to Next.
Without docs/roadmap.json, the mod reads a Markdown roadmap: the first of ROADMAP.md, docs/ROADMAP.md, docs/roadmap.md and roadmap.md at the root. It is plain Markdown with checkboxes, so it reads well on GitHub too:
# Lighthouse
Until the beta.
Dono: Sam
## Fase 1 · Prototype
- [x] Home screen (2026-09-20 · commit abc123)
## Fase 2 · Closed beta
> Gate: ten people use it for a week — pending
- [x] Sync notes (2026-10-02)
- [ ] Email sign-in @Claude (em andamento)
- use a magic link
- [ ] Store account @ambos (bloqueado: card approval)
- [ ] Invite the testers @Sam (depois de: Email sign-in) (gate)
### UI
- [ ] Pick the app icon @Sam
## Fase 3 · Launch
- [ ] Publish on the store @ambos (depois de: Store account)
## Decisões
- Charge a subscription? (fase 3)
# title, the line under it is the goal, and Dono: Name is who you are (without it, the most common @name that is not Claude).## Fase N · Name (also Phase, Etapa, Marco, Milestone, Sprint, or any heading that holds items). (feita) or ✓ marks it done and (atual) current; otherwise its items decide. > Gate: text — status is the gate. Without phase headings, everything is one phase.- [ ] to do, - [x] done, - [/] or - [~] in progress, - [-] blocked. After the title: @Owner (@Claude, a name, @ambos), (em andamento), (bloqueado: reason), (depois de: Title), (gate) and (date · evidence) on a done item. Indented bullets are the item's notes, and a deeper heading is its area. The id is the title in lowercase with dashes (email-sign-in).## Decisões (or Decisions) section, with (fase N).roadmap_update and roadmap_add change only the item's line (or add one at the end of the phase, or after after); the rest of the file, with your notes and sections, stays as it is. short labels are JSON-only.| Option | Default | |
|---|---|---|
file | docs/roadmap.json | Relative to the git root, or absolute; a .md file is read as a ROADMAP.md. With the default, a ROADMAP.md counts too |
showBand | true | The line above the prompt |
staleAfter | 10 | Commits without touching the roadmap before the warning; 0 turns it off |
claudeCanEdit | true | Gives Claude roadmap_update and roadmap_add; roadmap_status is always there |
hooks/register.tsx 1030 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4import type { Board, Card, ColumnId, Counts, Decision, Item, Kanban, KanbanColumn, Roadmap, Summary } from '../types'
5import {
6 CARD_STEP,
7 DECISIONS_SHOWN,
8 ITEM_LABEL,
9 ITEM_MARK,
10 applyAdd,
11 applyUpdate,
12 bandBar,
13 cardLabel,
14 countsText,
15 currentPhase,
16 cut,
17 gateChip,
18 hash,
19 isStuck,
20 itemLabel,
21 kanban,
22 mineCount,
23 missingText,
24 newlyDone,
25 ownerChip,
26 ownerKind,
27 paneSvgWidth,
28 parseRoadmap,
29 pendingDeps,
30 phaseCounts,
31 phaseRingSvg,
32 phaseTitle,
33 planBand,
34 reportText,
35 segBarSvg,
36 splitBar,
37 staleChip,
38 staleCount,
39 staleText,
40 startPrompt,
41 statusLine,
42 summarize,
43 trailAlt,
44 trailSvg,
45 trailText,
46} from './roadmap'
47import type { AddInput, Change, Seg, StatusInput, UpdateInput } from './roadmap'
48import { applyAddMarkdown, applyUpdateMarkdown, isMarkdown, parseMarkdown } from './markdown'
49
50const PANE = 'roadmap'
51
52const board = atom({ plugin: 'roadmap', key: 'board' } as const, null)
53const stale = atom({ plugin: 'roadmap', key: 'stale' } as const, null)
54const hidden = atom({ plugin: 'roadmap', key: 'hidden' } as const, null)
55const filtering = atom({ plugin: 'roadmap', key: 'filter' } as const, 'all')
56const expanded = atom({ plugin: 'roadmap', key: 'expanded' } as const, {})
57const selected = atom({ plugin: 'roadmap', key: 'selected' } as const, null)
58const decisionsOpen = atom({ plugin: 'roadmap', key: 'decisions' } as const, false)
59const doneSeen = atom({ plugin: 'roadmap', key: 'doneSeen' } as const, {})
60
61/** Onde está o roadmap desta sessão; `candidates` são os arquivos que valem, na ordem. */
62const where = { root: '', file: '', rel: '', project: '', hasGit: false, staleAt: 0, candidates: [] as string[] }
63
64/** Sem o JSON, um ROADMAP.md também serve: estes, na ordem, depois do arquivo da opção. */
65const MARKDOWN_NAMES = ['ROADMAP.md', 'docs/ROADMAP.md', 'docs/roadmap.md', 'roadmap.md']
66
67/** Escolhe o primeiro arquivo que existe; sem nenhum, fica o da opção (onde criar). */
68const discover = async ($: EngineInterface) => {
69 for (const rel of where.candidates) {
70 const path = isAbsolute(rel) ? rel : join(where.root, rel)
71 try {
72 if (await $.fs.exists(path)) {
73 where.rel = rel
74 where.file = path
75 return
76 }
77 } catch {
78 // Sem acesso: tenta o próximo.
79 }
80 }
81 const rel = where.candidates[0] ?? 'docs/roadmap.json'
82 where.rel = rel
83 where.file = isAbsolute(rel) ? rel : join(where.root, rel)
84}
85
86const parseAny = (text: string) => (isMarkdown(where.file) ? parseMarkdown(text) : parseRoadmap(text))
87
88const isAbsolute = (p: string) => /^([A-Za-z]:)?[\\/]/.test(p)
89const join = (root: string, rel: string) => `${root.replace(/[\\/]+$/, '')}/${rel.replace(/^[\\/]+/, '')}`
90const norm = (p: string) => p.replaceAll('\\', '/').toLowerCase()
91
92const today = (now: number) => {
93 const d = new Date(now)
94 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
95}
96
97/** Relê o roadmap; `force` relê mesmo sem mudança na data do arquivo. Diz se mudou. */
98const load = async ($: EngineInterface, force = false): Promise<boolean> => {
99 if (!where.file) return false
100 const held = await read($, board)
101 let next: Board
102 try {
103 // O arquivo sumiu, ou ainda não havia nenhum: outro dos candidatos pode ter aparecido.
104 if (!(await $.fs.exists(where.file))) await discover($)
105 const exists = await $.fs.exists(where.file)
106 const mtimeMs = exists ? (await $.fs.stat(where.file)).mtimeMs : 0
107 if (!force && held && held.exists === exists && held.mtimeMs === mtimeMs) return false
108 const text = exists ? await $.fs.read(where.file) : ''
109 const parsed = exists ? parseAny(text) : undefined
110 next = {
111 path: where.file,
112 rel: where.rel,
113 project: where.project,
114 exists,
115 mtimeMs,
116 sig: exists ? hash(text) : '',
117 roadmap: parsed?.ok ? parsed.roadmap : null,
118 error: parsed && !parsed.ok ? parsed.error : null,
119 }
120 } catch {
121 next = { path: where.file, rel: where.rel, project: where.project, exists: true, mtimeMs: 0, sig: 'erro', roadmap: null, error: 'não consegui ler o arquivo' }
122 }
123 if (held && JSON.stringify(held) === JSON.stringify(next)) return false
124 // O que virou feito desde a última leitura: no mesmo dia, ele vem primeiro na coluna Feito.
125 const fresh = held?.roadmap && next.roadmap ? newlyDone(held.roadmap, next.roadmap) : []
126 if (fresh.length > 0) {
127 const now = await $.clock.now()
128 await update($, doneSeen, seen => ({ ...seen, ...Object.fromEntries(fresh.map((id, n) => [id, now + n / 1000])) }))
129 }
130 await update($, board, () => next)
131 $.ui.status(!next.exists ? undefined : next.roadmap ? statusLine(summarize(next.roadmap)) : 'Roadmap com erro')
132
133 return true
134}
135
136const git = ($: EngineInterface, args: string[]) => $.process.run(['git', ...args], { cwd: where.root, timeoutMs: 5000 })
137
138/** Quantos commits o projeto tem depois do último que mexeu no roadmap (null sem git ou sem histórico). */
139const refreshStale = async ($: EngineInterface) => {
140 where.staleAt = await $.clock.now()
141 let commits: number | null = null
142 if (where.hasGit && where.file) {
143 try {
144 const log = await git($, ['log', '-1', '--format=%H', '--', where.rel])
145 const last = log.exitCode === 0 ? log.stdout.trim() : ''
146 if (last) {
147 const count = await git($, ['rev-list', '--count', `${last}..HEAD`])
148 const n = count.exitCode === 0 ? Number.parseInt(count.stdout.trim(), 10) : Number.NaN
149 commits = Number.isFinite(n) ? n : null
150 // Mexido e ainda não commitado: está sendo atualizado agora.
151 if (commits !== null && commits > 0 && (await git($, ['diff', '--quiet', 'HEAD', '--', where.rel])).exitCode === 1) commits = 0
152 }
153 } catch {
154 commits = null
155 }
156 }
157 if ((await read($, stale)) !== commits) await update($, stale, () => commits)
158}
159
160type Answer = { deny: string } | { result: string }
161
162/**
163 * As gravações do mod, uma de cada vez: duas chamadas ao mesmo tempo (subagentes em
164 * paralelo) partiriam do mesmo texto, e a segunda apagaria a primeira.
165 */
166let writing: Promise<unknown> = Promise.resolve()
167const serial = <T,>(work: () => Promise<T>): Promise<T> => {
168 const run = writing.then(work)
169 writing = run.catch(() => undefined)
170
171 return run
172}
173
174const ATTEMPTS = 3
175
176/**
177 * Aplica uma mudança ao arquivo (só se o roadmap for válido) e avisa na tela. Relê o
178 * arquivo dentro da fila e, se ele mudou por fora entre a leitura e a gravação (um
179 * editor, o Edit do Claude), aplica de novo sobre o texto novo.
180 */
181const change = ($: EngineInterface, apply: (text: string, day: string) => Change, by: string): Promise<Answer> =>
182 serial(async () => {
183 if (!where.file || !(await $.fs.exists(where.file))) return { deny: missingText(where.rel) }
184 for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
185 const before = (await $.fs.stat(where.file)).mtimeMs
186 const text = await $.fs.read(where.file)
187 const result = apply(text, today(await $.clock.now()))
188 if (!result.ok) return { deny: result.error }
189 if (result.text === text) return { result: result.message }
190 if ((await $.fs.stat(where.file)).mtimeMs !== before) continue
191 await $.fs.write(where.file, result.text)
192 await load($, true)
193 await refreshStale($)
194 $.ui.toast(`${by}: ${result.message}`, { timeoutMs: 6000 })
195
196 return { result: result.message }
197 }
198
199 return { deny: 'O roadmap mudou por fora enquanto eu gravava (outro programa está mexendo nele); nada foi gravado. Tente de novo.' }
200 })
201
202const statusText = async ($: EngineInterface, limit: number, input: StatusInput) => {
203 const b = await read($, board)
204 if (!b?.exists) return missingText(where.rel)
205 if (!b.roadmap) return `O roadmap (${b.rel}) tem erro: ${b.error}`
206
207 return reportText(b.roadmap, b.rel, staleText(await read($, stale), limit), input)
208}
209
210/** "git commit", também com opções antes do subcomando (git -C pasta commit). */
211const GIT_COMMIT = /\bgit(?:\s+-[cC]\s+\S+|\s+--?[\w-]+(?:=\S+)?)*\s+commit\b/
212
213/**
214 * O lembrete que vai para o Claude depois de um commit: o roadmap existe e o que
215 * terminou se marca com roadmap_update. Nada quando o próprio commit (ou uma mudança
216 * ainda não commitada) mexeu no roadmap.
217 */
218const commitNote = async ($: EngineInterface, limit: number): Promise<string | undefined> => {
219 await load($)
220 const b = await read($, board)
221 if (!b?.roadmap) return undefined
222 await refreshStale($)
223 const commits = await read($, stale)
224 if (commits === 0) return undefined
225 const warning = staleText(commits, limit)
226
227 return (
228 `Este projeto tem roadmap (${b.rel}). Se este commit terminou um item, marque com roadmap_update e a evidência ` +
229 `(o commit); o roadmap_status com query acha o id.${warning ? ` Aviso: ${warning}.` : ''}`
230 )
231}
232
233// ---------- interface ----------
234
235type BasicUi = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
236/** As superfícies remotas desenham SVG; o terminal, glifos. */
237type Ui = BasicUi & { Svg?: Elements['desktop']['Svg'] }
238type Acts = { start: (item: Item) => () => void; view: () => void; hide: () => void; reload: () => void; create: () => void }
239type View = { b: Board; r: Roadmap; s: Summary; commits?: number }
240
241/** Os tons do desenho nas cores do tema. */
242const TONE = { done: 'success', current: 'claude', doing: 'claude', todo: 'subtle' } as const
243const OWNER_COLOR = { me: 'claude', claude: 'suggestion', both: 'merged' } as const
244const STATUS_COLOR: Partial<Record<Item['status'], 'success' | 'claude' | 'inactive'>> = { done: 'success', doing: 'claude', blocked: 'inactive' }
245
246/** Um pedaço de texto com o tom dele (o "dim" sai apagado). */
247const seg = (ui: Ui, s: Seg) => {
248 const { Text } = ui
249 return s.tone === 'dim' ? <Text dimColor>{s.text}</Text> : <Text color={TONE[s.tone]} bold={s.tone === 'current'}>{s.text}</Text>
250}
251
252/** O chip do dono, colorido: "você" no destaque, "Claude", "os dois" ou o nome. */
253const OwnerChip = (ui: Ui, item: Item | Card, r: Roadmap) => {
254 const { Text } = ui
255 const kind = 'label' in item ? item.owner : ownerKind(item, r)
256 const text = 'label' in item ? item.ownerText : ownerChip(item, r)
257 if (!kind || !text) return undefined
258
259 return kind === 'other' ? <Text dimColor>{text}</Text> : <Text color={OWNER_COLOR[kind]}>{text}</Text>
260}
261
262/**
263 * A faixa: uma linha. "Fase 2 ▰▰▰▱▱ 43% › próxima (você) comigo: 2 ⚠ … [ Fazer ] [ Roadmap ] ocultar".
264 * O rótulo da próxima fica inteiro: para caber, saem antes os chips, a barrinha, a moldura dos botões e o "Fase N".
265 */
266const Band = (ui: Ui, v: View, width: number, act: Acts, isTerminal: boolean) => {
267 const { Box, Text, Button, Svg } = ui
268 const { r, s } = v
269 const first = s.next[0]
270 const cur = s.current
271 const isAllDone = r.items.length > 0 && r.items.every(i => i.status === 'done')
272 const mine = mineCount(r)
273 const head = cur ? `Fase ${cur.phase.id}` : 'Roadmap'
274 const pct = cur ? `${cur.pct}%` : ''
275 const owner = first ? ownerChip(first, r) : undefined
276 const mineText = mine > 0 ? `comigo: ${mine}` : undefined
277 const warn = v.commits !== undefined ? `⚠ ${staleChip(v.commits)}` : undefined
278 const plan = planBand({
279 width,
280 head,
281 pct,
282 hasBar: !!cur,
283 label: first ? itemLabel(first) : isAllDone ? 'tudo feito' : 'nada livre agora',
284 hasNext: !!first,
285 owner,
286 mine: mineText,
287 stale: warn,
288 hide: isTerminal ? 'ocultar' : 'Ocultar',
289 })
290 const bar = cur && plan.bar ? bandBar(cur.pct) : undefined
291 const counts = cur ? phaseCounts(r, cur.phase.id) : undefined
292 const plain = plan.framed ? {} : ({ plain: true } as const)
293
294 return (
295 <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="nowrap">
296 {plan.showHead ? (
297 <Text bold color="claude">
298 {head}
299 </Text>
300 ) : undefined}
301 {bar && counts && Svg ? (
302 <Svg source={segBarSvg(counts, 72)} alt={`${pct} da fase feito`} />
303 ) : bar ? (
304 <Text>
305 <Text color="claude">{bar.full}</Text>
306 <Text dimColor>{bar.empty}</Text>
307 </Text>
308 ) : undefined}
309 <Text wrap="truncate-end">
310 {pct ? <Text bold>{pct}</Text> : ''}
311 {first ? (
312 <Text>
313 {' › '}
314 {plan.label}
315 {plan.showOwner ? <Text dimColor> ({owner})</Text> : ''}
316 </Text>
317 ) : (
318 <Text dimColor>
319 {' '}
320 {plan.label}
321 </Text>
322 )}
323 {plan.showMine ? <Text color="claude">{` ${mineText}`}</Text> : ''}
324 {plan.showStale ? <Text color="warning">{` ${warn}`}</Text> : ''}
325 </Text>
326 {first ? <Button key="roadmap-go" label="Fazer" variant="primary" {...plain} onPress={act.start(first)} /> : undefined}
327 <Button key="roadmap-view" label={plan.framed ? 'Roadmap' : 'Ver'} {...plain} onPress={act.view} />
328 <Button key="roadmap-hide" label={isTerminal ? 'ocultar' : 'Ocultar'} plain dimColor role="dismiss" onPress={act.hide} />
329 </Box>
330 )
331}
332
333const ErrorBand = (ui: Ui, b: Board, width: number, act: Acts, isTerminal: boolean) => {
334 const { Box, Text, Button } = ui
335
336 return (
337 <Box flexDirection="row" columnGap={1} alignItems="center" flexWrap="nowrap">
338 <Text color="error" wrap="truncate-end">
339 ⚠ Roadmap com erro: {cut(b.error ?? '', Math.max(10, width - 40))}
340 </Text>
341 <Button key="roadmap-view" label="Roadmap" onPress={act.view} />
342 <Button key="roadmap-hide" label={isTerminal ? 'ocultar' : 'Ocultar'} plain dimColor role="dismiss" onPress={act.hide} />
343 </Box>
344 )
345}
346
347/** A trilha das fases: um SVG que se ajusta à largura, ou glifos no terminal (os nós e o nome, em até duas linhas). */
348const Trail = (ui: Ui, s: Summary, width: number) => {
349 const { Box, Text, Svg } = ui
350 if (s.phases.length === 0) return undefined
351 // Sem width e height: a superfície usa a largura do próprio desenho, limitada ao espaço que há.
352 if (Svg) return <Svg source={trailSvg(s.phases, paneSvgWidth(width))} alt={trailAlt(s.phases)} />
353 const { nodes, labels } = trailText(s.phases, width)
354
355 return (
356 <Box key="trail" flexDirection="column">
357 <Text wrap="truncate-end">{nodes.map(n => seg(ui, n))}</Text>
358 {labels.map(line => (
359 <Text wrap="truncate-end">{line.map(n => seg(ui, n))}</Text>
360 ))}
361 </Box>
362 )
363}
364
365/** A barra da fase atual, segmentada: feito, em andamento e esperando (este mais claro, para a parte cheia bater com o %). */
366const PhaseBar = (ui: Ui, c: Counts, width: number) => {
367 const { Text, Svg } = ui
368 if (Svg) {
369 const px = Math.max(140, Math.min(480, paneSvgWidth(width) - 90))
370 return <Svg source={segBarSvg(c, px, 10)} alt={`${c.done} de ${c.total} feitos · ${c.doing} em andamento · ${c.blocked} esperando`} />
371 }
372 const cells = splitBar(c, Math.max(10, Math.min(40, width - 12)))
373
374 return (
375 <Text>
376 <Text color="success">{'█'.repeat(cells.done)}</Text>
377 <Text color="claude">{'█'.repeat(cells.doing)}</Text>
378 <Text color="inactive">{'▒'.repeat(cells.blocked)}</Text>
379 <Text dimColor>{'░'.repeat(cells.rest)}</Text>
380 </Text>
381 )
382}
383
384/** Um número com o rótulo embaixo, no cartão da fase do desktop. */
385const Count = (ui: Ui, key: string, n: number, label: string, color?: 'success' | 'claude' | 'inactive') => {
386 const { Box, Text } = ui
387
388 return (
389 <Box key={key} flexDirection="column" minWidth={88}>
390 <Text bold {...(color ? { color } : {})} {...(n === 0 ? { dimColor: true } : {})}>
391 {String(n)}
392 </Text>
393 <Text dimColor>{label}</Text>
394 </Box>
395 )
396}
397
398/** O cartão da fase no desktop: o anel com o %, o nome e o objetivo, as contagens e o gate inteiro. */
399const RichPhaseCard = (ui: Ui & Required<Pick<Ui, 'Svg'>>, v: View) => {
400 const { Box, Text, Svg } = ui
401 const cur = v.s.current!
402 const p = cur.phase
403 const c = phaseCounts(v.r, p.id)
404 const mine = mineCount(v.r)
405
406 return (
407 <Box key="phase" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1} paddingY={1} rowGap={1}>
408 <Box flexDirection="row" columnGap={2} alignItems="center">
409 <Svg source={phaseRingSvg(c, cur.pct, 88)} alt={`${c.done} de ${c.total} feitos · ${c.doing} em andamento · ${c.blocked} esperando`} width={88} height={88} />
410 <Box flexDirection="column" rowGap={1} flexGrow={1}>
411 <Box flexDirection="column">
412 <Text bold>{phaseTitle(p)}</Text>
413 {p.goal ? <Text dimColor>{cut(p.goal, 140)}</Text> : undefined}
414 </Box>
415 <Box flexDirection="row" columnGap={2} rowGap={1} flexWrap="wrap">
416 {Count(ui, 'n-done', c.done, 'feitos', 'success')}
417 {Count(ui, 'n-doing', c.doing, 'em andamento', 'claude')}
418 {Count(ui, 'n-blocked', c.blocked, 'esperando', 'inactive')}
419 {Count(ui, 'n-todo', c.todo, 'a fazer')}
420 {mine > 0 ? Count(ui, 'n-mine', mine, 'com você', 'claude') : undefined}
421 </Box>
422 </Box>
423 </Box>
424 {p.gate ? (
425 <Text>
426 <Text color="warning">⚑ Gate </Text>
427 {cut(p.gate, 140)}
428 {p.gateStatus ? <Text dimColor> · {cut(p.gateStatus, 60)}</Text> : ''}
429 </Text>
430 ) : undefined}
431 </Box>
432 )
433}
434
435/** O cartão da fase atual: título, chip do gate, barra com o % e contagens. */
436const PhaseCard = (ui: Ui, v: View, width: number) => {
437 const { Box, Text, Svg } = ui
438 const cur = v.s.current
439 if (cur && Svg) return RichPhaseCard({ ...ui, Svg }, v)
440 if (!cur) {
441 return (
442 <Box key="phase" borderStyle="round" borderColor="subtle" paddingX={1}>
443 <Text dimColor>Roadmap sem fases</Text>
444 </Box>
445 )
446 }
447 const c = phaseCounts(v.r, cur.phase.id)
448 const [done, doing, blocked, todo] = countsText(c).split(' ')
449 const gate = gateChip(cur.phase, width < 60)
450
451 return (
452 <Box key="phase" flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
453 <Box flexDirection="row" columnGap={2} flexWrap="wrap" justifyContent="space-between">
454 <Text bold>{phaseTitle(cur.phase)}</Text>
455 {gate ? (
456 <Text color="warning" wrap="truncate-end">
457 ⚑ {gate}
458 </Text>
459 ) : undefined}
460 </Box>
461 <Box flexDirection="row" columnGap={1} alignItems="center">
462 {PhaseBar(ui, c, width)}
463 <Text bold color="claude">
464 {`${cur.pct}%`}
465 </Text>
466 </Box>
467 <Text>
468 <Text color="success">{done}</Text>
469 {' '}
470 <Text color="claude">{doing}</Text>
471 {' '}
472 <Text color="inactive">{blocked}</Text>
473 {' '}
474 <Text dimColor>{todo}</Text>
475 </Text>
476 </Box>
477 )
478}
479
480type BoardActs = {
481 select: (id: string) => () => void
482 more: (column: ColumnId) => () => void
483 less: (column: ColumnId) => () => void
484 filter: (to: 'all' | 'mine') => () => void
485 start: (item: Item) => () => void
486 close: () => void
487}
488
489const BORDER: Record<Card['status'], { style: string; color: 'claude' | 'subtle' | 'inactive' | 'success' }> = {
490 doing: { style: 'round', color: 'claude' },
491 todo: { style: 'round', color: 'subtle' },
492 blocked: { style: 'dashed', color: 'inactive' },
493 done: { style: 'round', color: 'success' },
494}
495
496/**
497 * O detalhe de um item: o título inteiro (se o rótulo é outro), estado, dono, espera,
498 * dependências abertas, data e evidência, notas e os botões. Dentro do cartão aberto,
499 * ou numa caixa própria quando o cartão não está à vista.
500 */
501const Detail = (ui: Ui, item: Item, r: Roadmap, act: BoardActs, isBoxed: boolean) => {
502 const { Box, Text, Button } = ui
503 const deps = pendingDeps(item, r)
504 const done = item.status === 'done'
505 const owner = OwnerChip(ui, item, r)
506 const color = STATUS_COLOR[item.status]
507 const mark = `${ITEM_MARK[item.status]} ${ITEM_LABEL[item.status]}`
508 const facts = [item.doneAt && `feito em ${item.doneAt}`, item.evidence && cut(item.evidence, 80)].filter(Boolean).join(' · ')
509 // Dentro do cartão, o rótulo já aparece. Com "short", vai do título só o que ele acrescenta;
510 // sem "short", o cartão aberto já mostra o título inteiro no lugar do rótulo.
511 const short = item.short
512 const more = !short || item.title === short ? '' : item.title.startsWith(short) ? item.title.slice(short.length).replace(/^[\s:;,.–—-]+/, '') : item.title
513
514 const state = (
515 <Text key="detail-state">
516 {color ? <Text color={color}>{mark}</Text> : <Text>{mark}</Text>}
517 {owner ? [' ', owner] : ''}
518 {isBoxed || item.phase !== currentPhase(r) ? <Text dimColor>{` Fase ${item.phase}`}</Text> : ''}
519 {item.gate ? <Text color="warning">{' Gate'}</Text> : ''}
520 </Text>
521 )
522
523 // Avulso, o título vem inteiro em cima; dentro do cartão, o estado primeiro e o título
524 // (só se for diferente do rótulo) e as notas cortados, para o cartão não crescer demais.
525 return (
526 <Box key="detail" flexDirection="column" {...(isBoxed ? { borderStyle: 'round', borderColor: 'claude', paddingX: 1 } : {})}>
527 {isBoxed ? <Text bold>{item.title}</Text> : state}
528 {isBoxed ? state : more ? <Text>{cut(more, 120)}</Text> : undefined}
529 {item.waitingOn && !done ? <Text>espera: {cut(item.waitingOn, 80)}</Text> : undefined}
530 {deps.length > 0 ? <Text>depois de: {deps.map(itemLabel).join(', ')}</Text> : undefined}
531 {done && facts ? <Text dimColor>{facts}</Text> : undefined}
532 {item.notes ? <Text dimColor>{cut(item.notes, isBoxed ? 160 : 100)}</Text> : undefined}
533 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
534 {!done ? <Button key="detail-start" label={isStuck(item, r) ? 'Ver o que falta' : 'Fazer agora'} variant="primary" onPress={act.start(item)} /> : undefined}
535 <Button key="detail-close" label="Fechar" onPress={act.close} />
536 </Box>
537 </Box>
538 )
539}
540
541/**
542 * Um cartão: o rótulo (clicar abre e fecha) e, embaixo, o dono, o gate e a fase; nos que
543 * esperam, o que esperam. Aberto, o cartão mostra o detalhe ali mesmo, com borda grossa.
544 */
545const CardView = (ui: Ui, card: Card, r: Roadmap, inner: number, sel: string | null, act: BoardActs) => {
546 const { Box, Text, Button } = ui
547 const done = card.status === 'done'
548 // Quem espera (bloqueado, ou a fazer à espera de outra tarefa) tem a borda tracejada.
549 const border = card.waitKind ? BORDER.blocked : BORDER[card.status]
550 const item = card.id === sel ? r.items.find(i => i.id === card.id) : undefined
551 const chips = [
552 OwnerChip(ui, card, r),
553 card.gate ? <Text color="warning">Gate</Text> : undefined,
554 card.isOtherPhase ? <Text dimColor>Fase {card.phase}</Text> : undefined,
555 ].filter(c => c !== undefined)
556 const line2 = item ? (
557 Detail(ui, item, r, act, false)
558 ) : card.wait ? (
559 <Text dimColor>{cardLabel(`${card.waitKind === 'dep' ? 'depois de' : '‖'} ${card.wait}`, inner)}</Text>
560 ) : !done && chips.length > 0 ? (
561 <Text wrap="truncate-end">{chips.flatMap((c, n) => (n === 0 ? [c] : [' ', c]))}</Text>
562 ) : undefined
563
564 return (
565 <Box
566 key={`box-${card.id}`}
567 flexDirection="column"
568 borderStyle={item ? 'bold' : border.style}
569 borderColor={item ? 'claude' : border.color}
570 {...(done && !item ? { borderDimColor: true } : {})}
571 paddingX={1}
572 >
573 <Button
574 key={`card-${card.id}`}
575 label={item && !item.short ? cardLabel(cut(item.title, 160), inner, 4) : cardLabel(card.label, inner)}
576 plain
577 {...(done && !item ? { dimColor: true } : {})}
578 onPress={act.select(card.id)}
579 />
580 {line2}
581 </Box>
582 )
583}
584
585const HEADER_COLOR: Partial<Record<ColumnId, 'claude' | 'inactive' | 'success'>> = { doing: 'claude', waiting: 'inactive', done: 'success' }
586
587/** Uma coluna: o nome e o contador, os cartões, o "+8 de N" (abre mais 8) e o "menos". */
588const ColumnView = (ui: Ui, col: KanbanColumn, width: number, r: Roadmap, sel: string | null, act: BoardActs) => {
589 const { Box, Text, Button } = ui
590 const color = HEADER_COLOR[col.id]
591
592 return (
593 <Box key={`col-${col.id}`} flexDirection="column" width={width}>
594 <Text>
595 {ui.Svg ? <Text {...(color ? { color } : { dimColor: true })}>● </Text> : ''}
596 {color ? (
597 <Text bold color={color}>
598 {col.title}
599 </Text>
600 ) : (
601 <Text bold>{col.title}</Text>
602 )}{' '}
603 <Text dimColor>{String(col.total)}</Text>
604 </Text>
605 {col.cards.length === 0 ? <Text dimColor>—</Text> : undefined}
606 {col.cards.map(card => CardView(ui, card, r, Math.max(6, width - 4), sel, act))}
607 {col.more > 0 || col.isExpanded ? (
608 <Box flexDirection="row" columnGap={2}>
609 {col.more > 0 ? (
610 <Button key={`more-${col.id}`} label={col.more > CARD_STEP ? `+${CARD_STEP} de ${col.more}` : `+${col.more}`} plain dimColor onPress={act.more(col.id)} />
611 ) : undefined}
612 {col.isExpanded ? <Button key={`less-${col.id}`} label="menos" plain dimColor onPress={act.less(col.id)} /> : undefined}
613 </Box>
614 ) : undefined}
615 </Box>
616 )
617}
618
619/** O quadro: 4 colunas lado a lado, grade 2x2 ou empilhado (com Feito numa linha só). */
620const BoardView = (ui: Ui, kb: Kanban, r: Roadmap, sel: string | null, act: BoardActs) => {
621 const { Box, Text } = ui
622 const [doing, next, waiting, done] = kb.columns as [KanbanColumn, KanbanColumn, KanbanColumn, KanbanColumn]
623 const col = (c: KanbanColumn) => ColumnView(ui, c, kb.columnWidth, r, sel, act)
624 if (kb.layout === 'row') {
625 return (
626 <Box key="board" flexDirection="row" columnGap={1}>
627 {kb.columns.map(col)}
628 </Box>
629 )
630 }
631 if (kb.layout === 'grid') {
632 return (
633 <Box key="board" flexDirection="column" rowGap={1}>
634 <Box flexDirection="row" columnGap={1}>
635 {col(doing)}
636 {col(next)}
637 </Box>
638 <Box flexDirection="row" columnGap={1}>
639 {col(waiting)}
640 {col(done)}
641 </Box>
642 </Box>
643 )
644 }
645
646 return (
647 <Box key="board" flexDirection="column" rowGap={1}>
648 {col(doing)}
649 {col(next)}
650 {col(waiting)}
651 <Text>
652 <Text bold color="success">
653 ✓ {done.title}
654 </Text>{' '}
655 <Text dimColor>{String(done.total)}</Text>
656 </Text>
657 </Box>
658 )
659}
660
661/** O rodapé: o chip das decisões (abre uma lista curta) e o aviso de desatualizado. */
662const Footer = (ui: Ui, decisions: Decision[], isOpen: boolean, commits: number | undefined, onToggle: () => void) => {
663 const { Box, Text, Button } = ui
664 if (decisions.length === 0 && commits === undefined) return undefined
665
666 return (
667 <Box key="footer" flexDirection="column">
668 <Box flexDirection="row" columnGap={2} flexWrap="wrap" alignItems="center">
669 {decisions.length > 0 ? <Button key="decisions" label={`Decisões ${decisions.length}`} onPress={onToggle} /> : undefined}
670 {commits !== undefined ? <Text color="warning">⚠ {staleChip(commits)}</Text> : undefined}
671 </Box>
672 {isOpen
673 ? decisions.slice(0, DECISIONS_SHOWN).map(d => (
674 <Text dimColor wrap="truncate-end">
675 • {cut(d.title, 70)}
676 </Text>
677 ))
678 : undefined}
679 </Box>
680 )
681}
682
683type PaneState = { kb: Kanban; sel: string | null; isDecisionsOpen: boolean }
684
685/** O cartão do item está à vista no quadro (no empilhado, a coluna Feito é só uma contagem). */
686const isOnBoard = (kb: Kanban, id: string) => kb.columns.some(c => (kb.layout !== 'stack' || c.id !== 'done') && c.cards.some(k => k.id === id))
687
688/**
689 * O painel /roadmap: trilha, fase atual, filtro, quadro e rodapé. Sem listas de texto. O
690 * cartão escolhido abre no lugar; se ele não está à vista, o detalhe vem acima do quadro.
691 */
692const PaneView = (ui: Ui, v: View, width: number, st: PaneState, act: Acts, boardAct: BoardActs, onDecisions: () => void) => {
693 const { Box, Text, Button } = ui
694 const { b, r, s, commits } = v
695 const item = st.sel ? r.items.find(i => i.id === st.sel) : undefined
696 const chip = (key: string, label: string, on: boolean, onPress: () => void) => (
697 <Button key={key} label={label} {...(on ? { variant: 'primary' as const } : { dimColor: true })} onPress={onPress} />
698 )
699
700 return (
701 <Box flexDirection="column" rowGap={1}>
702 {ui.Svg ? (
703 <Box flexDirection="row" columnGap={2} justifyContent="space-between" alignItems="flex-start">
704 <Box flexDirection="column" flexGrow={1}>
705 <Text bold>{r.project ?? b.project}</Text>
706 {r.goal ? <Text dimColor>{cut(r.goal, 160)}</Text> : undefined}
707 </Box>
708 <Button key="roadmap-reload" label="↻ Atualizar" onPress={act.reload} />
709 </Box>
710 ) : (
711 <Box flexDirection="row" columnGap={2} justifyContent="space-between">
712 <Text bold>{r.project ?? b.project}</Text>
713 <Button key="roadmap-reload" label="↻" plain onPress={act.reload} />
714 </Box>
715 )}
716 {Trail(ui, s, width)}
717 {PhaseCard(ui, v, width)}
718 <Box key="filters" flexDirection="row" columnGap={1}>
719 {chip('filter-all', 'Tudo', st.kb.filter === 'all', boardAct.filter('all'))}
720 {chip('filter-mine', `Comigo ${st.kb.mine}`, st.kb.filter === 'mine', boardAct.filter('mine'))}
721 </Box>
722 {item && !isOnBoard(st.kb, item.id) ? Detail(ui, item, r, boardAct, true) : undefined}
723 {BoardView(ui, st.kb, r, item ? item.id : null, boardAct)}
724 {Footer(ui, s.decisions, st.isDecisionsOpen, commits, onDecisions)}
725 </Box>
726 )
727}
728
729/** O painel quando não há roadmap, ou quando o arquivo tem erro: um cartão curto. */
730const EmptyPane = (ui: Ui, b: Board | null, act: Acts) => {
731 const { Box, Text, Button } = ui
732 const rel = b?.rel ?? where.rel
733
734 if (b?.exists) {
735 return (
736 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
737 <Box flexDirection="row" columnGap={2} justifyContent="space-between">
738 <Text bold color="error">
739 ⚠ Roadmap com erro
740 </Text>
741 <Button key="roadmap-reload" label="↻" plain onPress={act.reload} />
742 </Box>
743 <Text>{b.error}</Text>
744 <Text dimColor>{rel}</Text>
745 </Box>
746 )
747 }
748
749 return (
750 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} rowGap={1}>
751 <Text>
752 <Text bold>Sem roadmap</Text>
753 <Text dimColor> · {rel}</Text>
754 </Text>
755 <Box>
756 <Button key="roadmap-create" label="Pedir ao Claude para criar" variant="primary" onPress={act.create} />
757 </Box>
758 </Box>
759 )
760}
761
762const reload = async ($: EngineInterface) => {
763 await load($, true)
764 await refreshStale($)
765}
766
767/** Põe o pedido no prompt, sem enviar; sem caixa de prompt (um diálogo aberto), não faz nada. */
768const fill = async ($: EngineInterface, text: string) => {
769 try {
770 await $.prompt.fill({ text })
771 } catch {
772 // Nada a fazer: a pessoa pode clicar de novo.
773 }
774}
775
776const CREATE =
777 'no formato versão 1 do mod roadmap (a ferramenta roadmap_status mostra o modelo e o que cada campo quer dizer). ' +
778 'Monte as fases e os itens a partir do que já existe no projeto (README, docs, tarefas), com um "short" de até 40 caracteres ' +
779 'em cada item de título longo (é o rótulo do cartão no quadro) e de até 18 em cada fase (o nome na trilha), e me mostre antes de gravar.'
780
781/** O que os botões fazem; `withTool` diz se o pedido cita a ferramenta roadmap_update. */
782const acts = ($: EngineInterface, b: Board | null, withTool: boolean): Acts => ({
783 start: item => () => {
784 if (b?.roadmap) void fill($, startPrompt(item, b.roadmap, withTool))
785 },
786 view: () => void $.ui.open({ id: PANE, title: 'Roadmap' }).catch(() => undefined),
787 hide: () => void update($, hidden, () => b?.sig ?? null).catch(() => undefined),
788 reload: () => void reload($).catch(() => undefined),
789 create: () => void fill($, `Crie o roadmap deste projeto em ${where.rel}, ${CREATE}`),
790})
791
792/** O roadmap com o resumo e o aviso, ou o quadro sem roadmap válido (ou null antes da primeira leitura). */
793const view = async ($: EngineInterface, staleAfter: number): Promise<View | Board | null> => {
794 const b = await read($, board)
795 if (!b?.exists || !b.roadmap) return b
796
797 return { b, r: b.roadmap, s: summarize(b.roadmap), commits: staleCount(await read($, stale), staleAfter) }
798}
799
800/** A tabela de elementos; o Svg só fora do terminal (a tabela do terminal também o traz, mas ele não desenha nada). */
801const uiOf = (table: BasicUi & { Svg?: unknown }, isTerminal: boolean): Ui =>
802 !isTerminal && 'Svg' in table && table.Svg ? (table as Ui) : { Box: table.Box, Text: table.Text, Button: table.Button }
803
804export const register: Register = (on, options) => {
805 const file = String(options.file ?? '').trim() || 'docs/roadmap.json'
806 const showBand = options.showBand !== false
807 const staleAfter = Number(options.staleAfter ?? 10)
808 const claudeCanEdit = options.claudeCanEdit !== false
809
810 on('session.start', async ($, e, next) => {
811 const started = await next(e)
812 let root = started.cwd
813 let hasGit = false
814 try {
815 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: started.cwd, timeoutMs: 5000 })
816 if (r.exitCode === 0 && r.stdout.trim()) {
817 root = r.stdout.trim()
818 hasGit = true
819 }
820 } catch {
821 // Sem git: a pasta da sessão é a raiz, e não há aviso de desatualizado.
822 }
823 where.root = root
824 // Com o arquivo padrão, um ROADMAP.md do projeto também vale; com outro, só ele.
825 where.candidates = file === 'docs/roadmap.json' ? [file, ...MARKDOWN_NAMES] : [file]
826 await discover($)
827 where.project = root.split(/[\\/]/).filter(Boolean).pop() ?? root
828 where.hasGit = hasGit
829 await load($, true)
830 // A conta de commits roda logo depois, para não atrasar o primeiro prompt.
831 $.clock.after(0, () => void refreshStale($).catch(() => undefined))
832
833 try {
834 await $.command.register({ name: 'roadmap', description: 'Mostra o roadmap do projeto num quadro: fases, o que está andando, as próximas e o que espera' })
835 } catch {
836 // Outro plugin já tem esse comando: a faixa e as ferramentas continuam.
837 }
838 await $.tool.register({
839 name: 'roadmap_status',
840 description:
841 'Mostra o roadmap do projeto: a fase atual e o %, as 5 próximas tarefas (com os ids), o que está com o usuário ' +
842 'e o que está esperando na fase atual (as outras fases só como contagem), e se o roadmap está desatualizado. ' +
843 'Para achar o id de um item, passe query com palavras do título (até 10 itens, com id, estado, dono, fase, ' +
844 'rótulo curto e notas); phase lista uma fase inteira; all: true mostra tudo sem cortes. Sem roadmap no projeto, ' +
845 'diz onde criar e mostra o formato, com cada campo.',
846 inputSchema: {
847 type: 'object',
848 properties: {
849 query: { type: 'string', description: 'Palavras do título (ou o id) do item que você procura' },
850 phase: { type: 'string', description: 'Id de uma fase: lista os itens dela que não estão feitos' },
851 all: { type: 'boolean', description: 'true: sem cortes; com phase, lista também os feitos' },
852 },
853 },
854 })
855 if (claudeCanEdit) {
856 await $.tool.register({
857 name: 'roadmap_update',
858 description:
859 'Muda o estado de um item do roadmap pelo id (o roadmap_status com query acha o id). Use ao começar um item (doing), ' +
860 'ao terminar (done, com a evidência: commit, arquivo, teste ou resultado), quando ele travar (blocked, com ' +
861 'waitingOn) ou para reabrir (todo). "done" sem doneAt usa a data de hoje. "short" troca o rótulo curto do cartão ' +
862 'no quadro (até 40 caracteres; "" tira). Com o id de uma fase, muda o estado da fase (done, doing ou todo), o ' +
863 '"short" dela, e "evidence" vira o resultado do gate. Use esta ferramenta em vez de editar o arquivo à mão (vale para o JSON e para o ROADMAP.md).',
864 inputSchema: {
865 type: 'object',
866 properties: {
867 id: { type: 'string', description: 'Id do item (ou da fase)' },
868 status: { type: 'string', enum: ['done', 'doing', 'todo', 'blocked'], description: 'Sem ele, o estado fica como está' },
869 evidence: { type: 'string', description: 'O que prova que está feito: commit, arquivo, teste, print' },
870 doneAt: { type: 'string', description: 'Data AAAA-MM-DD; sem ela, "done" usa a de hoje' },
871 note: { type: 'string', description: 'Observação que entra nas notas do item' },
872 waitingOn: { type: 'string', description: 'Com "blocked": o que o item espera' },
873 short: { type: 'string', description: 'Rótulo curto do cartão (até 40 caracteres; máximo 60); "" tira' },
874 },
875 required: ['id'],
876 },
877 })
878 await $.tool.register({
879 name: 'roadmap_add',
880 description:
881 'Cria um item no roadmap, numa fase que já existe; o id sai do título. Use quando surgir trabalho novo que faz ' +
882 'parte do plano (não para o que vai fazer agora mesmo). "after" põe o item logo depois de outro, no arquivo e ' +
883 'no caminho; sem ele, o item vai para o fim da fase. Com título longo, passe um "short" (o rótulo do cartão).',
884 inputSchema: {
885 type: 'object',
886 properties: {
887 phase: { type: 'string', description: 'Id da fase' },
888 title: { type: 'string', description: 'Acionável' },
889 short: { type: 'string', description: 'Rótulo curto do cartão no quadro (até 40 caracteres; máximo 60)' },
890 owner: { type: 'string', description: 'Quem faz: "Claude", o nome do usuário (o owner do roadmap) ou "both"' },
891 after: { type: 'string', description: 'Id do item que vem antes deste' },
892 status: { type: 'string', enum: ['todo', 'doing', 'blocked', 'done'] },
893 dependsOn: { type: 'array', items: { type: 'string' }, description: 'Ids dos itens que precisam estar feitos antes' },
894 waitingOn: { type: 'string' },
895 area: { type: 'string' },
896 gate: { type: 'boolean', description: 'true se o item faz parte do gate da fase' },
897 notes: { type: 'string' },
898 },
899 required: ['phase', 'title'],
900 },
901 })
902 }
903 // Mudanças feitas fora do Claude: confere a data do arquivo a cada 15 s e o git a cada 5 min.
904 $.clock.every(15_000, () => {
905 void load($)
906 .then(changed => (changed ? refreshStale($) : undefined))
907 .catch(() => undefined)
908 })
909 $.clock.every(300_000, () => void refreshStale($).catch(() => undefined))
910
911 return started
912 })
913
914 // Edições no roadmap feitas pelo Claude com Edit/Write: relê na hora. Depois de um
915 // git commit, lembra o Claude de marcar no roadmap o que o commit terminou.
916 on('tool.call', async ($, e, next) => {
917 const ran = await next(e)
918 if ((e.tool === 'Edit' || e.tool === 'Write') && where.file && norm(e.file_path) === norm(where.file)) {
919 await load($, true)
920 await refreshStale($)
921 }
922 if (e.tool === 'Bash' && claudeCanEdit && where.file && ran.deny === undefined && !ran.isError && GIT_COMMIT.test(e.command)) {
923 const note = await commitNote($, staleAfter)
924 if (note) return { ...ran, context: [...(ran.context ?? []), note] }
925 }
926 return ran
927 }).catch(($, e, next) => next(e))
928
929 // Commits feitos durante o turno: confere o aviso de desatualizado no fim (no máximo uma vez por minuto).
930 on('turn.complete', async ($, e, next) => {
931 const done = await next(e)
932 if (e.agentId || !where.file) return done
933 await load($)
934 if ((await $.clock.now()) - where.staleAt > 60_000) await refreshStale($)
935
936 return done
937 }).catch(($, e, next) => next(e))
938
939 on('tool.call', { tool: 'mcp__roadmap__roadmap_status' }, async ($, e) => {
940 await load($)
941 return { result: await statusText($, staleAfter, e as unknown as StatusInput) }
942 }).catch(() => ({ result: 'Não consegui ler o roadmap.' }))
943
944 on('tool.call', { tool: 'mcp__roadmap__roadmap_update' }, async ($, e) => {
945 const input = e as unknown as UpdateInput
946 return change($, (text, day) => (isMarkdown(where.file) ? applyUpdateMarkdown : applyUpdate)(text, input, day), 'Claude atualizou o roadmap')
947 }).catch(() => ({ deny: 'Não consegui gravar o roadmap.' }))
948
949 on('tool.call', { tool: 'mcp__roadmap__roadmap_add' }, async ($, e) => {
950 const input = e as unknown as AddInput
951 return change($, (text, day) => (isMarkdown(where.file) ? applyAddMarkdown : applyAdd)(text, input, day), 'Claude criou no roadmap')
952 }).catch(() => ({ deny: 'Não consegui gravar o roadmap.' }))
953
954 on('command.run', { command: 'roadmap' }, async $ => {
955 await reload($)
956 await $.ui.open({ id: PANE, title: 'Roadmap' })
957 const v = await view($, staleAfter)
958 if (v === null || !('s' in v)) return { text: v?.exists ? `Roadmap com erro: ${v.error}` : `Sem roadmap em ${where.rel}` }
959 const first = v.s.next[0]
960
961 return {
962 text: [
963 v.s.current ? `Fase ${v.s.current.phase.id} · ${v.s.current.pct}%` : 'Roadmap sem fases',
964 first ? `próxima: ${itemLabel(first)}` : 'nada livre agora',
965 v.commits !== undefined ? `⚠ ${staleChip(v.commits)}` : undefined,
966 ]
967 .filter(Boolean)
968 .join(' · '),
969 }
970 })
971
972 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
973 const v = await view($, staleAfter)
974 const b = v !== null && 's' in v ? v.b : v
975 const act = acts($, b, claudeCanEdit)
976 const ui = uiOf($.ui.resolve(e), e.surface === 'terminal')
977 if (v === null || !('s' in v)) return EmptyPane(ui, b, act)
978 const width = e.props.bodyColumns
979 const opened = await read($, expanded)
980 const st: PaneState = {
981 kb: kanban(v.r, {
982 filter: await read($, filtering),
983 // Um estado antigo (lista de colunas) vale como nada aberto.
984 expanded: opened && !Array.isArray(opened) ? opened : {},
985 width,
986 seen: await read($, doneSeen),
987 }),
988 sel: await read($, selected),
989 isDecisionsOpen: await read($, decisionsOpen),
990 }
991 const steps = (column: ColumnId, by: number | null) => () =>
992 void update($, expanded, cur => {
993 const map = cur && !Array.isArray(cur) ? cur : {}
994 return { ...map, [column]: by === null ? 0 : (map[column] ?? 0) + by }
995 }).catch(() => undefined)
996 const boardAct: BoardActs = {
997 select: id => () => void update($, selected, cur => (cur === id ? null : id)).catch(() => undefined),
998 more: column => steps(column, 1),
999 less: column => steps(column, null),
1000 filter: to => () => void update($, filtering, () => to).catch(() => undefined),
1001 start: act.start,
1002 close: () => void update($, selected, () => null).catch(() => undefined),
1003 }
1004 const toggleDecisions = () => void update($, decisionsOpen, open => !open).catch(() => undefined)
1005
1006 return PaneView(ui, v, width, st, act, boardAct, toggleDecisions)
1007 })
1008
1009 // Acima do prompt: uma linha com a fase, o %, a próxima e os botões, empilhada com o que outros plugins desenham.
1010 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1011 const below = await next(e)
1012 if (!showBand || e.props.hasSurvey) return below
1013 const v = await view($, staleAfter)
1014 if (v === null || (!('s' in v) && !v.exists)) return below
1015 const b = 's' in v ? v.b : v
1016 if ((await read($, hidden)) === b.sig) return below
1017 const act = acts($, b, claudeCanEdit)
1018 const ui = uiOf($.ui.resolve(e), e.surface === 'terminal')
1019 const isTerminal = e.surface === 'terminal'
1020 const width = e.props.bodyColumns
1021
1022 return (
1023 <ui.Box flexDirection="column">
1024 {'s' in v ? Band(ui, v, width, act, isTerminal) : ErrorBand(ui, b, width, act, isTerminal)}
1025 {below}
1026 </ui.Box>
1027 )
1028 })
1029}
1030hooks/roadmap.ts 2026 lines1import type {
2 Card,
3 ColumnId,
4 Counts,
5 Decision,
6 Filter,
7 Item,
8 ItemStatus,
9 Kanban,
10 KanbanColumn,
11 Layout,
12 OwnerKind,
13 Phase,
14 PhaseStatus,
15 PhaseView,
16 Roadmap,
17 Summary,
18} from '../types'
19
20/**
21 * O roadmap do projeto num JSON (formato versão 1, descrito no README do mod):
22 * { "version": 1, "project", "goal", "updated", "owner",
23 * "phases": [{ "id", "name", "short", "status", ... }],
24 * "items": [{ "id", "phase", "title", "short", "status", "owner", "dependsOn", ... }],
25 * "path": ["id", ...], "openDecisions": [{ "title", ... }] }
26 * Aqui fica só lógica pura: ler e conferir, montar o quadro (colunas, cartões,
27 * rótulos, layout e os desenhos) e gravar mudanças.
28 */
29
30export const ITEM_STATUSES: readonly ItemStatus[] = ['done', 'doing', 'todo', 'blocked']
31export const PHASE_STATUSES: readonly PhaseStatus[] = ['done', 'doing', 'todo']
32export const NEXT_LIMIT = 5
33/** Itens por lista no texto do roadmap_status, sem "all". */
34export const TEXT_LIMIT = 8
35/** Resultados de uma busca do roadmap_status. */
36export const QUERY_LIMIT = 10
37/** As notas do item no pedido de "Fazer a próxima". */
38const NOTES_MAX = 200
39/** O "short" mais longo que o arquivo aceita. */
40export const SHORT_MAX = 60
41/** Sem "short", o rótulo do item é o título cortado aqui. */
42export const LABEL_MAX = 40
43/** Sem "short", o rótulo da fase é o nome cortado aqui. */
44export const PHASE_LABEL_MAX = 18
45/** Cartões por coluna do quadro, antes do "+N". */
46export const CARD_LIMIT = 4
47/** Cartões da coluna Feito, antes do "+N". */
48export const DONE_LIMIT = 3
49/** Quantos cartões cada clique no "+N" abre a mais. */
50export const CARD_STEP = 8
51/** Decisões que o rodapé do painel lista. */
52export const DECISIONS_SHOWN = 5
53
54export const ITEM_LABEL: Record<ItemStatus, string> = {
55 done: 'feito',
56 doing: 'em andamento',
57 todo: 'a fazer',
58 blocked: 'bloqueado',
59}
60export const PHASE_LABEL: Record<PhaseStatus, string> = { done: 'feita', doing: 'em andamento', todo: 'a fazer' }
61export const ITEM_MARK: Record<ItemStatus, string> = { done: '✓', doing: '▶', todo: '○', blocked: '‖' }
62
63/** A ordem em que o mod escreve as chaves que ele mesmo cria; as que já existem ficam onde estão. */
64const ROOT_KEYS = ['version', 'project', 'goal', 'updated', 'owner', 'phases', 'items', 'path', 'openDecisions']
65const PHASE_KEYS = ['id', 'name', 'short', 'goal', 'status', 'gate', 'gateStatus', 'notes']
66const ITEM_KEYS = ['id', 'phase', 'title', 'short', 'status', 'owner', 'waitingOn', 'dependsOn', 'doneAt', 'evidence', 'area', 'gate', 'notes']
67
68// ---------- JSON: onde está cada valor e onde está o erro ----------
69
70class ScanError {
71 constructor(
72 readonly offset: number,
73 readonly message: string,
74 ) {}
75}
76
77const ESCAPES: Record<string, string> = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }
78const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y
79
80export type Scan = {
81 /** Onde começa cada valor, pelo caminho: "", "/items", "/items/3", "/items/3/status". */
82 at: Map<string, number>
83 /** Onde cada valor acaba (o primeiro caractere depois dele). */
84 end: Map<string, number>
85 /** Onde começa o nome de cada campo de objeto (as aspas), pelo caminho do valor. */
86 keyAt: Map<string, number>
87 /** Os nomes dos campos de cada objeto, na ordem do texto. */
88 keys: Map<string, string[]>
89 error?: { offset: number; message: string }
90}
91
92/**
93 * Percorre o JSON como o JSON.parse, guardando onde está cada valor e, num arquivo
94 * inválido, onde está o primeiro erro e por quê (a mensagem do JSON.parse muda de
95 * motor para motor e nem sempre diz a linha).
96 */
97export const scanJson = (text: string): Scan => {
98 const at = new Map<string, number>()
99 const end = new Map<string, number>()
100 const keyAt = new Map<string, number>()
101 const keys = new Map<string, string[]>()
102 let i = 0
103 let lastEnd = 0
104 const err = (message: string, offset = i) => new ScanError(offset, message)
105
106 const space = () => {
107 while (i < text.length) {
108 const c = text[i]
109 if (c === ' ' || c === '\t' || c === '\n' || c === '\r') i++
110 else if (c === '/' && (text[i + 1] === '/' || text[i + 1] === '*')) throw err('o JSON não aceita comentários')
111 else break
112 }
113 }
114
115 const string = (): string => {
116 const start = i
117 i++
118 let out = ''
119 for (;;) {
120 const c = text[i]
121 if (c === undefined || c === '\n' || c === '\r') throw err('aspas sem fechar', start)
122 if (c === '"') {
123 i++
124 lastEnd = i
125 return out
126 }
127 if (c < ' ') throw err('caractere de controle dentro de um texto')
128 if (c === '\\') {
129 const n = text[i + 1] ?? ''
130 if (n === 'u') {
131 const hex = text.slice(i + 2, i + 6)
132 if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw err('escape \\u inválido')
133 out += String.fromCharCode(Number.parseInt(hex, 16))
134 i += 6
135 continue
136 }
137 const esc = ESCAPES[n]
138 if (esc === undefined) throw err(`escape inválido: \\${n}`)
139 out += esc
140 i += 2
141 continue
142 }
143 out += c
144 i++
145 }
146 }
147
148 const value = (path: string): void => {
149 space()
150 at.set(path, i)
151 one(path)
152 end.set(path, lastEnd)
153 }
154
155 const one = (path: string): void => {
156 const c = text[i]
157 if (c === undefined) throw err('o arquivo acaba antes do fim do JSON')
158 if (c === '{') return object(path)
159 if (c === '[') return array(path)
160 if (c === '"') {
161 string()
162 return
163 }
164 if (c === '-' || (c >= '0' && c <= '9')) {
165 NUMBER.lastIndex = i
166 const m = NUMBER.exec(text)
167 if (!m) throw err('número inválido')
168 i += m[0].length
169 lastEnd = i
170 return
171 }
172 for (const word of ['true', 'false', 'null']) {
173 if (text.startsWith(word, i)) {
174 i += word.length
175 lastEnd = i
176 return
177 }
178 }
179 if (c === "'") throw err('use aspas duplas (") em vez de simples')
180 if (c === '}' || c === ']' || c === ',') throw err(`falta um valor antes de "${c}"`)
181 throw err(/[A-Za-z_]/.test(c) ? 'texto sem aspas' : `caractere inesperado: ${c}`)
182 }
183
184 const object = (path: string) => {
185 const open = i
186 const names: string[] = []
187 keys.set(path, names)
188 i++
189 space()
190 if (text[i] === '}') {
191 i++
192 lastEnd = i
193 return
194 }
195 let comma = -1
196 for (;;) {
197 space()
198 const c = text[i]
199 if (c === '}' && comma >= 0) throw err('vírgula sobrando antes de "}"', comma)
200 if (c === undefined) throw err('falta fechar a chave "{" aberta aqui', open)
201 if (c === "'") throw err('use aspas duplas (") em vez de simples')
202 if (c !== '"') throw err('nome de campo sem aspas')
203 const keyStart = i
204 const key = string()
205 names.push(key)
206 keyAt.set(`${path}/${key}`, keyStart)
207 space()
208 if (text[i] !== ':') throw err(`falta ":" depois de "${key}"`)
209 i++
210 value(`${path}/${key}`)
211 space()
212 const d = text[i]
213 if (d === ',') {
214 comma = i
215 i++
216 continue
217 }
218 if (d === '}') {
219 i++
220 lastEnd = i
221 return
222 }
223 if (d === undefined) throw err('falta fechar a chave "{" aberta aqui', open)
224 throw err('falta uma vírgula depois deste valor', lastEnd)
225 }
226 }
227
228 const array = (path: string) => {
229 const open = i
230 i++
231 space()
232 if (text[i] === ']') {
233 i++
234 lastEnd = i
235 return
236 }
237 let comma = -1
238 for (let n = 0; ; n++) {
239 space()
240 if (text[i] === ']' && comma >= 0) throw err('vírgula sobrando antes de "]"', comma)
241 if (text[i] === undefined) throw err('falta fechar o colchete "[" aberto aqui', open)
242 value(`${path}/${n}`)
243 space()
244 const d = text[i]
245 if (d === ',') {
246 comma = i
247 i++
248 continue
249 }
250 if (d === ']') {
251 i++
252 lastEnd = i
253 return
254 }
255 if (d === undefined) throw err('falta fechar o colchete "[" aberto aqui', open)
256 throw err('falta uma vírgula depois deste valor', lastEnd)
257 }
258 }
259
260 try {
261 value('')
262 space()
263 if (i < text.length) throw err('texto sobrando depois do fim do JSON')
264 } catch (e) {
265 if (e instanceof ScanError) return { at, end, keyAt, keys, error: { offset: e.offset, message: e.message } }
266 throw e
267 }
268
269 return { at, end, keyAt, keys }
270}
271
272export const lineCol = (text: string, offset: number) => {
273 const before = text.slice(0, offset)
274 const line = before.split('\n').length
275
276 return { line, column: offset - before.lastIndexOf('\n') }
277}
278
279// ---------- ler e conferir ----------
280
281export const stripBom = (text: string) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text)
282
283export type Parsed = { ok: true; roadmap: Roadmap } | { ok: false; error: string }
284
285type Raw = Record<string, unknown>
286
287const isObject = (v: unknown): v is Raw => typeof v === 'object' && v !== null && !Array.isArray(v)
288
289/** Ids de fase e de item: texto, ou número escrito sem aspas. */
290const idOf = (v: unknown): string | undefined =>
291 typeof v === 'string' ? v.trim() || undefined : typeof v === 'number' && Number.isFinite(v) ? String(v) : undefined
292
293const quote = (v: unknown) => JSON.stringify(v) ?? String(v)
294
295/**
296 * O que há de errado com um "short" (undefined: está bom ou não existe). `ideal` é o
297 * tamanho que cabe sem corte: 40 no cartão de um item, 18 na trilha das fases.
298 */
299export const shortProblem = (v: unknown, ideal = LABEL_MAX): string | undefined => {
300 if (v === undefined) return undefined
301 if (typeof v !== 'string') return '"short" precisa ser um texto'
302 if (!v.trim()) return '"short" está vazio (tire o campo ou escreva um rótulo curto)'
303 const n = Array.from(v.trim()).length
304 return n > SHORT_MAX ? `"short" tem ${n} caracteres; o máximo é ${SHORT_MAX} (o ideal é até ${ideal})` : undefined
305}
306
307/** Lê o texto do arquivo e confere o formato; o erro vem com a linha e o motivo. */
308export const parseRoadmap = (raw: string): Parsed => {
309 const text = stripBom(raw)
310 if (!text.trim()) return { ok: false, error: 'o arquivo está vazio' }
311 let doc: unknown
312 try {
313 doc = JSON.parse(text)
314 } catch (e) {
315 const scan = scanJson(text)
316 if (scan.error) {
317 const { line, column } = lineCol(text, scan.error.offset)
318 return { ok: false, error: `linha ${line}, coluna ${column}: ${scan.error.message}` }
319 }
320 return { ok: false, error: `JSON inválido: ${e instanceof Error ? e.message : String(e)}` }
321 }
322 const scan = scanJson(text)
323 const lineAt = (path: string): number => {
324 for (let p = path; ; p = p.slice(0, p.lastIndexOf('/'))) {
325 const offset = scan.at.get(p)
326 if (offset !== undefined) return lineCol(text, offset).line
327 if (!p) return 1
328 }
329 }
330
331 return check(doc, lineAt)
332}
333
334const check = (doc: unknown, lineAt: (path: string) => number): Parsed => {
335 const bad = (path: string, message: string): Parsed => ({ ok: false, error: `linha ${lineAt(path)}: ${message}` })
336 if (!isObject(doc)) return bad('', 'o roadmap precisa ser um objeto JSON: { "version": 1, ... }')
337 if (doc.version !== undefined && doc.version !== 1) {
338 return bad('/version', `version ${quote(doc.version)} não suportada: este mod lê a versão 1`)
339 }
340 for (const key of ['project', 'goal', 'updated', 'owner']) {
341 if (doc[key] !== undefined && typeof doc[key] !== 'string') return bad(`/${key}`, `"${key}" precisa ser um texto`)
342 }
343 for (const key of ['phases', 'items', 'path', 'openDecisions']) {
344 if (doc[key] !== undefined && !Array.isArray(doc[key])) return bad(`/${key}`, `"${key}" precisa ser uma lista: [ ... ]`)
345 }
346 const list = (key: string) => (Array.isArray(doc[key]) ? (doc[key] as unknown[]) : [])
347 const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v : undefined)
348
349 const phases: Phase[] = []
350 for (const [n, p] of list('phases').entries()) {
351 const at = `/phases/${n}`
352 if (!isObject(p)) return bad(at, `phases[${n}] precisa ser um objeto: { "id": ..., "name": ... }`)
353 const id = idOf(p.id)
354 if (!id) return bad(at, `phases[${n}]: falta o "id" da fase`)
355 if (phases.some(q => q.id === id)) return bad(`${at}/id`, `fase "${id}" repetida`)
356 const status = p.status ?? 'todo'
357 if (!PHASE_STATUSES.includes(status as PhaseStatus)) {
358 return bad(`${at}/status`, `fase "${id}": status ${quote(p.status)} inválido (use done, doing ou todo)`)
359 }
360 for (const key of ['name', 'goal', 'gate', 'gateStatus', 'notes']) {
361 if (p[key] !== undefined && typeof p[key] !== 'string') return bad(`${at}/${key}`, `fase "${id}": "${key}" precisa ser um texto`)
362 }
363 const badShort = shortProblem(p.short, PHASE_LABEL_MAX)
364 if (badShort) return bad(`${at}/short`, `fase "${id}": ${badShort}`)
365 phases.push({
366 id,
367 name: typeof p.name === 'string' ? p.name.trim() : '',
368 short: typeof p.short === 'string' ? p.short.trim() : undefined,
369 goal: text(p.goal),
370 status: status as PhaseStatus,
371 gate: text(p.gate),
372 gateStatus: text(p.gateStatus),
373 notes: text(p.notes),
374 })
375 }
376
377 const items: Item[] = []
378 for (const [n, it] of list('items').entries()) {
379 const at = `/items/${n}`
380 if (!isObject(it)) return bad(at, `items[${n}] precisa ser um objeto: { "id": ..., "title": ... }`)
381 const id = idOf(it.id)
382 if (!id) return bad(at, `items[${n}]: falta o "id" do item`)
383 if (items.some(q => q.id === id)) return bad(`${at}/id`, `item "${id}" repetido`)
384 const phase = idOf(it.phase)
385 if (!phase) return bad(at, `item "${id}": falta a "phase"`)
386 if (!phases.some(p => p.id === phase)) return bad(`${at}/phase`, `item "${id}": a fase "${phase}" não existe em "phases"`)
387 if (typeof it.title !== 'string' || !it.title.trim()) return bad(at, `item "${id}": falta o "title"`)
388 const status = it.status ?? 'todo'
389 if (!ITEM_STATUSES.includes(status as ItemStatus)) {
390 return bad(`${at}/status`, `item "${id}": status ${quote(it.status)} inválido (use done, doing, todo ou blocked)`)
391 }
392 for (const key of ['owner', 'waitingOn', 'doneAt', 'evidence', 'area', 'notes']) {
393 if (it[key] !== undefined && typeof it[key] !== 'string') return bad(`${at}/${key}`, `item "${id}": "${key}" precisa ser um texto`)
394 }
395 const itemShort = shortProblem(it.short)
396 if (itemShort) return bad(`${at}/short`, `item "${id}": ${itemShort}`)
397 if (it.gate !== undefined && typeof it.gate !== 'boolean') return bad(`${at}/gate`, `item "${id}": "gate" precisa ser true ou false`)
398 if (it.dependsOn !== undefined && !Array.isArray(it.dependsOn)) {
399 return bad(`${at}/dependsOn`, `item "${id}": "dependsOn" precisa ser uma lista de ids`)
400 }
401 const dependsOn: string[] = []
402 for (const [d, dep] of ((it.dependsOn as unknown[] | undefined) ?? []).entries()) {
403 const depId = idOf(dep)
404 if (!depId) return bad(`${at}/dependsOn/${d}`, `item "${id}": "dependsOn" precisa ser uma lista de ids`)
405 dependsOn.push(depId)
406 }
407 items.push({
408 id,
409 phase,
410 title: it.title.trim(),
411 short: typeof it.short === 'string' ? it.short.trim() : undefined,
412 status: status as ItemStatus,
413 owner: text(it.owner)?.trim(),
414 waitingOn: text(it.waitingOn),
415 dependsOn,
416 doneAt: text(it.doneAt),
417 evidence: text(it.evidence),
418 area: text(it.area),
419 gate: it.gate === true,
420 notes: text(it.notes),
421 })
422 }
423 const ids = new Set(items.map(i => i.id))
424 for (const [n, it] of items.entries()) {
425 for (const [d, dep] of it.dependsOn.entries()) {
426 if (dep === it.id) return bad(`/items/${n}/dependsOn/${d}`, `item "${it.id}" depende de si mesmo`)
427 if (!ids.has(dep)) return bad(`/items/${n}/dependsOn/${d}`, `item "${it.id}" depende de "${dep}", que não existe`)
428 }
429 }
430
431 const path: string[] = []
432 for (const [n, step] of list('path').entries()) {
433 const id = idOf(step)
434 if (!id || !ids.has(id)) return bad(`/path/${n}`, `"path" cita ${quote(step)}, que não é id de nenhum item`)
435 if (!path.includes(id)) path.push(id)
436 }
437
438 const openDecisions: Decision[] = []
439 for (const [n, d] of list('openDecisions').entries()) {
440 if (typeof d === 'string' && d.trim()) {
441 openDecisions.push({ title: d.trim() })
442 continue
443 }
444 if (!isObject(d) || typeof d.title !== 'string' || !d.title.trim()) {
445 return bad(`/openDecisions/${n}`, `openDecisions[${n}] precisa ter um "title"`)
446 }
447 openDecisions.push({ title: d.title.trim(), phase: idOf(d.phase), ref: text(d.ref) })
448 }
449
450 return {
451 ok: true,
452 roadmap: {
453 version: 1,
454 project: text(doc.project),
455 goal: text(doc.goal),
456 updated: text(doc.updated),
457 owner: text(doc.owner)?.trim(),
458 phases,
459 items,
460 path,
461 openDecisions,
462 },
463 }
464}
465
466// ---------- calcular ----------
467
468export const phaseLabel = (p: Phase) => `Fase ${p.id}${p.name ? ` ${p.name}` : ''}`
469
470const lower = (s: string | undefined) => (s ?? '').trim().toLowerCase()
471
472/** Corta o texto em `max` caracteres, com "…" no lugar do resto. */
473export const cut = (text: string, max: number) => {
474 const chars = Array.from(text)
475
476 return chars.length <= max ? text : `${chars.slice(0, max - 1).join('').trimEnd()}…`
477}
478
479/**
480 * Como o cut, mas, quando o corte cai no meio de uma palavra, volta ao espaço anterior,
481 * se isso não perde mais da metade: "desenvolvedor na…" em vez de "desenvolvedor na lo…".
482 */
483const cutAtWord = (text: string, max: number) => {
484 const chars = Array.from(text)
485 if (chars.length <= max) return text
486 const head = chars.slice(0, max - 1)
487 const space = head.lastIndexOf(' ')
488 const isMidWord = chars[max - 1] !== ' ' && head.at(-1) !== ' '
489
490 return `${(isMidWord && space >= Math.ceil(max / 2) ? head.slice(0, space) : head).join('').trimEnd()}…`
491}
492
493/** O item é do usuário: o "owner" do item é o "owner" do arquivo, ou "both". */
494export const isMine = (item: Item, r: Roadmap) => {
495 const who = lower(item.owner)
496 return who === 'both' || (who !== '' && who === lower(r.owner))
497}
498
499/** Quem faz, para mostrar: "você", "Claude", "você e Claude" ou o nome. */
500export const ownerLabel = (item: Item, r: Roadmap): string | undefined => {
501 const who = lower(item.owner)
502 if (!who) return undefined
503 if (who === 'both') return 'você e Claude'
504 if (who === lower(r.owner)) return 'você'
505 if (who === 'claude') return 'Claude'
506
507 return item.owner
508}
509
510/** As dependências ainda não feitas de um item. */
511export const pendingDeps = (item: Item, r: Roadmap): Item[] =>
512 item.dependsOn.map(id => r.items.find(i => i.id === id)).filter((i): i is Item => i !== undefined && i.status !== 'done')
513
514/** Dá para fazer agora: não feito, não bloqueado e com as dependências feitas. */
515export const isFree = (item: Item, r: Roadmap) => item.status !== 'done' && item.status !== 'blocked' && pendingDeps(item, r).length === 0
516
517export const phaseViews = (r: Roadmap): PhaseView[] => {
518 const current = r.phases.find(p => p.status === 'doing') ?? r.phases.find(p => p.status !== 'done') ?? r.phases.at(-1)
519
520 return r.phases.map(phase => {
521 const items = r.items.filter(i => i.phase === phase.id)
522 const done = items.filter(i => i.status === 'done').length
523 const total = items.length
524 const pct = total > 0 ? Math.floor((done * 100) / total) : phase.status === 'done' ? 100 : 0
525
526 return { phase, done, total, pct, isCurrent: phase === current }
527 })
528}
529
530/**
531 * A posição de cada item no caminho. Os que estão fora de "path" entram logo depois do
532 * último passo da mesma fase (sem passo na fase, antes do primeiro passo de uma fase
533 * seguinte; sem isso, no fim), entre si na ordem do arquivo.
534 */
535const pathRank = (r: Roadmap) => {
536 const step = new Map<string, number>(r.path.map((id, n): [string, number] => [id, n]))
537 const phaseOf = new Map(r.items.map(i => [i.id, i.phase]))
538 const order = new Map(r.phases.map((p, n) => [p.id, n]))
539 const lastOf = new Map<string, number>()
540 r.path.forEach((id, n) => lastOf.set(phaseOf.get(id) ?? '', n))
541
542 return (item: Item, n: number) => {
543 const at = step.get(item.id)
544 if (at !== undefined) return at
545 const tie = n / (r.items.length + 1) / 4
546 const last = lastOf.get(item.phase)
547 if (last !== undefined) return last + 0.25 + tie
548 const mine = order.get(item.phase) ?? Number.POSITIVE_INFINITY
549 const later = r.path.findIndex(id => (order.get(phaseOf.get(id) ?? '') ?? -1) > mine)
550
551 return later >= 0 ? later - 0.5 + tie : r.path.length + n
552 }
553}
554
555/** O caminho: a ordem de "path", com os itens de fora junto da sua fase. */
556export const nextItems = (r: Roadmap, limit = NEXT_LIMIT): Item[] => {
557 const done = new Set(r.items.filter(i => i.status === 'done').map(i => i.id))
558 const rank = pathRank(r)
559
560 return r.items
561 .map((item, n) => ({ item, key: rank(item, n) }))
562 .filter(({ item }) => item.status !== 'done' && item.status !== 'blocked' && item.dependsOn.every(d => done.has(d)))
563 .sort((a, b) => a.key - b.key)
564 .slice(0, limit)
565 .map(x => x.item)
566}
567
568export const summarize = (r: Roadmap): Summary => {
569 const phases = phaseViews(r)
570 const current = phases.find(v => v.isCurrent)
571 const here = (phase: string | undefined) => phase === current?.phase.id
572 const notDone = r.items.filter(i => i.status !== 'done')
573 const mine = notDone.filter(i => isMine(i, r))
574 const blocked = notDone.filter(i => i.status === 'blocked')
575 const decisionHere = (d: Decision) => d.phase === undefined || here(d.phase)
576
577 return {
578 current,
579 phases,
580 next: nextItems(r),
581 mineNow: mine.filter(i => here(i.phase) && isFree(i, r)),
582 mineLater: mine.filter(i => here(i.phase) && !isFree(i, r)),
583 mineElsewhere: mine.filter(i => !here(i.phase)).length,
584 waiting: blocked.filter(i => here(i.phase)),
585 waitingElsewhere: blocked.filter(i => !here(i.phase)).length,
586 decisions: r.openDecisions.filter(decisionHere),
587 decisionsElsewhere: r.openDecisions.filter(d => !decisionHere(d)).length,
588 doneElsewhere: phases
589 .filter(v => !v.isCurrent && v.done > 0)
590 .map(v => ({ phase: v.phase, count: v.done })),
591 }
592}
593
594/**
595 * Os últimos feitos de uma fase: pela data; no mesmo dia, o que o mod viu virar feito
596 * por último (`seen`: id → quando viu); depois, a ordem do arquivo, do fim para o começo.
597 */
598export const recentDone = (r: Roadmap, phase: string, limit: number, seen: Readonly<Record<string, number>> = {}): Item[] =>
599 r.items
600 .map((item, n) => ({ item, n, at: seen[item.id] ?? -1 }))
601 .filter(({ item }) => item.phase === phase && item.status === 'done')
602 .sort((a, b) => (b.item.doneAt ?? '').localeCompare(a.item.doneAt ?? '') || b.at - a.at || b.n - a.n)
603 .slice(0, limit)
604 .map(x => x.item)
605
606/** Os itens que viraram feitos de um roadmap para o outro (os novos já feitos também). */
607export const newlyDone = (before: Roadmap, after: Roadmap): string[] => {
608 const was = new Map(before.items.map(i => [i.id, i.status]))
609
610 return after.items.filter(i => i.status === 'done' && was.get(i.id) !== 'done').map(i => i.id)
611}
612
613// ---------- o quadro: rótulos, colunas, cartões e layout ----------
614
615/** O rótulo curto do item: o "short", ou o título cortado em 40 (no fim de uma palavra, quando dá). */
616export const itemLabel = (i: Item) => i.short ?? cutAtWord(i.title, LABEL_MAX)
617
618/** O rótulo curto da fase: o "short", ou o nome cortado em 18, no fim de uma palavra quando dá (vazio sem nome). */
619export const phaseShort = (p: Phase) => p.short ?? cutAtWord(p.name, PHASE_LABEL_MAX)
620
621/** "Fase 2 · Beta fechado", ou "Fase 2" sem nome. */
622export const phaseTitle = (p: Phase) => `Fase ${p.id}${phaseShort(p) ? ` · ${phaseShort(p)}` : ''}`
623
624export const COLUMN_TITLE: Record<ColumnId, string> = { doing: 'Agora', next: 'Próximas', waiting: 'Esperando', done: 'Feito' }
625export const COLUMN_IDS: readonly ColumnId[] = ['doing', 'next', 'waiting', 'done']
626export const OWNER_CHIP: Record<Exclude<OwnerKind, 'other'>, string> = { me: 'você', claude: 'Claude', both: 'os dois' }
627
628/** Quem faz, em uma palavra: o usuário, o Claude, os dois ou outra pessoa. */
629export const ownerKind = (item: Item, r: Roadmap): OwnerKind | undefined => {
630 const who = lower(item.owner)
631 if (!who) return undefined
632 if (who === 'both') return 'both'
633 if (who === lower(r.owner)) return 'me'
634 if (who === 'claude') return 'claude'
635
636 return 'other'
637}
638
639/** O chip do dono no cartão e na faixa: "você", "Claude", "os dois" ou o nome cortado. */
640export const ownerChip = (item: Item, r: Roadmap): string | undefined => {
641 const kind = ownerKind(item, r)
642 if (!kind) return undefined
643
644 return kind === 'other' ? cut(item.owner ?? '', 12) : OWNER_CHIP[kind]
645}
646
647/** O layout do quadro pela largura: 4 colunas, grade 2x2 ou empilhado. */
648export const layoutFor = (width: number): Layout => (width >= 96 ? 'row' : width >= 60 ? 'grid' : 'stack')
649
650/** A largura de cada coluna, em células, com uma de folga entre colunas. */
651export const columnWidth = (layout: Layout, width: number) =>
652 layout === 'row' ? Math.floor((width - 3) / 4) : layout === 'grid' ? Math.floor((width - 1) / 2) : width
653
654/** Feitos, em andamento, bloqueados e a fazer de uma fase. */
655export const phaseCounts = (r: Roadmap, phase: string | undefined): Counts => {
656 const items = r.items.filter(i => i.phase === phase)
657 const count = (s: ItemStatus) => items.filter(i => i.status === s).length
658
659 return { done: count('done'), doing: count('doing'), blocked: count('blocked'), todo: count('todo'), total: items.length }
660}
661
662/** Não feito e ainda sem poder começar: bloqueado, ou a fazer com uma dependência aberta. */
663export const isStuck = (item: Item, r: Roadmap) =>
664 item.status === 'blocked' || (item.status === 'todo' && pendingDeps(item, r).length > 0)
665
666const cardOf = (item: Item, r: Roadmap, current: string | undefined): Card => {
667 const deps = pendingDeps(item, r)
668 const wait: Pick<Card, 'wait' | 'waitKind'> =
669 item.status === 'blocked'
670 ? { wait: item.waitingOn ?? (deps[0] ? itemLabel(deps[0]) : undefined), waitKind: 'blocked' }
671 : item.status === 'todo' && deps[0]
672 ? { wait: itemLabel(deps[0]), waitKind: 'dep' }
673 : {}
674
675 return {
676 id: item.id,
677 label: itemLabel(item),
678 status: item.status,
679 owner: ownerKind(item, r),
680 ownerText: ownerChip(item, r),
681 gate: item.gate,
682 ...wait,
683 phase: item.phase,
684 isOtherPhase: item.phase !== current,
685 }
686}
687
688/** O id da fase atual. */
689export const currentPhase = (r: Roadmap) => phaseViews(r).find(v => v.isCurrent)?.phase.id
690
691/**
692 * Os itens de cada coluna, sem filtro nem corte. Agora: os em andamento, os da fase
693 * atual primeiro. Próximas: os a fazer livres, na ordem do caminho, de qualquer fase.
694 * Esperando: da fase atual, os bloqueados e depois os que esperam outra tarefa.
695 * Feito: os feitos da fase atual, os últimos primeiro.
696 */
697export const boardLists = (r: Roadmap, seen: Readonly<Record<string, number>> = {}): Record<ColumnId, Item[]> => {
698 const current = currentPhase(r)
699 const order = new Map(r.phases.map((p, n) => [p.id, n]))
700 const here = (i: Item) => i.phase === current
701 const doing = r.items.filter(i => i.status === 'doing')
702
703 return {
704 doing: [...doing.filter(here), ...doing.filter(i => !here(i)).sort((a, b) => (order.get(a.phase) ?? 0) - (order.get(b.phase) ?? 0))],
705 next: nextItems(r, Number.POSITIVE_INFINITY).filter(i => i.status === 'todo'),
706 waiting: [
707 ...r.items.filter(i => here(i) && i.status === 'blocked'),
708 ...r.items.filter(i => here(i) && i.status === 'todo' && pendingDeps(i, r).length > 0),
709 ],
710 done: recentDone(r, current ?? '', Number.POSITIVE_INFINITY, seen),
711 }
712}
713
714/** Quantos itens do quadro (Agora, Próximas e Esperando) são do usuário: o "Comigo" do painel e da faixa. */
715export const mineCount = (r: Roadmap) => {
716 const lists = boardLists(r)
717
718 return (['doing', 'next', 'waiting'] as const).reduce((n, c) => n + lists[c].filter(i => isMine(i, r)).length, 0)
719}
720
721export type KanbanInput = {
722 filter?: Filter
723 /** Quantas vezes o "+N" de cada coluna foi apertado. */
724 expanded?: Readonly<Partial<Record<ColumnId, number>>>
725 width: number
726 /** Quando o mod viu cada item virar feito (a ordem da coluna Feito no mesmo dia). */
727 seen?: Readonly<Record<string, number>>
728}
729
730/**
731 * O quadro: as 4 colunas de boardLists, com o filtro. Até 4 cartões por coluna
732 * (Feito, 3); cada "+N" abre mais 8, e "menos" volta ao começo.
733 */
734export const kanban = (r: Roadmap, input: KanbanInput): Kanban => {
735 const current = currentPhase(r)
736 const filter = input.filter ?? 'all'
737 const keep = (i: Item) => filter === 'all' || isMine(i, r)
738 const lists = boardLists(r, input.seen)
739 const layout = layoutFor(input.width)
740 const columns = COLUMN_IDS.map((id): KanbanColumn => {
741 const list = lists[id].filter(keep)
742 const base = id === 'done' ? DONE_LIMIT : CARD_LIMIT
743 const steps = Math.max(0, Math.floor(Number(input.expanded?.[id] ?? 0)) || 0)
744 const shown = list.slice(0, base + steps * CARD_STEP)
745
746 return {
747 id,
748 title: COLUMN_TITLE[id],
749 total: list.length,
750 cards: shown.map(i => cardOf(i, r, current)),
751 more: list.length - shown.length,
752 isExpanded: steps > 0 && list.length > base,
753 }
754 })
755
756 return { filter, mine: mineCount(r), layout, columnWidth: columnWidth(layout, input.width), columns }
757}
758
759const size = (s: string | undefined) => (s ? Array.from(s).length : 0)
760
761/** Uma linha de texto quebrado; `hard`: a linha acaba no meio de uma palavra comprida demais. */
762type Line = { text: string; hard: boolean }
763
764/** O texto quebrado por palavra em linhas de `width` células, como o terminal quebra (palavra maior que a linha é partida). */
765export const wrapWords = (text: string, width: number): Line[] => {
766 const w = Math.max(1, width)
767 const lines: Line[] = []
768 let line = ''
769 for (const word of text.split(/\s+/).filter(Boolean)) {
770 if (line && size(line) + 1 + size(word) <= w) {
771 line += ` ${word}`
772 continue
773 }
774 if (line) lines.push({ text: line, hard: false })
775 let rest = Array.from(word)
776 while (rest.length > w) {
777 lines.push({ text: rest.slice(0, w).join(''), hard: true })
778 rest = rest.slice(w)
779 }
780 line = rest.join('')
781 }
782 if (line || lines.length === 0) lines.push({ text: line, hard: false })
783
784 return lines
785}
786
787/** Como o wrapWords, mas sem partir palavra: a palavra maior que a linha é cortada com "…" e o texto para ali. */
788const wrapWhole = (text: string, w: number): Line[] => {
789 const lines: Line[] = []
790 let line = ''
791 for (const word of text.split(/\s+/).filter(Boolean)) {
792 if (line && size(line) + 1 + size(word) <= w) {
793 line += ` ${word}`
794 continue
795 }
796 if (line) lines.push({ text: line, hard: false })
797 if (size(word) > w) return [...lines, { text: cut(word, w), hard: false }]
798 line = word
799 }
800 if (line || lines.length === 0) lines.push({ text: line, hard: false })
801
802 return lines
803}
804
805/**
806 * O texto em até `lines` linhas de `width` células; a última leva o resto do texto, cortado
807 * com "…". `isWhole`: sem partir palavra (a palavra que não cabe na linha é cortada com "…").
808 */
809export const wrapFit = (text: string, width: number, lines: number, isWhole = false): string[] => {
810 const w = Math.max(1, width)
811 const all = isWhole ? wrapWhole(text, w) : wrapWords(text, w)
812 if (all.length <= lines) return all.map(l => l.text)
813 const rest = all
814 .slice(lines - 1)
815 .map((l, k) => (k === 0 || all[lines - 2 + k]!.hard ? l.text : ` ${l.text}`))
816 .join('')
817
818 return [...all.slice(0, lines - 1).map(l => l.text), cutAtWord(rest, w)]
819}
820
821/**
822 * O rótulo do cartão cabendo em `lines` linhas de `width` células, quebrado por palavra
823 * como o terminal quebra; o que não cabe vira "…" no fim da última linha.
824 */
825export const cardLabel = (label: string, width: number, lines = 2) => {
826 const all = wrapWords(label, width)
827 if (all.length <= lines) return label
828
829 return wrapFit(label, width, lines)
830 .map((t, k) => (k === 0 ? t : `${all[k - 1]!.hard ? '' : ' '}${t}`))
831 .join('')
832}
833
834/**
835 * As células de uma barra segmentada (feito, andamento, esperando, resto), pelo
836 * maior resto, somando `width`.
837 */
838export const splitBar = (c: Counts, width: number) => {
839 if (c.total === 0) return { done: 0, doing: 0, blocked: 0, rest: width }
840 const parts = (['done', 'doing', 'blocked'] as const).map(k => ({ k, exact: (c[k] / c.total) * width }))
841 const out = { done: 0, doing: 0, blocked: 0, rest: 0 }
842 for (const p of parts) out[p.k] = Math.floor(p.exact)
843 let left = Math.round(((c.done + c.doing + c.blocked) / c.total) * width) - (out.done + out.doing + out.blocked)
844 for (const p of [...parts].sort((a, b) => b.exact - Math.floor(b.exact) - (a.exact - Math.floor(a.exact)))) {
845 if (left <= 0) break
846 if (p.exact > 0) {
847 out[p.k]++
848 left--
849 }
850 }
851 out.rest = width - out.done - out.doing - out.blocked
852
853 return out
854}
855
856/** A linha de contagens do cartão da fase: "49 ✓ 3 ▶ 12 ‖ 49 ○". */
857export const countsText = (c: Counts) => `${c.done} ✓ ${c.doing} ▶ ${c.blocked} ‖ ${c.todo} ○`
858
859/** O chip do gate no cartão da fase: "Gate 2 · não rodado" (o gateStatus até a primeira pontuação, em até 16). */
860export const gateChip = (p: Phase, isNarrow = false): string | undefined => {
861 if (!p.gate) return undefined
862 const head = `Gate ${p.id}`
863 const first = (p.gateStatus ?? '').split(/[.,;:(]/)[0]?.trim() ?? ''
864
865 return isNarrow || !first ? head : `${head} · ${cut(first, 16)}`
866}
867
868// ---------- a trilha das fases ----------
869
870/** O estado de uma fase na trilha: feita, a atual, em andamento (sem ser a atual) ou a fazer. */
871export type NodeState = 'done' | 'current' | 'doing' | 'todo'
872
873export const nodeState = (v: PhaseView): NodeState =>
874 v.isCurrent ? 'current' : v.phase.status === 'done' ? 'done' : v.phase.status === 'doing' ? 'doing' : 'todo'
875
876const NODE_GLYPH: Record<NodeState, string> = { done: '●', current: '◉', doing: '◐', todo: '○' }
877
878/** Um pedaço de texto com o tom que o desenho dá a ele. */
879export type Seg = { text: string; tone: 'done' | 'current' | 'doing' | 'todo' | 'dim' }
880
881/** O nome da fase na trilha: o rótulo curto (ou o nome cortado em 18); sem nome, o id. */
882const trailName = (p: Phase) => phaseShort(p) || p.id
883
884/** Id curto (até 3 caracteres, como "2" ou "10"): vai na frente do nome quando cabe; "pos-beta", não. */
885const isShortId = (p: Phase) => size(p.id) <= 3
886
887/** A menor largura de slot (com 1 de folga) em que o texto cabe em `lines` linhas sem corte nem palavra partida. */
888const slotFor = (text: string, lines: number) => {
889 const n = size(text)
890 const longest = Math.max(1, ...text.split(/\s+/).filter(Boolean).map(size))
891 for (let w = longest; w < n; w++) if (wrapWords(text, w).length <= lines) return w + 1
892
893 return n + 1
894}
895
896/** Abaixo disto por fase, a trilha mostra uma janela em volta da fase atual. */
897const TRAIL_MIN = 8
898
899/**
900 * A trilha das fases para o terminal: uma linha de nós (● feita, ◉ atual com o %,
901 * ◐ em andamento, ○ a fazer) ligados por ━, e embaixo o nome de cada fase, em até duas
902 * linhas. Do mais rico ao mais curto: "id nome" numa linha, só o nome numa linha, o
903 * nome em duas linhas; se nem assim cabe, a fase atual fica com o nome inteiro e as
904 * outras dividem o resto. Com fases demais, uma janela em volta da atual, com "…".
905 */
906export const trailText = (views: readonly PhaseView[], width: number): { nodes: Seg[]; labels: Seg[][] } => {
907 const n = views.length
908 if (n === 0) return { nodes: [], labels: [] }
909 const cur = Math.max(0, views.findIndex(v => v.isCurrent))
910 let start = 0
911 let end = n
912 if (n * TRAIL_MIN > width) {
913 const room = Math.max(1, Math.floor((width - 4) / TRAIL_MIN))
914 start = Math.max(0, Math.min(cur - 1, n - room))
915 end = Math.min(n, start + room)
916 }
917 const shown = views.slice(start, end)
918 const at = cur - start
919 const avail = width - (start > 0 ? 2 : 0) - (end < n ? 2 : 0)
920 const pctOf = (v: PhaseView) => (v.isCurrent ? ` ${v.pct}% ` : '')
921 const least = (v: PhaseView) => size(pctOf(v)) + 2
922 const named = shown.map(v => ({ full: isShortId(v.phase) && phaseShort(v.phase) ? `${v.phase.id} ${phaseShort(v.phase)}` : trailName(v.phase), name: trailName(v.phase) }))
923 const sum = (list: number[]) => list.reduce((a, b) => a + b, 0)
924
925 let texts: string[] = []
926 let widths: number[] = []
927 let lines = 1
928 const tries = [
929 { pick: (x: (typeof named)[number]) => x.full, lines: 1 },
930 { pick: (x: (typeof named)[number]) => x.name, lines: 1 },
931 { pick: (x: (typeof named)[number]) => x.name, lines: 2 },
932 ]
933 const fits = tries.find(t => sum(named.map((x, i) => Math.max(least(shown[i]!), slotFor(t.pick(x), t.lines)))) <= avail)
934 if (fits) {
935 texts = named.map(fits.pick)
936 widths = texts.map((t, i) => Math.max(least(shown[i]!), slotFor(t, fits.lines)))
937 lines = fits.lines
938 // Slots iguais quando dá; senão, a sobra alonga as linhas entre os nós.
939 const even = Math.floor(avail / shown.length)
940 if (Math.max(...widths) <= even) widths = widths.map(() => even)
941 else {
942 const extra = avail - sum(widths)
943 const gaps = Math.max(1, shown.length - 1)
944 widths = widths.map((w, i) => (i < shown.length - 1 || shown.length === 1 ? w + Math.floor(extra / gaps) + (i < extra % gaps ? 1 : 0) : w))
945 }
946 } else {
947 // Não cabe: a fase atual fica com o nome inteiro (em duas linhas) e as outras dividem o resto, cortadas.
948 texts = named.map(x => x.name)
949 lines = 2
950 const mine = Math.max(least(shown[at]!), slotFor(texts[at]!, 2))
951 const each = shown.length > 1 ? Math.floor((avail - mine) / (shown.length - 1)) : 0
952 widths =
953 shown.length === 1
954 ? [avail]
955 : each >= 4
956 ? shown.map((_, i) => (i === at ? mine : each))
957 : shown.map((v, i) => Math.max(least(v), Math.floor(avail / shown.length) - (i === at ? 0 : 0)))
958 }
959
960 const nodes: Seg[] = []
961 const labels: Seg[][] = Array.from({ length: lines }, () => [])
962 if (start > 0) {
963 nodes.push({ text: '… ', tone: 'dim' })
964 for (const l of labels) l.push({ text: ' ', tone: 'dim' })
965 }
966 shown.forEach((v, i) => {
967 const w = widths[i]!
968 const state = nodeState(v)
969 const isLast = i === shown.length - 1
970 const pct = pctOf(v)
971 nodes.push({ text: NODE_GLYPH[state], tone: state })
972 if (!isLast || end < n) {
973 if (pct) nodes.push({ text: pct, tone: 'current' })
974 nodes.push({ text: '━'.repeat(Math.max(1, w - 1 - size(pct))), tone: state === 'done' ? 'done' : 'todo' })
975 } else if (pct) {
976 nodes.push({ text: pct.trimEnd(), tone: 'current' })
977 }
978 const text = wrapFit(texts[i]!, w - 1, lines, true)
979 labels.forEach((l, k) => {
980 const t = text[k] ?? ''
981 l.push({ text: isLast ? t : t.padEnd(w), tone: state === 'current' || state === 'doing' ? state : 'dim' })
982 })
983 })
984 if (end < n) nodes.push({ text: ' …', tone: 'dim' })
985
986 return { nodes, labels: labels.filter(l => l.some(s => s.text.trim())) }
987}
988
989/** Cores médias, que leem bem no tema claro e no escuro (o texto, com contraste de uns 4:1 nos dois). */
990export const HEX = { done: '#3fa66a', current: '#d97757', todo: '#8a8f98', blocked: '#8a8f98', text: '#737983', accentText: '#c4633f' } as const
991
992const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
993const fixed = (n: number) => Math.round(n * 10) / 10
994
995/** Largura em pixels dos desenhos do painel, pela largura do painel em células (a superfície limita ao espaço que há). */
996export const paneSvgWidth = (columns: number) => Math.max(220, Math.min(840, columns * 7 - 16))
997
998/** Largura média de um caractere do texto da trilha (12 px), normal e em negrito. */
999const CHAR_PX = 6.4
1000const BOLD_PX = 6.9
1001
1002/**
1003 * A trilha das fases em SVG, como um stepper: um nó por fase ligado por linha (a feita
1004 * cheia com check, a atual destacada com o número e o %, a em andamento com anel, a
1005 * fazer vazia com o número) e embaixo o nome, em até duas linhas.
1006 */
1007export const trailSvg = (views: readonly PhaseView[], width: number): string => {
1008 const n = Math.max(1, views.length)
1009 const slot = width / n
1010 const cy = 18
1011 const r = 10
1012 const font = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
1013 const names = views.map(v => wrapFit(trailName(v.phase), Math.max(1, Math.floor((slot - 8) / (v.isCurrent ? BOLD_PX : CHAR_PX))), 2, true))
1014 const rows = Math.max(1, ...names.map(l => l.length))
1015 const ci = views.findIndex(v => v.isCurrent)
1016 const pct = ci >= 0 ? `${views[ci]!.pct}%` : ''
1017 const pctPx = size(pct) * 7.4
1018 const beside = ci >= 0 && pctPx <= (ci === views.length - 1 ? slot / 2 - r - 8 : slot - 2 * r - 20)
1019 const labelY = (k: number) => 48 + k * 15
1020 const height = labelY(rows - 1) + 6 + (ci >= 0 && !beside ? 15 : 0)
1021 const cx = (i: number) => fixed(slot * (i + 0.5))
1022 const out: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`]
1023
1024 views.forEach((v, i) => {
1025 if (i === views.length - 1) return
1026 const green = nodeState(v) === 'done'
1027 const from = v.isCurrent && beside ? cx(i) + r + 6 + pctPx + 4 : cx(i) + r + 4
1028 const to = cx(i + 1) - r - 4
1029 if (to - from < 2) return
1030 out.push(
1031 `<line x1="${fixed(from)}" y1="${cy}" x2="${fixed(to)}" y2="${cy}" stroke="${green ? HEX.done : HEX.todo}" stroke-opacity="${green ? 1 : 0.5}" stroke-width="3" stroke-linecap="round"/>`,
1032 )
1033 })
1034 views.forEach((v, i) => {
1035 const x = cx(i)
1036 const state = nodeState(v)
1037 const id = isShortId(v.phase) && size(v.phase.id) <= 2 ? esc(v.phase.id) : ''
1038 const num = (color: string) =>
1039 id ? `<text x="${x}" y="${cy + 4}" text-anchor="middle" ${font} font-size="11" font-weight="700" fill="${color}">${id}</text>` : ''
1040 if (state === 'current') {
1041 out.push(`<circle cx="${x}" cy="${cy}" r="${r + 5}" fill="${HEX.current}" fill-opacity="0.22"/>`)
1042 out.push(`<circle cx="${x}" cy="${cy}" r="${r + 1}" fill="${HEX.current}"/>`, num('#fff'))
1043 } else if (state === 'done') {
1044 out.push(`<circle cx="${x}" cy="${cy}" r="${r}" fill="${HEX.done}"/>`)
1045 out.push(`<path d="M${fixed(x - 4.5)} ${cy} l3.2 3.4 l5.8 -6.6" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`)
1046 } else if (state === 'doing') {
1047 out.push(`<circle cx="${x}" cy="${cy}" r="${r - 1}" fill="${HEX.current}" fill-opacity="0.15" stroke="${HEX.current}" stroke-width="2.5"/>`, num(HEX.accentText))
1048 } else {
1049 out.push(`<circle cx="${x}" cy="${cy}" r="${r - 1}" fill="none" stroke="${HEX.todo}" stroke-width="2"/>`, num(HEX.text))
1050 }
1051 names[i]!.forEach((line, k) =>
1052 out.push(
1053 `<text x="${x}" y="${labelY(k)}" text-anchor="middle" ${font} font-size="12" font-weight="${v.isCurrent ? 600 : 500}" fill="${HEX.text}">${esc(line)}</text>`,
1054 ),
1055 )
1056 if (v.isCurrent) {
1057 out.push(
1058 beside
1059 ? `<text x="${fixed(x + r + 6)}" y="${cy + 4}" text-anchor="start" ${font} font-size="12" font-weight="700" fill="${HEX.accentText}">${pct}</text>`
1060 : `<text x="${x}" y="${labelY(rows)}" text-anchor="middle" ${font} font-size="12" font-weight="700" fill="${HEX.accentText}">${pct}</text>`,
1061 )
1062 }
1063 })
1064 out.push('</svg>')
1065
1066 return out.filter(Boolean).join('')
1067}
1068
1069/** O texto alternativo da trilha: cada fase com o estado. */
1070export const trailAlt = (views: readonly PhaseView[]) =>
1071 `Fases: ${views
1072 .map(v => `${v.phase.id}${phaseShort(v.phase) ? ` ${phaseShort(v.phase)}` : ''} ${v.isCurrent ? `atual, ${v.pct}%` : PHASE_LABEL[v.phase.status]}`)
1073 .join('; ')}`
1074
1075/** Barra segmentada em SVG: feito (verde), andamento (destaque), esperando (cinza claro) e o trilho. */
1076export const segBarSvg = (c: Counts, width: number, height = 8): string => {
1077 const x = (n: number) => fixed((c.total > 0 ? n / c.total : 0) * width)
1078 const r = height / 2
1079 const done = x(c.done)
1080 const doing = x(c.doing)
1081 const blocked = x(c.blocked)
1082
1083 return [
1084 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
1085 `<defs><clipPath id="c"><rect width="${width}" height="${height}" rx="${r}"/></clipPath></defs>`,
1086 `<rect width="${width}" height="${height}" rx="${r}" fill="${HEX.todo}" fill-opacity="0.22"/>`,
1087 '<g clip-path="url(#c)">',
1088 done > 0 ? `<rect width="${done}" height="${height}" fill="${HEX.done}"/>` : '',
1089 doing > 0 ? `<rect x="${done}" width="${doing}" height="${height}" fill="${HEX.current}"/>` : '',
1090 blocked > 0 ? `<rect x="${fixed(done + doing)}" width="${blocked}" height="${height}" fill="${HEX.blocked}" fill-opacity="0.35"/>` : '',
1091 '</g></svg>',
1092 ].join('')
1093}
1094
1095/** Um arco de `from` a `to` (% do círculo, do topo, no sentido horário). */
1096const arcPath = (cx: number, cy: number, r: number, from: number, to: number) => {
1097 const at = (pct: number) => {
1098 const a = (Math.min(pct, 99.99) / 100) * 2 * Math.PI - Math.PI / 2
1099 return `${fixed(cx + r * Math.cos(a))} ${fixed(cy + r * Math.sin(a))}`
1100 }
1101
1102 return `M ${at(from)} A ${r} ${r} 0 ${to - from > 50 ? 1 : 0} 1 ${at(to)}`
1103}
1104
1105/**
1106 * O anel da fase atual, para o painel no desktop: feito (verde), em andamento (destaque) e
1107 * esperando (cinza claro) em volta, o % grande no meio e "N de M" embaixo dele.
1108 */
1109export const phaseRingSvg = (c: Counts, pct: number, size = 88): string => {
1110 const stroke = Math.max(6, Math.round(size / 11))
1111 const mid = size / 2
1112 const r = mid - stroke / 2 - 1
1113 const font = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
1114 const at = (n: number) => (c.total > 0 ? (n / c.total) * 100 : 0)
1115 const arc = (from: number, to: number, color: string, opacity = 1) =>
1116 to - from > 0.1
1117 ? `<path d="${arcPath(mid, mid, r, from, to)}" fill="none" stroke="${color}" stroke-opacity="${opacity}" stroke-width="${stroke}"/>`
1118 : ''
1119 const done = at(c.done)
1120 const doing = done + at(c.doing)
1121 const blocked = doing + at(c.blocked)
1122
1123 return [
1124 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
1125 `<circle cx="${mid}" cy="${mid}" r="${fixed(r)}" fill="none" stroke="${HEX.todo}" stroke-opacity="0.22" stroke-width="${stroke}"/>`,
1126 c.total > 0 && c.done === c.total
1127 ? `<circle cx="${mid}" cy="${mid}" r="${fixed(r)}" fill="none" stroke="${HEX.done}" stroke-width="${stroke}"/>`
1128 : arc(0, done, HEX.done),
1129 arc(done, doing, HEX.current),
1130 arc(doing, blocked, HEX.blocked, 0.45),
1131 `<text x="${mid}" y="${fixed(mid + size * 0.04)}" text-anchor="middle" ${font} font-size="${Math.round(size * 0.24)}" font-weight="700" fill="${pct >= 100 ? HEX.done : HEX.accentText}">${pct}%</text>`,
1132 `<text x="${mid}" y="${fixed(mid + size * 0.22)}" text-anchor="middle" ${font} font-size="${Math.round(size * 0.12)}" fill="${HEX.text}">${c.done} de ${c.total}</text>`,
1133 '</svg>',
1134 ].join('')
1135}
1136
1137/** A barrinha da faixa: "▰▰▰▰▱▱▱▱▱▱". */
1138export const bandBar = (pct: number, cells = 10) => {
1139 const n = Math.round((Math.min(100, Math.max(0, pct)) / 100) * cells)
1140 return { full: '▰'.repeat(n), empty: '▱'.repeat(cells - n) }
1141}
1142
1143/** Quantos commits o aviso de desatualizado mostra (undefined: sem aviso). */
1144export const staleCount = (commits: number | null, limit: number): number | undefined =>
1145 commits !== null && limit > 0 && commits > limit ? commits : undefined
1146
1147/** O chip do aviso de desatualizado: "14 commits sem atualizar". */
1148export const staleChip = (commits: number) => `${commits} ${commits === 1 ? 'commit' : 'commits'} sem atualizar`
1149
1150/** As partes da faixa, para caber numa linha. */
1151export type BandInput = {
1152 width: number
1153 /** "Fase 2" (ou "Roadmap" sem fase). */
1154 head: string
1155 /** "43%", ou vazio sem fase. */
1156 pct: string
1157 /** Há fase atual: a barrinha pode aparecer. */
1158 hasBar: boolean
1159 /** O rótulo da próxima (ou "nada livre agora"). */
1160 label: string
1161 /** Há próxima: o rótulo vem depois de "›" e há o botão Fazer. */
1162 hasNext: boolean
1163 owner?: string
1164 mine?: string
1165 stale?: string
1166 /** O rótulo do botão de ocultar. */
1167 hide: string
1168}
1169
1170/** O que a faixa mostra: com ou sem "Fase N", barrinha e moldura nos botões, quais chips e o rótulo (cortado só no último caso). */
1171export type BandPlan = { showHead: boolean; bar: boolean; framed: boolean; label: string; showOwner: boolean; showMine: boolean; showStale: boolean }
1172
1173/** As células da barrinha da faixa (glifos ou SVG de 72 px). */
1174export const BAND_BAR_CELLS = 10
1175
1176/**
1177 * Cabe a faixa em `width` células: "Fase 2 ▰▰▰▱▱ 43% › rótulo (você) comigo: 2 ⚠ … [ Fazer ] [ Roadmap ] ocultar".
1178 * O rótulo da próxima é o que importa: para ele caber inteiro saem, nesta ordem, o aviso,
1179 * o "comigo: N", o dono, a barrinha, a moldura dos botões e o "Fase N". Só quando nem
1180 * assim cabe é que o rótulo encolhe; então ele nunca fica menor numa faixa mais larga.
1181 */
1182export const planBand = (b: BandInput): BandPlan => {
1183 type Config = Omit<BandPlan, 'label'>
1184 const cells = (c: Config, label: number) => {
1185 const parts = [
1186 c.showHead ? size(b.head) : -1,
1187 c.bar ? BAND_BAR_CELLS : -1,
1188 size(b.pct) +
1189 (b.hasNext ? 4 : 2) +
1190 label +
1191 (c.showOwner && b.owner ? size(b.owner) + 3 : 0) +
1192 (c.showMine && b.mine ? size(b.mine) + 2 : 0) +
1193 (c.showStale && b.stale ? size(b.stale) + 2 : 0),
1194 b.hasNext ? (c.framed ? 9 : 5) : -1,
1195 c.framed ? 11 : 3,
1196 size(b.hide),
1197 ].filter(p => p >= 0)
1198
1199 return parts.reduce((a, p) => a + p, 0) + parts.length - 1
1200 }hooks/markdown.ts 470 lines1/**
2 * O roadmap num ROADMAP.md: fases como títulos (## Fase 2 · Beta) e itens como caixas
3 * (- [ ] Login por email @Claude). Lê para o mesmo modelo do JSON, então o quadro, a
4 * faixa e as ferramentas não mudam; grava mexendo só na linha do item.
5 *
6 * # Lighthouse
7 * Até o beta.
8 * Dono: Sam
9 *
10 * ## Fase 1 · Protótipo
11 * - [x] Tela inicial (2026-09-20)
12 *
13 * ## Fase 2 · Beta fechado
14 * > Gate: dez pessoas usando por uma semana
15 * - [x] Sincronizar notas
16 * - [ ] Login por email @Claude (em andamento)
17 * - [ ] Conta na loja @ambos (bloqueado: aprovação do cartão)
18 * - [ ] Convidar os testadores @Sam (depois de: Login por email)
19 *
20 * ## Decisões
21 * - Cobrar assinatura? (fase 3)
22 */
23import type { Decision, Item, ItemStatus, Phase, PhaseStatus, Roadmap } from '../types'
24import { ITEM_LABEL, ITEM_STATUSES, PHASE_LABEL, PHASE_STATUSES, eolOf, phaseLabel, slugify, stripBom } from './roadmap'
25import type { AddInput, Change, Parsed, UpdateInput } from './roadmap'
26
27export const isMarkdown = (path: string) => /\.(md|markdown)$/i.test(path)
28
29const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
30const CHECKBOX = /^(\s*)[-*+]\s+\[([ xX~/\-])\]\s+(.*)$/
31const BULLET = /^(\s*)[-*+]\s+(.*)$/
32const DATE = /^\d{4}-\d{2}-\d{2}$/
33const OWNER_LINE = /^(?:dono|owner|respons[aá]vel)\s*:\s*(.+)$/i
34const GATE_LINE = /^>?\s*(?:gate|port[aã]o)\s*:\s*(.+)$/i
35const PHASE_WORD = /^(?:fase|phase|etapa|marco|milestone|sprint)\b/i
36const DECISIONS = /^(?:decis[oõ]es|decisions|decis[oõ]es em aberto|open decisions)\b/i
37const BOTH = /^(?:ambos|both|todos)$/i
38
39/** Um item como está na linha: o que o mod entende e o resto, para regravar igual. */
40type Line = {
41 indent: string
42 box: string
43 title: string
44 owner?: string
45 status: ItemStatus
46 doneAt?: string
47 evidence?: string
48 waitingOn?: string
49 /** O texto de "(depois de: …)", como está no arquivo. */
50 after?: string
51 gate: boolean
52}
53
54/** Tira os marcadores do fim do texto do item: "(em andamento)", "(bloqueado: x)", "@Claude"… */
55const readLine = (indent: string, box: string, text: string): Line => {
56 const line: Line = { indent, box, title: text.trim(), status: 'todo', gate: false }
57 if (box === 'x' || box === 'X') line.status = 'done'
58 else if (box === '~' || box === '/') line.status = 'doing'
59 else if (box === '-') line.status = 'blocked'
60 let rest = line.title
61 for (let guard = 0; guard < 8; guard++) {
62 const owner = /\s@([\p{L}\p{N}_.-]+)$/u.exec(rest)
63 if (owner) {
64 line.owner = BOTH.test(owner[1]!) ? 'both' : owner[1]
65 rest = rest.slice(0, owner.index).trimEnd()
66 continue
67 }
68 const paren = /\s*\(([^()]*)\)$/.exec(rest)
69 if (!paren) break
70 const inside = paren[1]!.trim()
71 const [head, ...tail] = inside.split(/\s*[:·—]\s*/)
72 const key = (head ?? '').toLowerCase()
73 const value = tail.join(': ').trim()
74 if (/^(em andamento|fazendo|doing|in progress|andando)$/.test(key)) line.status = line.status === 'done' ? 'done' : 'doing'
75 else if (/^(bloqueado|bloqueada|esperando|aguardando|blocked|waiting)$/.test(key)) {
76 if (line.status !== 'done') line.status = 'blocked'
77 if (value) line.waitingOn = value
78 } else if (/^(depois de|ap[oó]s|after|depende de|depends on)$/.test(key)) line.after = value
79 else if (key === 'gate' || key === 'portão' || key === 'portao') line.gate = true
80 else if (/^(feito|feita|done|concluído|concluido)$/.test(key) || DATE.test(key)) {
81 // "(2026-10-02)", "(feito 2026-10-02 · commit abc)", "(feito: 2026-10-02)"
82 const parts = inside.replace(/^(feito|feita|done|concluído|concluido)\s*:?\s*/i, '').split(/\s*·\s*/)
83 if (parts[0] && DATE.test(parts[0])) {
84 line.doneAt = parts[0]
85 if (parts.length > 1) line.evidence = parts.slice(1).join(' · ')
86 } else if (parts.join('').trim()) line.evidence = parts.join(' · ')
87 if (line.status !== 'done') line.status = 'done'
88 } else if (/^\d{4}-\d{2}-\d{2}\s*·/.test(inside)) {
89 const parts = inside.split(/\s*·\s*/)
90 line.doneAt = parts[0]
91 line.evidence = parts.slice(1).join(' · ')
92 } else break
93 rest = rest.slice(0, paren.index).trimEnd()
94 }
95 line.title = rest || line.title
96
97 return line
98}
99
100/** A linha de volta, com os marcadores na ordem do mod: @dono, estado, depois de, gate. */
101const writeLine = (l: Line): string => {
102 const box = l.status === 'done' ? 'x' : l.box === '~' || l.box === '/' ? (l.status === 'doing' ? l.box : ' ') : ' '
103 const marks: string[] = []
104 if (l.owner) marks.push(`@${l.owner === 'both' ? 'ambos' : l.owner}`)
105 if (l.status === 'doing' && box === ' ') marks.push('(em andamento)')
106 if (l.status === 'blocked') marks.push(l.waitingOn ? `(bloqueado: ${l.waitingOn})` : '(bloqueado)')
107 if (l.after) marks.push(`(depois de: ${l.after})`)
108 if (l.gate) marks.push('(gate)')
109 if (l.status === 'done' && (l.doneAt || l.evidence)) marks.push(`(${[l.doneAt, l.evidence].filter(Boolean).join(' · ')})`)
110
111 return `${l.indent}- [${box}] ${[l.title, ...marks].join(' ')}`
112}
113
114/** "Fase 2 · Beta fechado (atual)" → id 2, nome "Beta fechado", estado pedido. */
115const readHeading = (text: string, index: number): { id: string; name: string; status?: PhaseStatus; mark?: string } => {
116 let rest = text.trim()
117 let status: PhaseStatus | undefined
118 const mark = /\s*(?:\((feita|feito|conclu[ií]da|done|atual|em andamento|doing)\)|(✓|✅))\s*$/i.exec(rest)
119 if (mark) {
120 const word = (mark[1] ?? mark[2] ?? '').toLowerCase()
121 status = /atual|andamento|doing/.test(word) ? 'doing' : 'done'
122 rest = rest.slice(0, mark.index).trim()
123 }
124 const numbered = /^(?:(?:fase|phase|etapa|marco|milestone|sprint)\s*)?([A-Za-z]?\d+(?:\.\d+)?)\s*(?:[·:—–.)-]\s*|\s+)(.*)$/i.exec(rest)
125 if (numbered && numbered[1]) return { id: numbered[1], name: numbered[2]!.trim(), status }
126 const bare = /^(?:fase|phase|etapa|marco|milestone|sprint)\s+([A-Za-z]?\d+)$/i.exec(rest)
127 if (bare && bare[1]) return { id: bare[1], name: '', status }
128
129 return { id: String(index + 1), name: rest.replace(PHASE_WORD, '').replace(/^\s*[·:—–-]\s*/, '').trim() || rest, status }
130}
131
132/** O que o parser guarda para gravar depois: a linha de cada item e de cada fase. */
133type Located = {
134 roadmap: Roadmap
135 lines: string[]
136 itemLine: Map<string, number>
137 phaseLine: Map<string, number>
138 /** A última linha do bloco de cada item (com as sub-listas). */
139 itemEnd: Map<string, number>
140 /** Onde entra um item novo da fase, se ela ainda não tem nenhum. */
141 phaseEnd: Map<string, number>
142 gateLine: Map<string, number>
143}
144
145const locate = (raw: string): Located | { error: string } => {
146 const lines = stripBom(raw).split(/\r?\n/)
147 const headings: { at: number; level: number; text: string }[] = []
148 let inFence = false
149 lines.forEach((l, at) => {
150 if (/^\s*(```|~~~)/.test(l)) inFence = !inFence
151 if (inFence) return
152 const h = HEADING.exec(l)
153 if (h) headings.push({ at, level: h[1]!.length, text: h[2]! })
154 })
155 const isItem = (at: number) => !insideFence(lines, at) && CHECKBOX.test(lines[at]!)
156 const itemAts = lines.map((_, at) => at).filter(isItem)
157 if (itemAts.length === 0) {
158 return {
159 error:
160 'o ROADMAP.md não tem nenhum item em caixa de seleção. O mod lê fases como títulos (## Fase 1 · Nome) e itens como "- [ ] Título" embaixo deles',
161 }
162 }
163 const nearest = (at: number) => [...headings].reverse().find(h => h.at < at)
164 const titleH1 = headings.filter(h => h.level === 1)
165 const levels = itemAts.map(at => nearest(at)?.level).filter((n): n is number => n !== undefined && !(n === 1 && titleH1.length === 1))
166 const phaseLevel = levels.length > 0 ? Math.min(...levels) : 0
167
168 const phases: Phase[] = []
169 const items: Item[] = []
170 const decisions: Decision[] = []
171 const itemLine = new Map<string, number>()
172 const itemEnd = new Map<string, number>()
173 const phaseLine = new Map<string, number>()
174 const phaseEnd = new Map<string, number>()
175 const gateLine = new Map<string, number>()
176 const explicit = new Map<string, PhaseStatus>()
177 const taken = new Set<string>()
178 const afterText = new Map<string, string>()
179 let project: string | undefined
180 let goal: string | undefined
181 let owner: string | undefined
182 let phase: Phase | undefined
183 let inDecisions = false
184 let area: string | undefined
185 let lastItem: string | undefined
186 inFence = false
187
188 const newPhase = (id: string, name: string, at: number) => {
189 let unique = id
190 for (let n = 2; phases.some(p => p.id === unique); n++) unique = `${id}-${n}`
191 const p: Phase = { id: unique, name, status: 'todo' }
192 phases.push(p)
193 phaseLine.set(unique, at)
194 phaseEnd.set(unique, at)
195 return p
196 }
197
198 lines.forEach((l, at) => {
199 if (/^\s*(```|~~~)/.test(l)) inFence = !inFence
200 if (inFence) return
201 const h = HEADING.exec(l)
202 if (h) {
203 const level = h[1]!.length
204 const text = h[2]!.trim()
205 lastItem = undefined
206 if (level === 1 && titleH1.length === 1 && phaseLevel !== 1) {
207 project = text
208 return
209 }
210 if (level === phaseLevel || (phaseLevel === 0 && level === 2)) {
211 inDecisions = DECISIONS.test(text)
212 area = undefined
213 if (inDecisions) {
214 phase = undefined
215 return
216 }
217 // Título no nível das fases: é fase se tem itens embaixo ou se diz "Fase N".
218 const next = headings.find(x => x.at > at && x.level <= level)?.at ?? lines.length
219 const hasItems = itemAts.some(i => i > at && i < next)
220 if (!hasItems && !PHASE_WORD.test(text)) {
221 phase = undefined
222 return
223 }
224 const read = readHeading(text, phases.length)
225 phase = newPhase(read.id, read.name, at)
226 if (read.status) explicit.set(phase.id, read.status)
227 return
228 }
229 if (level > phaseLevel) area = text
230 return
231 }
232 // Antes da primeira fase: "Dono: Sam" e a primeira frase (o objetivo).
233 if (!phase && !inDecisions && phases.length === 0) {
234 const o = OWNER_LINE.exec(l.trim())
235 if (o) owner = o[1]!.trim().replace(/^@/, '')
236 else if (l.trim() && !goal && !CHECKBOX.test(l) && !BULLET.test(l)) goal = l.trim().replace(/^>\s*/, '')
237 }
238 if (inDecisions) {
239 const b = BULLET.exec(l)
240 if (b && !b[1]) {
241 const text = b[2]!.replace(/^\[[ xX]\]\s*/, '').trim()
242 const ph = /\s*\((?:fase|phase)\s+([^)]+)\)\s*$/i.exec(text)
243 decisions.push({ title: ph ? text.slice(0, ph.index).trim() : text, phase: ph ? ph[1]!.trim() : undefined })
244 }
245 return
246 }
247 if (phase) {
248 const g = GATE_LINE.exec(l.trim())
249 if (g && !CHECKBOX.test(l)) {
250 const [gate, ...status] = g[1]!.split(/\s+—\s+/)
251 phase.gate = gate!.trim()
252 if (status.length > 0) phase.gateStatus = status.join(' — ').trim()
253 gateLine.set(phase.id, at)
254 phaseEnd.set(phase.id, Math.max(phaseEnd.get(phase.id) ?? at, at))
255 return
256 }
257 }
258 const c = CHECKBOX.exec(l)
259 if (c) {
260 if (c[1]!.length > 0 && lastItem) {
261 // Sub-caixa: vira nota do item de cima.
262 itemEnd.set(lastItem, at)
263 const parent = items.find(i => i.id === lastItem)!
264 parent.notes = [parent.notes, c[3]!.trim()].filter(Boolean).join(' · ')
265 if (phase) phaseEnd.set(phase.id, at)
266 return
267 }
268 if (!phase) phase = newPhase('1', 'Roadmap', at - 1)
269 const line = readLine(c[1]!, c[2]!, c[3]!)
270 let id = slugify(line.title)
271 for (let n = 2; taken.has(id); n++) id = `${slugify(line.title)}-${n}`
272 taken.add(id)
273 const item: Item = { id, phase: phase.id, title: line.title, status: line.status, dependsOn: [], gate: line.gate }
274 if (line.owner) item.owner = line.owner
275 if (line.waitingOn) item.waitingOn = line.waitingOn
276 if (line.doneAt) item.doneAt = line.doneAt
277 if (line.evidence) item.evidence = line.evidence
278 if (area) item.area = area
279 if (line.after) afterText.set(id, line.after)
280 items.push(item)
281 itemLine.set(id, at)
282 itemEnd.set(id, at)
283 phaseEnd.set(phase.id, at)
284 lastItem = id
285 return
286 }
287 // Sub-item comum embaixo de um item: nota.
288 const b = BULLET.exec(l)
289 if (b && b[1]!.length > 0 && lastItem) {
290 const parent = items.find(i => i.id === lastItem)!
291 parent.notes = [parent.notes, b[2]!.trim()].filter(Boolean).join(' · ')
292 itemEnd.set(lastItem, at)
293 if (phase) phaseEnd.set(phase.id, at)
294 return
295 }
296 if (l.trim()) lastItem = undefined
297 })
298
299 // "depois de: Login por email, Tela inicial" → ids, por título ou id.
300 for (const item of items) {
301 const text = afterText.get(item.id)
302 if (!text) continue
303 for (const ref of text.split(/\s*[,;]\s*|\s+e\s+/)) {
304 const slug = slugify(ref)
305 const hit = items.find(i => i.id === slug) ?? items.find(i => i.id !== item.id && (i.id.startsWith(slug) || slugify(i.title).includes(slug)))
306 if (hit && hit.id !== item.id) item.dependsOn.push(hit.id)
307 }
308 }
309
310 // Estado das fases: o que o título diz; senão, pelos itens.
311 for (const p of phases) {
312 const mine = items.filter(i => i.phase === p.id)
313 const said = explicit.get(p.id)
314 p.status = said ?? (mine.length > 0 && mine.every(i => i.status === 'done') ? 'done' : mine.some(i => i.status !== 'todo') ? 'doing' : 'todo')
315 }
316
317 if (!owner) {
318 const counts = new Map<string, number>()
319 for (const i of items) if (i.owner && i.owner !== 'both' && !/^claude$/i.test(i.owner)) counts.set(i.owner, (counts.get(i.owner) ?? 0) + 1)
320 owner = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
321 }
322
323 const roadmap: Roadmap = { version: 1, phases, items, path: [], openDecisions: decisions }
324 if (project) roadmap.project = project
325 if (goal) roadmap.goal = goal
326 if (owner) roadmap.owner = owner
327
328 return { roadmap, lines, itemLine, phaseLine, itemEnd, phaseEnd, gateLine }
329}
330
331const insideFence = (lines: string[], at: number) => {
332 let inFence = false
333 for (let n = 0; n < at; n++) if (/^\s*(```|~~~)/.test(lines[n]!)) inFence = !inFence
334 return inFence
335}
336
337export const parseMarkdown = (raw: string): Parsed => {
338 const found = locate(raw)
339 return 'error' in found ? { ok: false, error: found.error } : { ok: true, roadmap: found.roadmap }
340}
341
342const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
343const join = (lines: string[], original: string) => {
344 const out = lines.join(eolOf(original))
345 return original.charCodeAt(0) === 0xfeff ? `${out}` : out
346}
347
348/** O roadmap_update num ROADMAP.md: muda só a linha do item (ou o título da fase). */
349export const applyUpdateMarkdown = (original: string, input: UpdateInput, today: string): Change => {
350 const found = locate(original)
351 if ('error' in found) return { ok: false, error: `O roadmap tem erro e não foi alterado (${found.error}).` }
352 const { roadmap: r, lines } = found
353 const id = text(input.id)
354 if (!id) return { ok: false, error: 'Falta o "id" do item.' }
355 if (input.short !== undefined) return { ok: false, error: 'O ROADMAP.md não guarda rótulo curto: o cartão usa o título. Encurte o título editando o arquivo, se quiser.' }
356 const note = text(input.note)
357 const evidence = text(input.evidence)
358 const waitingOn = text(input.waitingOn)
359 const item = r.items.find(i => i.id === id)
360
361 if (!item) {
362 const phase = r.phases.find(p => p.id === id)
363 if (!phase) {
364 const words = slugify(id).split('-').filter(w => w.length >= 3)
365 const hits = r.items.filter(i => words.some(w => i.id.includes(w))).slice(0, 5)
366 return { ok: false, error: `Não existe item "${id}" no roadmap.${hits.length ? ` Ids parecidos: ${hits.map(i => i.id).join(', ')}.` : ' Use roadmap_status para ver os ids.'}` }
367 }
368 const status = input.status ?? phase.status
369 if (typeof status !== 'string' || !PHASE_STATUSES.includes(status as PhaseStatus)) {
370 return { ok: false, error: `Status ${JSON.stringify(input.status)} inválido para fase: use done, doing ou todo.` }
371 }
372 const at = found.phaseLine.get(phase.id)!
373 const h = HEADING.exec(lines[at]!)
374 if (h && input.status !== undefined) {
375 const bare = h[2]!.replace(/\s*(?:\((?:feita|feito|conclu[ií]da|done|atual|em andamento|doing)\)|✓|✅)\s*$/i, '')
376 lines[at] = `${h[1]} ${bare}${status === 'done' ? ' (feita)' : status === 'doing' ? ' (atual)' : ''}`
377 }
378 if (evidence) {
379 const g = found.gateLine.get(phase.id)
380 if (g !== undefined) lines[g] = `> Gate: ${phase.gate ?? ''} — ${evidence}`
381 else lines.splice(at + 1, 0, `> Gate: — ${evidence}`)
382 }
383 if (note) lines.splice((found.gateLine.get(phase.id) ?? at) + 1, 0, `> ${note}`)
384 const out = join(lines, original)
385 if (out === original) return { ok: true, text: original, message: `${phaseLabel(phase)} já estava assim.` }
386 return { ok: true, text: out, message: `${phaseLabel(phase)} → ${PHASE_LABEL[status as PhaseStatus]}`, title: phaseLabel(phase) }
387 }
388
389 const status = input.status ?? item.status
390 if (typeof status !== 'string' || !ITEM_STATUSES.includes(status as ItemStatus)) {
391 return { ok: false, error: `Status ${JSON.stringify(input.status)} inválido: use done, doing, todo ou blocked.` }
392 }
393 if (input.doneAt !== undefined && (typeof input.doneAt !== 'string' || !DATE.test(input.doneAt))) {
394 return { ok: false, error: `"doneAt" precisa ser uma data AAAA-MM-DD (recebi ${JSON.stringify(input.doneAt)}).` }
395 }
396 if (input.doneAt !== undefined && status !== 'done') return { ok: false, error: '"doneAt" só vale para item feito: passe status "done" junto.' }
397 const at = found.itemLine.get(item.id)!
398 const c = CHECKBOX.exec(lines[at]!)!
399 const line = readLine(c[1]!, c[2]!, c[3]!)
400 line.status = status as ItemStatus
401 if (status === 'done') line.doneAt = (input.doneAt as string | undefined) ?? (item.status === 'done' ? line.doneAt : today)
402 else if (input.status !== undefined) line.doneAt = undefined
403 if (status !== 'blocked' && input.status !== undefined) line.waitingOn = undefined
404 if (waitingOn) line.waitingOn = waitingOn
405 if (evidence) line.evidence = evidence
406 if (status !== 'done') line.evidence = undefined
407 lines[at] = writeLine(line)
408 if (note) lines.splice(found.itemEnd.get(item.id)! + 1, 0, `${line.indent} - ${note}`)
409 const out = join(lines, original)
410 if (out === original) return { ok: true, text: original, message: `"${item.title}" já estava assim.`, title: item.title }
411
412 return {
413 ok: true,
414 text: out,
415 message: `"${item.title}" → ${ITEM_LABEL[status as ItemStatus]}${status === 'done' && line.doneAt ? ` em ${line.doneAt}` : ''}`,
416 title: item.title,
417 }
418}
419
420/** O roadmap_add num ROADMAP.md: uma linha nova depois de "after" ou no fim da fase. */
421export const applyAddMarkdown = (original: string, input: AddInput, today: string): Change & { id?: string } => {
422 const found = locate(original)
423 if ('error' in found) return { ok: false, error: `O roadmap tem erro e não foi alterado (${found.error}).` }
424 const { roadmap: r, lines } = found
425 const phaseId = typeof input.phase === 'number' ? String(input.phase) : text(input.phase)
426 const phase = r.phases.find(p => p.id === phaseId)
427 if (!phase) return { ok: false, error: `A fase ${JSON.stringify(input.phase)} não existe. Fases: ${r.phases.map(p => p.id).join(', ') || 'nenhuma'}.` }
428 const title = text(input.title)
429 if (!title) return { ok: false, error: 'Falta o "title" do item.' }
430 const twin = r.items.find(i => i.phase === phase.id && slugify(i.title) === slugify(title))
431 if (twin) return { ok: false, error: `Já existe esse item na ${phaseLabel(phase)}: "${twin.title}" (${twin.id}).` }
432 const status = (input.status ?? 'todo') as ItemStatus
433 if (!ITEM_STATUSES.includes(status)) return { ok: false, error: `Status ${JSON.stringify(input.status)} inválido: use done, doing, todo ou blocked.` }
434 const deps = input.dependsOn === undefined ? [] : Array.isArray(input.dependsOn) ? input.dependsOn : [input.dependsOn]
435 const depTitles: string[] = []
436 for (const d of deps) {
437 const dep = r.items.find(i => i.id === String(d))
438 if (!dep) return { ok: false, error: `"dependsOn" cita ${JSON.stringify(d)}, que não existe. Use roadmap_status para ver os ids.` }
439 depTitles.push(dep.title)
440 }
441 const after = text(input.after)
442 if (after && !r.items.some(i => i.id === after)) return { ok: false, error: `"after" cita "${after}", que não é id de nenhum item.` }
443 const line: Line = {
444 indent: '',
445 box: status === 'done' ? 'x' : ' ',
446 title,
447 owner: text(input.owner),
448 status,
449 waitingOn: text(input.waitingOn),
450 after: depTitles.length > 0 ? depTitles.join(', ') : undefined,
451 gate: input.gate === true,
452 doneAt: status === 'done' ? today : undefined,
453 }
454 const at = after ? found.itemEnd.get(after)! + 1 : (found.phaseEnd.get(phase.id) ?? found.phaseLine.get(phase.id)!) + 1
455 const added = [writeLine(line)]
456 const notes = text(input.notes)
457 if (notes) added.push(` - ${notes}`)
458 // Uma fase sem itens: o item entra depois de uma linha em branco, embaixo do título.
459 if (!after && !r.items.some(i => i.phase === phase.id) && lines[at - 1]?.trim()) added.unshift('')
460 lines.splice(at, 0, ...added)
461 const id = (() => {
462 const taken = new Set(r.items.map(i => i.id))
463 let candidate = slugify(title)
464 for (let n = 2; taken.has(candidate); n++) candidate = `${slugify(title)}-${n}`
465 return candidate
466 })()
467
468 return { ok: true, text: join(lines, original), message: `Item criado na ${phaseLabel(phase)}: "${title}" (${id})`, title, id }
469}
470types/index.d.ts 185 lines1export type PhaseStatus = 'done' | 'doing' | 'todo'
2export type ItemStatus = 'done' | 'doing' | 'todo' | 'blocked'
3
4/** Uma fase do roadmap, como o mod a leu (ids sempre em texto, estado padrão "todo"). */
5export type Phase = {
6 id: string
7 /** Vazio quando o arquivo não dá nome à fase. */
8 name: string
9 /** Rótulo curto da fase na trilha (até 60; sem ele, o nome cortado em 18). */
10 short?: string
11 goal?: string
12 status: PhaseStatus
13 gate?: string
14 gateStatus?: string
15 notes?: string
16}
17
18/** Um item do roadmap, como o mod o leu. */
19export type Item = {
20 id: string
21 phase: string
22 title: string
23 /** Rótulo curto do cartão (até 60; sem ele, o título cortado em 40). */
24 short?: string
25 status: ItemStatus
26 /** "Claude", o nome do usuário (o "owner" do arquivo) ou "both". */
27 owner?: string
28 waitingOn?: string
29 dependsOn: string[]
30 doneAt?: string
31 evidence?: string
32 area?: string
33 gate: boolean
34 notes?: string
35}
36
37export type Decision = { title: string; phase?: string; ref?: string }
38
39/** O roadmap lido e conferido (formato versão 1). */
40export type Roadmap = {
41 version: number
42 project?: string
43 goal?: string
44 updated?: string
45 /** Quem é o usuário nos itens: os itens dele (ou "both") formam o "com você". */
46 owner?: string
47 phases: Phase[]
48 items: Item[]
49 path: string[]
50 openDecisions: Decision[]
51}
52
53export type PhaseView = {
54 phase: Phase
55 done: number
56 total: number
57 /** Itens feitos sobre os itens da fase, arredondado para baixo (100 só com tudo feito). */
58 pct: number
59 isCurrent: boolean
60}
61
62/**
63 * O que a faixa, o painel e as ferramentas mostram, calculado do roadmap. As listas
64 * são da fase atual; as outras fases entram só como contagem.
65 */
66export type Summary = {
67 current?: PhaseView
68 phases: PhaseView[]
69 /** Até 5, na ordem do caminho: não feitos, não bloqueados e com as dependências feitas. */
70 next: Item[]
71 /** Do usuário (ou de "both"), da fase atual, não feitos e livres: não bloqueados e com as dependências feitas. */
72 mineNow: Item[]
73 /** Do usuário (ou de "both"), da fase atual, não feitos: bloqueados ou esperando uma dependência. */
74 mineLater: Item[]
75 /** Quantos itens não feitos do usuário (ou de "both") há nas outras fases. */
76 mineElsewhere: number
77 /** Bloqueados da fase atual. */
78 waiting: Item[]
79 /** Quantos bloqueados há nas outras fases. */
80 waitingElsewhere: number
81 /** Decisões em aberto da fase atual e as sem fase. */
82 decisions: Decision[]
83 /** Quantas decisões em aberto são de outras fases. */
84 decisionsElsewhere: number
85 /** Itens feitos das fases que não são a atual, por fase. */
86 doneElsewhere: { phase: Phase; count: number }[]
87}
88
89/** As colunas do quadro: Agora, Próximas, Esperando e Feito. */
90export type ColumnId = 'doing' | 'next' | 'waiting' | 'done'
91
92/** O filtro do quadro: tudo, ou só os itens do usuário (e os de "both"). */
93export type Filter = 'all' | 'mine'
94
95/** Quem faz o item: o usuário, o Claude, os dois ou outra pessoa. */
96export type OwnerKind = 'me' | 'claude' | 'both' | 'other'
97
98/** O layout do quadro pela largura: 4 colunas lado a lado, grade 2x2 ou empilhado. */
99export type Layout = 'row' | 'grid' | 'stack'
100
101/** Os itens de uma fase por estado. */
102export type Counts = { done: number; doing: number; blocked: number; todo: number; total: number }
103
104/** Um cartão do quadro. */
105export type Card = {
106 id: string
107 /** O "short", ou o título cortado em 40. */
108 label: string
109 status: ItemStatus
110 owner?: OwnerKind
111 /** "você", "Claude", "os dois" ou o nome. */
112 ownerText?: string
113 gate: boolean
114 /** Nos que esperam: o "waitingOn" do bloqueado (ou a primeira dependência aberta), ou a tarefa de que o item depende. */
115 wait?: string
116 /** "blocked": bloqueado; "dep": a fazer, esperando outra tarefa ficar pronta. */
117 waitKind?: 'blocked' | 'dep'
118 phase: string
119 /** O item é de outra fase que não a atual (nas Próximas e no Agora). */
120 isOtherPhase: boolean
121}
122
123export type KanbanColumn = {
124 id: ColumnId
125 title: string
126 /** Quantos itens a coluna tem, com o filtro. */
127 total: number
128 cards: Card[]
129 /** Quantos ficaram de fora (o "+N" abre mais 8 a cada clique). */
130 more: number
131 /** O "+N" já foi apertado: a coluna mostra o "menos". */
132 isExpanded: boolean
133}
134
135/** O quadro como o painel o desenha. */
136export type Kanban = {
137 filter: Filter
138 /** Itens do usuário em Agora, Próximas e Esperando (o "Comigo" do painel e da faixa). */
139 mine: number
140 layout: Layout
141 /** Largura de cada coluna, em células. */
142 columnWidth: number
143 columns: KanbanColumn[]
144}
145
146/** O arquivo do roadmap como o mod o leu por último. */
147export type Board = {
148 /** Caminho completo do arquivo. */
149 path: string
150 /** Como está na opção "file". */
151 rel: string
152 /** O nome da pasta do projeto. */
153 project: string
154 exists: boolean
155 /** Quando o arquivo mudou por último (para notar edições feitas fora do mod). */
156 mtimeMs: number
157 /** Assinatura do texto: o "Ocultar" vale até ela mudar. */
158 sig: string
159 roadmap: Roadmap | null
160 /** Linha e motivo, quando o arquivo não é um roadmap válido. */
161 error: string | null
162}
163
164declare module 'claude-code' {
165 interface PluginState {
166 roadmap: {
167 board: Board | null
168 /** Commits do projeto depois do último que mexeu no roadmap; null sem git. */
169 stale: number | null
170 /** A assinatura do roadmap quando a pessoa ocultou a faixa. */
171 hidden: string | null
172 /** O filtro do quadro no painel. */
173 filter: Filter
174 /** Quantas vezes o "+N" de cada coluna foi apertado (8 cartões a mais por vez). */
175 expanded: Partial<Record<ColumnId, number>>
176 /** O id do item do cartão selecionado (o cartão abre no lugar, com o detalhe). */
177 selected: string | null
178 /** A lista de decisões do rodapé aberta. */
179 decisions: boolean
180 /** Quando o mod viu cada item virar feito (id → instante): a ordem da coluna Feito no mesmo dia. */
181 doneSeen: Record<string, number>
182 }
183 }
184}
185