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…

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.

Docs · A run, step by step · speckit-companion.dev
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.
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.
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.

Three tabs. Press 1, 2 or 3 to switch. The spec's title and status stay at the top of each.
| Tab | What it shows |
|---|---|
| Run | The 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 |
| Overview | What the run recorded about the change: the intent, the approach, the decisions and why, what was verified, and any concerns |
| Specs | The recent specs, to pick the one to follow |

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.
The foot of the pane lists them.
| Key | What it does |
|---|---|
1 2 3 | Switch to Run, Overview or Specs |
| Tab or the arrow keys | Move between steps and documents |
↵ | Read the step or document you are on, inside the pane |
o | Open that file in your editor |
b | Go back from a document |
Esc | Give the keyboard back to the prompt |
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.

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.
/speckit-tracker| You type | What happens |
|---|---|
/speckit-tracker | The pane opens on its Specs tab |
/speckit-tracker 42 or /speckit-tracker export-csv | The band and the pane follow that spec |
/speckit-tracker auto | They 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.
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:
✓ 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.Writing the plan or Implementing: T004 next. Once the turn ends it reads Plan written · Tasks next.Next: /speckit-tasks.
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.
/speckit-* commands yourself. The one command it runs is your editor's, when you press o.claude -p draw nothing, so there /speckit-tracker answers with text: the followed spec, its band line, and the recent specs.specs/ and .specify/specs/, or in speckit.specDirectories from .vscode/settings.json when you set it.VISUAL, EDITOR, TERM_PROGRAM and CURSOR_TRACE_ID environment variables, only to pick your editor. It does not read the conversation.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.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 mod reads the same run record as the other two surfaces, so all three show the same steps, times and task counts.
Docs: install, what it shows, switch specs. Changes are in the changelog. MIT licensed.
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
| File | Job |
|---|---|
hooks/register.js | The hooks module: every call to Claude Code, the file reads, the band, the pane and /speckit-tracker. |
hooks/board.js | What 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.mjs | Generated 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.
hooks/register.js 743 lines1// 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}
743hooks/vendor/board-rules.mjs 607 lines1// 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};
607hooks/board.js 639 lines1// 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