SLOPSHOPPER

roadmap

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

newpanebandguardcommandtoast
★ 1v0.3.0MITupdated 2026-10-15kevinmedeiros/claude-mods/plugins/roadmap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · roadmap
│ ┃ Roadmap ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Sem roadmap · docs/roadmap.json │ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ [ Pedir ao Claude para criar ] │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /roadmap │ ⎿ roadmap: Sem roadmap em docs/roadmap.json │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Roadmap
╭──────────────────────────────────────────────────────────╮ │ Sem roadmap · docs/roadmap.json │ │ │ │ [ Pedir ao Claude para criar ] │ ╰──────────────────────────────────────────────────────────╯
README

roadmap

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

What it shows

The interface is in Brazilian Portuguese, like the other mods in this repository; it is shown here in English.

Above the prompt

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
  • What it holds: the phase, a ten-cell bar (a drawn bar on Desktop), the %, the next task's short label with who does it, how many cards on the board are yours (the same number as Mine in the pane), and an out-of-date chip when the roadmap is behind.
  • Buttons: Do it writes the request for Claude into the prompt, Roadmap opens the pane, and hide (Hide on Desktop, drawn as the surface's own close button) hides the line until the roadmap changes or the next session.
  • Narrow windows: the line never wraps, and the task's label stays whole as long as it can. To make room, the out-of-date chip goes first, then mine: N, the owner, the bar, the buttons' frames (Roadmap becomes View) and Phase N. Only then is the label cut, so a wider window never shows less of it.

Nothing is drawn in a project without a roadmap.

The pane

/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:

  • Phase trail: one node per phase with its short label underneath.
  • Desktop, VS Code and mobile: a drawn stepper. A done phase is a green node with a check, the current one is highlighted with its number and its %, a phase in progress that is not the current one has an orange ring, and a phase to do is an empty ring with its number.
  • Terminal: two or three lines of glyphs: ● done, ◉ current with its %, ◐ in progress, ○ to do. The label shows "id name" when it fits, then the name alone, then the name in two lines; it is cut with "…" but a word is never split across lines. With many phases, it shows a window around the current one.
  • Current phase card:
  • Desktop, VS Code and mobile: a ring split into done, in progress and waiting, with the % and 1 of 6 in the middle; beside it the phase, its 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.
  • Terminal: the phase and its short name, a gate chip (Gate 2 · pending: the 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 ○).
  • All / Mine N: Mine keeps only your items and the both ones.
  • Board:
  • Now: every item in progress, the current phase's first. A card from another phase says which.
  • Next: the items you can start (not blocked, dependencies done), in path order, from any phase. An item outside the path comes right after the path's items of its own phase.
  • Waiting: the current phase's blocked items, then its to-do items waiting for another task. Both have a dashed border and say what they wait on: ‖ card approval for a blocked one, after Email sign-in for one waiting on a dependency.
  • Done: the current phase's done items, the latest first: by doneAt, and on the same day the one the mod saw finished last.
  • Cards: each column shows up to four (three in Done). +8 of N shows eight more at a time and less folds the column back. A card is the item's short label, in up to two lines and cut with "…" (at the end of a word when it can), and one line with the owner (you, Claude, both or the name), Gate and the phase when it is another one, or what it waits on.
  • Layout: on the desktop, each column's name has a dot in its colour. Four columns side by side from 96 columns, a 2×2 grid from 60, and stacked below that, with Done as a single count line.
  • Opening a card: clicking a card opens it in place, with a heavy border:
  ┏━━━━━━━━━━━━━━━━━━━━━━┓
  ┃ 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.

  • Footer: a Decisions N chip that opens up to five open decisions (of the current phase and those without a phase), and the out-of-date chip.

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.

Status line and command

  • Status line: "Phase 2 · 16%".
  • /roadmap also answers in one line: "Phase 2 · 16% · next: Email sign-in".

Out-of-date warning

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.

Tools for Claude

  • roadmap_status {query?, phase?, all?}
  • On its own: the current phase and %, the next five tasks with their ids, what is with you now and later, what is waiting, the open decisions and the out-of-date warning. Each list has eight items, and other phases appear as counts.
  • It also points out the current phase's items that have a title over 40 characters and no 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.
  • Without a roadmap, it says where to create one, with a template and what every field means.
  • roadmap_update {id, status?, evidence?, doneAt?, note?, waitingOn?, short?} changes one item.
  • It refuses an unknown id or status, and a 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.
  • Given a phase id, it changes the phase (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.
  • It refuses an unknown phase, dependency or after, a title the phase already has, and a short over 60 characters.
  • Every change shows a toast.
  • Changes are written one at a time, so two calls at once (subagents in parallel) both land.
  • If another program writes the file between the read and the write, the change is applied again on the new text.
  • After a 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.

Requests and reloads

Do it and Do it now write a different request for each kind of owner, with the item's notes:

  • Claude's items: mark it in progress, do it, and mark it done with the evidence.
  • Your items: Claude walks you through it and marks it done when you say so.
  • both: Claude does its part, tells you yours, and marks it done only when both parts are.
  • Blocked or waiting items (See what's missing): Claude checks what holds the item and with whom, and does not start it. A blocked item whose block is gone goes back to to do, with a note.

The mod reloads the file:

  • when a tool writes it;
  • right after Claude edits it with Edit or Write;
  • within 15 seconds of any other change (it checks the file's modification time).

The commit count is refreshed after each turn (at most once a minute) and every 5 minutes.

The file

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
version1
project, goalproject is the pane's header.
updatedYYYY-MM-DD. The tools set it on every change.
ownerWho 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.
pathItem 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:

  • On an item, it is what the card and the band show. Aim for 40 characters or fewer (60 at most). Without it, the title is cut at 40, at the end of a word when that keeps at least half of it.
  • On a phase, it is the label on the trail and on the phase card. Aim for 18 characters or fewer (60 at most): seven phases of 18 fit a 120-column trail without a cut. Without it, the name is cut at 18.
  • Errors: a short that is empty, is not text, or is over 60 characters is an error, reported with its line.

How it reads the file:

  • Current phase: the first doing phase, otherwise the first one that is not done.
  • Progress: done items ÷ the phase's items, rounded down, so 100% means everything is done.
  • Next: items not done, not blocked and whose dependencies are done, in path order and then in file order. The band shows the first one; the board's Next column shows those still to do.
  • Yours: items whose owner is the file's owner or both. Mine N (and mine: N on the band) counts yours in Now, Next and Waiting.
  • Waiting: the current phase's 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:

  • In place: only the values that change are touched.
  • New keys and items: a new key goes next to its neighbours, on its own line or on the same line in an object written on one line; the key order of the format decides where. A new item follows the layout of the item before it, and lists keep their own layout.
  • Encoding: the file keeps its line endings (LF or CRLF) and final newline, and is written as UTF-8 without BOM.
  • Fallback: in the rare case a change cannot be made in place (an object with a repeated key, for example), the whole file is written in the mod's layout: two-space indentation, short lists of plain values on one line.
  • Safety: only valid JSON is written, and no item is ever deleted.

Example

{
  "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:

  • Now: Email sign-in.
  • Next: Dark mode and Pick the app icon. Publish on the store is not there: it waits for the store account, and it belongs to phase 3.
  • Waiting: Store account, the item's short, blocked on the card approval; then Invite the testers, after Email sign-in.
  • Done: Sync notes.

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.

ROADMAP.md

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)
  • Project: the # 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).
  • Phases: ## 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.
  • Items: - [ ] 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).
  • Decisions: the bullets of a ## Decisões (or Decisions) section, with (fase N).
  • Writing: 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.

Options

OptionDefault
filedocs/roadmap.jsonRelative to the git root, or absolute; a .md file is read as a ROADMAP.md. With the default, a ROADMAP.md counts too
showBandtrueThe line above the prompt
staleAfter10Commits without touching the roadmap before the warning; 0 turns it off
claudeCanEdittrueGives Claude roadmap_update and roadmap_add; roadmap_status is always there
Source 4 files
hooks/register.tsx 1030 lines
1import { 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}
1030
hooks/roadmap.ts 2026 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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 lines
1/**
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}
470
types/index.d.ts 185 lines
1export 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