SLOPSHOPPER

SpecKit Companion

Follow a Spec Kit run from inside Claude Code: a band above the prompt with where the run stands, a pane with the steps, their times, the documents and the…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · speckit-companion
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /speckit-tracker ⎿ speckit-companion: No specs found ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

SpecKit Companion for Claude Code

Follow a Spec Kit run without leaving Claude Code. This mod adds a band above the prompt that says where the run stands, and a pane beside the transcript with the steps, their times, the documents and the tasks ticking off. It works with stock Spec Kit and with SpecKit Companion, and it only reads.

A Claude Code terminal with the mod's pane beside the transcript on its Run tab: Specify, Plan and Tasks ticked with their times, Implement running, the documents, and three of six tasks ticked. The band above the prompt names the spec and reads Plan done, Tasks 3/6, Implement running.

Docs · A run, step by step · speckit-companion.dev

Install

claude plugin marketplace add https://speckit-companion.dev/plugins/marketplace.json
claude plugin install speckit-companion@speckit-companion

You need Claude Code 2.1.287 or later, the first version with mods on by default. Check with claude --version.

In a session that is already open, run /reload-plugins. To check it loaded, run /plugin: the line under the tabs names speckit-companion among the active mods. To update later, run claude plugin update speckit-companion@speckit-companion.

The pane opens by itself in a terminal at least 144 columns wide: at the start when the project has a spec, or as soon as the first one appears. It opens once, so a pane you closed stays closed until you run /speckit-tracker. In a narrower terminal, run /speckit-tracker and the pane sits above the prompt.

What it shows

Both the band and the pane follow one spec at a time. They update while the agent works: after each tool call, and every few seconds for changes made outside the session.

The band

One line above the prompt: the spec you are following, a bar of ticked tasks, and where the run stands. A finished spec reads Completed · Tasks 12/12 · 38m active.

The band, one line: a yellow dot, 041-profile-photo-upload, a bar half filled, Plan done, Tasks 3/6, and Implement running in yellow.

The pane

Three tabs. Press 1, 2 or 3 to switch. The spec's title and status stay at the top of each.

TabWhat it shows
RunThe four steps (specify, plan, tasks, implement) with the time each one took, the spec's documents, and the task list by phase with a bar of ticked tasks
OverviewWhat the run recorded about the change: the intent, the approach, the decisions and why, what was verified, and any concerns
SpecsThe recent specs, to pick the one to follow

The pane on its Run tab for Profile photo upload, Implementing. Specify, Plan and Tasks are ticked with their times, Implement reads running, six documents are listed with a read hint on each, the task bar reads 3/6, and tasks T001 to T003 are ticked under Phase 1. The foot lists the keys.

A step shows a time only when the run record measured it, and the waits between steps count toward nothing. When a small change is specified, planned and tasked in one pass, Plan and Tasks read with Specify.

The colours come from your Claude Code theme. A finished step is green, the running step is the warning colour, a failed check is red, and times are dim. Each section has a heading in capitals.

Keys

The foot of the pane lists them.

KeyWhat it does
1 2 3Switch to Run, Overview or Specs
Tab or the arrow keysMove between steps and documents
↵Read the step or document you are on, inside the pane
oOpen that file in your editor
bGo back from a document
EscGive the keyboard back to the prompt

Read a step's document

On the Run tab, every step and document that has a file ends in ↵ read. Move to it and press Enter to read it in the pane, rendered as markdown. Specify opens the spec, Plan opens plan.md, and Tasks and Implement open tasks.md. Press b to go back. The document refreshes as the agent writes it. A step whose file does not exist yet says not written yet.

The pane showing specs/_02_demo-tasked/plan.md after Enter on Plan. Under the path are b: Back and o: Open in editor, then the plan's title, its Approach and its list of files.

Open a file in your editor

Press o on a step or a document, or while you are reading one. The file opens in the editor named by $VISUAL or $EDITOR when that editor has a window of its own (code, cursor, zed, subl and the like, never vim), else in Cursor or VS Code, else in your system's default app. When none can be started, the file's path is copied to the clipboard and a toast says so.

Switch specs with /speckit-tracker

You typeWhat happens
/speckit-trackerThe pane opens on its Specs tab
/speckit-tracker 42 or /speckit-tracker export-csvThe band and the pane follow that spec
/speckit-tracker autoThey go back to following the most recently active spec

Your pick is remembered for the project. /spec is a shorter name for the same command.

Works with stock Spec Kit

A stock Spec Kit project has no run record, because the Companion Spec Kit extension is what writes it. The mod then works from the files in the spec folder:

  • Each step says when its document was written, such as ✓ Plan written 7:18 PM · 4m after the spec. Implement reads 3 of 10 tasks · last change 2m ago while tasks are being ticked. A line under the steps says these are file times, not measured ones.
  • The line under the title says what is happening now, such as Writing the plan or Implementing: T004 next. Once the turn ends it reads Plan written · Tasks next.
  • Documents lists each file in the spec folder with what it holds: stories, requirements and open questions in the spec, tasks by phase, decisions in the research, and how much of each checklist is checked.
  • The Overview comes from the spec: the description, the user stories, the open questions, the first requirements, the success criteria and the plan's summary.
  • The last line names the next command, such as Next: /speckit-tasks.

The pane on its Run tab in a stock Spec Kit project. The line under the title reads Implementing: T004 next. Specify, Plan and Tasks each read written 10:15 AM, Implement reads 3 of 6 tasks, last change just now, and the last line reads Next: /speckit-implement.

With the Companion Spec Kit extension in the project, each step shows the time it took, and the Overview tab adds the run's intent, decisions and checks.

Good to know

  • The mod only reads. It never writes a spec file or the run record, and never sends a prompt. You run the /speckit-* commands yourself. The one command it runs is your editor's, when you press o.
  • Where it draws. The Claude Code terminal and the Code tab of the Claude Desktop app draw the band and the pane. The VS Code extension's chat panel and claude -p draw nothing, so there /speckit-tracker answers with text: the followed spec, its band line, and the recent specs.
  • Where it looks for specs. In specs/ and .specify/specs/, or in speckit.specDirectories from .vscode/settings.json when you set it.
  • Tested on Claude Code 2.1.291. The mods API can change between releases.

What it reads, runs and sends

  • Reads: the spec files and the run record in your project, and the VISUAL, EDITOR, TERM_PROGRAM and CURSOR_TRACE_ID environment variables, only to pick your editor. It does not read the conversation.
  • Runs: one program, your editor, and only when you press o. It tries $VISUAL or $EDITOR when that editor has a window of its own, then cursor or code, then the system opener (open or xdg-open), each with the file's path as its only argument. When none works, the path goes to your clipboard.
  • Sends: nothing. The mod makes no network request and has no telemetry.
  • Hooks: session.start to find the specs, tool.call and turn.complete to look at the files again after the agent writes, and ui.focus to remember which row you are on in its own pane. It passes every call through unchanged.

The other places SpecKit Companion runs

The mod reads the same run record as the other two surfaces, so all three show the same steps, times and task counts.

  • VS Code: the SpecKit Companion extension has the sidebar, the spec viewer with review comments, and the Overview.
  • GitHub Copilot app: the spec board lists every spec next to the chat and runs the next step from a button.

Docs: install, what it shows, switch specs. Changes are in the changelog. MIT licensed.

Develop

npm run mod:build                          # from the repo root: rebuild hooks/vendor and the test fixtures
npm run test:mod                           # build, then claude plugin test
claude plugin validate --strict ./apps/claude-mod
claude --plugin-dir ./apps/claude-mod      # load this checkout for one session, reloading on save
FileJob
hooks/register.jsThe hooks module: every call to Claude Code, the file reads, the band, the pane and /speckit-tracker.
hooks/board.jsWhat the band, the pane's three tabs, a step's document and the text replies say, worked out from the rows. No IO.
hooks/vendor/board-rules.mjsGenerated by build.mjs from apps/copilot-canvas/spec-rules.mjs, the board's own rules for statuses, steps, tasks and timing. Never edit by hand.
tests/claude plugin test suites. fixtures/demo-specs.js is generated from the repo's specs/_0N_demo-* fixtures, because a plugin test cannot read files.

A hooks module may import only files inside its plugin, so the board's rules arrive as a generated bundle. CI fails when the bundle or the fixtures are stale.


This repository is a release mirror. The mod is developed in alfredoperez/speckit-companion; issues and pull requests go there.

Source 3 files
hooks/register.js 743 lines
1// Every $ call lives here, as the hooks module rules require; the mod only reads, never submits a prompt, and runs one command: the editor, when asked.
2
3import {
4  DEFAULT_SPEC_DIRS,
5  PIPELINE_STEPS,
6  buildSpecRow,
7  findSpec,
8  isSpecFolder,
9  isWritten,
10  parseSpecContext,
11  parseSpecDirsSetting,
12  pickFeatureSpecName,
13  recordLiveIn,
14  sortSpecs,
15} from './vendor/board-rules.mjs'
16import {
17  FROM_FILES_NOTE,
18  bandParts,
19  defaultFollow,
20  documentChunks,
21  documentFacts,
22  documentKind,
23  editorCommands,
24  fileLink,
25  fileOverview,
26  followText,
27  listText,
28  overviewModel,
29  paneModel,
30  progressBar,
31  taskSummaryLines,
32} from './board.js'
33
34const REFRESH_MS = 3000
35const PANE = 'speckit-companion'
36const TITLE = 'SpecKit Companion'
37const PICKER_SIZE = 15
38const SCAN_BATCH = 32
39const COMMAND = 'speckit-tracker'
40const ALIAS = 'spec'
41const MAX_DOCS = 40
42const MAX_SUBDIRS = 8
43const MAX_PARSED_BYTES = 1 << 20
44// The documents whose text is read; every other markdown file is listed by name only.
45const PARSED = ['spec', 'plan', 'tasks', 'research', 'data-model', 'checklist']
46const COMPANION_SKILL = ['.claude', 'skills', 'speckit-companion-plan']
47const GLYPH = { completed: '✓', 'in-progress': '●', 'not-started': '○' }
48// Every colour is a theme key, so the pane follows the user's theme: one per state, one per section heading.
49const C = { accent: 'warning', done: 'success', failed: 'error', title: 'claude', steps: 'suggestion', documents: 'autoAccept', tasks: 'planMode', chip: 'subtle' }
50const HEADING = {
51  intent: C.steps,
52  approach: C.title,
53  'user stories': C.documents,
54  'open questions': C.accent,
55  expectations: C.tasks,
56  decisions: C.documents,
57  verified: C.done,
58  concerns: C.accent,
59  'success criteria': C.done,
60  'plan summary': C.title,
61}
62const RUNNING = { color: C.accent }
63const FAILED = { color: C.failed }
64const STEP_STYLE = { completed: { color: C.done }, 'in-progress': RUNNING, 'not-started': { dimColor: true } }
65const BAND_BAR = 8
66const BAND_BAR_MIN_COLUMNS = 80
67const ROW_WIDTH = 34
68const EDITOR_TIMEOUT_MS = 10000
69const READ_HINT = '↵ read'
70const TONE = { plain: {}, dim: { dimColor: true }, running: RUNNING }
71
72let view = 'run'
73let root = null
74let rows = []
75let pinned = null
76let followed = null
77let signature = ''
78let timer = null
79// The document that is open, and the control the Run view puts the focus on.
80let doc = null
81let focusKey = null
82// The step or document the focus was last on, which `o` opens from the Run tab.
83let ringKey = null
84let companionSkills = false
85// Whether Companion's recorder owns this project's run records, and the templates Spec Kit copies into a new spec folder.
86let recordLive = false
87let templates = {}
88const STEP_DOCS = ['spec', 'plan', 'tasks']
89// When the last turn of the main loop ended; a file written before then is not being written any more.
90let settledAt = null
91// The pane is offered once; after that it is the user's to close and to open.
92let offered = false
93// Each followed file's text and facts, kept until its time or size changes.
94const parsed = new Map()
95
96const at = (...parts) => [root, ...parts].join('/')
97const followKey = () => 'follow:' + root
98
99async function readText($, path) {
100  try {
101    return await $.fs.read(path)
102  } catch {
103    return null
104  }
105}
106
107async function listDir($, path) {
108  try {
109    return await $.fs.list(path)
110  } catch {
111    return null
112  }
113}
114
115/** One document's text and facts, read again only when the file's time or size changed. */
116async function readDocument($, path, entry, kind) {
117  const stamp = entry.mtimeMs > 0 ? entry.mtimeMs + ':' + entry.size : null
118  const hit = parsed.get(path)
119  if (stamp && hit?.stamp === stamp) return hit
120  const small = kind === 'spec' || kind === 'tasks' || !(entry.size > MAX_PARSED_BYTES)
121  const text = small ? await readText($, path) : null
122  // A file too large to read is taken as written.
123  const written = STEP_DOCS.includes(kind) ? (small ? isWritten(kind, text, templates[kind]) : true) : null
124  const next = { stamp, text: kind === 'spec' || kind === 'tasks' ? text : null, facts: documentFacts(kind, text), written }
125  parsed.set(path, next)
126  return next
127}
128
129/** The followed folder's markdown files, one level of subfolders deep, with when each was written and what it says. */
130async function readFolder($, id, entries, specFile) {
131  const found = entries.filter(f => f.kind === 'file' && f.name.endsWith('.md')).map(f => ({ rel: f.name, entry: f }))
132  let contracts = null
133  for (const dir of entries.filter(f => f.kind === 'dir' && !f.name.startsWith('.')).slice(0, MAX_SUBDIRS)) {
134    const inside = ((await listDir($, at(id, dir.name))) ?? []).filter(f => f.kind === 'file')
135    if (dir.name === 'contracts') contracts = inside.length
136    for (const f of inside) if (f.name.endsWith('.md')) found.push({ rel: dir.name + '/' + f.name, entry: f })
137  }
138  const texts = {}
139  const written = { spec: false, plan: false, tasks: false }
140  const files = await Promise.all(
141    found.slice(0, MAX_DOCS).map(async ({ rel, entry }) => {
142      const kind = documentKind(rel, specFile)
143      const read = PARSED.includes(kind) ? await readDocument($, at(id, rel), entry, kind) : null
144      if (read?.text != null) texts[kind] = read.text
145      if (read && !rel.includes('/') && kind in written) written[kind] = Boolean(read.written)
146      return { rel, kind, mtimeMs: entry.mtimeMs > 0 ? entry.mtimeMs : null, facts: read?.facts ?? null }
147    }),
148  )
149  return { folder: { files, contracts }, texts, written }
150}
151
152/** One folder's row; `full` also reads the folder's documents, which the list view can do without. */
153async function readSpec($, id, full) {
154  const entries = await listDir($, at(id))
155  if (!entries) return null
156  const names = entries.filter(f => f.kind === 'file').map(f => f.name)
157  if (!isSpecFolder(names)) return null
158  const ctxText = names.includes('.spec-context.json') ? await readText($, at(id, '.spec-context.json')) : null
159  const ctx = parseSpecContext(ctxText)
160  const specFile = pickFeatureSpecName(id.split('/').pop(), names)
161  const hasSpec = names.includes(specFile)
162  const hasTasks = names.includes('tasks.md')
163  const read = full ? await readFolder($, id, entries, specFile) : null
164  // A list row of a closed spec reads only the title its record lacks; every other row reads what the followed spec reads, so the two agree.
165  const closed = ctx?.status === 'completed' || ctx?.status === 'archived'
166  const doc = async (name, kind) => {
167    const entry = entries.find(f => f.kind === 'file' && f.name === name)
168    return entry ? readDocument($, at(id, name), entry, kind) : null
169  }
170  const light = read ? null : { spec: !closed || !ctx?.specName ? await doc(specFile, 'spec') : null, plan: closed ? null : await doc('plan.md', 'plan'), tasks: closed ? null : await doc('tasks.md', 'tasks') }
171  const specText = (read ? read.texts.spec : light.spec?.text) ?? null
172  const tasksText = (read ? read.texts.tasks : light.tasks?.text) ?? null
173  const written = read ? read.written : closed ? null : { spec: Boolean(light.spec?.written), plan: Boolean(light.plan?.written), tasks: Boolean(light.tasks?.written) }
174  const newest = Math.max(0, ...entries.map(f => f.mtimeMs || 0))
175  const row = buildSpecRow({
176    id,
177    ctx,
178    specText,
179    files: { spec: hasSpec ? specFile : null, plan: names.includes('plan.md') ? 'plan.md' : null, tasks: hasTasks ? 'tasks.md' : null },
180    written,
181    tasksText,
182    recordLive,
183    updatedAt: newest ? new Date(newest).toISOString() : null,
184  })
185  return { row, ctx, tasksText, ctxText, folder: read?.folder ?? null }
186}
187
188let knownFolders = ''
189
190/** The spec folders that exist right now. Cheap: it lists the spec directories and reads no spec. */
191async function listSpecIds($) {
192  const settings = await readText($, at('.vscode', 'settings.json'))
193  const dirs = (settings != null && parseSpecDirsSetting(settings)) || DEFAULT_SPEC_DIRS
194  const ids = []
195  for (const dir of dirs) {
196    const entries = await listDir($, at(dir))
197    for (const entry of entries ?? []) {
198      if (entry.kind === 'dir' && !entry.name.startsWith('.')) ids.push(dir.replace(/\/+$/, '') + '/' + entry.name)
199    }
200  }
201  return ids
202}
203
204/** Every spec folder under the spec directories, most recently active first. */
205async function scanAll($) {
206  const ids = await listSpecIds($)
207  knownFolders = ids.join('\n')
208  companionSkills = Boolean(await listDir($, at(...COMPANION_SKILL)))
209  recordLive = Boolean(await recordLiveIn(async path => (await readText($, at(path))) != null))
210  templates = {}
211  for (const kind of STEP_DOCS) templates[kind] = await readText($, at('.specify', 'templates', kind + '-template.md'))
212  const read = []
213  for (let i = 0; i < ids.length; i += SCAN_BATCH) {
214    read.push(...(await Promise.all(ids.slice(i, i + SCAN_BATCH).map(id => readSpec($, id, false)))))
215  }
216  rows = sortSpecs(read.filter(Boolean).map(r => r.row))
217}
218
219/** The hand-picked spec while its folder still exists; a vanished one counts as following the latest. */
220const activePin = () => (pinned && rows.some(r => r.id === pinned) ? pinned : null)
221
222/** Re-read the followed spec; true when anything it shows changed. */
223const target = () => activePin() ?? defaultFollow(rows)?.id ?? null
224
225async function refreshFollowed($) {
226  const now = await $.clock.now()
227  let id = target()
228  let next = id ? await readSpec($, id, true) : null
229  if (id && !next) {
230    // The followed folder went away; look again so the band falls back to another spec.
231    await scanAll($)
232    id = target()
233    next = id ? await readSpec($, id, true) : null
234  }
235  // The target moved while this read was in flight, so the call that moved it draws instead.
236  if (id !== target()) return false
237  if (doc && doc.spec !== id) doc = null
238  for (const path of parsed.keys()) if (!id || !path.startsWith(at(id) + '/')) parsed.delete(path)
239  // The texts that count minutes are part of what is shown, so a minute passing redraws too.
240  const live = next ? [bandParts(next.row, next.ctx, next.folder, now, settledAt), paneModel(next.row, next.ctx, next.tasksText, { folder: next.folder, now, companionSkills, settledAt })] : null
241  const sig = next ? [JSON.stringify(next.row), next.ctxText, next.tasksText, JSON.stringify(next.folder), JSON.stringify(live)].join('\u0000') : ''
242  followed = next
243  if (sig === signature) return false
244  signature = sig
245  return true
246}
247
248/** Re-read the open document, so it grows as the agent writes it; true when its text changed. */
249async function refreshDocument($) {
250  const open = doc
251  if (!open) return false
252  const text = await readText($, at(open.path))
253  if (doc !== open || text === open.text) return false
254  doc = { ...open, text }
255  return true
256}
257
258async function openDocument($, key, step, path) {
259  const spec = followed?.row.id
260  const text = await readText($, at(path))
261  focusKey = ringKey = key
262  doc = { spec, step, path, text }
263  $.ui.invalidate('ui.render')
264  await moveFocus($, 'doc-back')
265}
266
267/** Opens a workspace file in the user's editor; when no command works the path goes to the clipboard instead. */
268async function openInEditor($, path) {
269  const set = read => read.catch(() => undefined)
270  const env = {
271    visual: await set($.env.get('VISUAL')),
272    editor: await set($.env.get('EDITOR')),
273    termProgram: await set($.env.get('TERM_PROGRAM')),
274    cursor: await set($.env.get('CURSOR_TRACE_ID')),
275  }
276  for (const argv of editorCommands(at(path), env)) {
277    try {
278      const { exitCode } = await $.process.run(argv, { timeoutMs: EDITOR_TIMEOUT_MS })
279      if (exitCode === 0) return $.ui.toast(`Opened ${path} with ${argv[0].split('/').pop()}`)
280    } catch {
281      // Not installed here; the next command may be.
282    }
283  }
284  const copied = await $.ui.copy({ text: at(path) }).catch(() => null)
285  $.ui.toast(copied?.isCopied ? `No editor command worked, so the path of ${path} is on the clipboard` : `No editor command worked for ${path}`)
286}
287
288/** autoFocus only counts when the pane takes the keyboard, so a redraw that swaps the controls moves the focus itself. */
289async function moveFocus($, key) {
290  try {
291    await $.ui.focus({ requestId: PANE, key })
292  } catch {
293    // Without the keyboard there is no focus to move.
294  }
295}
296
297async function tick($) {
298  try {
299    // A spec created after the session started is in no row yet, so a new or removed folder means looking again.
300    if ((await listSpecIds($)).join('\n') !== knownFolders) await scanAll($)
301    const changed = await refreshFollowed($)
302    if ((await refreshDocument($)) || changed) $.ui.invalidate('ui.render')
303  } catch {
304    // A failed read leaves the last drawing up; the next tick tries again.
305  }
306}
307
308/** Follow a spec by id, or the latest with null, and remember the choice for this project. */
309async function follow($, id) {
310  pinned = id
311  if (id) await $.store.set(followKey(), id)
312  else await $.store.delete(followKey())
313  await refreshFollowed($)
314  $.ui.invalidate('ui.render')
315}
316
317/** Only the terminal and the Desktop app draw a mod's pane and band. */
318async function drawsHere($) {
319  const surfaces = await $.session.surfaces()
320  return surfaces.includes('terminal') || surfaces.includes('desktop')
321}
322
323/** Opened unasked, Claude Code places the pane only beside the transcript of a wide terminal. */
324async function offerPane($) {
325  if (offered || !followed || !(await drawsHere($))) return
326  offered = true
327  await $.ui.open({ id: PANE, title: TITLE })
328}
329
330async function start($, cwd) {
331  root = cwd ?? (await $.session.cwd())
332  const saved = await $.store.get(followKey())
333  pinned = typeof saved === 'string' ? saved : null
334  await scanAll($)
335  await refreshFollowed($)
336  timer?.cancel()
337  // The timer offers the pane for a session's first spec, so it is placed by the same width rule as at the start.
338  timer = $.clock.every(REFRESH_MS, () => tick($).then(() => offerPane($)).catch(() => undefined))
339  const commands = [
340    [COMMAND, 'Show the specs and pick the one the SpecKit Companion pane follows'],
341    [ALIAS, 'Same as /' + COMMAND],
342  ]
343  for (const [name, description] of commands) {
344    try {
345      await $.command.register({ name, description, argumentHint: '[number | name | auto]', immediate: true })
346    } catch {
347      // A name another command already holds is skipped, and the other name still works.
348    }
349  }
350  await offerPane($)
351}
352
353/** A task bar: the filled part in the running colour, or the done colour once every task is ticked. */
354function barText(Text, bar) {
355  return Text({
356    children: [
357      ...(bar.filled ? [Text({ color: bar.done ? C.done : C.accent, children: [bar.filled] })] : []),
358      ...(bar.empty ? [Text({ dimColor: true, children: [bar.empty] })] : []),
359    ],
360  })
361}
362
363/** The band's facts after the spec name: dim, with the running step bold and the next step in the accent colour. */
364function bandTexts(Text, parts) {
365  const at = parts.findIndex(p => p.running || p.next)
366  const live = parts[at]
367  const texts = some => some.map(p => p.text)
368  const lead = [''].concat(texts(live ? parts.slice(0, at) : parts), live ? [''] : []).join(' · ')
369  const trail = live ? [''].concat(texts(parts.slice(at + 1))).join(' · ') : ''
370  return [
371    ...(lead ? [Text({ dimColor: true, wrap: 'truncate-end', children: [lead] })] : []),
372    ...(live ? [Text({ ...RUNNING, bold: live.running, wrap: 'truncate-end', children: [live.text] })] : []),
373    ...(trail ? [Text({ dimColor: true, wrap: 'truncate-end', children: [trail] })] : []),
374  ]
375}
376
377/** The band's leading dot: done, running, or waiting for its next step. */
378function bandDot(Text, row, parts) {
379  if (row.done || row.steps.implement === 'completed') return Text({ color: C.done, children: ['● '] })
380  if (parts.some(p => p.running)) return Text({ ...RUNNING, children: ['● '] })
381  return Text({ dimColor: true, children: ['○ '] })
382}
383
384let starting = null
385
386/** A mod loaded by /reload-plugins never sees session.start, so the first hook that runs starts it. */
387function ensureStarted($) {
388  if (root) return undefined
389  starting ??= start($).catch(() => undefined).finally(() => {
390    starting = null
391  })
392  return starting
393}
394
395/** /speckit-tracker and its alias /spec: follow a spec, then open the pane or answer in text. */
396async function runCommand($, e) {
397  await ensureStarted($)
398  const query = e.args.trim()
399  await scanAll($)
400  if (!rows.length) return { text: 'No specs found' }
401  if (query === 'auto') {
402    await follow($, null)
403  } else if (query) {
404    const hit = findSpec(rows, query)
405    if (!hit) return { text: `No spec matches "${query}"` }
406    await follow($, hit.id)
407  } else {
408    await refreshFollowed($)
409  }
410  if (!followed) return { text: 'No specs found' }
411  if (!(await drawsHere($))) {
412    const now = await $.clock.now()
413    return { text: query ? followText(followed, activePin(), now) : listText(followed, rows, activePin(), now) }
414  }
415  view = query ? 'run' : 'specs'
416  doc = null
417  offered = true
418  await $.ui.open({ id: PANE, title: TITLE, focus: true })
419  $.ui.invalidate('ui.render')
420  return {}
421}
422
423export function register(on) {
424  on('session.start', async ($, e, next) => {
425    await start($, e.cwd)
426    return next(e)
427  })
428
429  on('ui.focus', ($, e, next) => {
430    // The person's move names the element; the mod's own $.ui.focus names it as the key it asked for.
431    const key = e.element ?? e.key
432    if (e.requestId === PANE && typeof key === 'string' && /^(step-|doc-)/.test(key) && key !== 'doc-back') ringKey = key
433    return next(e)
434  })
435
436  on('command.run', { command: COMMAND }, runCommand)
437  on('command.run', { command: ALIAS }, runCommand)
438
439  // Capture writes arrive through the agent's tool calls, so look again once each one finishes.
440  on('tool.call', async ($, e, next) => {
441    const result = await next(e)
442    await ensureStarted($)
443    if (root) await tick($)
444    return result
445  })
446
447  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
448    await ensureStarted($)
449    if (!followed || e.props.hasSurvey) return next(e)
450    const { Box, Text } = $.ui.resolve(e)
451    const parts = bandParts(followed.row, followed.ctx, followed.folder, await $.clock.now(), settledAt)
452    const count = followed.row.tasks
453    const bar = e.props.bodyColumns >= BAND_BAR_MIN_COLUMNS ? progressBar(count?.checked ?? 0, count?.total ?? 0, BAND_BAR) : null
454    const mine = Box({
455      key: 'speckit-band',
456      flexDirection: 'row',
457      children: [
458        bandDot(Text, followed.row, parts),
459        Text({ bold: true, wrap: 'truncate-end', children: [followed.row.name] }),
460        ...(bar ? [Text({ children: [' '] }), barText(Text, bar)] : []),
461        ...bandTexts(Text, parts),
462      ],
463    })
464    const theirs = await next(e)
465    return theirs ? Box({ flexDirection: 'column', children: [mine, theirs] }) : mine
466  })
467
468  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
469    if (e.requestId !== PANE) return next(e)
470    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
471    // A Button takes no colour, so the tab in view is marked by the Text beside it.
472    const tab = (name, label, hotkey) =>
473      Box({
474        flexDirection: 'row',
475        children: [
476          Text({ ...RUNNING, bold: true, children: [view === name ? '▸' : ' '] }),
477          Button({
478            key: 'tab-' + name,
479            label,
480            hotkey,
481            plain: true,
482            dimColor: view !== name,
483            onPress: async () => {
484              view = name
485              doc = null
486              if (name === 'specs') await scanAll($)
487              $.ui.invalidate('ui.render')
488            },
489          }),
490        ],
491      })
492    const line = (text, style = {}) => Text({ wrap: 'truncate-end', ...style, children: [text] })
493    const para = (text, style = {}) => Text({ wrap: 'wrap', ...style, children: [text] })
494    const gap = () => line(' ')
495    const heading = (title, color) => line(title.toUpperCase(), { bold: true, color: color ?? HEADING[title.toLowerCase()] ?? C.tasks })
496    const section = (title, color) => [gap(), heading(title, color)]
497    // A background, not reverse video: the terminal draws the focused control in reverse.
498    const chip = (key, label) =>
499      Box({ flexDirection: 'row', columnGap: 1, children: [Text({ backgroundColor: C.chip, bold: true, children: [' ' + key + ' '] }), Text({ dimColor: true, children: [label] })] })
500    const readHint = () => Box({ flexShrink: 0, children: [Text({ dimColor: true, children: [READ_HINT] })] })
501    const editorButton = (path, label = 'Open in editor') =>
502      Button({
503        key: 'open-editor',
504        label,
505        hotkey: 'o',
506        plain: true,
507        dimColor: true,
508        onPress: async () => {
509          const file = path()
510          if (file) await openInEditor($, file)
511          else $.ui.toast('Move to a step or a document first, then press o')
512        },
513      })
514    const width = Math.max(12, Math.min(e.props.bodyColumns ?? ROW_WIDTH, ROW_WIDTH))
515    const now = await $.clock.now()
516    const m = followed ? paneModel(followed.row, followed.ctx, followed.tasksText, { folder: followed.folder, now, companionSkills, settledAt }) : null
517    const header = [Box({ flexDirection: 'row', columnGap: 2, children: [tab('run', 'Run', '1'), tab('overview', 'Overview', '2'), tab('specs', 'Specs', '3')] })]
518    if (m) header.push(line(m.title, { bold: true, color: C.title }), line(m.recorded ? m.name + ' · ' + m.statusLabel : m.name, { dimColor: true }))
519    if (m?.activity) header.push(line(m.activity.text, m.activity.live ? RUNNING : { dimColor: true }))
520    const body = []
521    let readable = false
522
523    if (view === 'specs') {
524      body.push(line('Pick the spec this pane and the band follow.', { dimColor: true }))
525      body.push(
526        Button({
527          key: 'follow-auto',
528          label: activePin() ? 'Follow the latest' : 'Follow the latest (now)',
529          plain: true,
530          onPress: async () => {
531            view = 'run'
532            await follow($, null)
533          },
534        }),
535      )
536      rows.slice(0, PICKER_SIZE).forEach((spec, i) => {
537        body.push(
538          Button({
539            key: 'follow-' + i,
540            label: spec.name + ' · ' + spec.statusLabel,
541            plain: true,
542            dimColor: spec.id !== followed?.row.id,
543            onPress: async () => {
544              view = 'run'
545              await follow($, spec.id)
546            },
547          }),
548        )
549      })
550    } else if (!followed) {
551      body.push(line('No specs found in this project yet. Run /speckit-specify or /speckit-companion-specify to start one.'))
552    } else if (view === 'overview') {
553      const o = overviewModel(followed.row, followed.ctx)
554      const f = fileOverview(followed.folder)
555      const item = text => para('- ' + text)
556      const titled = title => (body.length ? section(title) : [heading(title)])
557      const stories = () => {
558        if (f.stories.length) body.push(...titled('User stories'), ...f.stories.map(s => item(s.priority ? s.title + ' · ' + s.priority : s.title)))
559        if (f.questions.length) body.push(...titled('Open questions'), ...f.questions.map(q => para('- ' + q, RUNNING)))
560      }
561      if (!o) {
562        if (f.description) body.push(para(f.description))
563        stories()
564        if (f.requirements) {
565          body.push(...titled(f.requirements.title), ...f.requirements.first.map(item))
566          if (f.requirements.more) body.push(line(f.requirements.more + ' more in the spec', { dimColor: true }))
567        }
568        if (f.success.length) body.push(...titled('Success criteria'), ...f.success.map(item))
569        if (f.summary) body.push(...titled('Plan summary'), para(f.summary))
570        if (f.empty) body.push(para('The spec files have nothing to summarise yet.', { dimColor: true }))
571        body.push(gap(), para(FROM_FILES_NOTE, { dimColor: true }))
572      } else if (o.empty) {
573        body.push(para('The run record has no overview details yet.', { dimColor: true }))
574        stories()
575      } else {
576        if (o.intent) body.push(heading('Intent'), para(o.intent))
577        if (o.approach) body.push(...titled('Approach'), para(o.approach))
578        if (o.facts) body.push(...(body.length ? [gap()] : []), line(o.facts, { dimColor: true }))
579        stories()
580        if (o.expectations.length) body.push(...section('Expectations'), line('Out of scope', { dimColor: true }), ...o.expectations.map(item))
581        if (o.decisions.length) {
582          body.push(...section('Decisions'))
583          for (const d of o.decisions) body.push(item(d.decision), ...(d.why ? [para('  ' + d.why, { dimColor: true })] : []))
584        }
585        if (o.verified.length) {
586          body.push(...section('Verified'))
587          for (const v of o.verified) {
588            body.push(
589              Box({
590                flexDirection: 'row',
591                columnGap: 1,
592                children: [para('- ' + v.what), ...(v.failed ? [Text({ ...FAILED, bold: true, children: [v.mark] })] : [])],
593              }),
594              ...(v.result ? [para('  ' + v.result, { dimColor: true })] : []),
595            )
596          }
597        }
598        if (o.concerns.length) body.push(...section('Concerns'), ...o.concerns.map(item))
599        if (o.requirements) body.push(gap(), line(o.requirements))
600      }
601    } else if (doc) {
602      const link = fileLink(root, doc.path)
603      body.push(link ? Markdown({ key: 'doc-path', text: link }) : line(doc.path, { bold: true, color: C.documents }))
604      body.push(
605        Box({
606          key: 'doc-controls',
607          flexDirection: 'row',
608          columnGap: 3,
609          children: [
610            Button({
611              key: 'doc-back',
612              label: 'Back',
613              hotkey: 'b',
614              plain: true,
615              autoFocus: true,
616              onPress: async () => {
617                doc = null
618                $.ui.invalidate('ui.render')
619                await moveFocus($, focusKey)
620              },
621            }),
622            editorButton(() => doc?.path),
623          ],
624        }),
625        gap(),
626      )
627      const did = doc.step === 'implement' ? taskSummaryLines(followed.ctx) : []
628      if (did.length) {
629        body.push(line('What each finished task did', { bold: true, color: C.tasks }), ...did.map(t => para(t.id + ' ' + t.did)), gap())
630      }
631      if (doc.text == null) {
632        body.push(line('not written yet', { dimColor: true }))
633      } else {
634        const { chunks, note } = documentChunks(doc.text)
635        if (!chunks.length) body.push(line('This file is empty.', { dimColor: true }))
636        chunks.forEach((text, i) => body.push(Markdown({ key: 'doc-' + i, text })))
637        if (note) body.push(gap(), line(note, { dimColor: true }))
638      }
639    } else {
640      const focus = key => (focusKey === key ? { autoFocus: true } : {})
641      body.push(heading('Steps', C.steps))
642      // Measured times sit in one column, so the read hints after them do too.
643      const timed = m.steps.some(s => s.time)
644      for (const s of m.steps) {
645        const pressable = Boolean(s.document)
646        const notes = []
647        if (s.time) notes.push(Box({ flexGrow: 1 }), Box({ flexShrink: 0, children: [Text({ dimColor: true, children: [s.time] })] }))
648        else if (s.notes.length) notes.push(...s.notes.map(n => Text({ ...TONE[n.tone], wrap: 'truncate-end', children: [n.text] })))
649        else if (s.state === 'in-progress') notes.push(Text({ ...RUNNING, children: ['running'] }))
650        else if (s.folded) notes.push(Text({ dimColor: true, children: ['with Specify'] }))
651        // Without a record Implement has no file of its own to wait for, so it says nothing until tasks are ticked.
652        const awaited = PIPELINE_STEPS.includes(s.step) && !pressable && (m.recorded || s.step !== 'implement')
653        if (awaited) notes.push(Text({ dimColor: true, children: ['not written yet'] }))
654        const name = pressable
655          ? Button({ key: 'step-' + s.step, label: s.label, plain: true, ...focus('step-' + s.step), onPress: () => openDocument($, 'step-' + s.step, s.step, s.document) })
656          : Text({ children: [s.label] })
657        body.push(
658          Box({
659            key: 'row-' + s.step,
660            flexDirection: 'row',
661            columnGap: 1,
662            ...(s.time || (timed && pressable) ? { width } : {}),
663            children: [Text({ ...STEP_STYLE[s.state], children: [GLYPH[s.state]] }), Box({ width: 10, children: [name] }), ...notes, ...(pressable ? [...(timed && !s.time ? [Box({ flexGrow: 1 })] : []), readHint()] : [])],
664          }),
665        )
666      }
667      if (m.total) body.push(line(m.total, { dimColor: true }))
668      if (m.footnote) body.push(para(m.footnote, { dimColor: true }))
669      if (m.documents.length) body.push(...section('Documents', C.documents))
670      for (const d of m.documents) {
671        const name = d.path
672          ? Button({ key: d.key, label: d.label, plain: true, ...focus(d.key), onPress: () => openDocument($, d.key, null, d.path) })
673          : Text({ children: [d.label] })
674        body.push(
675          Box({ key: 'row-' + d.key, flexDirection: 'row', columnGap: 2, children: [name, ...(d.note ? [Text({ dimColor: true, wrap: 'truncate-end', children: [d.note] })] : []), ...(d.path ? [readHint()] : [])] }),
676        )
677        // A line of its own, so a narrow pane cannot cut the one fact that needs an answer.
678        if (d.warn) body.push(line('  ' + d.warn, RUNNING))
679      }
680      const files = { ...Object.fromEntries(m.steps.map(s => ['step-' + s.step, s.document])), ...Object.fromEntries(m.documents.map(d => [d.key, d.path])) }
681      readable = Object.values(files).some(Boolean)
682      if (readable) body.push(gap(), editorButton(() => files[ringKey], 'Open the focused file in your editor'))
683      const bar = progressBar(m.tasks.checked, m.tasks.total, width - (m.tasks.checked + '/' + m.tasks.total).length - 1)
684      if (bar) {
685        body.push(
686          ...section('Tasks', C.tasks),
687          Box({
688            key: 'task-bar',
689            flexDirection: 'row',
690            columnGap: 1,
691            width,
692            children: [barText(Text, bar), Text({ ...(bar.done ? { color: C.done } : { dimColor: true }), children: [m.tasks.checked + '/' + m.tasks.total] })],
693          }),
694        )
695      }
696      // Tasks under no phase heading are counted by the bar alone.
697      const named = m.phases.length > 1 || m.phases[0]?.name !== 'Tasks'
698      m.phases.forEach((phase, i) => {
699        if (named) {
700          body.push(
701            ...(i ? [gap()] : []),
702            Box({
703              key: 'phase-' + i,
704              flexDirection: 'row',
705              columnGap: 2,
706              children: [
707                Box({ flexShrink: 1, children: [line(phase.name, { bold: true })] }),
708                Box({ flexShrink: 0, children: [Text({ ...(phase.checked === phase.total ? { color: C.done } : { dimColor: true }), children: [phase.checked + '/' + phase.total] })] }),
709              ],
710            }),
711          )
712        }
713        for (const t of phase.tasks) {
714          const mark = t.checked ? Text({ color: C.done, children: ['✓'] }) : t.current ? Text({ ...RUNNING, bold: true, children: ['▸'] }) : Text({ dimColor: true, children: ['○'] })
715          const style = t.checked ? { dimColor: true } : t.current ? RUNNING : {}
716          body.push(Box({ flexDirection: 'row', columnGap: 1, children: [mark, Box({ flexShrink: 1, children: [line(t.id + ' ' + t.text, style)] })] }))
717        }
718      })
719      if (m.next) body.push(gap(), para(m.next, { dimColor: true }))
720    }
721
722    const hints = [chip('1', 'Run'), chip('2', 'Overview'), chip('3', 'Specs'), ...(doc && view === 'run' ? [chip('b', 'Back'), chip('o', 'Editor')] : []), ...(readable ? [chip('↵', 'Read'), chip('o', 'Editor')] : []), chip('Esc', 'Prompt')]
723    return Box({
724      flexDirection: 'column',
725      children: [
726        Box({ key: 'pane', flexDirection: 'column', children: [Box({ key: 'header', flexDirection: 'column', children: header }), gap(), ...body] }),
727        gap(),
728        Box({ key: 'hints', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: hints }),
729      ],
730    })
731  })
732
733  // A new run may have started a new spec; follow it unless the user picked one. Whatever the turn was writing is written.
734  on('turn.complete', async ($, e, next) => {
735    if (root && !e.agentId) {
736      settledAt = await $.clock.now()
737      await scanAll($)
738      await tick($)
739    }
740    return next(e)
741  })
742}
743
hooks/vendor/board-rules.mjs 607 lines
1// Generated by apps/claude-mod/build.mjs from apps/copilot-canvas/spec-rules.mjs. Do not edit.
2
3// apps/copilot-canvas/tasks.mjs
4var FENCE_PATTERN = /^\s*(`{3,}|~{3,})/;
5var INLINE_CODE_PATTERN = /(`+)[^`]*?\1/g;
6var TASK_LINE_PATTERN = /^\s*[-*+]\s*\[([ xX])\]\s*(?:\*\*)?(T\d+)(?:\*\*)?\s*(.*)$/;
7var PHASE_HEADING = /^#{2,3}\s+(.+?)\s*$/;
8var CHECKBOX_LINE = /^\s*(?:[-*+]|\d+[.)])\s+\[[ xX]\]\s+\S/;
9function* proseEntries(content) {
10  let openFence = null;
11  for (const raw of content.split(/\r\n?|\n/)) {
12    const fence = raw.match(FENCE_PATTERN)?.[1];
13    if (openFence) {
14      if (fence && fence[0] === openFence[0] && fence.length >= openFence.length) openFence = null;
15      continue;
16    }
17    if (fence) {
18      openFence = fence;
19      continue;
20    }
21    yield { prose: raw.replace(INLINE_CODE_PATTERN, ""), raw };
22  }
23}
24function hasCheckboxLine(content) {
25  for (const { prose } of proseEntries(content)) if (CHECKBOX_LINE.test(prose)) return true;
26  return false;
27}
28function firstHeading(content) {
29  for (const { raw } of proseEntries(content)) {
30    const heading = raw.match(/^#\s+(.*?)\s*$/);
31    if (heading) return heading[1];
32  }
33  return null;
34}
35function countTaskCheckboxes(content) {
36  let checked = 0;
37  let total = 0;
38  for (const task of listTasks(content)) {
39    total++;
40    if (task.checked) checked++;
41  }
42  return { checked, total };
43}
44function listTasks(content) {
45  const tasks = [];
46  let phase = null;
47  for (const { prose, raw } of proseEntries(content)) {
48    const heading = prose.match(PHASE_HEADING);
49    if (heading) {
50      phase = heading[1];
51      continue;
52    }
53    const match = prose.match(TASK_LINE_PATTERN);
54    if (!match) continue;
55    const text = raw.match(TASK_LINE_PATTERN)?.[3] ?? match[3];
56    tasks.push({ id: match[2], checked: match[1].toLowerCase() === "x", text: text.trim(), phase });
57  }
58  return tasks;
59}
60function phaseProgress(content) {
61  const phases = /* @__PURE__ */ new Map();
62  for (const task of listTasks(content)) {
63    const key = task.phase ?? "Tasks";
64    const entry = phases.get(key) ?? { name: key, checked: 0, total: 0 };
65    entry.total++;
66    if (task.checked) entry.checked++;
67    phases.set(key, entry);
68  }
69  return [...phases.values()];
70}
71
72// apps/copilot-canvas/vendor/step-history.mjs
73var STEP_NAMES = [
74  "specify",
75  "clarify",
76  "plan",
77  "tasks",
78  "analyze",
79  "implement",
80  "converge"
81];
82var DEFAULT_PIPELINE_STEPS = ["specify", "plan", "tasks", "implement"];
83var SpecStatuses = {
84  ACTIVE: "active",
85  TASKS_DONE: "tasks-done",
86  // Terminal status written when the pipeline finishes implement autonomously
87  // (all tasks checked). Distinct from COMPLETED — `completed` is reserved for
88  // the user's explicit Mark Completed action; there is NO auto-advance from
89  // `implemented` to `completed`. A spec at `implemented` is done work awaiting
90  // that explicit user decision.
91  IMPLEMENTED: "implemented",
92  COMPLETED: "completed",
93  ARCHIVED: "archived"
94};
95function isStepLevelEntry(e) {
96  return e.substep == null && e.task == null;
97}
98function isTerminalStatus(status) {
99  return status === SpecStatuses.COMPLETED || status === SpecStatuses.ARCHIVED;
100}
101function dedupeConsecutive(transitions) {
102  const out = [];
103  for (const t of transitions) {
104    const prev = out[out.length - 1];
105    const sameStep = prev !== void 0 && prev.step === t.step;
106    const sameSubstep = (prev?.substep ?? null) === (t.substep ?? null);
107    const sameTask = (prev?.task ?? null) === (t.task ?? null);
108    const sameKind = (prev?.kind ?? null) === (t.kind ?? null);
109    const sameFromStep = (prev?.from?.step ?? null) === (t.from?.step ?? null);
110    const sameFromSubstep = (prev?.from?.substep ?? null) === (t.from?.substep ?? null);
111    if (sameStep && sameSubstep && sameTask && sameKind && sameFromStep && sameFromSubstep) continue;
112    out.push(t);
113  }
114  return out;
115}
116function rowName(t) {
117  if (t.substep !== null && t.substep !== void 0) return t.substep;
118  if (typeof t.task === "string") return t.task;
119  return null;
120}
121function groupStepsInOrder(transitions) {
122  const out = [];
123  const seen = /* @__PURE__ */ new Map();
124  for (let i = 0; i < transitions.length; i++) {
125    const t = transitions[i];
126    let entry = seen.get(t.step);
127    if (!entry) {
128      entry = { step: t.step, transitions: [], nextStepFirstIdx: -1 };
129      seen.set(t.step, entry);
130      out.push(entry);
131    }
132    entry.transitions.push(t);
133  }
134  for (let g = 0; g < out.length; g++) {
135    const cur = out[g];
136    const lastOwn = transitions.lastIndexOf(cur.transitions[cur.transitions.length - 1]);
137    for (let j = lastOwn + 1; j < transitions.length; j++) {
138      if (transitions[j].step !== cur.step) {
139        cur.nextStepFirstIdx = j;
140        break;
141      }
142    }
143  }
144  return out;
145}
146function buildSubsteps(stepTxs, fallbackEnd, stepStart) {
147  const subs = stepTxs.filter((t) => rowName(t) !== null);
148  const out = [];
149  let prevEnd = stepStart;
150  for (let i = 0; i < subs.length; i++) {
151    const s = subs[i];
152    const name = rowName(s);
153    const next = subs[i + 1];
154    if (s.kind === "complete") {
155      const completedAt2 = s.at ?? fallbackEnd;
156      out.push({ name, startedAt: prevEnd, completedAt: completedAt2 });
157      prevEnd = completedAt2 ?? prevEnd;
158      continue;
159    }
160    if (next && rowName(next) === name && next.kind === "complete") {
161      out.push({ name, startedAt: s.at, completedAt: next.at });
162      prevEnd = next.at;
163      i++;
164      continue;
165    }
166    const completedAt = next ? next.at : fallbackEnd;
167    out.push({ name, startedAt: s.at, completedAt });
168    prevEnd = completedAt ?? prevEnd;
169  }
170  return out;
171}
172var FOLD_WINDOW_MS = 1e3;
173var TRUSTED_BOUNDARY_WRITERS = /* @__PURE__ */ new Set(["extension", "cli", "derive", "user", "ai"]);
174function isTrustedBoundaryWriter(by) {
175  return by !== void 0 && TRUSTED_BOUNDARY_WRITERS.has(by);
176}
177function deriveStepHistory(transitions, currentStep, status) {
178  const out = {};
179  if (!transitions || transitions.length === 0) return out;
180  const deduped = dedupeConsecutive(transitions);
181  const isTerminal = isTerminalStatus(status);
182  const groups = groupStepsInOrder(deduped);
183  for (let i = 0; i < groups.length; i++) {
184    const g = groups[i];
185    const isCurrent = currentStep === g.step;
186    const isLastSeen = i === groups.length - 1;
187    const startedAt = g.transitions[0].at;
188    let completedAt = null;
189    let closeEntry = null;
190    const rawStepTransitions = transitions.filter((t) => t.step === g.step);
191    let lastStartIdx = -1;
192    rawStepTransitions.forEach((t, idx) => {
193      if (isStepLevelEntry(t) && t.kind === "start") lastStartIdx = idx;
194    });
195    const ownCompletes = rawStepTransitions.filter((t, idx) => idx > lastStartIdx && isStepLevelEntry(t) && t.kind === "complete");
196    const ownCompletion = ownCompletes.find((t) => isTrustedBoundaryWriter(t.by)) ?? ownCompletes[0] ?? null;
197    if (g.nextStepFirstIdx !== -1) {
198      const boundary = deduped[g.nextStepFirstIdx];
199      const gIdx = STEP_NAMES.indexOf(g.step);
200      const bIdx = STEP_NAMES.indexOf(boundary.step);
201      const rolledBack = gIdx >= 0 && bIdx >= 0 && bIdx < gIdx && isStepLevelEntry(boundary);
202      if (rolledBack) {
203        if (!ownCompletion) continue;
204        closeEntry = ownCompletion;
205      } else {
206        let attemptIdx = deduped.indexOf(g.transitions[0]);
207        deduped.forEach((t, idx) => {
208          if (t.step === g.step && isStepLevelEntry(t) && t.kind === "start") attemptIdx = idx;
209        });
210        const interruption = deduped.slice(attemptIdx + 1).find((t) => t.step !== g.step) ?? boundary;
211        if (ownCompletion === null) closeEntry = boundary;
212        else closeEntry = Date.parse(ownCompletion.at) <= Date.parse(interruption.at) ? ownCompletion : interruption;
213      }
214      completedAt = closeEntry.at;
215    } else if (ownCompletion) {
216      closeEntry = ownCompletion;
217      completedAt = closeEntry.at;
218    } else if (isLastSeen && isCurrent && isTerminal) {
219      closeEntry = g.transitions[g.transitions.length - 1];
220      completedAt = closeEntry.at;
221    } else if (isLastSeen && isCurrent) {
222      completedAt = null;
223    } else if (isLastSeen) {
224      closeEntry = g.transitions[g.transitions.length - 1];
225      completedAt = closeEntry.at;
226    }
227    const substeps = buildSubsteps(g.transitions, completedAt, startedAt);
228    const explicitStarts = rawStepTransitions.filter(
229      (t) => isStepLevelEntry(t) && t.kind === "start" && isTrustedBoundaryWriter(t.by)
230    );
231    const trustedStart = explicitStarts.length === 1 ? explicitStarts[0] : null;
232    const startMs = trustedStart ? Date.parse(trustedStart.at) : NaN;
233    const closeMs = completedAt ? Date.parse(completedAt) : NaN;
234    const closeIsOwnCompletion = closeEntry !== null && closeEntry.step === g.step && isStepLevelEntry(closeEntry) && closeEntry.kind === "complete" && isTrustedBoundaryWriter(closeEntry.by);
235    const closeIsNextStart = closeEntry !== null && closeEntry.step !== g.step && isStepLevelEntry(closeEntry) && closeEntry.kind === "start" && isTrustedBoundaryWriter(closeEntry.by);
236    const completionBeforeStart = trustedStart !== null && rawStepTransitions.some(
237      (t) => isStepLevelEntry(t) && t.kind === "complete" && Number.isFinite(Date.parse(t.at)) && Date.parse(t.at) < startMs
238    );
239    const competingLaterStart = trustedStart !== null && completedAt !== null && rawStepTransitions.some(
240      (t) => t !== trustedStart && isStepLevelEntry(t) && t.kind === "start" && Number.isFinite(Date.parse(t.at)) && Date.parse(t.at) > startMs && Date.parse(t.at) <= closeMs
241    );
242    const spanTrusted = trustedStart !== null && completedAt !== null && (closeIsOwnCompletion || closeIsNextStart) && Number.isFinite(startMs) && Number.isFinite(closeMs) && closeMs > startMs && !completionBeforeStart && !competingLaterStart;
243    const entry = {
244      startedAt: spanTrusted ? trustedStart.at : startedAt,
245      completedAt
246    };
247    entry.durationTrusted = spanTrusted;
248    const foldStart = explicitStarts.length === 1 && explicitStarts[0].by === "extension" ? explicitStarts[0] : null;
249    const foldStartMs = foldStart ? Date.parse(foldStart.at) : NaN;
250    const foldClose = rawStepTransitions.find(
251      (t) => isStepLevelEntry(t) && t.kind === "complete" && t.by === "extension" && Number.isFinite(Date.parse(t.at)) && Date.parse(t.at) >= foldStartMs
252    );
253    const prevGroupClose = i > 0 ? [...groups[i - 1].transitions].reverse().find((t) => isStepLevelEntry(t) && t.kind === "complete" && t.by === "extension") : void 0;
254    const foldSpanMs = foldClose ? Date.parse(foldClose.at) - foldStartMs : NaN;
255    const foldGapMs = prevGroupClose ? foldStartMs - Date.parse(prevGroupClose.at) : NaN;
256    if (foldStart !== null && foldSpanMs >= 0 && foldSpanMs < FOLD_WINDOW_MS && foldGapMs >= 0 && foldGapMs < FOLD_WINDOW_MS) {
257      entry.folded = true;
258    }
259    if (substeps.length > 0) entry.substeps = substeps;
260    out[g.step] = entry;
261  }
262  let previous = null;
263  for (const step of STEP_NAMES) {
264    const entry = out[step];
265    if (!entry?.durationTrusted || !entry.completedAt) continue;
266    const current = {
267      name: step,
268      start: Date.parse(entry.startedAt),
269      end: Date.parse(entry.completedAt)
270    };
271    if (previous && current.start < previous.end) {
272      out[previous.name].durationTrusted = false;
273      entry.durationTrusted = false;
274      previous = null;
275    } else {
276      previous = current;
277    }
278  }
279  return out;
280}
281function deriveTimingSummary(stepHistory, expectedPhases = DEFAULT_PIPELINE_STEPS) {
282  const expected = [...new Set(expectedPhases.filter(Boolean))];
283  const measured = expected.filter((name) => {
284    const entry = stepHistory[name];
285    return entry?.durationTrusted === true && !!entry.completedAt;
286  });
287  const base = {
288    measuredPhases: measured.length,
289    expectedPhases: expected.length,
290    complete: expected.length > 0 && measured.length === expected.length
291  };
292  if (!base.complete) return base;
293  const entries = expected.map((name) => stepHistory[name]);
294  const spans = entries.map((entry) => ({
295    start: Date.parse(entry.startedAt),
296    end: Date.parse(entry.completedAt)
297  }));
298  const sequenceValid = spans.every(
299    (span, index) => Number.isFinite(span.start) && Number.isFinite(span.end) && span.end > span.start && (index === 0 || span.start >= spans[index - 1].end)
300  );
301  if (!sequenceValid) return { ...base, complete: false };
302  const startedAt = entries[0].startedAt;
303  let endedAt = entries[entries.length - 1].completedAt;
304  let runEnd = Date.parse(endedAt);
305  const lastExpectedIdx = Math.max(...expected.map((name) => STEP_NAMES.indexOf(name)));
306  for (const name of lastExpectedIdx < 0 ? [] : STEP_NAMES.slice(lastExpectedIdx + 1)) {
307    const entry = stepHistory[name];
308    if (expected.includes(name) || entry?.durationTrusted !== true || !entry.completedAt) continue;
309    const [start, end] = [Date.parse(entry.startedAt), Date.parse(entry.completedAt)];
310    if (start < runEnd || !(end > start)) continue;
311    endedAt = entry.completedAt;
312    runEnd = end;
313  }
314  const runStart = Date.parse(startedAt);
315  const elapsedMs = Object.values(stepHistory).reduce((sum, entry) => {
316    if (entry?.durationTrusted !== true || !entry.completedAt) return sum;
317    const [start, end] = [Date.parse(entry.startedAt), Date.parse(entry.completedAt)];
318    return start >= runStart && end <= runEnd && end > start ? sum + (end - start) : sum;
319  }, 0);
320  if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return { ...base, complete: false };
321  return { ...base, startedAt, endedAt, elapsedMs };
322}
323function formatElapsed(ms) {
324  if (Number.isNaN(ms)) return "unknown";
325  const clamped = Math.max(0, ms);
326  if (clamped < 1e3) return "<1s";
327  const seconds = Math.floor(clamped / 1e3);
328  if (seconds < 60) return `${seconds}s`;
329  const minutes = Math.floor(seconds / 60);
330  const remSec = seconds % 60;
331  if (minutes < 60) return remSec === 0 ? `${minutes}m` : `${minutes}m ${remSec}s`;
332  const hours = Math.floor(minutes / 60);
333  const remMin = minutes % 60;
334  if (hours < 24) return remMin === 0 ? `${hours}h` : `${hours}h ${remMin}m`;
335  const days = Math.floor(hours / 24);
336  const remHours = hours % 24;
337  return remHours === 0 ? `${days}d` : `${days}d ${remHours}h`;
338}
339var IDLE_GAP_CAP_MS = 5 * 60 * 1e3;
340
341// apps/copilot-canvas/spec-rules.mjs
342var PIPELINE_STEPS = ["specify", "plan", "tasks", "implement"];
343var DEFAULT_SPEC_DIRS = ["specs", ".specify/specs"];
344var STATUS_LABELS = {
345  draft: "Draft",
346  specifying: "Specifying",
347  specified: "Specified",
348  planning: "Planning",
349  planned: "Planned",
350  tasking: "Tasking",
351  "ready-to-implement": "Ready to Implement",
352  implementing: "Implementing",
353  implemented: "Implemented",
354  completed: "Completed",
355  archived: "Archived"
356};
357var STATUS_REACH = {
358  draft: [0, null],
359  specifying: [0, "specify"],
360  specified: [1, null],
361  planning: [1, "plan"],
362  planned: [2, null],
363  tasking: [2, "tasks"],
364  "ready-to-implement": [3, null],
365  implementing: [3, "implement"],
366  implemented: [4, null],
367  completed: [4, null],
368  archived: [4, null]
369};
370var TERMINAL = /* @__PURE__ */ new Set(["completed", "archived"]);
371var NAMED_SPEC_SUFFIX = ".spec.md";
372function specStatusLabel(status) {
373  if (!status) return "No record";
374  return (Object.hasOwn(STATUS_LABELS, status) ? STATUS_LABELS[status] : null) ?? status.split("-").filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join(" ");
375}
376function pickFeatureSpecName(folderName, fileNames) {
377  const named = fileNames.filter((n) => n.endsWith(NAMED_SPEC_SUFFIX)).sort();
378  if (named.length > 0) {
379    const own = folderName.replace(/^\d+-/, "") + NAMED_SPEC_SUFFIX;
380    return named.includes(own) ? own : named[0];
381  }
382  return "spec.md";
383}
384function isSpecFolder(fileNames) {
385  return fileNames.some((n) => n.endsWith(".md") || n === ".spec-context.json");
386}
387function parseSpecDirsSetting(raw) {
388  try {
389    const settings = JSON.parse(raw.replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
390    const dirs = settings["speckit.specDirectories"];
391    if (Array.isArray(dirs) && dirs.length > 0) {
392      return dirs.filter((d) => typeof d === "string" && !/[*?[\]{}]/.test(d));
393    }
394  } catch {
395  }
396  return null;
397}
398function parseSpecContext(text) {
399  if (typeof text !== "string") return null;
400  try {
401    const parsed = JSON.parse(text);
402    return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
403  } catch {
404    return null;
405  }
406}
407var isStepLevel = (entry) => entry && entry.substep == null && entry.task == null;
408function deriveStepBadges(ctx) {
409  const history = Array.isArray(ctx?.history) ? ctx.history : [];
410  const [reached, inFlight] = typeof ctx?.status === "string" && Object.hasOwn(STATUS_REACH, ctx.status) ? STATUS_REACH[ctx.status] : [0, null];
411  const currentIdx = PIPELINE_STEPS.indexOf(ctx?.currentStep);
412  const badges = {};
413  PIPELINE_STEPS.forEach((step, idx) => {
414    const entries = history.filter((e) => e?.step === step);
415    const finished = entries.some((e) => e.kind === "complete" && isStepLevel(e));
416    if (finished || idx < reached || currentIdx >= 0 && idx < currentIdx) {
417      badges[step] = "completed";
418    } else if (inFlight === step || step === ctx?.currentStep && entries.length > 0 && !TERMINAL.has(ctx?.status)) {
419      badges[step] = "in-progress";
420    } else {
421      badges[step] = "not-started";
422    }
423  });
424  return badges;
425}
426function deriveBadgesFromFiles(files, tasks) {
427  const done = (step) => files[step] ? "completed" : "not-started";
428  let implement = "not-started";
429  if (tasks && tasks.total > 0) {
430    if (tasks.checked === tasks.total) implement = "completed";
431    else if (tasks.checked > 0) implement = "in-progress";
432  }
433  return { specify: done("spec"), plan: done("plan"), tasks: done("tasks"), implement };
434}
435function firstHeading2(markdown) {
436  const line = markdown?.split("\n").find((l) => /^#\s+/.test(l));
437  return line ? line.replace(/^#\s+/, "").replace(/^Feature Specification:\s*/i, "").trim() : null;
438}
439var FILE_STEPS = ["specify", "plan", "tasks"];
440var DOC_OF_STEP = { specify: "spec", plan: "plan", tasks: "tasks" };
441var DONE_STATUS = { specify: "specified", plan: "planned", tasks: "ready-to-implement", implement: "implemented" };
442var RUNNING_STATUS = { specify: "specifying", plan: "planning", tasks: "tasking", implement: "implementing" };
443var UNFILLED_TITLE = /^[^[\]]*:\s*\[FEATURE(?: NAME)?\]$/;
444var squash = (text) => text.replace(/\s+/g, " ").trim();
445var CONTEXT_WRITER = ".specify/extensions/companion/scripts/write-context.py";
446function recordLiveIn(exists) {
447  return exists(CONTEXT_WRITER);
448}
449function isWritten(kind, text, template = null) {
450  if (typeof text !== "string") return false;
451  const body = squash(text);
452  if (!body || UNFILLED_TITLE.test(firstHeading(text) ?? "") || typeof template === "string" && body === squash(template)) return false;
453  return kind !== "tasks" || hasCheckboxLine(text);
454}
455function writtenDocs(texts, templates = {}) {
456  return { spec: isWritten("spec", texts.spec, templates.spec), plan: isWritten("plan", texts.plan, templates.plan), tasks: isWritten("tasks", texts.tasks, templates.tasks) };
457}
458function statusRank(status) {
459  return Object.keys(STATUS_REACH).indexOf(status);
460}
461function statusFromSteps(steps) {
462  const running = PIPELINE_STEPS.find((step) => steps[step] === "in-progress");
463  if (running) return RUNNING_STATUS[running];
464  const last = [...PIPELINE_STEPS].reverse().find((step) => steps[step] === "completed");
465  return last ? DONE_STATUS[last] : "draft";
466}
467function withRunningStep(row, step) {
468  const steps = { ...row.steps, [step]: "in-progress" };
469  const status = RUNNING_STATUS[step];
470  return { ...row, steps, status, statusLabel: specStatusLabel(status), done: false };
471}
472function stepEvidence(row, step) {
473  if (step === "implement") return Boolean(row.tasks && row.tasks.total > 0 && row.tasks.checked === row.tasks.total);
474  return Boolean(row.written?.[DOC_OF_STEP[step]]);
475}
476function fillFromFiles(ctx, recorded, fromFiles) {
477  const history = Array.isArray(ctx.history) ? ctx.history : [];
478  const open = PIPELINE_STEPS.findIndex((step) => recorded[step] === "in-progress");
479  const steps = { ...recorded };
480  FILE_STEPS.forEach((step, idx) => {
481    const untouched = recorded[step] === "not-started" && !history.some((e) => e?.step === step);
482    if (untouched && (open === -1 || idx < open) && fromFiles[step] === "completed") steps[step] = "completed";
483  });
484  return steps;
485}
486function filesLead(recorded, fromFiles) {
487  const steps = {};
488  for (const step of PIPELINE_STEPS) steps[step] = recorded[step] === "completed" ? "completed" : fromFiles[step];
489  PIPELINE_STEPS.forEach((step, idx) => {
490    const overtaken = PIPELINE_STEPS.slice(idx + 1).some((later) => steps[later] !== "not-started");
491    if (recorded[step] === "in-progress" && steps[step] === "not-started" && !overtaken) steps[step] = "in-progress";
492  });
493  return steps;
494}
495function reconcileSteps(ctx, recorded, fromFiles, recordLive) {
496  const steps = recordLive ? fillFromFiles(ctx, recorded, fromFiles) : filesLead(recorded, fromFiles);
497  return PIPELINE_STEPS.every((step) => steps[step] === recorded[step]) ? null : steps;
498}
499function buildSpecRow({ id, ctx, specText, files, written = null, tasksText, updatedAt, recordLive = true }) {
500  const name = id.split("/").pop();
501  const tasks = tasksText != null ? countTaskCheckboxes(tasksText) : null;
502  const present = written ?? { spec: Boolean(files.spec), plan: Boolean(files.plan), tasks: Boolean(files.tasks) };
503  const fromFiles = deriveBadgesFromFiles(present, tasks);
504  const recorded = ctx ? deriveStepBadges(ctx) : null;
505  const reconciled = ctx ? reconcileSteps(ctx, recorded, fromFiles, recordLive) : null;
506  const steps = reconciled ?? recorded ?? fromFiles;
507  const recordedStatus = typeof ctx?.status === "string" ? ctx.status : null;
508  const status = reconciled && !TERMINAL.has(recordedStatus) ? statusFromSteps(steps) : recordedStatus;
509  const history = Array.isArray(ctx?.history) ? ctx.history : [];
510  const lastActivity = history.reduce((max, e) => typeof e?.at === "string" && e.at > max ? e.at : max, "") || null;
511  const done = status ? TERMINAL.has(status) : steps.implement === "completed";
512  const pendingReviews = Array.isArray(ctx?.reviewComments) ? ctx.reviewComments.filter((c) => c?.status !== "applied").length : 0;
513  return {
514    id,
515    name,
516    number: name.match(/^(\d+)-/)?.[1] ?? null,
517    local: name.startsWith("_"),
518    title: typeof ctx?.specName === "string" && ctx.specName || firstHeading2(specText) || name,
519    workflow: typeof ctx?.workflow === "string" ? ctx.workflow : null,
520    branch: typeof ctx?.branch === "string" ? ctx.branch : null,
521    hasContext: ctx != null,
522    status,
523    statusLabel: specStatusLabel(status),
524    currentStep: typeof ctx?.currentStep === "string" ? ctx.currentStep : null,
525    steps,
526    tasks,
527    files,
528    written: present,
529    done,
530    pendingReviews,
531    lastActivity,
532    updatedAt: updatedAt ?? null
533  };
534}
535var sortKey = (spec) => spec.lastActivity ?? spec.updatedAt ?? "";
536function sortSpecs(rows) {
537  return rows.sort((a, b) => sortKey(b).localeCompare(sortKey(a)) || b.name.localeCompare(a.name));
538}
539function findSpec(specs, query) {
540  const q = String(query ?? "").trim().replace(/\/+$/, "");
541  if (!q) return null;
542  return specs.find((s) => s.id === q) ?? specs.find((s) => s.name === q) ?? specs.find((s) => s.number && s.number === q.padStart(s.number.length, "0")) ?? specs.find((s) => s.name.toLowerCase().includes(q.toLowerCase())) ?? null;
543}
544function currentTask(ctx) {
545  const open = /* @__PURE__ */ new Set();
546  for (const entry of Array.isArray(ctx?.history) ? ctx.history : []) {
547    if (typeof entry?.task !== "string") continue;
548    if (entry.kind === "start") open.add(entry.task);
549    else if (entry.kind === "complete") open.delete(entry.task);
550  }
551  return [...open].pop() ?? null;
552}
553function stepTiming(ctx) {
554  const history = (Array.isArray(ctx?.history) ? ctx.history : []).filter((entry) => entry && typeof entry.step === "string" && typeof entry.at === "string");
555  return deriveStepHistory(history, ctx?.currentStep, ctx?.status);
556}
557function phaseTimings(ctx) {
558  const timing = stepTiming(ctx);
559  const known = PIPELINE_STEPS.filter((s) => timing[s]);
560  const names = [...known, ...Object.keys(timing).filter((s) => !PIPELINE_STEPS.includes(s))];
561  const phases = names.map((step) => {
562    const entry = timing[step];
563    const measured = entry.durationTrusted && entry.completedAt && !entry.folded;
564    return {
565      step,
566      durationMs: measured ? Date.parse(entry.completedAt) - Date.parse(entry.startedAt) : null,
567      inFlight: Boolean(entry.startedAt && !entry.completedAt)
568    };
569  });
570  const summary = deriveTimingSummary(timing, PIPELINE_STEPS);
571  const totalMs = summary.complete && summary.elapsedMs !== void 0 ? summary.elapsedMs : null;
572  return { phases, totalMs, measuredPhases: summary.measuredPhases, expectedPhases: summary.expectedPhases };
573}
574function timingSummaryText(timings) {
575  return timings.totalMs != null ? `${formatElapsed(timings.totalMs)} active` : `Timing coverage: ${timings.measuredPhases} of ${timings.expectedPhases} phases`;
576}
577export {
578  CONTEXT_WRITER,
579  DEFAULT_SPEC_DIRS,
580  PIPELINE_STEPS,
581  buildSpecRow,
582  countTaskCheckboxes,
583  currentTask,
584  deriveBadgesFromFiles,
585  deriveStepBadges,
586  findSpec,
587  formatElapsed,
588  isSpecFolder,
589  isWritten,
590  listTasks,
591  parseSpecContext,
592  parseSpecDirsSetting,
593  phaseProgress,
594  phaseTimings,
595  pickFeatureSpecName,
596  recordLiveIn,
597  sortSpecs,
598  specStatusLabel,
599  statusFromSteps,
600  statusRank,
601  stepEvidence,
602  stepTiming,
603  timingSummaryText,
604  withRunningStep,
605  writtenDocs
606};
607
hooks/board.js 639 lines
1// What the band, the pane and the text replies say, from rows the shared board rules built. No IO.
2
3import { PIPELINE_STEPS, currentTask, formatElapsed, listTasks, phaseTimings, stepTiming, timingSummaryText } from './vendor/board-rules.mjs'
4
5const SEP = ' · '
6// Optional Spec Kit phases, in the order they run around the pipeline; other history steps are not phases.
7const STEP_ORDER = ['specify', 'clarify', 'plan', 'tasks', 'analyze', 'implement', 'converge']
8const RECENT = 10
9const COMMAND = 'speckit-tracker'
10const ITEM_MAX = 400
11const CHUNK_MAX = 10000
12const DOC_MAX = 60000
13const FOLD_MS = 1000
14const RECENT_MS = 2 * 60000
15const TICKING_MS = 10 * 60000
16const NOTE_MAX = 60
17const TITLE_MAX = 120
18const FIRST_REQUIREMENTS = 5
19const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
20const DOC_OF_STEP = { specify: 'spec', plan: 'plan', tasks: 'tasks' }
21const STEP_OF_DOC = { spec: 'Specify', plan: 'Plan', tasks: 'Tasks' }
22const GAP_AFTER = { plan: ['spec', 'the spec'], tasks: ['plan', 'the plan'] }
23const PLAN_KINDS = ['plan', 'research', 'data-model', 'quickstart', 'contract']
24export const NO_RECORD_NOTE = 'Times are when each file was last written. Nothing recorded this run.'
25export const FROM_FILES_NOTE = 'From the spec files. Install the Companion Spec Kit extension to record step times, decisions and what was verified.'
26const cap = s => s.charAt(0).toUpperCase() + s.slice(1)
27// A file last written before the turn ended is finished, however lately that was; `settledAt` is when the last turn ended.
28const lately = (at, now, within, settledAt) => at > 0 && now - at <= within && (settledAt == null || at > settledAt)
29
30/** The most recently active unfinished spec, else the most recent one. Rows arrive sorted. */
31export function defaultFollow(rows) {
32  return rows.find(r => !r.done) ?? rows[0] ?? null
33}
34
35/** The band's facts in order, the one naming a running step marked; empty with no spec. */
36export function bandParts(row, ctx, folder, now, settledAt = null) {
37  if (!row) return []
38  const { steps, tasks } = row
39  const counted = tasks != null && tasks.total > 0
40  const fact = text => (text ? { text, running: false } : null)
41  if (!ctx && folder && now != null && !row.done) {
42    const fromFiles = fileBandParts(row, folder, now, settledAt)
43    if (fromFiles) return fromFiles
44  }
45  const live = step => (step ? { text: `${cap(step)} running`, running: true } : null)
46  const upNext = step => (step ? { text: `${cap(step)} next`, running: false, next: true } : null)
47  const count = fact(counted ? `Tasks ${tasks.checked}/${tasks.total}` : null)
48  if (row.done || steps.implement === 'completed') {
49    const timings = ctx ? phaseTimings(ctx) : null
50    // A phase after implement, such as converge, can still be running on a finished pipeline.
51    const extra = timings?.phases.find(p => p.inFlight && STEP_ORDER.includes(p.step) && !PIPELINE_STEPS.includes(p.step))
52    const tail = extra ? live(extra.step) : fact(timings?.totalMs != null ? `${formatElapsed(timings.totalMs)} active` : null)
53    return [fact(row.status ? row.statusLabel : 'Done'), count, tail].filter(Boolean)
54  }
55  // The task count stands in for a finished tasks step, so it is not also named as done.
56  const done = PIPELINE_STEPS.filter(s => steps[s] === 'completed' && !(s === 'tasks' && counted)).pop()
57  const running = PIPELINE_STEPS.find(s => steps[s] === 'in-progress')
58  const next = running ? null : PIPELINE_STEPS.find(s => steps[s] === 'not-started')
59  return [fact(done ? `${cap(done)} done` : null), count, live(running), upNext(next)].filter(Boolean)
60}
61
62/** The band of a run with no record: the last document written and how long ago, or the task count while tasks are ticked. */
63function fileBandParts(row, folder, now, settledAt) {
64  const written = kind => folder.files.find(f => f.kind === kind)?.mtimeMs || null
65  const tasks = row.tasks
66  if (tasks?.total > 0 && tasks.checked > 0) {
67    const changed = written('tasks')
68    const count = { text: `Implement ${tasks.checked}/${tasks.total}`, running: lately(changed, now, TICKING_MS, settledAt) }
69    return changed != null ? [count, { text: `last change ${ago(now - changed)}`, running: false }] : [count]
70  }
71  const last = ['tasks', 'plan', 'spec'].find(kind => written(kind))
72  if (!last) return null
73  const next = PIPELINE_STEPS.find(s => row.steps[s] !== 'completed')
74  return [
75    { text: `${STEP_OF_DOC[last]} written ${ago(now - written(last))}`, running: false },
76    ...(next ? [{ text: `${cap(next)} next`, running: false, next: true }] : []),
77  ]
78}
79
80/** A bar of `width` cells for real task counts: full only when every task is ticked, never empty once one is. Null with no tasks. */
81export function progressBar(checked, total, width) {
82  if (!(total > 0) || !(width >= 1)) return null
83  const done = checked >= total
84  const share = Math.floor((Math.max(0, checked) / total) * width)
85  const cells = done ? width : Math.min(width - 1, checked > 0 ? Math.max(1, share) : 0)
86  return { filled: '█'.repeat(cells), empty: '░'.repeat(width - cells), done }
87}
88
89/** "Plan done · Tasks 7/12 · Implement running" for a spec, or null with no spec. */
90export function bandLine(row, ctx, folder, now) {
91  return row ? bandParts(row, ctx, folder, now).map(p => p.text).join(SEP) : null
92}
93
94/** "7:14 PM" for a time today, "Oct 3, 7:14 PM" for another day, in the local time zone. */
95export function writtenAt(ms, now) {
96  const d = new Date(ms)
97  const n = new Date(now)
98  const hours = d.getHours()
99  const time = `${hours % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`
100  const today = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
101  return today ? time : `${MONTHS[d.getMonth()]} ${d.getDate()}, ${time}`
102}
103
104/** "2m ago" for a span in milliseconds; under a minute, or a file time ahead of the clock, is "just now". */
105export function ago(ms) {
106  const minutes = Math.floor(ms / 60000)
107  if (!(minutes >= 1)) return 'just now'
108  if (minutes < 60) return `${minutes}m ago`
109  const hours = Math.floor(minutes / 60)
110  return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
111}
112
113const between = ms => {
114  const minutes = Math.floor(ms / 60000)
115  if (minutes < 60) return `${minutes}m`
116  const hours = Math.floor(minutes / 60)
117  if (hours >= 24) return `${Math.floor(hours / 24)}d`
118  return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`
119}
120
121/** What a step says from its file when nothing measured it: when it was written, or the task count for Implement. */
122function fileNotes(step, row, folder, now, recorded, settledAt) {
123  if (!folder || now == null) return []
124  const written = kind => folder.files.find(f => f.kind === kind)?.mtimeMs || null
125  if (step === 'implement') {
126    const tasks = row.tasks
127    if (recorded || !tasks?.total || !tasks.checked) return []
128    const count = `${tasks.checked} of ${tasks.total} tasks`
129    if (tasks.checked === tasks.total) return [{ text: count, tone: 'plain' }]
130    const changed = written('tasks')
131    const live = lately(changed, now, TICKING_MS, settledAt)
132    return [{ text: count, tone: live ? 'running' : 'dim' }, ...(changed != null ? [{ text: `· last change ${ago(now - changed)}`, tone: 'dim' }] : [])]
133  }
134  const at = written(DOC_OF_STEP[step])
135  if (!at) return []
136  const notes = [{ text: `written ${writtenAt(at, now)}`, tone: 'plain' }]
137  const [before, name] = GAP_AFTER[step] ?? []
138  const earlier = before ? written(before) : null
139  // Ticking a task rewrites tasks.md, so its time stops saying when the list was written.
140  const ticked = step === 'tasks' && row.tasks?.checked > 0
141  // Files checked out together are seconds apart, which says nothing about the run.
142  if (earlier && at - earlier >= 60000 && !ticked) notes.push({ text: `· ${between(at - earlier)} after ${name}`, tone: 'dim' })
143  return notes
144}
145
146/** The file each pipeline step opens, relative to the workspace, or null while it is not written. */
147export function stepDocument(row, step) {
148  const file = { specify: row.files?.spec, plan: row.files?.plan, tasks: row.files?.tasks, implement: row.files?.tasks }[step]
149  return file ? `${row.id}/${file}` : null
150}
151
152const WINDOWED_EDITORS = ['code', 'code-insiders', 'codium', 'cursor', 'windsurf', 'zed', 'subl', 'mate', 'idea', 'webstorm', 'fleet']
153
154/** The commands that could open a file in an editor window, in the order to try them: $VISUAL or $EDITOR, the editor whose terminal this is, VS Code, then the system's opener. */
155export function editorCommands(path, env = {}) {
156  const commands = []
157  const add = argv => {
158    if (!commands.some(c => c[0] === argv[0])) commands.push(argv)
159  }
160  for (const set of [env.visual, env.editor]) {
161    const [bin, ...flags] = String(set ?? '').trim().split(/\s+/)
162    const name = bin.split(/[\\/]/).pop().replace(/\.(exe|cmd)$/i, '')
163    // vim and its kind need the terminal the pane is drawn in, so only an editor with a window of its own is run, and never told to wait.
164    if (WINDOWED_EDITORS.includes(name)) add([bin, ...flags.filter(f => f !== '-w' && f !== '--wait'), path])
165  }
166  if (/vscode|cursor/i.test(env.termProgram ?? '')) add([env.cursor || /cursor/i.test(env.termProgram) ? 'cursor' : 'code', path])
167  for (const bin of ['code', 'open', 'xdg-open']) add([bin, path])
168  return commands
169}
170
171/** A markdown link that shows a workspace path and points at the file itself, for a terminal that opens file links; null off POSIX paths. */
172export function fileLink(root, path) {
173  if (typeof root !== 'string' || !root.startsWith('/')) return null
174  const href = 'file://' + encodeURI(root.replace(/\/+$/, '') + '/' + path).replace(/[()#?]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase())
175  return `[${path.replace(/[\\\[\]_*`<>]/g, '\\$&')}](${href})`
176}
177
178/** Plan or tasks finished inside the specify pass: done, with no span of its own, while specify has one. */
179export function foldedSteps(row, ctx) {
180  const timing = stepTiming(ctx ?? {})
181  const measured = step => Boolean(timing[step]?.durationTrusted && timing[step].completedAt && !timing[step].folded)
182  if (!measured('specify')) return []
183  return ['plan', 'tasks'].filter(step => {
184    if (row.steps[step] !== 'completed' || measured(step)) return false
185    const entry = timing[step]
186    if (!entry || entry.folded) return true
187    return Boolean(entry.completedAt) && Math.abs(Date.parse(entry.completedAt) - Date.parse(entry.startedAt)) < FOLD_MS
188  })
189}
190
191/** Everything the pane's Run view draws for the followed spec. */
192export function paneModel(row, ctx, tasksText, { folder = null, now = null, companionSkills = false, settledAt = null } = {}) {
193  const recorded = Boolean(ctx)
194  const fromFiles = !recorded && folder != null && now != null
195  const timings = phaseTimings(ctx ?? {})
196  const timeOf = step => timings.phases.find(p => p.step === step)
197  const time = phase => (phase?.durationMs != null ? formatElapsed(phase.durationMs) : null)
198  // The four pipeline steps always show; clarify, analyze and converge only once the record has them.
199  const folded = foldedSteps(row, ctx)
200  const steps = STEP_ORDER.flatMap(step => {
201    const phase = timeOf(step)
202    if (PIPELINE_STEPS.includes(step)) {
203      const measured = time(phase)
204      const isFolded = folded.includes(step)
205      const state = row.steps[step]
206      // A step takes its file's time only when nothing measured it, so the two never share a line.
207      const silent = measured || isFolded || (recorded && state !== 'completed')
208      const notes = silent ? [] : fileNotes(step, row, folder, now, recorded, settledAt)
209      // Without a record, tasks ticked a while ago do not mean Implement is running now.
210      const stalled = fromFiles && step === 'implement' && state === 'in-progress' && notes[0]?.tone !== 'running'
211      return [{ step, label: cap(step), state: stalled ? 'not-started' : state, time: measured, folded: isFolded, document: stepDocument(row, step), notes }]
212    }
213    return phase ? [{ step, label: cap(step), state: phase.inFlight ? 'in-progress' : 'completed', time: time(phase), folded: false, document: null, notes: [] }] : []
214  })
215  // A folded step's time is inside Specify, so a run whose other steps are all measured still has a total.
216  const measuredMs = PIPELINE_STEPS.map(step => timeOf(step)?.durationMs ?? null)
217  const foldedOnly = folded.length > 0 && PIPELINE_STEPS.every((step, i) => measuredMs[i] != null || folded.includes(step))
218  const summary = foldedOnly ? `${formatElapsed(measuredMs.reduce((sum, ms) => sum + (ms ?? 0), 0))} active` : timingSummaryText(timings)
219  const inFlight = ctx ? currentTask(ctx) : null
220  const phases = []
221  for (const task of listTasks(tasksText ?? '')) {
222    const name = task.phase ?? 'Tasks'
223    let phase = phases.find(p => p.name === name)
224    if (!phase) phases.push((phase = { name, checked: 0, total: 0, tasks: [] }))
225    phase.total++
226    if (task.checked) phase.checked++
227    phase.tasks.push({ id: task.id, text: task.text, checked: task.checked, current: task.id === inFlight })
228  }
229  return {
230    title: row.title,
231    name: row.name,
232    statusLabel: row.statusLabel,
233    steps,
234    total: timings.phases.length ? summary : null,
235    phases,
236    tasks: { checked: phases.reduce((sum, p) => sum + p.checked, 0), total: phases.reduce((sum, p) => sum + p.total, 0) },
237    recorded,
238    footnote: fromFiles && steps.some(st => st.notes.length) ? NO_RECORD_NOTE : null,
239    activity: recorded ? null : activityLine(row, folder, now, settledAt),
240    documents: documentLines(row, folder),
241    next: nextStepLine(row, ctx, companionSkills),
242  }
243}
244
245/** The text reply for bare `/speckit-tracker` where nothing draws. */
246export function listText(followed, rows, pinned, now) {
247  if (!rows.length) return 'No specs found'
248  const lines = []
249  if (followed) {
250    lines.push(`Following ${followed.row.name}${pinned ? '' : ' (picked automatically)'}: ${bandLine(followed.row, followed.ctx, followed.folder, now)}`, '')
251  }
252  lines.push('Recent specs:')
253  for (const row of rows.slice(0, RECENT)) lines.push(`  ${row.name}${SEP}${row.statusLabel}`)
254  lines.push('', `Run /${COMMAND} <number or name> to follow one, or /${COMMAND} auto to follow the latest.`)
255  return lines.join('\n')
256}
257
258/** The text reply after `/speckit-tracker <query>` or `/speckit-tracker auto` where nothing draws. */
259export function followText(followed, pinned, now) {
260  const lead = pinned ? `Following ${followed.row.name}` : `Following automatically: ${followed.row.name}`
261  return `${lead}\n${bandLine(followed.row, followed.ctx, followed.folder, now)}`
262}
263
264// Escape sequences and control characters a record or a file can carry; an element takes neither.
265const clean = value => String(value ?? '').replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
266const oneLine = value => clean(value).replace(/\s+/g, ' ').trim()
267const clip = value => {
268  const text = oneLine(value)
269  return text.length > ITEM_MAX ? text.slice(0, ITEM_MAX - 1).trimEnd() + '…' : text
270}
271const short = (value, max) => {
272  const text = oneLine(value)
273  return text.length > max ? text.slice(0, max - 1).trimEnd() + '…' : text
274}
275const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`
276const list = value => (Array.isArray(value) ? value : [])
277const names = value => (Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []).map(v => String(v).trim()).filter(Boolean)
278
279/** What the Overview tab shows, each part only when the record has it; null with no record. */
280export function overviewModel(row, ctx) {
281  if (!ctx) return null
282  const size = typeof ctx.size === 'string' ? ctx.size : typeof ctx.classification?.verdict === 'string' ? ctx.classification.verdict : null
283  const facts = [size ? `Size: ${oneLine(size)}` : null, row?.workflow ? `Workflow: ${oneLine(row.workflow)}` : null].filter(Boolean)
284  const decisions = list(ctx.decisions)
285    .map(d => (typeof d === 'string' ? { decision: clip(d), why: null } : { decision: clip(d?.decision), why: d?.why ? 'because ' + clip(d.why) : null }))
286    .filter(d => d.decision)
287  const verified = list(ctx.verified)
288    .map(v => {
289      if (typeof v === 'string') return { what: clip(v), result: null, failed: false }
290      const exit = typeof v?.exitCode === 'number' ? v.exitCode : null
291      const result = v?.result ? clip(v.result) : null
292      // A measured exit code outranks the words of the result, which can say "0 failed".
293      const failed = exit != null ? exit !== 0 : /^fail/i.test(result ?? '')
294      return { what: clip(v?.what), result, failed, mark: failed ? (exit ? `failed (exit ${exit})` : 'failed') : null }
295    })
296    .filter(v => v.what)
297  const coverage = ctx.coverage && typeof ctx.coverage === 'object' ? Object.values(ctx.coverage).filter(entry => entry && typeof entry === 'object') : []
298  const model = {
299    intent: typeof ctx.intent === 'string' && ctx.intent.trim() ? clip(ctx.intent) : null,
300    approach: typeof ctx.approach === 'string' && ctx.approach.trim() ? clip(ctx.approach) : null,
301    facts: facts.length ? facts.join(SEP) : null,
302    expectations: list(ctx.expectations).map(clip).filter(Boolean),
303    decisions,
304    verified,
305    concerns: list(ctx.concerns).map(c => clip(typeof c === 'string' ? c : c?.note)).filter(Boolean),
306    requirements: coverage.length
307      ? `Requirements: ${coverage.filter(entry => names(entry.tests).length > 0).length} covered by tests of ${coverage.length}`
308      : null,
309  }
310  const empty = !model.intent && !model.approach && !model.facts && !model.requirements
311    && !model.expectations.length && !model.decisions.length && !model.verified.length && !model.concerns.length
312  return { ...model, empty }
313}
314
315/** What each finished task did, from the record's task summaries, in task order. */
316export function taskSummaryLines(ctx) {
317  const summaries = ctx?.task_summaries
318  if (!summaries || typeof summaries !== 'object' || Array.isArray(summaries)) return []
319  return Object.keys(summaries)
320    .sort()
321    .filter(id => typeof summaries[id]?.did === 'string' && summaries[id].did.trim())
322    .map(id => ({ id, did: clip(summaries[id].did) }))
323}
324
325const cut = (text, max) => {
326  const pieces = []
327  for (let rest = text; rest; ) {
328    if (rest.length <= max) {
329      pieces.push(rest)
330      break
331    }
332    const line = rest.lastIndexOf('\n', max)
333    const end = line > 0 ? line : max
334    pieces.push(rest.slice(0, end))
335    rest = rest.slice(end).replace(/^\n/, '')
336  }
337  return pieces
338}
339
340/** A file's text as pieces one Markdown element each can hold, split between paragraphs, and how much was left out. */
341export function documentChunks(text, max = CHUNK_MAX, limit = DOC_MAX) {
342  const whole = clean(text)
343  const shown = whole.length > limit ? whole.slice(0, limit) : whole
344  const blocks = []
345  let block = []
346  let fence = false
347  const close = () => {
348    if (block.some(l => l.trim())) blocks.push(block.join('\n'))
349    block = []
350  }
351  for (const line of shown.split('\n')) {
352    if (/^\s*(```|~~~)/.test(line)) fence = !fence
353    // A blank line inside a code fence belongs to the code, so the fence stays in one piece.
354    if (!line.trim() && !fence) close()
355    else block.push(line)
356  }
357  close()
358  const chunks = []
359  let current = ''
360  for (const paragraph of blocks.flatMap(b => (b.length > max ? cut(b, max) : [b]))) {
361    if (current && current.length + 2 + paragraph.length > max) {
362      chunks.push(current)
363      current = ''
364    }
365    current = current ? current + '\n\n' + paragraph : paragraph
366  }
367  if (current) chunks.push(current)
368  const omitted = whole.length - shown.length
369  return { chunks, omitted, note: omitted ? `${omitted.toLocaleString('en-US')} more characters not shown` : null }
370}
371
372/** A file's lines outside HTML comments, each marked as inside a code fence or as a heading. */
373function scan(text) {
374  const rows = []
375  let fence = null
376  for (const line of clean(text).replace(/<!--[\s\S]*?-->/g, '').split('\n')) {
377    const mark = line.match(/^\s*(`{3,}|~{3,})/)?.[1]
378    if (fence) {
379      if (mark && mark[0] === fence[0] && mark.length >= fence.length) fence = null
380      else rows.push({ line, fenced: true, level: 0, title: null })
381      continue
382    }
383    if (mark) {
384      fence = mark
385      continue
386    }
387    const heading = line.match(/^(#{1,6})\s+(.+?)\s*$/)
388    rows.push({ line, fenced: false, level: heading ? heading[1].length : 0, title: heading ? heading[2] : null })
389  }
390  return rows
391}
392
393/** The rows under the first heading that matches, up to the next heading of its level or above. */
394function section(rows, pattern) {
395  const start = rows.findIndex(r => r.level && pattern.test(r.title))
396  if (start < 0) return null
397  const rest = rows.slice(start + 1)
398  const end = rest.findIndex(r => r.level && r.level <= rows[start].level)
399  return end < 0 ? rest : rest.slice(0, end)
400}
401
402/** The first run of plain prose lines: no heading, list, table, quote or `**Label**:` line. */
403function firstParagraph(rows) {
404  const lines = []
405  for (const row of rows) {
406    const text = row.line.trim()
407    const prose = !row.fenced && !row.level && text && !/^([-*+]\s|\d+[.)]\s|\||>|\*\*[^*]+\*\*\s*:|\*\*[^*]+:\*\*|-{3,}$)/.test(text)
408    if (prose) lines.push(text)
409    else if (lines.length) break
410  }
411  return lines.length ? clip(lines.join(' ')) : null
412}
413
414const plain = text => oneLine(text).replace(/\*\*|`/g, '')
415
416function specFacts(text) {
417  const rows = scan(text)
418  const stories = []
419  const requirements = []
420  const success = []
421  const questions = []
422  let input = null
423  for (const row of rows) {
424    if (row.fenced) continue
425    if (row.level) {
426      const story = row.title.match(/^User Story\s+\d+\s*[-–—:]\s*(.+)$/i)
427      if (story) {
428        const ranked = story[1].match(/^(.*?)\s*\(Priority:\s*(P\d)\)\s*$/i)
429        stories.push({ title: short(plain(ranked ? ranked[1] : story[1]), TITLE_MAX), priority: ranked ? ranked[2].toUpperCase() : null })
430      }
431      continue
432    }
433    const item = row.line.match(/^\s*[-*+]\s*\*\*((FR|SC)-\d+[a-z]?)\*\*\s*:?\s*(.*)$/i)
434    if (item) (item[2].toUpperCase() === 'FR' ? requirements : success).push(short(`${item[1].toUpperCase()} ${plain(item[3])}`, TITLE_MAX))
435    for (const marker of row.line.matchAll(/\[NEEDS CLARIFICATION(?:\s*:\s*([^\]]*))?\]?/gi)) {
436      const asked = oneLine(marker[1] ?? '')
437      questions.push(clip(asked || plain(row.line.replace(/^\s*[-*+]\s*/, ''))))
438    }
439    if (input == null) {
440      const given = row.line.match(/^\*\*Input\*\*\s*:\s*(.+)$/i) ?? row.line.match(/^\*\*Input:\*\*\s*(.+)$/i)
441      if (given) input = clip(given[1].replace(/^User description:\s*/i, '').replace(/^"(.*)"$/, '$1')) || null
442    }
443  }
444  return { description: input ?? firstParagraph(rows), stories, requirements, success, questions }
445}
446
447const TREE_FILE = /^[\w@.()[\]/-]*[\w)\]]\.[A-Za-z]\w{0,7}$/
448
449/** Files a plan names in its structure section: tree lines in a code block, or list items that open with a path in backticks. */
450function planFiles(rows) {
451  const part = section(rows, /^Source Code\b/i) ?? section(rows, /^(Project Structure|File Structure|Files)$/i)
452  if (!part) return null
453  let files = 0
454  for (const row of part) {
455    const name = row.fenced
456      ? row.line.replace(/^[\s│├└─|+\\-]*/, '').split(/\s+/)[0].replace(/,$/, '')
457      : row.line.match(/^\s*[-*+]\s+`([^`\s]+)`/)?.[1] ?? ''
458    if (TREE_FILE.test(name)) files++
459  }
460  return files || null
461}
462
463function planFacts(text) {
464  const rows = scan(text)
465  const summary = section(rows, /^Summary$/i)
466  return { summary: summary ? firstParagraph(summary) : null, files: planFiles(rows) }
467}
468
469function tasksFacts(text) {
470  const tasks = listTasks(text)
471  const open = tasks.find(t => !t.checked)
472  let phase = null
473  let inPhases = 0
474  const phases = new Set()
475  for (const row of scan(text)) {
476    if (row.fenced) continue
477    if (row.level === 2) phase = /^Phase\s+\d+/i.test(row.title) ? row.title : null
478    else if (/^\s*[-*+]\s*\[[ xX]\]\s*(?:\*\*)?T\d+/.test(row.line) && phase) {
479      phases.add(phase)
480      inPhases++
481    }
482  }
483  return {
484    total: tasks.length,
485    checked: tasks.filter(t => t.checked).length,
486    parallel: tasks.filter(t => /(^|\s)\[P\](\s|$)/.test(t.text)).length,
487    // A task outside every phase heading makes "in N phases" untrue, so the count is left out.
488    phases: phases.size && inPhases === tasks.length ? phases.size : null,
489    firstOpen: open ? { id: open.id, text: plain(open.text.replace(/\[(P|US\d+)\]\s*/g, '')) } : null,
490  }
491}
492
493function researchFacts(text) {
494  const rows = scan(text).filter(r => !r.fenced)
495  const labels = rows.filter(r => !r.level && /^\s*(?:[-*+]\s*)?\*{0,2}Decision(?:\*{0,2}\s*:|:\*{0,2})/i.test(r.line)).length
496  const headings = rows.filter(r => r.level >= 2 && /^Decision\b/i.test(r.title)).length
497  // Both ways of marking a decision in one file would count some twice.
498  return { decisions: labels && headings && labels !== headings ? null : labels || headings || null }
499}
500
501function dataModelFacts(text) {
502  const rows = scan(text).filter(r => !r.fenced)
503  const named = rows.filter(r => r.level >= 2 && /^Entity\s*[:\-–—]\s*\S/i.test(r.title)).length
504  const listed = section(rows, /^(Key )?Entities$/i)?.filter(r => r.level === 3).length ?? 0
505  return { entities: named || listed || null }
506}
507
508function checklistFacts(text) {
509  const boxes = scan(text).filter(r => !r.fenced).map(r => r.line.match(/^\s*[-*+]\s*\[([ xX])\]/)).filter(Boolean)
510  return { total: boxes.length, checked: boxes.filter(b => b[1] !== ' ').length }
511}
512
513/** Which Spec Kit document a markdown file in a spec folder is, by its path inside the folder. */
514export function documentKind(rel, specFile = 'spec.md') {
515  if (rel === specFile) return 'spec'
516  const top = { 'plan.md': 'plan', 'tasks.md': 'tasks', 'research.md': 'research', 'data-model.md': 'data-model', 'quickstart.md': 'quickstart' }[rel]
517  if (top) return top
518  if (/^checklists\/[^/]+\.md$/.test(rel)) return 'checklist'
519  return rel.startsWith('contracts/') ? 'contract' : 'other'
520}
521
522const PARSERS = { spec: specFacts, plan: planFacts, tasks: tasksFacts, research: researchFacts, 'data-model': dataModelFacts, checklist: checklistFacts }
523
524/** What one document says, counted from Spec Kit's own headings and markers; null for a kind with nothing to count. */
525export function documentFacts(kind, text) {
526  if (typeof text !== 'string' || !Object.hasOwn(PARSERS, kind)) return null
527  try {
528    return PARSERS[kind](text)
529  } catch {
530    return null
531  }
532}
533
534const factsOf = (folder, kind) => folder?.files.find(f => f.kind === kind)?.facts ?? null
535const baseName = rel => rel.split('/').pop().replace(/\.md$/, '')
536
537function specNote(facts) {
538  const { stories, requirements, success } = facts
539  const ranks = stories.every(s => s.priority) ? [...new Set(stories.map(s => s.priority))].sort() : []
540  const byRank = ranks.map(rank => `${stories.filter(s => s.priority === rank).length} ${rank}`).join(', ')
541  return [
542    stories.length ? plural(stories.length, 'story', 'stories') + (byRank ? ` (${byRank})` : '') : null,
543    requirements.length ? plural(requirements.length, 'requirement') : null,
544    success.length ? `${success.length} success criteria` : null,
545  ].filter(Boolean).join(SEP)
546}
547
548function tasksNote(facts) {
549  if (!facts.total) return ''
550  return [
551    plural(facts.total, 'task') + (facts.phases ? ` in ${plural(facts.phases, 'phase')}` : ''),
552    facts.parallel ? `${facts.parallel} can run in parallel` : null,
553  ].filter(Boolean).join(SEP)
554}
555
556/** The Documents block: one line per file in the spec folder, with what it holds and the file it opens. */
557export function documentLines(row, folder) {
558  if (!row || !folder) return []
559  const lines = []
560  const add = (file, label, note = '', warn = null) => lines.push({ key: 'doc-' + file.rel.replace(/[^\w.-]+/g, '-'), path: `${row.id}/${file.rel}`, label, note, warn })
561  const of = kind => folder.files.filter(f => f.kind === kind).sort((a, b) => a.rel.localeCompare(b.rel))
562  for (const file of of('spec')) {
563    const asked = file.facts?.questions.length ?? 0
564    add(file, 'spec', file.facts ? specNote(file.facts) : '', asked ? plural(asked, 'open question') : null)
565  }
566  for (const file of of('plan')) {
567    const facts = file.facts
568    add(file, 'plan', facts?.files ? `${plural(facts.files, 'file')} named` : facts?.summary ? short(facts.summary, NOTE_MAX) : '')
569  }
570  for (const file of of('tasks')) add(file, 'tasks', file.facts ? tasksNote(file.facts) : '')
571  for (const file of of('research')) add(file, 'research', file.facts?.decisions ? plural(file.facts.decisions, 'decision') : '')
572  for (const file of of('data-model')) add(file, 'data-model', file.facts?.entities ? plural(file.facts.entities, 'entity', 'entities') : '')
573  for (const file of of('quickstart')) add(file, 'quickstart')
574  for (const file of of('checklist')) add(file, 'checklist: ' + baseName(file.rel), file.facts?.total ? `${file.facts.checked} of ${file.facts.total} checked` : '')
575  const contracts = of('contract')
576  if (folder.contracts) {
577    // The count line opens the contract when there is one to read; several each get a line of their own.
578    lines.push({ key: 'doc-contracts', path: contracts.length === 1 ? `${row.id}/${contracts[0].rel}` : null, label: 'contracts', note: plural(folder.contracts, 'file'), warn: null })
579    if (contracts.length > 1) for (const file of contracts) add(file, 'contract: ' + baseName(file.rel))
580  }
581  for (const file of of('other')) add(file, file.rel.replace(/\.md$/, ''))
582  return lines
583}
584
585/** What is happening in a run with no record, from which files exist and how lately each changed; nothing written before `settledAt` reads as in progress. */
586export function activityLine(row, folder, now, settledAt = null) {
587  if (!row || !folder || now == null) return null
588  const has = kind => folder.files.some(f => f.kind === kind)
589  const fresh = (kinds, within = RECENT_MS) => lately(Math.max(0, ...folder.files.filter(f => kinds.includes(f.kind)).map(f => f.mtimeMs || 0)), now, within, settledAt)
590  const writing = text => ({ text, live: true })
591  // The band's own words, without its minutes: the step row beside it carries the time.
592  const written = (doc, step) => ({ text: (doc ? STEP_OF_DOC[doc] + ' written' + SEP : '') + cap(step) + ' next', live: false })
593  if (has('tasks')) {
594    const tasks = factsOf(folder, 'tasks')
595    if (tasks?.total > 0 && tasks.checked === tasks.total) return { text: `All ${plural(tasks.total, 'task')} ticked`, live: false }
596    if (tasks?.checked > 0 && tasks.firstOpen) {
597      const { id, text } = tasks.firstOpen
598      if (fresh(['tasks'], TICKING_MS)) return writing(`Implementing: ${id} next${text ? SEP + short(text, NOTE_MAX) : ''}`)
599      return { text: `Implement stopped at ${tasks.checked} of ${tasks.total}${SEP}${id} left`, live: false }
600    }
601    return fresh(['tasks']) ? writing('Writing the tasks') : written('tasks', 'implement')
602  }
603  if (has('plan')) return fresh(PLAN_KINDS) ? writing('Writing the plan') : written('plan', 'tasks')
604  if (has('spec')) return fresh(['spec', 'checklist']) ? writing('Writing the spec') : written('spec', 'plan')
605  return written(null, 'specify')
606}
607
608/** The Overview a spec's own files can give: what it is for, its stories, open questions, requirements and plan summary. */
609export function fileOverview(folder) {
610  const spec = factsOf(folder, 'spec')
611  const plan = factsOf(folder, 'plan')
612  const requirements = spec?.requirements ?? []
613  const model = {
614    description: spec?.description ?? null,
615    stories: spec?.stories ?? [],
616    questions: spec?.questions ?? [],
617    requirements: requirements.length
618      ? { title: `Requirements${SEP}${requirements.length}`, first: requirements.slice(0, FIRST_REQUIREMENTS), more: Math.max(0, requirements.length - FIRST_REQUIREMENTS) }
619      : null,
620    success: spec?.success ?? [],
621    summary: plan?.summary ?? null,
622  }
623  const empty = !model.description && !model.stories.length && !model.questions.length && !model.requirements && !model.success.length && !model.summary
624  return { ...model, empty }
625}
626
627/** "Next: /speckit-plan", the command for the step that comes next; null once the spec is complete or its last step is running. */
628export function nextStepLine(row, ctx, companionSkills = false) {
629  if (!row || row.done || row.steps.implement === 'completed') return null
630  const running = PIPELINE_STEPS.findIndex(s => row.steps[s] === 'in-progress')
631  // A recorded step in flight was already started, so the step after it is next; without a record nothing says a step is done but its file.
632  const step = ctx && running >= 0
633    ? PIPELINE_STEPS.slice(running + 1).find(s => row.steps[s] === 'not-started')
634    : PIPELINE_STEPS.find(s => row.steps[s] !== 'completed')
635  if (!step) return null
636  const companion = ctx ? /companion/i.test(row.workflow ?? '') : Boolean(companionSkills)
637  return companion ? `Next: /speckit-companion-${step} ${row.id}` : `Next: /speckit-${step}`
638}
639