Experimental Claude Code mod for Phase Runner: a toast when a run needs you, and /phase-status with no model call. phase-viewer is the dashboard.

An experimental Claude Code mod for Phase Runner. It raises a toast when a run needs you, and /phase-status prints where the run is without a model call. It draws nothing else. For the full picture of a run, use phase-viewer, the dashboard.
Phase Runner works the same without it. Nothing in the main plugin depends on the mod, and the mod never writes to your project: it only reads docs/phases/ and the run logs in docs/phases/.runs/.
Toasts. One toast each time a run stops for you:
x.Each of these raises one toast, not one per poll. Two sprints that fail the same verify share a toast. Anything that happened before the session started raises nothing. The mod checks the newest run log every 2 seconds, so a toast lands within about 2 seconds of the line that caused it. The task lines an implementer logs as it starts and finishes each task raise no toast.
/phase-status. Prints the phase of the newest run log, each sprint's tasks as done/eligible, the current wave and gate, and when the last event was logged:
Phase 8 — Claude Integration
Sprints: 8.1 5/5 · 8.2 9/10 · 8.3 2/6 · 8.4 0/4 (16/25 tasks)
Now: wave 2 · 8.3 verify 2/3
Last event: 4:12 PM, 3 min ago
Now: names the wave, the sprint, the gate (implement, verify, wave-test or doc-sync) and the attempt. A gate that just failed reads verify failed 2/3 until the retry starts, and a sprint that has passed everything reads done. When the log has had no event for 30 minutes the line says so, and when the phase is complete it reads phase complete.
The mod answers this itself from the files on disk. It starts no turn and uses no tokens. The states and words are phase-viewer's own: the mod reads the files with the viewer's parser and derive code, so the two never disagree.
In Claude Code:
/plugin install phase-runner-hud --marketplace AyeJK/phase-runner
Start a new session from your project folder, the one that holds docs/phases/. In a folder with no docs/phases/ the mod stays quiet and nothing raises a toast.
Both are in /config once the mod is installed.
| Option | Default | What it does |
|---|---|---|
workspace | empty | The folder that holds docs/phases/, when it isn't the session's directory. Absolute, or relative to the session's directory. |
sound | off | Plays a short chime with each toast. In the terminal, Claude Code plays sounds on macOS only for now; on Windows and Linux the toast shows without one. |
This is a Claude Code mod, so it runs in Claude Code: the terminal and the desktop app's Code tab. Cursor and other hosts have no mods and won't load it. Phase Runner's skills run there as before.
Claude Code's mods API is early access and changes between releases. If a Claude Code update breaks the mod, uninstall it from /plugin. Your phase runs are not affected.
Versions before 0.3.0 also drew a Phase Runner pane with phase-viewer's screens and a live line for the running phase. Both are gone: phase-viewer is the one dashboard.
claude plugin validate hud # the manifest and the hooks module, as the engine reads them
claude plugin test hud # the tests in hud/test/
Run both from the repo root.
The mod reads phase files and run logs with the same parser and derive code as phase-viewer. A mod can only import files from its own folder, so those modules are copied from viewer/src/core/ into hud/hooks/core/. Don't edit the copies. After a change to the source, copy them again:
node hud/scripts/sync-core.mjs # write the copy
node hud/scripts/sync-core.mjs --check # exit 1 if the copy has drifted from the source
| Path | What it is |
|---|---|
hooks/register.ts | The hooks module: the poll, the toasts and /phase-status, every call on Claude Code's $ |
hooks/hud.ts | The toast and /phase-status texts, as plain functions |
hooks/core/ | Copied from the viewer. Don't edit |
types/index.d.ts | The values the mod keeps in $.state: when it started and which toasts it raised |
sounds/chime.wav | The sound the sound option plays |
test/ | Tests and their fixtures |
hooks/register.ts 229 lines1/**
2 * phase-runner-hud: the wiring between Claude Code and `hud.ts`.
3 *
4 * - `session.start` registers `/phase-status` and starts a 2-second poll.
5 * - Each poll lists `docs/phases/` and `docs/phases/.runs/`, reads the newest
6 * run log and its phase file when either changed, and raises any toast that
7 * is due.
8 * - `command.run` answers `/phase-status` from the same files. No model call.
9 *
10 * The mod draws nothing: no pane, no status line, no band. phase-viewer is the
11 * dashboard. Files are read through `$.fs` only (`exists`, `list`, `read`) and
12 * nothing is ever written to the project. With no `docs/phases/` the mod
13 * raises nothing.
14 *
15 * The engine wants every function that takes `$` declared at the top of this
16 * file, so what one load of the mod remembers travels in a {@link Memory}.
17 */
18import type { EngineInterface, Register } from 'claude-code';
19
20import type { HudSince, HudToasted } from '../types';
21import {
22 RUNS_DIR,
23 buildView,
24 completeKey,
25 isAbsolutePath,
26 isPhaseComplete,
27 joinPath,
28 newestRunLog,
29 occurrences,
30 phaseFileFor,
31 readOptions,
32 statusReport,
33 workspaceFolder,
34 type Entry,
35 type HudOptions,
36 type HudView,
37} from './hud.js';
38
39/** How often the files are checked. */
40const POLL_MS = 2000;
41/** How long a toast stays up. These ask for the user, so longer than the 4-second default. */
42const TOAST_MS = 8000;
43/** How many toast keys are kept, newest last. */
44const KEPT_KEYS = 100;
45/** The sound the `sound` option plays, a file of the mod. */
46const SOUND = 'sounds/chime.wav';
47
48const SINCE = { plugin: 'phase-runner-hud', key: 'since' } as const;
49const TOASTED = { plugin: 'phase-runner-hud', key: 'toasted' } as const;
50
51/** What one load of the mod remembers between polls. A hot reload starts it over. */
52interface Memory extends HudOptions {
53 /** The session's directory as `session.start` gave it, for a build without `$.session.root`. */
54 startDir: string;
55 /** `true` while a poll is running, so a slow one is never doubled. */
56 isPolling: boolean;
57 /** The last look at the workspace, kept while the files it read stay the same. */
58 loaded: Loaded | null;
59 /** Copies of the two `$.state` values, used on a build that has no `$.state` yet. */
60 since: HudSince | undefined;
61 toasted: HudToasted;
62}
63
64/** What one look at the workspace found. */
65interface Loaded {
66 /** Names, sizes and times of the files read, to tell when anything changed. */
67 stamp: string;
68 /** The workspace folder: the one that should hold `docs/phases/`. */
69 root: string;
70 /** `false` when the workspace has no `docs/phases/`. */
71 hasPhases: boolean;
72 /** The newest run log's phase; `null` without `docs/phases/` or with no run log yet. */
73 view: HudView | null;
74}
75
76/** The folder that should hold `docs/phases/`: the `workspace` option, or the session's directory. */
77async function workspaceRoot($: EngineInterface, mem: Memory): Promise<string> {
78 let dir = mem.startDir;
79 if (!isAbsolutePath(mem.workspace)) {
80 try {
81 dir = await $.session.root();
82 } catch {
83 // Keep the directory the session started in.
84 }
85 }
86 return workspaceFolder(dir, mem.workspace);
87}
88
89const stampOf = (e: Entry | null): string => (e === null ? '-' : `${e.name}:${e.mtimeMs}:${e.size}`);
90
91/** Look at the workspace: the newest run log and its phase file, read again only when either changed. */
92async function load($: EngineInterface, mem: Memory): Promise<Loaded> {
93 const root = await workspaceRoot($, mem);
94 const phasesDir = joinPath(root, 'docs', 'phases');
95 if (!(await $.fs.exists(phasesDir))) {
96 return { stamp: '', root, hasPhases: false, view: null };
97 }
98
99 const entries = await $.fs.list(phasesDir);
100 const runsDir = joinPath(phasesDir, RUNS_DIR);
101 const runEntries = entries.some((e) => e.name === RUNS_DIR && e.kind === 'dir') ? await $.fs.list(runsDir) : [];
102 const log = newestRunLog(runEntries);
103 const plan = log === null ? null : phaseFileFor(entries, log.phase);
104 const stamp = [root, stampOf(log), stampOf(plan)].join('|');
105 if (mem.loaded?.stamp === stamp) return mem.loaded;
106
107 let view: HudView | null = null;
108 if (log !== null) {
109 const logText = await $.fs.read(joinPath(runsDir, log.name));
110 const planText = plan === null ? null : await $.fs.read(joinPath(phasesDir, plan.name));
111 view = buildView(log.phase, logText, plan !== null && planText !== null ? { file: plan.name, text: planText } : null);
112 }
113 mem.loaded = { stamp, root, hasPhases: true, view };
114 return mem.loaded;
115}
116
117/** Record when the mod first started in this session. Later loads keep the first time. */
118async function markStart($: EngineInterface, mem: Memory, now: number): Promise<void> {
119 mem.since ??= now;
120 try {
121 if ((await $.state.get(SINCE)).value === undefined) await $.state.set(SINCE, now);
122 } catch {
123 // A build without `$.state`: the copy in memory stands in.
124 }
125}
126
127async function readSince($: EngineInterface, mem: Memory, now: number): Promise<HudSince> {
128 try {
129 return (await $.state.get(SINCE)).value ?? mem.since ?? now;
130 } catch {
131 return mem.since ?? now;
132 }
133}
134
135async function readToasted($: EngineInterface, mem: Memory): Promise<HudToasted> {
136 try {
137 return (await $.state.get(TOASTED)).value ?? mem.toasted;
138 } catch {
139 return mem.toasted;
140 }
141}
142
143async function writeToasted($: EngineInterface, mem: Memory, toasted: HudToasted): Promise<void> {
144 mem.toasted = toasted;
145 try {
146 await $.state.set(TOASTED, toasted);
147 } catch {
148 // A build without `$.state`: the copy in memory stands in.
149 }
150}
151
152/** Raise each toast that is due and hasn't been raised yet. */
153async function raiseToasts($: EngineInterface, mem: Memory, view: HudView, now: number): Promise<void> {
154 const since = await readSince($, mem, now);
155 const toasted = await readToasted($, mem);
156
157 const due = occurrences(view, since).filter((o) => !toasted.includes(o.key));
158 // A phase seen incomplete again can complete again, and gets a new toast then.
159 const kept = isPhaseComplete(view) ? toasted : toasted.filter((key) => key !== completeKey(view.phase));
160 if (due.length === 0 && kept.length === toasted.length) return;
161
162 // Record first, so a failure below can't raise the same toast twice.
163 await writeToasted($, mem, [...kept, ...due.map((o) => o.key)].slice(-KEPT_KEYS));
164
165 for (const o of due) $.ui.toast(o.text, { timeoutMs: TOAST_MS });
166 if (mem.hasSound && due.length > 0) {
167 try {
168 await $.audio.play({ asset: SOUND });
169 } catch {
170 // No player on this platform, or the clip couldn't play: the toast is enough.
171 }
172 }
173}
174
175/** One poll: read what changed and raise any toast that is due. */
176async function poll($: EngineInterface, mem: Memory): Promise<void> {
177 if (mem.isPolling) return;
178 mem.isPolling = true;
179 try {
180 const loaded = await load($, mem);
181 if (loaded.view) await raiseToasts($, mem, loaded.view, await $.clock.now());
182 } catch {
183 // A file caught mid-write or a folder that vanished: the next poll reads again.
184 } finally {
185 mem.isPolling = false;
186 }
187}
188
189/** The `/phase-status` answer. */
190async function report($: EngineInterface, mem: Memory): Promise<string> {
191 let loaded: Loaded;
192 try {
193 loaded = await load($, mem);
194 } catch {
195 return 'Phase Runner: could not read docs/phases/ just now. Try again.';
196 }
197 if (!loaded.hasPhases) return `Phase Runner: no docs/phases/ folder in ${loaded.root}.`;
198 if (loaded.view === null) return 'Phase Runner: no run log in docs/phases/.runs/ yet.';
199 const now = await $.clock.now();
200 return statusReport(loaded.view, now, -new Date(now).getTimezoneOffset());
201}
202
203export const register: Register = (on, options) => {
204 const mem: Memory = {
205 ...readOptions(options),
206 startDir: '',
207 isPolling: false,
208 loaded: null,
209 since: undefined,
210 toasted: [],
211 };
212
213 on('session.start', async ($, e, next) => {
214 mem.startDir = e.cwd;
215 await $.command.register({
216 name: 'phase-status',
217 description: 'Phase Runner: print the running phase, sprint progress, current gate and last event',
218 });
219 await markStart($, mem, await $.clock.now());
220 await poll($, mem);
221 $.clock.every(POLL_MS, () => {
222 void poll($, mem);
223 });
224 return next(e);
225 });
226
227 on('command.run', { command: 'phase-status' }, async ($) => ({ text: await report($, mem) }));
228};
229hooks/hud.ts 416 lines1/**
2 * The HUD's read model and its texts: the toasts and the `/phase-status`
3 * answer. Pure (file listings and file text in, strings out) and never throws;
4 * `register.ts` makes every `$` call.
5 *
6 * Parsing and run derivation are the viewer's own modules, copied into
7 * `core/` by `hud/scripts/sync-core.mjs`. The reading rules are the run-log
8 * spec's (`plugin/skills/phase-builder/run-log.md`, "Reading the log"):
9 * unparseable lines are skipped, `implement` `start` lines are markers, a drop
10 * in `wave` starts a new run, and an escalation is a `fail` at the retry limit
11 * with no later line for that sprint. `task` lines (an implementer starting
12 * and finishing each task) are markers too: the derive code keeps them out of
13 * every wave's and sprint's events, so they change neither the current gate
14 * `/phase-status` names nor any toast. They do count as the log's last event.
15 */
16import { taskProgress } from './core/derive/progress.js';
17import { deriveRun } from './core/derive/run.js';
18import type { Phase, RunEvent, RunGate, Sprint, SprintRun, WaveRun } from './core/model.js';
19import { parsePhaseFile } from './core/parser/phase.js';
20import { readRunLog } from './core/runlog/read.js';
21
22/** A run log with no event newer than this is not a running phase. */
23export const STALE_MS = 30 * 60 * 1000;
24
25/** Name of the run-log folder inside `docs/phases/`. */
26export const RUNS_DIR = '.runs';
27
28/** The fields of a `$.fs.list` entry the HUD reads. */
29export interface Entry {
30 /** File or folder name, without its directory. */
31 name: string;
32 /** `file`, `dir` or `other` (a symbolic link is `other`). */
33 kind: 'file' | 'dir' | 'other';
34 /** Size in bytes, for a file. */
35 size: number;
36 /** Last-modified time in milliseconds since the epoch, for a file. */
37 mtimeMs: number;
38}
39
40/** A run log found in `docs/phases/.runs/`. */
41export interface RunLogEntry extends Entry {
42 /** Phase number `N` from `phase-{N}.jsonl`. */
43 phase: number;
44}
45
46/** Everything the HUD knows about the phase it is watching. */
47export interface HudView {
48 /** Phase number, from the run log's file name. */
49 phase: number;
50 /** The parsed `Phase-{N}-*.md`, or `null` when the folder has none for this phase. */
51 plan: Phase | null;
52 /** Every readable run-log event, in line order. */
53 events: RunEvent[];
54 /** Waves in log order, across all runs of the phase. */
55 waves: WaveRun[];
56 /** Per sprint id, its run in every wave it appears in, oldest first. */
57 history: Record<string, SprintRun[]>;
58}
59
60/** Where the run is now: the sprint `/phase-status` names. */
61export interface Current {
62 /** Wave number within the latest run. */
63 wave: number;
64 /** Sprint id, e.g. `"8.3"`. */
65 sprint: string;
66 /** The gate it is at and the attempt, e.g. `"verify 2/3"`, `"verify failed 3/3"`, `"done"`. */
67 step: string;
68}
69
70/** One thing that raises a toast, with the key that keeps it to one toast. */
71export interface Occurrence {
72 /** See `HudToasted` in `types/index.d.ts`. */
73 key: string;
74 /** The toast's text. */
75 text: string;
76}
77
78/** The mod's two `userConfig` options, as `register` uses them. */
79export interface HudOptions {
80 /** The `workspace` option, trimmed; empty for the session's directory. */
81 workspace: string;
82 /** The `sound` option. */
83 hasSound: boolean;
84}
85
86/** Read the options `register(on, options)` was given. Anything of the wrong type counts as unset. */
87export function readOptions(options: Readonly<Record<string, unknown>>): HudOptions {
88 const workspace = options['workspace'];
89 return {
90 workspace: typeof workspace === 'string' ? workspace.trim() : '',
91 hasSound: options['sound'] === true,
92 };
93}
94
95/** Join path parts with `/`, which every platform's file calls accept. */
96export function joinPath(base: string, ...parts: string[]): string {
97 return [base.replace(/[\\/]+$/, ''), ...parts].join('/');
98}
99
100/** `true` for `/x`, `\x`, `C:\x` and `C:/x`. */
101export function isAbsolutePath(path: string): boolean {
102 return /^(?:[\\/]|[A-Za-z]:[\\/])/.test(path);
103}
104
105/**
106 * The folder that should hold `docs/phases/`: the `workspace` option when it
107 * is absolute, the option under the session's directory when it is relative,
108 * and the session's directory when it is empty.
109 */
110export function workspaceFolder(sessionDir: string, workspace: string): string {
111 if (workspace === '') return sessionDir;
112 return isAbsolutePath(workspace) ? workspace : joinPath(sessionDir, workspace);
113}
114
115const RUN_LOG_NAME = /^phase-(0|[1-9]\d*)\.jsonl$/;
116
117/**
118 * The run log to watch: the most recently modified `phase-{N}.jsonl`, the
119 * higher phase number on a tie. `null` when the folder has none.
120 */
121export function newestRunLog(entries: readonly Entry[]): RunLogEntry | null {
122 let newest: RunLogEntry | null = null;
123 for (const entry of entries) {
124 if (entry.kind !== 'file') continue;
125 const m = RUN_LOG_NAME.exec(entry.name);
126 if (!m) continue;
127 const phase = Number(m[1]);
128 if (
129 newest === null ||
130 entry.mtimeMs > newest.mtimeMs ||
131 (entry.mtimeMs === newest.mtimeMs && phase > newest.phase)
132 ) {
133 newest = { name: entry.name, kind: entry.kind, size: entry.size, mtimeMs: entry.mtimeMs, phase };
134 }
135 }
136 return newest;
137}
138
139/** The `Phase-{N}-*.md` file for a phase (the first by name when there are several), or `null`. */
140export function phaseFileFor(entries: readonly Entry[], phase: number): Entry | null {
141 const name = new RegExp(`^Phase-${phase}(?:-.*)?\\.md$`, 'i');
142 const found = entries.filter((e) => e.kind === 'file' && name.test(e.name));
143 found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
144 return found[0] ?? null;
145}
146
147/**
148 * Build the view of one phase.
149 *
150 * @param phase Phase number, from the run log's file name.
151 * @param logText Contents of `phase-{N}.jsonl`.
152 * @param plan The phase file's name and contents, or `null` when there is none.
153 */
154export function buildView(phase: number, logText: string, plan: { file: string; text: string } | null): HudView {
155 const parsed = plan ? parsePhaseFile(plan.text, plan.file).phase : null;
156 const { events } = readRunLog(logText, `phase-${phase}.jsonl`);
157 const uiSprints = (parsed?.sprints ?? [])
158 .filter((s) => s.verification.ui.length > 0 && s.verification.skipUi !== true)
159 .map((s) => s.id);
160 const { waves, sprintHistory } = deriveRun(events, { uiSprints });
161 return { phase, plan: parsed, events, waves, history: sprintHistory };
162}
163
164/** Milliseconds since the epoch for a run-log `ts`, or `null` when it isn't a time. */
165export function parseTs(ts: string): number | null {
166 const at = Date.parse(ts);
167 return Number.isNaN(at) ? null : at;
168}
169
170/** The log's last event, or `null` for an empty log. */
171export function lastEvent(view: HudView): RunEvent | null {
172 return view.events[view.events.length - 1] ?? null;
173}
174
175/**
176 * A sprint is complete when its latest gate result is a passing `doc_sync`, or
177 * when it has tasks and every eligible one is `x`.
178 */
179export function isSprintComplete(sprint: Sprint, history: Record<string, SprintRun[]>): boolean {
180 const runs = history[sprint.id];
181 if (runs?.[runs.length - 1]?.state === 'done') return true;
182 const progress = taskProgress(sprint.tasks);
183 return progress.total > 0 && progress.done === progress.eligible;
184}
185
186/** A phase is complete when its phase file has sprints and every one is complete. Without the file it never is. */
187export function isPhaseComplete(view: HudView): boolean {
188 const plan = view.plan;
189 if (plan === null || plan.sprints.length === 0) return false;
190 return plan.sprints.every((sprint) => isSprintComplete(sprint, view.history));
191}
192
193const GATE_LABELS: Readonly<Record<RunGate, string>> = {
194 implement: 'implement',
195 verify: 'verify',
196 wave_test: 'wave-test',
197 doc_sync: 'doc-sync',
198 // Never shown: `task` lines are in no sprint's events. Here to complete the record.
199 task: 'task',
200 unknown: 'gate',
201};
202
203/** `2/3`, or just `2` when the gate has no retry limit (`max: 0`). */
204function count(attempt: number, max: number): string {
205 return max > 0 ? `${attempt}/${max}` : String(attempt);
206}
207
208/** The latest event of a gate in a sprint's wave, if it logged one. */
209function latestOf(sr: SprintRun, gate: RunGate): RunEvent | undefined {
210 for (let i = sr.events.length - 1; i >= 0; i--) {
211 if (sr.events[i]!.gate === gate) return sr.events[i];
212 }
213 return undefined;
214}
215
216/** What a sprint is doing in its wave, as `/phase-status` says it. */
217function stepOf(sr: SprintRun): string {
218 const last = sr.lastEvent;
219 if (last === null) return 'implement';
220 switch (sr.state) {
221 case 'implementing':
222 return `implement ${count(last.attempt, last.max)}`;
223 case 'verifying':
224 // phase-verify logs `implement` as it starts; verify shares its counter.
225 return `verify ${count(last.attempt, last.max)}`;
226 case 'testing': {
227 const before = latestOf(sr, 'wave_test');
228 return `wave-test ${before ? count(before.attempt + 1, before.max) : count(1, 3)}`;
229 }
230 case 'syncing': {
231 const before = latestOf(sr, 'doc_sync');
232 return `doc-sync ${before ? count(before.attempt + 1, before.max) : count(1, 1)}`;
233 }
234 case 'failed':
235 return `${GATE_LABELS[last.gate]} failed ${count(last.attempt, last.max)}`;
236 case 'done':
237 return 'done';
238 }
239}
240
241function lastLine(events: readonly RunEvent[]): number {
242 let line = 0;
243 for (const e of events) if (e.line > line) line = e.line;
244 return line;
245}
246
247/**
248 * Where the run is now. Looks at the latest run only: the wave with the newest
249 * line among those with a sprint still not done, and in it the sprint that
250 * isn't done and logged last. When every wave is done, the wave and sprint of
251 * the log's last line.
252 */
253export function current(view: HudView): Current | null {
254 const latest = view.waves[view.waves.length - 1];
255 if (!latest) return null;
256 const inRun = view.waves.filter((w) => w.run === latest.run);
257 const newestFirst = [...inRun].sort((a, b) => lastLine(b.events) - lastLine(a.events));
258 const wave = newestFirst.find((w) => w.sprints.some((s) => s.state !== 'done')) ?? newestFirst[0];
259 if (!wave) return null;
260
261 const byNewest = [...wave.sprints].sort((a, b) => lastLine(b.events) - lastLine(a.events));
262 const sprint = byNewest.find((s) => s.state !== 'done') ?? byNewest[0];
263 if (!sprint) return null;
264 return { wave: wave.wave, sprint: sprint.sprint, step: stepOf(sprint) };
265}
266
267/** The toast key for a completed phase. */
268export function completeKey(phase: number): string {
269 return `complete:${phase}`;
270}
271
272/** `sprint 8.2`, `sprints 8.2 and 8.3`, `sprints 8.1, 8.2 and 8.3`. */
273function sprintList(ids: readonly string[]): string {
274 const unique = [...new Set(ids)];
275 if (unique.length <= 1) return `sprint ${unique[0] ?? ''}`;
276 return `sprints ${unique.slice(0, -1).join(', ')} and ${unique[unique.length - 1]}`;
277}
278
279/**
280 * Everything in the view that should have raised a toast by now, limited to
281 * what happened since the mod started:
282 *
283 * - Escalation: a sprint whose last line in the log is a gate `fail` at
284 * `attempt >= max` with `max > 0`. One per wave and gate, so two sprints
285 * failing the same verify share a toast.
286 * - Blocker: an `implement` `blocked` line. One per wave, so a retry that
287 * reports the same blocked task again doesn't repeat it.
288 * - Phase complete: see {@link isPhaseComplete}.
289 *
290 * The caller drops the ones whose key it has already raised.
291 *
292 * @param since Milliseconds since the epoch. Events older than this (to the second) raise nothing.
293 */
294export function occurrences(view: HudView, since: number): Occurrence[] {
295 const out: Occurrence[] = [];
296 const isFresh = (events: readonly RunEvent[]): boolean =>
297 events.some((e) => {
298 const at = parseTs(e.ts);
299 // `ts` is written to the second.
300 return at !== null && at + 1000 > since;
301 });
302
303 for (const wave of view.waves) {
304 const failed = new Map<RunGate, RunEvent[]>();
305 for (const sr of wave.sprints) {
306 const runs = view.history[sr.sprint];
307 const last = sr.lastEvent;
308 if (last === null || runs?.[runs.length - 1] !== sr) continue;
309 if (last.result !== 'fail' || last.max <= 0 || last.attempt < last.max) continue;
310 const list = failed.get(last.gate);
311 if (list) list.push(last);
312 else failed.set(last.gate, [last]);
313 }
314 for (const [gate, events] of failed) {
315 if (!isFresh(events)) continue;
316 events.sort((a, b) => a.line - b.line);
317 const first = events[0]!;
318 out.push({
319 key: `fail:${view.phase}:${first.line}`,
320 text: `Phase ${view.phase} needs you: ${sprintList(events.map((e) => e.sprint))} ${GATE_LABELS[gate]} failed ${count(first.attempt, first.max)}.`,
321 });
322 }
323
324 const blocked = wave.events.filter((e) => e.gate === 'implement' && e.result === 'blocked');
325 if (blocked.length > 0 && isFresh(blocked)) {
326 const ids = blocked.map((e) => e.sprint);
327 out.push({
328 key: `blocked:${view.phase}:${blocked[0]!.line}`,
329 text: `Phase ${view.phase} needs you: ${sprintList(ids)} ${new Set(ids).size > 1 ? 'have' : 'has'} a blocked task.`,
330 });
331 }
332 }
333
334 const last = lastEvent(view);
335 if (last !== null && isFresh([last]) && isPhaseComplete(view)) {
336 out.push({ key: completeKey(view.phase), text: `Phase ${view.phase} is complete. The run is at its checkpoint.` });
337 }
338 return out;
339}
340
341const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] as const;
342
343/**
344 * A time as the user's clock shows it, 12-hour: `4:12 PM`, with the date in
345 * front (`Sep 28, 4:12 PM`) when it isn't today.
346 *
347 * @param at The time, in milliseconds since the epoch.
348 * @param now The current time, to tell whether `at` is today.
349 * @param offsetMinutes The local time zone's offset from UTC in minutes, east positive (`-420` for UTC-7).
350 */
351export function formatTime(at: number, now: number, offsetMinutes: number): string {
352 const local = new Date(at + offsetMinutes * 60_000);
353 const today = new Date(now + offsetMinutes * 60_000);
354 const hours = local.getUTCHours();
355 const time = `${hours % 12 || 12}:${String(local.getUTCMinutes()).padStart(2, '0')} ${hours < 12 ? 'AM' : 'PM'}`;
356 const sameDay =
357 local.getUTCFullYear() === today.getUTCFullYear() &&
358 local.getUTCMonth() === today.getUTCMonth() &&
359 local.getUTCDate() === today.getUTCDate();
360 return sameDay ? time : `${MONTHS[local.getUTCMonth()]} ${local.getUTCDate()}, ${time}`;
361}
362
363/** How long ago, in words: `just now`, `3 min ago`, `2 h 5 min ago`, `4 days ago`. */
364export function formatAgo(ms: number): string {
365 const minutes = Math.floor(Math.max(0, ms) / 60_000);
366 if (minutes < 1) return 'just now';
367 if (minutes < 60) return `${minutes} min ago`;
368 const hours = Math.floor(minutes / 60);
369 if (hours < 24) return minutes % 60 === 0 ? `${hours} h ago` : `${hours} h ${minutes % 60} min ago`;
370 const days = Math.floor(hours / 24);
371 return days === 1 ? '1 day ago' : `${days} days ago`;
372}
373
374/**
375 * The `/phase-status` answer: the phase, each sprint's tasks as `done/eligible`,
376 * the current wave and gate, and when the last event was logged.
377 *
378 * @param now Milliseconds since the epoch.
379 * @param offsetMinutes The local time zone's offset from UTC in minutes, east positive.
380 */
381export function statusReport(view: HudView, now: number, offsetMinutes: number): string {
382 const lines: string[] = [];
383 const plan = view.plan;
384 const complete = isPhaseComplete(view);
385 lines.push(`Phase ${view.phase}${plan && plan.title !== '' ? ` — ${plan.title}` : ''}${complete ? ' (complete)' : ''}`);
386
387 if (plan && plan.sprints.length > 0) {
388 const all = taskProgress(plan.sprints.flatMap((s) => s.tasks));
389 const each = plan.sprints.map((s) => {
390 const p = taskProgress(s.tasks);
391 return `${s.id} ${p.done}/${p.eligible}`;
392 });
393 lines.push(`Sprints: ${each.join(' · ')} (${all.done}/${all.eligible} tasks)`);
394 } else {
395 lines.push(`Sprints: no Phase-${view.phase}-*.md file to read tasks from`);
396 }
397
398 const last = lastEvent(view);
399 const cur = current(view);
400 if (last === null || cur === null) {
401 lines.push('Now: no run logged yet');
402 return lines.join('\n');
403 }
404 const at = parseTs(last.ts);
405 const where = `wave ${cur.wave} · ${cur.sprint} ${cur.step}`;
406 if (complete) lines.push(`Now: phase complete (last: ${where})`);
407 else if (at !== null && now - at >= STALE_MS) lines.push(`Now: ${where} (no event in the last 30 min)`);
408 else lines.push(`Now: ${where}`);
409 lines.push(
410 at === null
411 ? `Last event: ${last.ts}`
412 : `Last event: ${formatTime(at, now, offsetMinutes)}, ${formatAgo(now - at)}`,
413 );
414 return lines.join('\n');
415}
416hooks/core/derive/progress.ts 106 lines1// Copied from viewer/src/core/derive/progress.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Task progress: counts per sprint, per phase and overall, plus the Overview's
5 * "Next up", "Blocked" and "Deferred or cut" answers. Pure and never throws.
6 *
7 * Rules:
8 *
9 * - Every task counts in `total` and `byStatus`.
10 * - `cut` and `deferred` tasks are not eligible: `eligible = total - cut - deferred`,
11 * and `percent = round(done / eligible * 100)` (`0` when nothing is eligible).
12 * `blocked`, `manual` and `unknown` tasks are eligible and not done.
13 * - Next up: the first sprint with a `todo` or `active` task, walking phases
14 * by ascending number and sprints in file order.
15 * - Blocked: every `blocked` task, in the same order.
16 */
17import {
18 TASK_STATUSES,
19 type BlockedTask,
20 type NextUp,
21 type Phase,
22 type Progress,
23 type ProjectProgress,
24 type Task,
25 type TaskStatus,
26} from '../model.js';
27
28/** Statuses left out of the denominator. */
29const NOT_ELIGIBLE: ReadonlySet<TaskStatus> = new Set<TaskStatus>(['cut', 'deferred']);
30
31/** Statuses that make a sprint "next up". */
32const OPEN: ReadonlySet<TaskStatus> = new Set<TaskStatus>(['todo', 'active']);
33
34/** A zero count for every status. */
35function zeroByStatus(): Record<TaskStatus, number> {
36 return Object.fromEntries(TASK_STATUSES.map((s) => [s, 0])) as Record<TaskStatus, number>;
37}
38
39/** {@link Progress} for any list of tasks. */
40export function taskProgress(tasks: readonly Task[]): Progress {
41 const byStatus = zeroByStatus();
42 for (const t of tasks) byStatus[t.status] = (byStatus[t.status] ?? 0) + 1;
43 const total = tasks.length;
44 let excluded = 0;
45 for (const s of NOT_ELIGIBLE) excluded += byStatus[s];
46 const eligible = total - excluded;
47 const done = byStatus.done;
48 const percent = eligible > 0 ? Math.round((done / eligible) * 100) : 0;
49 return { total, eligible, done, percent, byStatus };
50}
51
52/**
53 * Progress at every level for a list of phases.
54 *
55 * @param phases Parsed phases; walked by ascending phase number for next-up
56 * and blocked order (the input order is not changed).
57 */
58export function deriveProgress(phases: readonly Phase[]): ProjectProgress {
59 const ordered = [...phases].sort((a, b) => a.number - b.number);
60 const byPhase: Record<string, Progress> = {};
61 const bySprint: Record<string, Progress> = {};
62 const allTasks: Task[] = [];
63 const blocked: BlockedTask[] = [];
64 let nextUp: NextUp | null = null;
65
66 // Two files with the same phase number share one entry.
67 const tasksByPhase = new Map<string, Task[]>();
68
69 for (const phase of ordered) {
70 const key = String(phase.number);
71 let phaseTasks = tasksByPhase.get(key);
72 if (!phaseTasks) tasksByPhase.set(key, (phaseTasks = []));
73 for (const sprint of phase.sprints) {
74 phaseTasks.push(...sprint.tasks);
75 allTasks.push(...sprint.tasks);
76 bySprint[sprint.id] = taskProgress(sprint.tasks);
77 if (nextUp === null && sprint.tasks.some((t) => OPEN.has(t.status))) {
78 nextUp = { phase: phase.number, sprint: sprint.id, title: sprint.title };
79 }
80 for (const t of sprint.tasks) {
81 if (t.status !== 'blocked') continue;
82 blocked.push({
83 phase: phase.number,
84 sprint: sprint.id,
85 number: t.number,
86 text: t.text,
87 file: phase.file,
88 line: t.line,
89 });
90 }
91 }
92 }
93 for (const [key, tasks] of tasksByPhase) byPhase[key] = taskProgress(tasks);
94
95 const overall = taskProgress(allTasks);
96 return {
97 overall,
98 byPhase,
99 bySprint,
100 nextUp,
101 blocked,
102 deferred: overall.byStatus.deferred,
103 cut: overall.byStatus.cut,
104 };
105}
106hooks/core/derive/run.ts 424 lines1// Copied from viewer/src/core/derive/run.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Run derivation: one phase's run-log events in, waves, per-sprint run state
5 * and escalations out. Pure (data in, data out) and never throws on any event
6 * list the reader can produce.
7 *
8 * Everything comes from **line order**; `ts` is only copied, never compared
9 * (clocks skew, and parallel appends share a second).
10 *
11 * Runs and waves (spec: `run-log.md`, "Reading the log"):
12 *
13 * - Events are cut into waves wherever `wave` changes between consecutive
14 * lines. A drop in `wave` (e.g. 3 → 1) starts a new run, because
15 * phase-builder restarts wave numbers on every run of the phase; a rise
16 * (1 → 2) is the next wave of the same run. A run resumed inside the same
17 * wave number reads as a continuation of that wave.
18 * - Within a wave, events are grouped by sprint in first-seen order.
19 * - Wave start is the wave's first event. Wave end is reached once every
20 * sprint in the wave has a passing `doc_sync` as its latest event.
21 * - `implement` `start` markers don't cut waves. The next wave's
22 * implementation starts while the previous wave's doc sync runs, so its
23 * start lines can land between that wave's lines. Each marker joins the wave
24 * with its wave number in the run it belongs to (the run of the wave before
25 * it in the log, or the next run when its wave number is lower), preferring
26 * one with lines after it; with none logged yet it opens that wave on its
27 * own. Markers are never gate steps and never attempts.
28 * - `task` lines (an implementer's "task N started" / "task N finished") are
29 * markers too, and kept further out: they are in no wave's or sprint's
30 * `events`, so they are never gate steps, attempts, retries or escalations,
31 * never cut a wave, and never move a wave's or sprint's start, end or
32 * state. Each one is placed like a start marker, in the wave with its wave
33 * number in the run it belongs to, but never opens a wave: one with no such
34 * wave, or whose sprint has no event in it, is left out. What they say is
35 * {@link SprintRun.tasks}: per sprint and wave, the latest `task` line per
36 * task number (`start` is running, `pass` is built; any other result is
37 * ignored). A log without them derives exactly as before.
38 *
39 * Sprint state: see {@link SprintRunState} for the table. A wave "runs a wave
40 * test" when any of its sprints is in {@link DeriveRunOptions.uiSprints}, or
41 * when a `wave_test` line already appeared in the wave.
42 *
43 * Escalations: a sprint's latest event in a wave is a `verify` or `wave_test`
44 * `fail` with `max > 0` and `attempt >= max`, and the sprint has no later
45 * event anywhere in the log. `max: 0` never escalates. The escalation carries
46 * every run of that gate for the sprint in the wave as its retry history.
47 */
48import type {
49 Escalation,
50 GateStep,
51 PhaseRuns,
52 RunEvent,
53 RunGate,
54 SprintRun,
55 SprintRunState,
56 TaskProgress,
57 WaveRun,
58} from '../model.js';
59
60/** Extra knowledge {@link deriveRun} can use that the log itself lacks. */
61export interface DeriveRunOptions {
62 /**
63 * Ids of sprints that have a browser test (a `ui:` route and no
64 * `skip-ui: true`). A wave holding any of them runs a wave test, so a sprint
65 * whose `verify` passed is `testing` rather than `syncing`. `loadProject`
66 * fills this from the phase file; without it, only a `wave_test` line
67 * already seen in the wave marks it as a UI wave.
68 */
69 uiSprints?: Iterable<string>;
70 /**
71 * Task numbers per sprint id, from the phase file. A `task` line naming a
72 * number its sprint doesn't have is ignored. A sprint not listed here (or
73 * no option at all) keeps every `task` line: there is nothing to check
74 * against. `loadProject` fills this from the phase files.
75 */
76 sprintTasks?: Readonly<Record<string, readonly number[]>>;
77}
78
79/** What {@link deriveRun} returns: the derived parts of {@link PhaseRuns}. */
80export type RunDerivation = Pick<PhaseRuns, 'waves' | 'escalations' | 'sprintHistory'>;
81
82/** Gates whose failure at the retry limit escalates. */
83const ESCALATING_GATES: ReadonlySet<RunGate> = new Set<RunGate>(['verify', 'wave_test']);
84
85/**
86 * Derive waves, sprint runs and escalations from one phase's events.
87 *
88 * @param events The phase's events in log (line) order, as `readRunLog` returns them.
89 * @param options See {@link DeriveRunOptions}.
90 */
91export function deriveRun(events: readonly RunEvent[], options: DeriveRunOptions = {}): RunDerivation {
92 const uiSprints = new Set<string>(options.uiSprints ?? []);
93 const sprintTasks = options.sprintTasks ?? {};
94 const waves: WaveRun[] = [];
95
96 // 1. Cut the log into waves by line order. Start markers wait for step 1b,
97 // task lines for step 1c.
98 let run = 0;
99 let current: RunEvent[] | null = null;
100 let currentWave = -1;
101 const groups: WaveGroup[] = [];
102 const starts: RunEvent[] = [];
103 const taskLines: RunEvent[] = [];
104 for (const event of events) {
105 if (isTaskLine(event)) {
106 taskLines.push(event);
107 continue;
108 }
109 if (isStartMarker(event)) {
110 starts.push(event);
111 continue;
112 }
113 if (current === null || event.wave !== currentWave) {
114 if (current === null || event.wave < currentWave) run++;
115 currentWave = event.wave;
116 current = [];
117 groups.push({ run, wave: event.wave, events: current, tasks: [] });
118 }
119 current.push(event);
120 }
121
122 // 1b. Place each start marker in its wave (see the module comment).
123 for (const start of starts) placeStartMarker(start, groups);
124 for (const group of groups) group.events.sort((a, b) => a.line - b.line);
125
126 // 1c. Hand each task line to its wave. It joins no `events` list and opens no wave.
127 for (const line of taskLines) waveOfMarker(line, groups).target?.tasks.push(line);
128
129 // 2. Build each wave and its sprint runs.
130 for (const group of groups) {
131 waves.push(buildWave(group.run, group.wave, group.events, group.tasks, uiSprints, sprintTasks));
132 }
133
134 // 3. A later event for a sprint (in any later wave or run) clears an
135 // earlier escalation of it: only a sprint's last SprintRun can escalate.
136 const sprintHistory: Record<string, SprintRun[]> = {};
137 for (const wave of waves) {
138 for (const sr of wave.sprints) {
139 (sprintHistory[sr.sprint] ??= []).push(sr);
140 }
141 }
142 for (const runs of Object.values(sprintHistory)) {
143 for (let i = 0; i < runs.length - 1; i++) runs[i]!.escalation = null;
144 }
145
146 const escalations: Escalation[] = [];
147 for (const wave of waves) {
148 wave.escalated = wave.sprints.some((s) => s.escalation !== null);
149 for (const sr of wave.sprints) if (sr.escalation) escalations.push(sr.escalation);
150 }
151 escalations.sort((a, b) => a.line - b.line);
152
153 return { waves, escalations, sprintHistory };
154}
155
156/** One wave's events while the log is being cut, before it becomes a {@link WaveRun}. */
157interface WaveGroup {
158 run: number;
159 wave: number;
160 /** The wave's events, start markers included. Never a `task` line. */
161 events: RunEvent[];
162 /** The `task` lines placed in the wave, in line order. */
163 tasks: RunEvent[];
164}
165
166/** An `implement` `start` line: a marker, never a gate step or an attempt. */
167export function isStartMarker(event: Pick<RunEvent, 'gate' | 'result'>): boolean {
168 return event.gate === 'implement' && event.result === 'start';
169}
170
171/** A `task` line, whatever its result: a marker kept out of every wave's and sprint's events. */
172export function isTaskLine(event: Pick<RunEvent, 'gate'>): boolean {
173 return event.gate === 'task';
174}
175
176/** The first and last line of a group's events. Markers are appended unsorted until every one is placed, so scan. */
177const firstLine = (g: WaveGroup): number => Math.min(...g.events.map((e) => e.line));
178const lastLine = (g: WaveGroup): number => Math.max(...g.events.map((e) => e.line));
179
180/**
181 * Where a marker (a start marker or a `task` line) belongs: the run it is in
182 * (the run of the wave before it in the log, or the next run when its wave
183 * number is lower), and the wave with its wave number in that run, preferring
184 * one with lines after the marker. `target` is `null` when that wave has
185 * nothing logged yet.
186 */
187function waveOfMarker(marker: RunEvent, groups: readonly WaveGroup[]): { run: number; target: WaveGroup | null } {
188 let before: WaveGroup | null = null;
189 for (const g of groups) if (firstLine(g) < marker.line) before = g;
190 const run = before === null ? 1 : marker.wave < before.wave ? before.run + 1 : before.run;
191
192 const same = groups.filter((g) => g.wave === marker.wave && g.run === run);
193 return { run, target: same.find((g) => lastLine(g) > marker.line) ?? same[same.length - 1] ?? null };
194}
195
196/**
197 * Put one start marker into the wave it belongs to, or open that wave when
198 * nothing else of it is logged yet. `groups` stays ordered by first line.
199 */
200function placeStartMarker(start: RunEvent, groups: WaveGroup[]): void {
201 const { run, target } = waveOfMarker(start, groups);
202 if (target) {
203 target.events.push(start);
204 return;
205 }
206 const group: WaveGroup = { run, wave: start.wave, events: [start], tasks: [] };
207 const at = groups.findIndex((g) => firstLine(g) > start.line);
208 if (at === -1) groups.push(group);
209 else groups.splice(at, 0, group);
210}
211
212/**
213 * Build one {@link WaveRun} from its events (all sharing one run and wave
214 * number) and the `task` lines placed in it.
215 */
216function buildWave(
217 run: number,
218 wave: number,
219 events: RunEvent[],
220 taskLines: readonly RunEvent[],
221 uiSprints: ReadonlySet<string>,
222 sprintTasks: Readonly<Record<string, readonly number[]>>,
223): WaveRun {
224 const bySprint = new Map<string, RunEvent[]>();
225 for (const e of events) {
226 let list = bySprint.get(e.sprint);
227 if (!list) bySprint.set(e.sprint, (list = []));
228 list.push(e);
229 }
230
231 const first = events[0]!;
232 const last = events[events.length - 1]!;
233 const hasWaveTest =
234 events.some((e) => e.gate === 'wave_test') || [...bySprint.keys()].some((id) => uiSprints.has(id));
235
236 const sprints: SprintRun[] = [];
237 for (const [sprint, list] of bySprint) {
238 const sr = buildSprintRun(sprint, run, wave, list, hasWaveTest);
239 const tasks = taskLineProgress(
240 taskLines.filter((e) => e.sprint === sprint),
241 Object.prototype.hasOwnProperty.call(sprintTasks, sprint) ? sprintTasks[sprint] : undefined,
242 );
243 if (tasks.length > 0) sr.tasks = tasks;
244 sprints.push(sr);
245 }
246
247 const files = unionFiles(events);
248 const allDone = sprints.length > 0 && sprints.every((s) => s.state === 'done');
249
250 return {
251 phase: first.phase,
252 run,
253 wave,
254 sprints,
255 events,
256 files,
257 startedAt: first.ts,
258 endedAt: allDone ? last.ts : null,
259 escalated: sprints.some((s) => s.escalation !== null),
260 };
261}
262
263/** Build one sprint's run within a wave. */
264function buildSprintRun(
265 sprint: string,
266 run: number,
267 wave: number,
268 events: RunEvent[],
269 waveHasWaveTest: boolean,
270): SprintRun {
271 const first = events[0]!;
272 const last = events[events.length - 1]!;
273
274 const gateEvents = events.filter((e) => !isStartMarker(e));
275 const steps = toSteps(gateEvents);
276 const attempts: Partial<Record<RunGate, number>> = {};
277 for (const e of gateEvents) {
278 const prev = attempts[e.gate];
279 if (prev === undefined || e.attempt > prev) attempts[e.gate] = e.attempt;
280 }
281
282 const sr: SprintRun = {
283 sprint,
284 phase: first.phase,
285 run,
286 wave,
287 events,
288 steps,
289 state: sprintState(events, waveHasWaveTest),
290 attempts,
291 lastEvent: last,
292 escalation: null,
293 files: unionFiles(events),
294 startedAt: first.ts,
295 updatedAt: last.ts,
296 };
297 sr.escalation = escalationFor(sr, events);
298 return sr;
299}
300
301/**
302 * The state after a sprint's events in one wave. See {@link SprintRunState}
303 * for the table this implements.
304 *
305 * @param events The sprint's events in this wave, in line order.
306 * @param waveHasWaveTest Whether the wave runs a wave test.
307 */
308export function sprintState(events: readonly RunEvent[], waveHasWaveTest: boolean): SprintRunState {
309 let latest: RunEvent | undefined;
310 for (let i = events.length - 1; i >= 0; i--) {
311 // A sprint run's events never hold a task line; skipped here for callers that pass raw events.
312 if (events[i]!.gate !== 'unknown' && events[i]!.gate !== 'task') {
313 latest = events[i];
314 break;
315 }
316 }
317 if (!latest) return 'implementing';
318 const failed = latest.result === 'fail';
319 switch (latest.gate) {
320 case 'implement':
321 return latest.result === 'start' ? 'implementing' : 'verifying';
322 case 'verify':
323 if (failed) return 'failed';
324 return waveHasWaveTest ? 'testing' : 'syncing';
325 case 'wave_test':
326 return failed ? 'failed' : 'syncing';
327 case 'doc_sync':
328 return failed ? 'failed' : 'done';
329 default:
330 return 'implementing';
331 }
332}
333
334/**
335 * The escalation ending this sprint's wave, if its latest event is an
336 * escalating gate's `fail` at the limit. Whether a later wave clears it is
337 * decided by the caller.
338 */
339function escalationFor(sr: SprintRun, events: readonly RunEvent[]): Escalation | null {
340 const last = events[events.length - 1];
341 if (!last) return null;
342 if (!ESCALATING_GATES.has(last.gate) || last.result !== 'fail') return null;
343 if (last.max <= 0 || last.attempt < last.max) return null;
344 return {
345 phase: last.phase,
346 run: sr.run,
347 wave: sr.wave,
348 sprint: sr.sprint,
349 gate: last.gate,
350 attempt: last.attempt,
351 max: last.max,
352 summary: last.summary,
353 ts: last.ts,
354 line: last.line,
355 history: events
356 .filter((e) => e.gate === last.gate)
357 .map((e) => ({ attempt: e.attempt, result: e.result, summary: e.summary, ts: e.ts, line: e.line })),
358 };
359}
360
361/**
362 * One sprint's task progress in one wave: the latest `task` line per task
363 * number, ordered by task number. `start` reads `running`, `pass` reads
364 * `built`; a line with any other result, or with no task number, says nothing
365 * and is skipped.
366 *
367 * @param lines The sprint's `task` lines in the wave, in line order.
368 * @param known The sprint's task numbers, when the phase file is at hand:
369 * a line for any other number is ignored. `undefined` keeps every number.
370 */
371export function taskLineProgress(lines: readonly RunEvent[], known?: readonly number[]): TaskProgress[] {
372 const latest = new Map<number, TaskProgress>();
373 for (const e of lines) {
374 if (e.gate !== 'task' || e.task === undefined) continue;
375 if (e.result !== 'start' && e.result !== 'pass') continue;
376 if (known !== undefined && !known.includes(e.task)) continue;
377 latest.set(e.task, {
378 task: e.task,
379 state: e.result === 'start' ? 'running' : 'built',
380 attempt: e.attempt,
381 ts: e.ts,
382 line: e.line,
383 });
384 }
385 return [...latest.values()].sort((a, b) => a.task - b.task);
386}
387
388/** Events as {@link GateStep}s, numbering each gate's occurrences in line order. */
389export function toSteps(events: readonly RunEvent[]): GateStep[] {
390 const seen: Partial<Record<RunGate, number>> = {};
391 return events.map((e) => {
392 const seq = (seen[e.gate] ?? 0) + 1;
393 seen[e.gate] = seq;
394 const step: GateStep = {
395 gate: e.gate,
396 result: e.result,
397 attempt: e.attempt,
398 max: e.max,
399 seq,
400 summary: e.summary,
401 ts: e.ts,
402 line: e.line,
403 };
404 if (e.files) step.files = [...e.files];
405 return step;
406 });
407}
408
409/** Union of `files` over `doc_sync` events, first-seen order; `null` when none carried `files`. */
410function unionFiles(events: readonly RunEvent[]): string[] | null {
411 let out: string[] | null = null;
412 const seen = new Set<string>();
413 for (const e of events) {
414 if (e.gate !== 'doc_sync' || !e.files) continue;
415 out ??= [];
416 for (const f of e.files) {
417 if (seen.has(f)) continue;
418 seen.add(f);
419 out.push(f);
420 }
421 }
422 return out;
423}
424hooks/core/model.ts 695 lines1// Copied from viewer/src/core/model.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * The phase-viewer core model.
5 *
6 * One typed snapshot of a project folder: its phase plans (phases → sprints →
7 * tasks) plus the run-log events phase-builder's gates appended while running
8 * them, grouped into waves with derived escalations and progress.
9 *
10 * Three layers fill it:
11 *
12 * - The phase-file parser fills {@link Phase}, {@link Sprint}, {@link Task} and
13 * everything under them. It never throws; anything it can't read becomes a
14 * {@link Warning}.
15 * - The run-log reader fills {@link RunEvent}. It skips malformed lines, also
16 * with a warning.
17 * - `loadProject` (`load.ts`) ties both together and derives {@link PhaseRuns},
18 * {@link WaveRun}, {@link SprintRun}, {@link Escalation} (`derive/run.ts`)
19 * and {@link ProjectProgress} (`derive/progress.ts`). `sprintHistory`
20 * (`derive/history.ts`) answers the Sprint detail page's run questions.
21 *
22 * Everything here is plain JSON data (no Maps, Sets, Dates or classes) so the
23 * server can send a {@link Project} over SSE unchanged.
24 *
25 * Line numbers are always 1-based. Paths in {@link Task.module} are relative to
26 * the app root; paths in {@link Task.reference} are relative to the project
27 * folder. Both are kept exactly as written in the phase file.
28 */
29
30// ---------------------------------------------------------------------------
31// Enumerations
32// ---------------------------------------------------------------------------
33
34/**
35 * Every normalized task status, in display order.
36 *
37 * Phase files write these as `—` (todo), `~` (active), `x` (done), `BLOCKED`,
38 * `MANUAL`, `CUT` and `DEFERRED`. The parser also accepts common variants (`-`,
39 * `–`, `[ ]`, `[x]`, lowercase words). Anything else normalizes to `unknown`
40 * and the raw text is kept in {@link Task.rawStatus}.
41 *
42 * `blocked` means an agent tried the task and got stuck; `manual` is a task
43 * only the user can do (publish, reinstall, record). Both need the user, and
44 * both are eligible and not done.
45 */
46export const TASK_STATUSES = [
47 'todo',
48 'active',
49 'done',
50 'blocked',
51 'manual',
52 'cut',
53 'deferred',
54 'unknown',
55] as const;
56
57/** A task's normalized status. See {@link TASK_STATUSES}. */
58export type TaskStatus = (typeof TASK_STATUSES)[number];
59
60/**
61 * Run-log gate names from event format v1, plus `unknown` for any other value
62 * a future writer might send.
63 *
64 * `task` is not a gate that runs: an implementer writes a `task` line as it
65 * starts and as it finishes each task. Those lines are markers, kept out of
66 * every wave's and sprint's events and steps; what they say is in
67 * {@link SprintRun.tasks} (see `derive/run.ts`).
68 */
69export const RUN_GATES = ['implement', 'verify', 'wave_test', 'doc_sync', 'task', 'unknown'] as const;
70
71/** Which gate wrote a {@link RunEvent}. See {@link RUN_GATES}. */
72export type RunGate = (typeof RUN_GATES)[number];
73
74/**
75 * Run-log results from event format v1, plus `unknown` for any other value.
76 *
77 * `start` is only valid on `implement` and `task`. On `implement`,
78 * phase-builder logs it when a sprint's implementation begins (logs written
79 * before it existed have none, and read exactly as before). It is a marker,
80 * not an outcome: it is never a {@link GateStep}, never counts as an attempt,
81 * and never cuts a wave (see `derive/run.ts`). On `task`, the implementer logs
82 * it as it begins a task, and `pass` as it finishes it. `start` on any other
83 * gate is read as `unknown`, with a warning.
84 */
85export const RUN_RESULTS = ['pass', 'partial', 'warn', 'fail', 'blocked', 'start', 'unknown'] as const;
86
87/** A gate's outcome for one sprint. See {@link RUN_RESULTS}. */
88export type RunResult = (typeof RUN_RESULTS)[number];
89
90/**
91 * Where a sprint stands inside one wave, derived from its latest event with a
92 * known gate (events with gate `unknown` are skipped, and `task` lines are
93 * never among a sprint's events). Any result other than
94 * `fail` counts as the gate passing (`partial`, `warn`, `blocked` and
95 * `unknown` included):
96 *
97 * | Latest event | State |
98 * |-------------------------------------|-------|
99 * | none with a known gate | `implementing` |
100 * | `implement` `start` | `implementing`: phase-builder logs it as implementation begins |
101 * | `implement` (any other result) | `verifying`: phase-verify logs `implement` as it starts, so its `ts` marks when verifying began |
102 * | `verify` fail | `failed` |
103 * | `verify` not fail, UI wave | `testing`: the wave runs a wave test (a sprint in it has a browser test, or a `wave_test` line was already seen in the wave) |
104 * | `verify` not fail, data-only wave | `syncing` |
105 * | `wave_test` fail | `failed` |
106 * | `wave_test` not fail | `syncing` |
107 * | `doc_sync` fail | `failed` |
108 * | `doc_sync` not fail | `done` |
109 *
110 * - `implementing`: an `implement` `start` line is the latest event. Logs
111 * without start lines only yield this for a sprint whose every event has an
112 * unknown gate. Consumers may use it for a sprint they know is in the
113 * running wave but that has no events yet.
114 * - `failed`: a gate failed and nothing later was logged for the sprint. That
115 * covers a retry in progress, a run the user stopped, and an escalation
116 * alike; the viewer doesn't guess which. {@link SprintRun.escalation} tells
117 * an escalation apart.
118 */
119export const SPRINT_RUN_STATES = [
120 'implementing',
121 'verifying',
122 'testing',
123 'syncing',
124 'done',
125 'failed',
126] as const;
127
128/** A sprint's derived state within one wave. See {@link SPRINT_RUN_STATES}. */
129export type SprintRunState = (typeof SPRINT_RUN_STATES)[number];
130
131// ---------------------------------------------------------------------------
132// Warnings
133// ---------------------------------------------------------------------------
134
135/**
136 * Something the parser or reader could not fully understand. Warnings never
137 * stop parsing; the model is always returned alongside them.
138 */
139export interface Warning {
140 /** Path of the file the warning is about, as given to the parser (`loadProject` passes absolute paths). */
141 file: string;
142 /** 1-based line number the problem is on. `0` when it concerns the whole file (e.g. the file could not be read). */
143 line: number;
144 /** The offending line exactly as read, without its line ending. Empty when `line` is `0`. */
145 raw: string;
146 /** Human-readable description of the problem, e.g. `"Table row has no closing pipe"`. */
147 message: string;
148}
149
150/**
151 * The shape every never-throw parse function returns: its best-effort value
152 * plus whatever it had to warn about.
153 */
154export interface ParseResult<T> {
155 /** The parsed value. Always present, even when the input was garbage. */
156 value: T;
157 /** Problems found while parsing, in file order. */
158 warnings: Warning[];
159}
160
161// ---------------------------------------------------------------------------
162// Phase files
163// ---------------------------------------------------------------------------
164
165/**
166 * A markdown section the parser keeps as raw text rather than interpreting:
167 * unknown `###` sections inside a sprint, or `##` sections at the end of a
168 * phase file such as `## Scope Guard` and `## Risk Mitigations`.
169 */
170export interface Section {
171 /** Heading text without the leading `#` marks, trimmed, e.g. `"Scope Guard"`. */
172 heading: string;
173 /** Heading level: the number of `#` marks (2 for `##`, 3 for `###`). */
174 level: number;
175 /** Raw markdown between this heading and the next heading of the same or higher level, trimmed. */
176 body: string;
177 /** 1-based line of the heading. */
178 line: number;
179}
180
181/** One row of a sprint's task table. */
182export interface Task {
183 /** The `#` cell as a number. `null` when the cell is missing or not an integer. */
184 number: number | null;
185 /** The Task cell text exactly as written (inline markdown such as backticks kept). */
186 text: string;
187 /** Normalized status. Always `todo` for a legacy row (a table without a Status column). */
188 status: TaskStatus;
189 /** The Status cell as written, trimmed (e.g. `"x"`, `"—"`, `"[ ]"`). `null` for a legacy row. */
190 rawStatus: string | null;
191 /** `true` when the row comes from a legacy table that has no Status column. */
192 legacy: boolean;
193 /** The Module cell (a path relative to the app root). `null` when the row omits it or writes a placeholder (`—`, `-`). */
194 module: string | null;
195 /** The Reference cell (a path relative to the project folder, may include a `#fragment`). `null` when omitted or a placeholder. */
196 reference: string | null;
197 /** 1-based line of the row in the phase file. */
198 line: number;
199}
200
201/** One bullet from a sprint's `### Acceptance Criteria` section. */
202export interface AcceptanceCriterion {
203 /** Bullet text without the bullet marker or checkbox, trimmed. */
204 text: string;
205 /** Checkbox state when the bullet is written as `- [ ]` / `- [x]`; `null` for a plain bullet. */
206 checked: boolean | null;
207 /** 1-based line of the bullet. */
208 line: number;
209}
210
211/**
212 * One bullet from a sprint's `### Dependencies` section, with the sprint and
213 * phase ids it mentions pulled out. Resolution against the project's actual
214 * sprints and phases happens in `loadProject`, which warns about ids that
215 * don't exist.
216 *
217 * Only `Sprint X.Y` and `Phase N` mentions count (also `Sprints X.Y and X.Z`,
218 * `Sprint X.Y, X.Z`); a bare `X.Y` is not an id. Text in parentheses and in
219 * inline code is a note and is not scanned.
220 */
221export interface Dependency {
222 /** Bullet text without the bullet marker, trimmed, e.g. `"Sprint 1.1 (preferences table must exist)"`. */
223 raw: string;
224 /** Sprint ids mentioned in the bullet, in order of first mention, without duplicates, e.g. `["1.1"]`. */
225 sprints: string[];
226 /** Phase numbers mentioned as whole phases (`"Phase 1 (…)"`), in order. Sprint mentions do not add their phase here. */
227 phases: number[];
228 /** `true` when the bullet says there is no dependency (`"None"`). */
229 none: boolean;
230 /** 1-based line of the bullet. */
231 line: number;
232}
233
234/**
235 * A sprint's `### Verification` block: the keys phase-builder reads to decide
236 * which checks and browser tests to run.
237 */
238export interface VerificationConfig {
239 /** `cli:` value, the check command phase-verify runs (e.g. `"npm run check"`). Absent when the key is missing. */
240 cli?: string;
241 /** `ui:` routes, split on commas and trimmed. Empty when absent. */
242 ui: string[];
243 /** `skills:` names, split on commas and trimmed. Empty when absent. */
244 skills: string[];
245 /** `viewports:` widths in CSS pixels. Non-numeric entries are dropped with a warning. Empty when absent. */
246 viewports: number[];
247 /** `skip-ui:` as a boolean. Absent when the key is missing or its value isn't a boolean (which warns). */
248 skipUi?: boolean;
249 /** `assert:` values, one entry per `assert:` bullet (or nested bullet under `assert:`), in order. Empty when absent. */
250 assert: string[];
251 /**
252 * Any other `key: value` bullet, keyed by the normalized key (lowercased,
253 * spaces and underscores as hyphens). A repeated key keeps its last value.
254 */
255 extra: Record<string, string>;
256 /** 1-based line of the `### Verification` heading; `0` when the sprint has no Verification block. */
257 line: number;
258}
259
260/** One `# Sprint N.M — Title` block inside a phase file. */
261export interface Sprint {
262 /** Sprint id as written in the header, e.g. `"2.1"`. */
263 id: string;
264 /** Phase number, the part of the id before the dot. */
265 phase: number;
266 /** Sprint number within the phase, the part of the id after the dot. */
267 number: number;
268 /** Title after the separator (`—`, `–`, `-` or `:`), trimmed. */
269 title: string;
270 /** Text of the `### Goal` section, trimmed. `null` when the sprint has no Goal section. */
271 goal: string | null;
272 /** Rows of the `### Tasks` table, in file order. Empty when there is no table. */
273 tasks: Task[];
274 /** `true` when the task table has no Status column (every task is then `legacy`). */
275 legacyTable: boolean;
276 /** Bullets from `### Acceptance Criteria`, in order. */
277 acceptanceCriteria: AcceptanceCriterion[];
278 /** Bullets from `### Dependencies`, in order. */
279 dependencies: Dependency[];
280 /**
281 * The `### Verification` block. A sprint without one gets an empty config
282 * (every list empty, no `cli` or `skipUi`, `line` `0`), not a warning.
283 */
284 verification: VerificationConfig;
285 /** Any other `###` section inside the sprint (e.g. `### Notes`), kept raw, in order. */
286 sections: Section[];
287 /** 1-based line of the `# Sprint` header. */
288 line: number;
289 /** 1-based line of the sprint's last line (before the next sprint header or trailing `##` section, or end of file). */
290 endLine: number;
291}
292
293/** One `docs/phases/Phase-{N}-{Name}.md` file. */
294export interface Phase {
295 /** Phase number from the `# Phase N — Title` header (falls back to the file name). */
296 number: number;
297 /** Title after the separator in the `# Phase` header, trimmed. */
298 title: string;
299 /** Prose between the phase header and the first sprint, trimmed, with `---` dividers removed. Empty when there is none. */
300 intro: string;
301 /** Path of the phase file (absolute when produced by `loadProject`). */
302 file: string;
303 /** Sprints in file order. */
304 sprints: Sprint[];
305 /** `##` sections after the last sprint (e.g. `Scope Guard`, `Risk Mitigations`), in order. */
306 trailingSections: Section[];
307 /** 1-based line of the `# Phase` header, or `0` when the file has none. */
308 line: number;
309}
310
311// ---------------------------------------------------------------------------
312// Run log
313// ---------------------------------------------------------------------------
314
315/**
316 * One line of `docs/phases/.runs/phase-{N}.jsonl`, run-log event format v1.
317 * Field meanings follow the spec in `plugin/skills/phase-builder/run-log.md`.
318 */
319export interface RunEvent {
320 /**
321 * Format version as logged: normally `1`. A line with a newer version (`> 1`)
322 * is still read best-effort, with one warning per file; anything else is skipped.
323 */
324 v: number;
325 /** UTC timestamp of the append, `YYYY-MM-DDTHH:MM:SSZ`, kept as written. */
326 ts: string;
327 /** Phase number. */
328 phase: number;
329 /** Wave number within the run. A drop in wave number between lines marks a new run. */
330 wave: number;
331 /** Sprint id, e.g. `"2.4"`. */
332 sprint: string;
333 /** Gate that wrote the event; `unknown` when the log has a gate this reader doesn't know. */
334 gate: RunGate;
335 /** The `gate` field exactly as logged. */
336 rawGate: string;
337 /** Gate outcome; `unknown` when the log has a result this reader doesn't know. */
338 result: RunResult;
339 /** The `result` field exactly as logged. */
340 rawResult: string;
341 /** 1-based attempt of this gate in this wave. A label only; line order is the true sequence. */
342 attempt: number;
343 /**
344 * Retry limit for this gate; `0` means no limit. When the line has no valid
345 * `max`, the reader fills the spec default: `1` for `doc_sync`, `3` otherwise.
346 */
347 max: number;
348 /** One-line summary; empty string when missing. */
349 summary: string;
350 /** `doc_sync` only: files the wave changed, relative to the app root. Absent when not logged. */
351 files?: string[];
352 /** `task` lines only: the task number, read from `summary`. Absent on every other gate. */
353 task?: number;
354 /** 1-based line of the event in its `.jsonl` file. */
355 line: number;
356}
357
358/**
359 * Where one task stands while its sprint is being implemented, from the
360 * sprint's latest `task` line for that task number in one wave.
361 *
362 * It is the implementer's own report, not a status: the gates still decide
363 * whether the work passes, and the task's {@link Task.status} changes only
364 * when doc sync edits the phase file.
365 */
366export interface TaskProgress {
367 /** Task number: the `#` of the task's row. */
368 task: number;
369 /**
370 * `running` after the task's `task` `start` line; `built` after its `pass`
371 * line: the implementer finished it, and no gate has checked it yet.
372 */
373 state: 'running' | 'built';
374 /** `attempt` as logged: the implementation run that wrote the line. */
375 attempt: number;
376 /** Timestamp as logged. */
377 ts: string;
378 /** 1-based line of the `task` line in the `.jsonl` file. */
379 line: number;
380}
381
382/**
383 * One event of a sprint within one wave, as a step of its gate timeline.
384 * Copies the event's fields and adds {@link GateStep.seq}.
385 */
386export interface GateStep {
387 /** Gate that wrote the event. */
388 gate: RunGate;
389 /** Gate outcome. */
390 result: RunResult;
391 /** `attempt` as logged. A label: a verify re-run after a wave-test failure repeats it, and it resets to 1 after "continue retrying". */
392 attempt: number;
393 /** Retry limit as logged (or the reader's default); `0` means no limit. */
394 max: number;
395 /**
396 * 1-based count of this gate's steps for this sprint in this wave, in line
397 * order: the true sequence, which {@link GateStep.attempt} may not be.
398 */
399 seq: number;
400 /** One-line summary; empty when none was logged. */
401 summary: string;
402 /** Timestamp as logged. */
403 ts: string;
404 /** 1-based line of the event in the `.jsonl` file. */
405 line: number;
406 /** `doc_sync` only: files the wave changed, as logged. Absent when the event carried none. */
407 files?: string[];
408}
409
410/** One run of the escalated gate, as listed in {@link Escalation.history}. */
411export interface EscalationAttempt {
412 /** `attempt` as logged. */
413 attempt: number;
414 /** Gate outcome of this run. */
415 result: RunResult;
416 /** One-line summary (the failure reason on a `fail`). */
417 summary: string;
418 /** Timestamp as logged. */
419 ts: string;
420 /** 1-based line in the `.jsonl` file. */
421 line: number;
422}
423
424/**
425 * A derived escalation: a `verify` or `wave_test` `fail` at `attempt >= max`
426 * (with `max > 0`) and no later line for that sprint anywhere later in the
427 * log. Never logged directly; any later event for the sprint (an `implement`
428 * after "continue retrying", a skipped gate's `doc_sync`, a new run) clears it.
429 */
430export interface Escalation {
431 /** Phase number. */
432 phase: number;
433 /** 1-based index of the run within the phase's log (see {@link WaveRun.run}). */
434 run: number;
435 /** Wave number within that run. */
436 wave: number;
437 /** Sprint id. */
438 sprint: string;
439 /** Gate that failed for the last time. */
440 gate: RunGate;
441 /** Attempt number of the final failure (normally equal to `max`; never below it). */
442 attempt: number;
443 /** Retry limit that was reached. */
444 max: number;
445 /** Summary of the final failing event. */
446 summary: string;
447 /** Timestamp of the final failing event. */
448 ts: string;
449 /** 1-based line of the final failing event in the `.jsonl` file. */
450 line: number;
451 /**
452 * Every run of the escalated gate for this sprint in this wave, in line
453 * order (the retry history: attempt 1, 2, 3 …, including earlier cycles
454 * before a "continue retrying" reset). The last entry is the final failure.
455 */
456 history: EscalationAttempt[];
457}
458
459/** Everything that happened to one sprint within one wave of one run. */
460export interface SprintRun {
461 /** Sprint id. */
462 sprint: string;
463 /** Phase number. */
464 phase: number;
465 /** 1-based run index (see {@link WaveRun.run}). */
466 run: number;
467 /** Wave number within the run. */
468 wave: number;
469 /** This sprint's events in this wave, in log order, `implement` `start` markers included. `task` lines are not: see {@link SprintRun.tasks}. */
470 events: RunEvent[];
471 /** The same events as ordered gate steps, with a per-gate sequence number. `start` markers are left out. */
472 steps: GateStep[];
473 /** Derived state after the latest event. See {@link SprintRunState} for the table. */
474 state: SprintRunState;
475 /** Highest `attempt` seen per gate (gates never logged are absent). `start` markers don't count. */
476 attempts: Partial<Record<RunGate, number>>;
477 /** The latest event, or `null` if the sprint has none yet. */
478 lastEvent: RunEvent | null;
479 /** The escalation that ended this sprint's wave, or `null` (none, or cleared by a later event for the sprint). */
480 escalation: Escalation | null;
481 /**
482 * Union of `files` from this sprint's `doc_sync` events in this wave, in
483 * first-seen order. `null` when none carried `files`. doc-sync logs the
484 * whole wave's diff on every sprint's line; `sprintHistory` puts the files
485 * under the sprint's Module paths first.
486 */
487 files: string[] | null;
488 /**
489 * Task progress from the sprint's `task` lines in this wave: the latest
490 * line per task number, ordered by task number. Absent when the wave has no
491 * `task` line for the sprint, so a log without them derives exactly as it
492 * did before they existed. `task` lines change nothing else here: not
493 * `events`, `steps`, `state`, `attempts`, `lastEvent`, `startedAt` or
494 * `updatedAt`.
495 */
496 tasks?: TaskProgress[];
497 /** `ts` of the first event. */
498 startedAt: string;
499 /** `ts` of the latest event. */
500 updatedAt: string;
501}
502
503/** One wave of one run, as reconstructed from line order. */
504export interface WaveRun {
505 /** Phase number. */
506 phase: number;
507 /**
508 * 1-based run index within the phase's log. Wave numbers restart at 1 each
509 * time phase-builder runs the phase again, so a drop in `wave` starts a new run.
510 */
511 run: number;
512 /** Wave number within the run. */
513 wave: number;
514 /** One entry per sprint that appears in the wave, in first-seen order. */
515 sprints: SprintRun[];
516 /**
517 * Every event of the wave, in log order, including the `implement` `start`
518 * markers placed in it (which may sit on lines between the previous wave's
519 * events: the next wave's implementation starts as the previous wave's doc
520 * sync runs). `task` lines are left out; they are in each sprint's
521 * {@link SprintRun.tasks}.
522 */
523 events: RunEvent[];
524 /** Union of `files` from the wave's `doc_sync` events, or `null` when none carried `files`. */
525 files: string[] | null;
526 /** `ts` of the wave's first event (derived wave start; a `start` marker when one was logged). */
527 startedAt: string;
528 /**
529 * Derived wave end: once every sprint in the wave has a passing `doc_sync`
530 * as its latest event (every sprint `done`), the `ts` of the wave's last
531 * event. `null` while any sprint is not `done` (running, failed, escalated).
532 */
533 endedAt: string | null;
534 /** `true` when any sprint in the wave escalated. */
535 escalated: boolean;
536}
537
538/** All run-log data for one phase. */
539export interface PhaseRuns {
540 /** Phase number. */
541 phase: number;
542 /** Path of the `.jsonl` file (absolute when produced by `loadProject`). */
543 file: string;
544 /** Every valid event, in log order, `task` lines included. */
545 events: RunEvent[];
546 /** Waves in log order, across all runs. */
547 waves: WaveRun[];
548 /** Every derived escalation, in log order. */
549 escalations: Escalation[];
550 /** Per sprint id, every {@link SprintRun} for that sprint across runs and waves, oldest first. */
551 sprintHistory: Record<string, SprintRun[]>;
552}
553
554/** One wave of a sprint's history, as returned by `sprintHistory`. */
555export interface SprintWaveHistory {
556 /** 1-based run index (see {@link WaveRun.run}). */
557 run: number;
558 /** Wave number within the run. */
559 wave: number;
560 /** The sprint's derived state at the end of this wave (or now, for the running wave). */
561 state: SprintRunState;
562 /** The sprint's gate steps in this wave, in line order. */
563 steps: GateStep[];
564 /** The escalation that ended this wave for the sprint, or `null`. */
565 escalation: Escalation | null;
566 /**
567 * Files the wave changed (union of the wave's `doc_sync` `files`), with the
568 * files under this sprint's Module paths first, each group in logged order.
569 * `null` when no `doc_sync` in the wave carried `files` ("not logged").
570 */
571 files: string[] | null;
572 /** The leading part of {@link SprintWaveHistory.files} that falls under the sprint's Module paths. Empty when `files` is `null`. */
573 moduleFiles: string[];
574 /** `ts` of the wave's latest `doc_sync` event that carried `files`; `null` when none did. */
575 filesAt: string | null;
576 /** `ts` of the sprint's first event in the wave. */
577 startedAt: string;
578 /** `ts` of the sprint's latest event in the wave. */
579 updatedAt: string;
580}
581
582/** A gate step placed in its run and wave, for a flat cross-wave timeline. */
583export interface HistoryStep extends GateStep {
584 /** 1-based run index. */
585 run: number;
586 /** Wave number within the run. */
587 wave: number;
588}
589
590/** Everything the run log says about one sprint, across runs and waves. */
591export interface SprintHistory {
592 /** Sprint id. */
593 sprint: string;
594 /** Phase number. */
595 phase: number;
596 /** Path of the phase's `.jsonl` run log. */
597 file: string;
598 /** One entry per wave the sprint appears in, oldest first. Empty when the log never mentions the sprint. */
599 waves: SprintWaveHistory[];
600 /** Every step across those waves, oldest first (line order). */
601 steps: HistoryStep[];
602}
603
604// ---------------------------------------------------------------------------
605// Progress and project
606// ---------------------------------------------------------------------------
607
608/**
609 * Task counts for a sprint, a phase or the whole project. Cut and deferred
610 * tasks are not eligible: they count in `byStatus` and `total` but not in
611 * `eligible` or `percent`. Blocked, manual and unknown tasks are eligible and
612 * not done.
613 */
614export interface Progress {
615 /** Every task, including cut and deferred. */
616 total: number;
617 /** Tasks that count toward completion: `total` minus `cut` and `deferred`. */
618 eligible: number;
619 /** Tasks with status `done`. */
620 done: number;
621 /** `done / eligible * 100`, rounded to a whole number; `0` when `eligible` is `0`. */
622 percent: number;
623 /** Count of tasks per status (every status present, zero when none). */
624 byStatus: Record<TaskStatus, number>;
625}
626
627/** The sprint the Overview's "Next up" card points at. */
628export interface NextUp {
629 /** Phase number. */
630 phase: number;
631 /** Sprint id. */
632 sprint: string;
633 /** Sprint title. */
634 title: string;
635}
636
637/** A task with status `blocked`, located for the Overview's "Blocked" card. */
638export interface BlockedTask {
639 /** Phase number. */
640 phase: number;
641 /** Sprint id. */
642 sprint: string;
643 /** The task's `#`, or `null` when the row has none. */
644 number: number | null;
645 /** Task text as written. */
646 text: string;
647 /** Path of the phase file. */
648 file: string;
649 /** 1-based line of the task row. */
650 line: number;
651}
652
653/** Derived progress at every level, keyed so it serializes as plain JSON. */
654export interface ProjectProgress {
655 /** All tasks in all phases. */
656 overall: Progress;
657 /** Per phase number (as a string key, e.g. `"2"`). */
658 byPhase: Record<string, Progress>;
659 /** Per sprint id (e.g. `"2.1"`). */
660 bySprint: Record<string, Progress>;
661 /**
662 * The first sprint with a `todo` or `active` task, walking phases from the
663 * lowest number and sprints in file order. `null` when no sprint has one.
664 */
665 nextUp: NextUp | null;
666 /** Every `blocked` task, in phase then file order. */
667 blocked: BlockedTask[];
668 /** Number of `deferred` tasks in the project (left out of every percent). */
669 deferred: number;
670 /** Number of `cut` tasks in the project (left out of every percent). */
671 cut: number;
672}
673
674/** Everything the viewer knows about one project folder. */
675export interface Project {
676 /** Absolute path of the project folder (the folder that contains `docs/phases/`). */
677 root: string;
678 /** Absolute path of the phases folder, normally `{root}/docs/phases`. */
679 phasesDir: string;
680 /** Parsed phase files, sorted by phase number. */
681 phases: Phase[];
682 /** Run-log data, one entry per phase that has a `.runs/phase-{N}.jsonl`, sorted by phase number. */
683 runs: PhaseRuns[];
684 /** `true` when `{root}/docs/design/design-system.md` exists. */
685 hasDesignSystem: boolean;
686 /** Derived task progress. */
687 progress: ProjectProgress;
688 /**
689 * Every warning from the loader, every phase file and every run log,
690 * grouped by file in load order (phase files by number, then run logs).
691 * A missing `docs/phases/` is one warning on `phasesDir`.
692 */
693 warnings: Warning[];
694}
695hooks/core/parser/phase.ts 432 lines1// Copied from viewer/src/core/parser/phase.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Phase-file parser: `docs/phases/Phase-{N}-{Name}.md` text in, one typed
5 * {@link Phase} out. Pure (no fs) and never throws.
6 *
7 * Parsing runs in two passes:
8 *
9 * 1. {@link splitPhaseFile} walks the lines once and cuts the file into
10 * blocks: the `# Phase` header, the intro, one {@link SprintBlock} per
11 * `# Sprint N.M — Title` header (itself split into `###` {@link RawSection}s),
12 * and the `##` sections after the sprints. Headings inside fenced code
13 * blocks are ignored.
14 * 2. Each sprint's sections are handed to the parser registered for their
15 * heading in {@link SPRINT_SECTION_PARSERS} (`Goal`, `Tasks`,
16 * `Acceptance Criteria`, `Dependencies`, `Verification`). A section with no
17 * registered parser (`### Notes`, anything else) is kept raw in
18 * {@link Sprint.sections}, never warned about. `##` sections after the
19 * sprints (`Scope Guard`, `Risk Mitigations`, anything else) are kept raw
20 * in {@link Phase.trailingSections}.
21 *
22 * Warning policy (each warning carries the file, the 1-based line and the raw
23 * line text):
24 *
25 * - no `# Phase` header (line 0), or a `# Phase` header without a number
26 * - a `# Sprint` header without an `N.M` id: one warning on the header; the
27 * whole block up to the next sprint header or `##` section is skipped
28 * - a sprint without a `### Goal` section (on the sprint header)
29 * - a second section of a kind already parsed in the sprint (e.g. two
30 * `### Tasks`): only the first is parsed; the repeat is kept raw in
31 * {@link Sprint.sections}
32 * - a line holding control characters or invalid UTF-8 (anywhere in the file)
33 * - task-table problems, from {@link parseTaskTable}: a row with no closing
34 * pipe, text directly after the table, an unrecognized status, extra cells
35 * - verification problems, from {@link parseVerification}: a line that isn't
36 * `key: value`, a known key with no value, a non-numeric viewport, a
37 * `skip-ui` that isn't a boolean
38 * - an unexpected exception: caught, reported, and whatever parsed so far returned
39 *
40 * Acceptance criteria and dependencies never warn. Dependency ids that name
41 * no real sprint or phase are left for `loadProject` to warn about.
42 */
43import type { Phase, Section, Sprint, Warning } from '../model.js';
44import { hasJunkBytes, isBlank, isDivider, joinLines, splitLines, trimBlock, warningAt, type SourceLine } from './lines.js';
45import { parseAcceptanceCriteria, parseDependencies } from './sections.js';
46import { parseTaskTable } from './tasks.js';
47import { emptyVerification, parseVerification } from './verification.js';
48
49/** What {@link parsePhaseFile} returns. */
50export interface PhaseFileResult {
51 /** The parsed phase. Always present, even for garbage input. */
52 phase: Phase;
53 /** Problems found, sorted by line. */
54 warnings: Warning[];
55}
56
57/** A heading and the lines under it, up to the next heading of the same or higher level. */
58export interface RawSection {
59 /** Heading text without the `#` marks, trimmed. */
60 heading: string;
61 /** Number of `#` marks. */
62 level: number;
63 /** The heading line itself. */
64 header: SourceLine;
65 /** Every line after the heading, untrimmed (use {@link trimBlock} for the content). */
66 body: SourceLine[];
67}
68
69/** One `# Sprint N.M — Title` block, cut into its `###` sections. */
70export interface SprintBlock {
71 /** The `# Sprint` header line. */
72 header: SourceLine;
73 /** Sprint id as written, e.g. `"2.1"`. */
74 id: string;
75 /** Phase number (before the dot). */
76 phase: number;
77 /** Sprint number (after the dot). */
78 number: number;
79 /** Title after the separator, trimmed. */
80 title: string;
81 /** Lines between the header and the first `###` heading. */
82 preamble: SourceLine[];
83 /** `###` sections in file order. */
84 sections: RawSection[];
85 /** 1-based line of the sprint's last non-blank line (a closing `---` counts). */
86 endLine: number;
87}
88
89/** A phase file cut into blocks by {@link splitPhaseFile}. */
90export interface PhaseBlocks {
91 /** The `# Phase` header line, or `null` when the file has none. */
92 header: SourceLine | null;
93 /** Lines between the phase header and the first sprint. */
94 intro: SourceLine[];
95 /** Sprints with a valid `N.M` id, in file order. */
96 sprints: SprintBlock[];
97 /** `##` sections that come after a sprint, in file order. */
98 trailing: RawSection[];
99}
100
101/** Shared state handed to each sprint-section parser. */
102export interface SprintParseContext {
103 /** File path for warnings. */
104 file: string;
105 /** Warnings sink. */
106 warnings: Warning[];
107 /** Parsers already applied in this sprint (the first section of each kind wins). */
108 seen: Set<SprintSectionParser>;
109}
110
111/**
112 * Fills part of a {@link Sprint} from one of its `###` sections. Registered in
113 * {@link SPRINT_SECTION_PARSERS} under the section's key (see {@link sectionKey}).
114 */
115export type SprintSectionParser = (section: RawSection, sprint: Sprint, ctx: SprintParseContext) => void;
116
117/** Normalize a heading into a lookup key: markdown emphasis and a trailing colon dropped, lowercased. */
118export function sectionKey(heading: string): string {
119 return heading
120 .replace(/[*_`]/g, '')
121 .trim()
122 .replace(/:$/, '')
123 .trim()
124 .toLowerCase();
125}
126
127/** Trimmed text content of a section. */
128export function sectionText(section: RawSection): string {
129 return joinLines(trimBlock(section.body)).trim();
130}
131
132const parseGoal: SprintSectionParser = (section, sprint) => {
133 sprint.goal = sectionText(section);
134};
135
136const parseTasks: SprintSectionParser = (section, sprint, ctx) => {
137 const result = parseTaskTable(section.body, ctx.file);
138 sprint.tasks = result.value.tasks;
139 sprint.legacyTable = result.value.legacyTable;
140 ctx.warnings.push(...result.warnings);
141};
142
143const parseCriteria: SprintSectionParser = (section, sprint) => {
144 sprint.acceptanceCriteria = parseAcceptanceCriteria(section.body);
145};
146
147const parseDeps: SprintSectionParser = (section, sprint) => {
148 sprint.dependencies = parseDependencies(section.body);
149};
150
151const parseVerificationSection: SprintSectionParser = (section, sprint, ctx) => {
152 const result = parseVerification(section.body, section.header.line, ctx.file);
153 sprint.verification = result.value;
154 ctx.warnings.push(...result.warnings);
155};
156
157/**
158 * Section parsers by {@link sectionKey}. Only the first section of each kind
159 * in a sprint is parsed. Sections with no entry here are kept raw in
160 * {@link Sprint.sections} without a warning.
161 */
162export const SPRINT_SECTION_PARSERS = new Map<string, SprintSectionParser>([
163 ['goal', parseGoal],
164 ['tasks', parseTasks],
165 ['task', parseTasks],
166 ['acceptance criteria', parseCriteria],
167 ['acceptance', parseCriteria],
168 ['dependencies', parseDeps],
169 ['dependency', parseDeps],
170 ['depends on', parseDeps],
171 ['verification', parseVerificationSection],
172]);
173
174/** A section kept as raw markdown: heading, level, trimmed body and heading line. */
175export function rawSection(section: RawSection): Section {
176 return { heading: section.heading, level: section.level, body: sectionText(section), line: section.header.line };
177}
178
179const HEADING = /^(#{1,6})\s+(.*?)\s*$/;
180const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
181const PHASE_START = /^#\s+Phase\b/i;
182const PHASE_HEADER = /^#\s+Phase\s+(\d+)(?:\s*[—–:-]\s*|\s+|$)(.*)$/i;
183/** Level-1 `# Sprint …` is always meant as a sprint header; level-2 only when a number follows. */
184const SPRINT_START = /^(?:#\s+Sprint\b|##\s+Sprint\s+\d)/i;
185const SPRINT_HEADER = /^#{1,2}\s+Sprint\s+(\d+)\.(\d+)(?:\s*[—–:-]\s*|\s+|$)(.*)$/i;
186
187/** Phase number from a `Phase-{N}-…` file name, or `0`. */
188function numberFromFileName(file: string): number {
189 const base = file.split(/[\\/]/).pop() ?? '';
190 const m = /^Phase-(\d+)/i.exec(base);
191 return m ? Number(m[1]) : 0;
192}
193
194/**
195 * Cut a phase file's lines into blocks. Warns (into `warnings`) about sprint
196 * headers without an `N.M` id; everything else is left to the block parsers.
197 */
198export function splitPhaseFile(lines: SourceLine[], file: string, warnings: Warning[]): PhaseBlocks {
199 const blocks: PhaseBlocks = { header: null, intro: [], sprints: [], trailing: [] };
200 let state: 'intro' | 'sprint' | 'skip' | 'trailing' = 'intro';
201 let sprint: SprintBlock | null = null;
202 let section: RawSection | null = null;
203 let fence: string | null = null;
204
205 for (const line of lines) {
206 let heading: RegExpExecArray | null = null;
207 const fenceMark = FENCE.exec(line.text)?.[1];
208 if (fence !== null) {
209 if (fenceMark && fenceMark[0] === fence[0] && fenceMark.length >= fence.length) fence = null;
210 } else if (fenceMark) {
211 fence = fenceMark;
212 } else {
213 heading = HEADING.exec(line.text);
214 }
215
216 if (heading) {
217 const level = heading[1]!.length;
218 const text = heading[2] ?? '';
219
220 if (level <= 2 && SPRINT_START.test(line.text)) {
221 sprint = null;
222 section = null;
223 const m = SPRINT_HEADER.exec(line.text);
224 if (m) {
225 const block: SprintBlock = {
226 header: line,
227 id: `${m[1]}.${m[2]}`,
228 phase: Number(m[1]),
229 number: Number(m[2]),
230 title: (m[3] ?? '').trim(),
231 preamble: [],
232 sections: [],
233 endLine: line.line,
234 };
235 blocks.sprints.push(block);
236 sprint = block;
237 state = 'sprint';
238 } else {
239 warnings.push(warningAt(file, line, 'Sprint header has no N.M id; the sprint and its tasks are skipped'));
240 state = 'skip';
241 }
242 continue;
243 }
244
245 if (level === 1 && state === 'intro' && blocks.header === null && PHASE_START.test(line.text)) {
246 blocks.header = line;
247 blocks.intro = [];
248 continue;
249 }
250
251 if (level === 2 && state !== 'intro') {
252 sprint = null;
253 const trailing: RawSection = { heading: text, level, header: line, body: [] };
254 blocks.trailing.push(trailing);
255 section = trailing;
256 state = 'trailing';
257 continue;
258 }
259
260 if (level === 3 && state === 'sprint' && sprint !== null) {
261 const current: SprintBlock = sprint;
262 const sub: RawSection = { heading: text, level, header: line, body: [] };
263 current.sections.push(sub);
264 current.endLine = line.line;
265 section = sub;
266 continue;
267 }
268 }
269
270 switch (state) {
271 case 'intro':
272 blocks.intro.push(line);
273 break;
274 case 'sprint':
275 if (sprint !== null) {
276 const current: SprintBlock = sprint;
277 const target: RawSection | null = section;
278 if (target) target.body.push(line);
279 else current.preamble.push(line);
280 if (!isBlank(line)) current.endLine = line.line;
281 }
282 break;
283 case 'trailing': {
284 const target: RawSection | null = section;
285 if (target) target.body.push(line);
286 break;
287 }
288 case 'skip':
289 break;
290 }
291 }
292
293 return blocks;
294}
295
296function emptySprint(block: SprintBlock): Sprint {
297 return {
298 id: block.id,
299 phase: block.phase,
300 number: block.number,
301 title: block.title,
302 goal: null,
303 tasks: [],
304 legacyTable: false,
305 acceptanceCriteria: [],
306 dependencies: [],
307 verification: emptyVerification(),
308 sections: [],
309 line: block.header.line,
310 endLine: block.endLine,
311 };
312}
313
314function errorMessage(err: unknown): string {
315 try {
316 return err instanceof Error ? err.message : String(err);
317 } catch {
318 return 'unknown error';
319 }
320}
321
322/**
323 * Parse one phase file. Never throws: any unexpected error becomes a warning
324 * and the phase parsed so far is returned.
325 *
326 * @param text The file's contents (LF or CRLF).
327 * @param file The file's path, used for warnings, {@link Phase.file}, and as
328 * the phase-number fallback when the header has none.
329 */
330export function parsePhaseFile(text: string, file: string): PhaseFileResult {
331 const warnings: Warning[] = [];
332 const phase: Phase = { number: 0, title: '', intro: '', file: '', sprints: [], trailingSections: [], line: 0 };
333 const cursor: { file: string; at: SourceLine | null } = { file: '', at: null };
334
335 try {
336 // Guard against non-string input from untyped callers.
337 const rawFile: unknown = file;
338 const rawText: unknown = text;
339 cursor.file = typeof rawFile === 'string' ? rawFile : String(rawFile ?? '');
340 phase.file = cursor.file;
341 phase.number = numberFromFileName(cursor.file);
342
343 const source = typeof rawText === 'string' ? rawText : String(rawText ?? '');
344 const lines = splitLines(source);
345
346 for (const line of lines) {
347 if (hasJunkBytes(line)) {
348 warnings.push(warningAt(cursor.file, line, 'Line contains stray control characters or invalid UTF-8'));
349 }
350 }
351
352 const blocks = splitPhaseFile(lines, cursor.file, warnings);
353
354 // Phase header and intro.
355 if (blocks.header) {
356 const header = blocks.header;
357 cursor.at = header;
358 phase.line = header.line;
359 const m = PHASE_HEADER.exec(header.text);
360 if (m) {
361 phase.number = Number(m[1]);
362 phase.title = (m[2] ?? '').trim();
363 } else {
364 phase.title = header.text.replace(PHASE_START, '').replace(/^\s*[—–:-]?\s*/, '').trim();
365 warnings.push(warningAt(cursor.file, header, 'Phase header has no number; phase number taken from the file name'));
366 }
367 } else {
368 warnings.push(
369 warningAt(cursor.file, null, 'No "# Phase N — Title" header; phase number taken from the file name'),
370 );
371 }
372 phase.intro = joinLines(trimBlock(blocks.intro.filter((l) => !isDivider(l))))
373 .replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, '\n\n')
374 .trim();
375
376 // Sprints.
377 for (const block of blocks.sprints) {
378 const sprint = emptySprint(block);
379 phase.sprints.push(sprint);
380 const ctx: SprintParseContext = { file: cursor.file, warnings, seen: new Set() };
381 try {
382 for (const section of block.sections) {
383 cursor.at = section.header;
384 const parse = SPRINT_SECTION_PARSERS.get(sectionKey(section.heading));
385 if (!parse) {
386 // Unregistered section (Notes, …): kept raw, no warning.
387 sprint.sections.push(rawSection(section));
388 continue;
389 }
390 if (ctx.seen.has(parse)) {
391 warnings.push(
392 warningAt(
393 cursor.file,
394 section.header,
395 `Repeated "### ${section.heading}" section in sprint ${block.id}; only the first is parsed, this one is kept as a raw section`,
396 ),
397 );
398 sprint.sections.push(rawSection(section));
399 continue;
400 }
401 ctx.seen.add(parse);
402 parse(section, sprint, ctx);
403 }
404 if (sprint.goal === null) {
405 warnings.push(warningAt(cursor.file, block.header, 'Sprint has no ### Goal section'));
406 }
407 } catch (err) {
408 warnings.push(
409 warningAt(
410 cursor.file,
411 cursor.at ?? block.header,
412 `Parser error in sprint ${block.id}: ${errorMessage(err)}; keeping what was parsed so far`,
413 ),
414 );
415 }
416 }
417
418 // `##` sections after the sprints.
419 for (const section of blocks.trailing) {
420 cursor.at = section.header;
421 phase.trailingSections.push(rawSection(section));
422 }
423 } catch (err) {
424 warnings.push(
425 warningAt(cursor.file, cursor.at, `Parser error: ${errorMessage(err)}; returning what was parsed so far`),
426 );
427 }
428
429 warnings.sort((a, b) => a.line - b.line);
430 return { phase, warnings };
431}
432hooks/core/runlog/read.ts 323 lines1// Copied from viewer/src/core/runlog/read.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Run-log reader: turns the text of one `docs/phases/.runs/phase-{N}.jsonl`
5 * file into typed {@link RunEvent}s plus {@link Warning}s. Pure (text in, data
6 * out) and never throws.
7 *
8 * Rules (spec: `plugin/skills/phase-builder/run-log.md`, "Reading the log"):
9 *
10 * - One JSON object per LF-terminated line. A trailing CR is dropped, and a
11 * leading UTF-8 BOM is ignored.
12 * - The text after the last `\n` is an append still being written, so it is
13 * skipped silently, even when it happens to be complete JSON. An empty file
14 * yields nothing.
15 * - Blank (whitespace-only) lines are skipped silently.
16 * - A terminated line that isn't a JSON object, or lacks a required field
17 * (`v`, `ts`, `phase`, `wave`, `sprint`, `gate`, `result`, `attempt`), or has
18 * one of the wrong type, is skipped with a warning carrying its line number.
19 * Strings must be non-blank; `phase`, `wave` and `attempt` must be whole
20 * numbers `>= 0` (the writer logs `0` for an empty placeholder).
21 * - `v > 1`: the file gets one warning (at the first such line) and every line
22 * is still read best-effort. `v` that isn't a number `>= 1` skips the line.
23 * - Unknown `gate` / `result` values are kept as `unknown`, with the raw value
24 * in `rawGate` / `rawResult` and a warning. `result: "start"` is read without
25 * a warning on `implement` and `task` lines only; on any other gate it is
26 * `unknown`.
27 * - A `task` line carries its task number alone in `summary`. It is read into
28 * {@link RunEvent.task}; a `task` line whose `summary` isn't a whole number
29 * is skipped with a warning.
30 * - Optional fields: `summary` (missing → `""`), `files` (kept only when it is
31 * an array; non-string entries dropped), `max` (missing or invalid → the spec
32 * default, see {@link DEFAULT_MAX}). Extra fields are ignored.
33 * - Events keep file order. `ts` is kept as written and never used for sorting.
34 */
35import {
36 RUN_GATES,
37 RUN_RESULTS,
38 type RunEvent,
39 type RunGate,
40 type RunResult,
41 type Warning,
42} from '../model.js';
43
44/** The run-log format version this reader was written for. */
45export const RUN_LOG_VERSION = 1;
46
47/**
48 * `max` used when a line has none (or an invalid one): the spec's defaults,
49 * `max_verify_retries` / `max_wave_test_retries` = 3 and doc-sync's fixed 1.
50 * An unknown gate gets 3 like the retrying gates, and so does a `task` line,
51 * which carries its implementation run's `max`.
52 */
53export const DEFAULT_MAX: Readonly<Record<RunGate, number>> = {
54 implement: 3,
55 verify: 3,
56 wave_test: 3,
57 doc_sync: 1,
58 task: 3,
59 unknown: 3,
60};
61
62/** Gates whose lines may carry `result: "start"`. */
63const START_GATES: ReadonlySet<RunGate> = new Set<RunGate>(['implement', 'task']);
64
65/** A `task` line's `summary`: the task number and nothing else. */
66const TASK_NUMBER = /^\d+$/;
67
68/** What {@link readRunLog} returns. */
69export interface RunLogRead {
70 /** Every readable event, in file order. */
71 events: RunEvent[];
72 /** Problems found, in file order. */
73 warnings: Warning[];
74}
75
76const KNOWN_GATES: ReadonlySet<string> = new Set<string>(RUN_GATES.filter((g) => g !== 'unknown'));
77const KNOWN_RESULTS: ReadonlySet<string> = new Set<string>(RUN_RESULTS.filter((r) => r !== 'unknown'));
78
79/**
80 * Read one run-log file.
81 *
82 * @param text The file's contents. A `Uint8Array` (e.g. a `Buffer`) is decoded
83 * as UTF-8 with invalid bytes replaced, so binary garbage is safe to pass.
84 * @param file The file's path, copied into every {@link Warning.file}.
85 */
86export function readRunLog(text: string | Uint8Array, file: string): RunLogRead {
87 const events: RunEvent[] = [];
88 const warnings: Warning[] = [];
89 const warn = (line: number, raw: string, message: string): void => {
90 warnings.push({ file, line, raw, message });
91 };
92
93 try {
94 const content = decode(text);
95 const segments = content.split('\n');
96 // The last segment is either '' (the file ends with a newline) or an append
97 // still in progress. Both are skipped silently.
98 const terminated = segments.length - 1;
99 let versionWarned = false;
100
101 for (let i = 0; i < terminated; i++) {
102 const lineNo = i + 1;
103 let raw = segments[i] ?? '';
104 if (raw.endsWith('\r')) raw = raw.slice(0, -1);
105 if (raw.trim() === '') continue;
106
107 try {
108 const lineWarn = (message: string): void => warn(lineNo, raw, message);
109 const event = readLine(raw, lineNo, lineWarn, () => {
110 if (versionWarned) return false;
111 versionWarned = true;
112 return true;
113 });
114 if (event) events.push(event);
115 } catch (err) {
116 warn(lineNo, raw, `Could not read line: ${errorMessage(err)}; skipped`);
117 }
118 }
119 } catch (err) {
120 warn(0, '', `Could not read run log: ${errorMessage(err)}`);
121 }
122
123 return { events, warnings };
124}
125
126/**
127 * Parse one terminated, non-blank line. Returns `null` (after warning) when the
128 * line can't become an event.
129 *
130 * @param claimVersionWarning Returns `true` the first time it is called per
131 * file, so the `v > 1` warning is raised once.
132 */
133function readLine(
134 raw: string,
135 line: number,
136 warn: (message: string) => void,
137 claimVersionWarning: () => boolean,
138): RunEvent | null {
139 let parsed: unknown;
140 try {
141 parsed = JSON.parse(raw);
142 } catch {
143 warn('Line is not valid JSON; skipped');
144 return null;
145 }
146 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
147 warn('Line is not a JSON object; skipped');
148 return null;
149 }
150 const obj = parsed as Record<string, unknown>;
151
152 // Version first: it decides whether the rest is read at all.
153 const v = obj['v'];
154 if (v === undefined) {
155 warn('Missing required field "v"; skipped');
156 return null;
157 }
158 if (typeof v !== 'number' || !Number.isFinite(v) || v < RUN_LOG_VERSION) {
159 warn(`Unsupported run-log version "v": ${show(v)}; skipped`);
160 return null;
161 }
162 if (v > RUN_LOG_VERSION && claimVersionWarning()) {
163 warn(
164 `Run log uses format v${v}, newer than this reader (v${RUN_LOG_VERSION}); reading it best-effort`,
165 );
166 }
167
168 // Required fields.
169 const problems: string[] = [];
170 const ts = requireString(obj, 'ts', problems);
171 const phase = requireCount(obj, 'phase', problems);
172 const wave = requireCount(obj, 'wave', problems);
173 const sprint = requireString(obj, 'sprint', problems);
174 const rawGate = requireString(obj, 'gate', problems);
175 const rawResult = requireString(obj, 'result', problems);
176 const attempt = requireCount(obj, 'attempt', problems);
177 if (
178 problems.length > 0 ||
179 ts === null ||
180 phase === null ||
181 wave === null ||
182 sprint === null ||
183 rawGate === null ||
184 rawResult === null ||
185 attempt === null
186 ) {
187 warn(`${problems.join('; ')}; skipped`);
188 return null;
189 }
190
191 // Enumerations: unknown values are kept as `unknown`.
192 let gate: RunGate = 'unknown';
193 if (KNOWN_GATES.has(rawGate)) gate = rawGate as RunGate;
194 else warn(`Unknown gate ${show(rawGate)}; kept as "unknown"`);
195
196 let result: RunResult = 'unknown';
197 if (KNOWN_RESULTS.has(rawResult)) result = rawResult as RunResult;
198 else warn(`Unknown result ${show(rawResult)}; kept as "unknown"`);
199 if (result === 'start' && !START_GATES.has(gate)) {
200 result = 'unknown';
201 warn(`Result "start" is only used on implement and task lines, not ${show(rawGate)}; kept as "unknown"`);
202 }
203
204 // A task line names its task in `summary`; without a number it says nothing.
205 let task: number | null = null;
206 if (gate === 'task') {
207 const rawTask = obj['summary'];
208 const text = typeof rawTask === 'string' ? rawTask.trim() : '';
209 task = TASK_NUMBER.test(text) ? Number(text) : null;
210 if (task === null || !Number.isSafeInteger(task)) {
211 warn(`A task line's "summary" should be the task number alone, got ${show(rawTask)}; skipped`);
212 return null;
213 }
214 }
215
216 // Optional fields.
217 let max = DEFAULT_MAX[gate];
218 const rawMax = obj['max'];
219 if (rawMax !== undefined && rawMax !== null) {
220 if (isCount(rawMax)) max = rawMax;
221 else warn(`Field "max" should be a whole number >= 0, got ${show(rawMax)}; using ${max}`);
222 }
223
224 let summary = '';
225 const rawSummary = obj['summary'];
226 if (typeof rawSummary === 'string') summary = rawSummary;
227 else if (rawSummary !== undefined && rawSummary !== null) {
228 warn(`Field "summary" should be a string, got ${show(rawSummary)}; treated as empty`);
229 }
230
231 const event: RunEvent = {
232 v,
233 ts,
234 phase,
235 wave,
236 sprint,
237 gate,
238 rawGate,
239 result,
240 rawResult,
241 attempt,
242 max,
243 summary,
244 line,
245 };
246
247 const rawFiles = obj['files'];
248 if (Array.isArray(rawFiles)) {
249 const files = rawFiles.filter((f): f is string => typeof f === 'string');
250 if (files.length !== rawFiles.length) {
251 warn(`Field "files" has ${rawFiles.length - files.length} non-string entries; dropped them`);
252 }
253 event.files = files;
254 } else if (rawFiles !== undefined && rawFiles !== null) {
255 warn(`Field "files" should be an array of strings, got ${show(rawFiles)}; ignored`);
256 }
257
258 if (task !== null) event.task = task;
259
260 return event;
261}
262
263/** A required string field; `null` (with a problem noted) when missing or not a string. */
264function requireString(obj: Record<string, unknown>, key: string, problems: string[]): string | null {
265 const value = obj[key];
266 if (value === undefined) {
267 problems.push(`Missing required field "${key}"`);
268 return null;
269 }
270 if (typeof value !== 'string') {
271 problems.push(`Field "${key}" should be a string, got ${show(value)}`);
272 return null;
273 }
274 if (value.trim() === '') {
275 problems.push(`Field "${key}" is empty`);
276 return null;
277 }
278 return value;
279}
280
281/** A required whole-number field (>= 0); `null` (with a problem noted) otherwise. */
282function requireCount(obj: Record<string, unknown>, key: string, problems: string[]): number | null {
283 const value = obj[key];
284 if (value === undefined) {
285 problems.push(`Missing required field "${key}"`);
286 return null;
287 }
288 if (!isCount(value)) {
289 problems.push(`Field "${key}" should be a whole number >= 0, got ${show(value)}`);
290 return null;
291 }
292 return value;
293}
294
295function isCount(value: unknown): value is number {
296 return typeof value === 'number' && Number.isInteger(value) && value >= 0;
297}
298
299/** Decode input to a string, dropping a leading BOM. */
300function decode(text: string | Uint8Array): string {
301 let s: string;
302 if (typeof text === 'string') s = text;
303 // Not fatal (the default): invalid bytes become U+FFFD.
304 else if (text instanceof Uint8Array) s = new TextDecoder('utf-8').decode(text);
305 else s = '';
306 return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
307}
308
309/** Short JSON rendering of a value for warning messages. */
310function show(value: unknown): string {
311 let s: string;
312 try {
313 s = JSON.stringify(value) ?? String(value);
314 } catch {
315 s = String(value);
316 }
317 return s.length > 60 ? `${s.slice(0, 57)}...` : s;
318}
319
320function errorMessage(err: unknown): string {
321 return err instanceof Error ? err.message : String(err);
322}
323hooks/core/parser/lines.ts 74 lines1// Copied from viewer/src/core/parser/lines.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Line-level helpers shared by the phase-file parsers.
5 *
6 * Every parser works on {@link SourceLine}s rather than bare strings so that
7 * each piece of the model, and each warning, can point back at its 1-based
8 * line in the original file.
9 */
10import type { Warning } from '../model.js';
11
12/** One line of the input, without its line ending. */
13export interface SourceLine {
14 /** The line exactly as read (no `\n` / `\r\n`). */
15 text: string;
16 /** 1-based line number in the file. */
17 line: number;
18}
19
20/**
21 * Split text into lines, tolerating LF and CRLF endings and a leading BOM.
22 * The result always has at least one line (an empty input is one empty line).
23 */
24export function splitLines(text: string): SourceLine[] {
25 const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
26 const parts = body.split(/\r?\n/);
27 // A trailing newline leaves one empty last part; it is not a real line.
28 if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop();
29 return parts.map((t, i) => ({ text: t, line: i + 1 }));
30}
31
32/** Build a {@link Warning} about one line (or the whole file when `at` is `null`). */
33export function warningAt(file: string, at: SourceLine | null, message: string): Warning {
34 return at ? { file, line: at.line, raw: at.text, message } : { file, line: 0, raw: '', message };
35}
36
37/** `true` for an empty or whitespace-only line. */
38export function isBlank(line: SourceLine): boolean {
39 return line.text.trim() === '';
40}
41
42/** `true` for a markdown thematic break made of dashes (`---`, `- - -`, `-----`). */
43export function isDivider(line: SourceLine): boolean {
44 return /^ {0,3}(?:-\s*){3,}$/.test(line.text);
45}
46
47/**
48 * Drop leading blank lines and trailing blank / `---` divider lines, so a
49 * section's body ends at its last line of real content.
50 */
51export function trimBlock(lines: SourceLine[]): SourceLine[] {
52 let start = 0;
53 let end = lines.length;
54 while (start < end && isBlank(lines[start]!)) start++;
55 while (end > start && (isBlank(lines[end - 1]!) || isDivider(lines[end - 1]!))) end--;
56 return lines.slice(start, end);
57}
58
59/** Join lines back into text. */
60export function joinLines(lines: SourceLine[]): string {
61 return lines.map((l) => l.text).join('\n');
62}
63
64/**
65 * Control characters (other than tab), DEL, and U+FFFD (what invalid UTF-8
66 * decodes to) never belong in a phase file.
67 */
68const JUNK = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F�]/;
69
70/** `true` when the line holds stray binary bytes or invalid UTF-8. */
71export function hasJunkBytes(line: SourceLine): boolean {
72 return JUNK.test(line.text);
73}
74hooks/core/parser/sections.ts 176 lines1// Copied from viewer/src/core/parser/sections.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Parsers for the list-shaped sprint sections: `### Acceptance Criteria` and
5 * `### Dependencies`, plus the markdown list reader that
6 * `### Verification` (see `verification.ts`) shares.
7 *
8 * Pure and never throws. Neither section produces warnings: any bullet is a
9 * valid criterion, and a dependency bullet that names no sprint or phase is
10 * kept as prose. Whether a named sprint or phase exists is checked later, by
11 * `loadProject`, which sees every phase file.
12 */
13import type { AcceptanceCriterion, Dependency } from '../model.js';
14import { isBlank, type SourceLine } from './lines.js';
15
16/** One item of a markdown list (or a stand-alone paragraph line) inside a section. */
17export interface ListItem {
18 /** Item text without the bullet marker, trimmed. Continuation lines are joined on with a single space. */
19 text: string;
20 /** `true` when the item starts with a bullet (`-`, `*`, `+`, `1.`, `1)`); `false` for a plain paragraph line. */
21 bullet: boolean;
22 /** Leading whitespace width of the item's first line (a tab counts as 4). Nested bullets have a larger indent. */
23 indent: number;
24 /** The item's first line. */
25 line: SourceLine;
26}
27
28const BULLET = /^(\s*)(?:[-*+]|\d{1,9}[.)])(?:\s+(.*))?$/;
29/** A thematic break of dashes, asterisks or underscores (`---`, `* * *`, `___`). */
30const THEMATIC_BREAK = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
31const CHECKBOX = /^\[([ xX])\](?:\s+|$)(.*)$/;
32
33function indentOf(text: string): number {
34 let width = 0;
35 for (const ch of text) {
36 if (ch === ' ') width += 1;
37 else if (ch === '\t') width += 4;
38 else break;
39 }
40 return width;
41}
42
43/**
44 * Read a section body as a flat list of items, in file order.
45 *
46 * - Every bullet starts a new item, at any nesting depth (nested bullets are
47 * flattened; {@link ListItem.indent} tells them apart).
48 * - A non-bullet line indented deeper than the current item continues it
49 * (joined with a space), even after a blank line. Any other non-bullet line
50 * starts a paragraph item of its own, so bullet-less lines such as
51 * `cli: npm test` / `skip-ui: true` stay separate. (Markdown's lazy
52 * continuation, an unindented line gluing onto the item above, is
53 * deliberately not followed.)
54 * - Blank lines and thematic breaks (`---`) are skipped; a break also ends
55 * the current item.
56 * - Items whose text ends up empty (a bare `-`) are dropped.
57 */
58export function parseListItems(lines: SourceLine[]): ListItem[] {
59 const items: ListItem[] = [];
60 let current: ListItem | null = null;
61
62 for (const line of lines) {
63 if (isBlank(line)) continue;
64 if (THEMATIC_BREAK.test(line.text)) {
65 current = null;
66 continue;
67 }
68
69 const indent = indentOf(line.text);
70 const bullet = BULLET.exec(line.text);
71 if (bullet) {
72 current = { text: (bullet[2] ?? '').trim(), bullet: true, indent, line };
73 items.push(current);
74 } else if (current !== null && indent > current.indent) {
75 const more = line.text.trim();
76 current.text = current.text === '' ? more : `${current.text} ${more}`;
77 } else {
78 current = { text: line.text.trim(), bullet: false, indent, line };
79 items.push(current);
80 }
81 }
82
83 return items.filter((item) => item.text !== '');
84}
85
86/**
87 * Parse a `### Acceptance Criteria` body. Every list item (and any stray
88 * paragraph line) becomes one criterion. A leading checkbox (`[ ]`, `[x]`,
89 * `[X]`) on a bullet is stripped into {@link AcceptanceCriterion.checked}.
90 */
91export function parseAcceptanceCriteria(lines: SourceLine[]): AcceptanceCriterion[] {
92 const criteria: AcceptanceCriterion[] = [];
93 for (const item of parseListItems(lines)) {
94 let text = item.text;
95 let checked: boolean | null = null;
96 const box = item.bullet ? CHECKBOX.exec(text) : null;
97 if (box) {
98 checked = box[1] !== ' ';
99 text = (box[2] ?? '').trim();
100 }
101 // A bare checkbox with no text says nothing.
102 if (text === '') continue;
103 criteria.push({ text, checked, line: item.line.line });
104 }
105 return criteria;
106}
107
108/** List separators allowed between ids after one `Sprint` / `Phase` word: `,` `&` `/` `and` `or`. */
109const SEP = String.raw`\s*(?:,|&|/|\band\b|\bor\b)\s*`;
110const SPRINT_ID = String.raw`\d+\.\d+(?!\d|\.\d)`;
111const PHASE_ID = String.raw`\d+(?!\d|\.\d)`;
112/** `Sprint 1.2`, `Sprints 1.2 and 1.3`, `Sprint 1.2, 1.3`, `Sprint #1.2`. */
113const SPRINT_REF = new RegExp(String.raw`\bsprints?\s*#?\s*(${SPRINT_ID}(?:${SEP}${SPRINT_ID})*)`, 'gi');
114/** `Phase 1`, `Phases 1 and 2`, `Phase 1, 2`. Never `Phase 1.2`. */
115const PHASE_REF = new RegExp(String.raw`\bphases?\s*#?\s*(${PHASE_ID}(?:${SEP}${PHASE_ID})*)`, 'gi');
116const NONE = /^(?:none|n\/a|no dependencies|nothing)\b/i;
117const DASH_ONLY = /^[—–-]+$/;
118
119/**
120 * The part of a dependency bullet that is scanned for ids: inline code spans
121 * and parenthesized notes removed, so `Sprint 1.3 (needs the Phase 2 schema)`
122 * names only Sprint 1.3.
123 */
124export function dependencyScanText(text: string): string {
125 let s = text.replace(/`[^`]*`/g, ' ');
126 let prev = '';
127 while (prev !== s) {
128 prev = s;
129 s = s.replace(/\([^()]*\)/g, ' ');
130 }
131 // An unclosed "(" runs to the end of the bullet.
132 return s.replace(/\([^)]*$/, ' ');
133}
134
135function pushUnique<T>(list: T[], value: T): void {
136 if (!list.includes(value)) list.push(value);
137}
138
139/**
140 * Pull the sprint and phase ids out of one dependency bullet.
141 *
142 * - `Sprint X.Y` (or `Sprints X.Y and X.Z`, `Sprint X.Y, X.Z`) adds sprint ids.
143 * - `Phase N` (or `Phases N and M`) adds whole-phase ids.
144 * - A bare `X.Y` with no `Sprint` word in front of its list is not an id
145 * (it's too easily a version number or a measurement).
146 * - Text in parentheses and inside inline code is a note and never scanned.
147 * - Ids are kept in order of first mention, without duplicates.
148 */
149export function extractDependencyRefs(text: string): { sprints: string[]; phases: number[] } {
150 const scan = dependencyScanText(text);
151 const sprints: string[] = [];
152 const phases: number[] = [];
153 for (const m of scan.matchAll(SPRINT_REF)) {
154 for (const id of (m[1] ?? '').match(/\d+\.\d+/g) ?? []) pushUnique(sprints, id);
155 }
156 for (const m of scan.matchAll(PHASE_REF)) {
157 for (const n of (m[1] ?? '').match(/\d+/g) ?? []) pushUnique(phases, Number(n));
158 }
159 return { sprints, phases };
160}
161
162/**
163 * Parse a `### Dependencies` body. Every list item (and any stray paragraph
164 * line) becomes one {@link Dependency}, its ids pulled out by
165 * {@link extractDependencyRefs}. A bullet that says `None` (or `N/A`,
166 * `No dependencies`, a lone dash) and names no id is `none: true`.
167 */
168export function parseDependencies(lines: SourceLine[]): Dependency[] {
169 return parseListItems(lines).map((item) => {
170 const { sprints, phases } = extractDependencyRefs(item.text);
171 const bare = dependencyScanText(item.text).trim();
172 const none = sprints.length === 0 && phases.length === 0 && (NONE.test(bare) || DASH_ONLY.test(bare));
173 return { raw: item.text, sprints, phases, none, line: item.line.line };
174 });
175}
176hooks/core/parser/tasks.ts 303 lines1// Copied from viewer/src/core/parser/tasks.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Task-table parsing for one sprint's `### Tasks` section.
5 *
6 * Columns are mapped from the header row, so the canonical
7 * `Status | # | Task | Module | Reference`, the legacy `# | Task | Module | Reference`
8 * (no Status column) and the rare `Status | # | Task | Reference` all work.
9 *
10 * Rows may omit trailing cells (phase-planner's sparse-row rule). A row that
11 * stops right after the Module column, while the header also has a Reference
12 * column right after it, is ambiguous: `| — | 4 | Task | docs/x.md |` is a
13 * Reference-only row, `| — | 2 | Task | src/x.ts |` a Module-only row. The cell's
14 * shape decides (see {@link looksLikeDoc}).
15 *
16 * Never throws. Problems become warnings on the offending line.
17 */
18import type { ParseResult, Task, TaskStatus, Warning } from '../model.js';
19import { isBlank, warningAt, type SourceLine } from './lines.js';
20import { normalizeStatus } from './status.js';
21
22/** A logical task-table column. */
23export type TaskColumn = 'status' | 'number' | 'task' | 'module' | 'reference';
24
25/** What {@link parseTaskTable} extracts from a `### Tasks` section. */
26export interface TaskTable {
27 /** Rows in file order. */
28 tasks: Task[];
29 /** `true` when the table has no Status column. */
30 legacyTable: boolean;
31}
32
33/** One table row split into cells. */
34export interface SplitRow {
35 /** Cell contents, untrimmed, with `\|` unescaped to `|`. */
36 cells: string[];
37 /** `false` when the row does not end with a pipe. */
38 closed: boolean;
39}
40
41/** Column order assumed when the header can't be read. */
42const CANONICAL: readonly TaskColumn[] = ['status', 'number', 'task', 'module', 'reference'];
43
44const HEADER_NAMES = new Map<string, TaskColumn>([
45 ['status', 'status'],
46 ['state', 'status'],
47 ['#', 'number'],
48 ['no', 'number'],
49 ['num', 'number'],
50 ['number', 'number'],
51 ['id', 'number'],
52 ['task', 'task'],
53 ['tasks', 'task'],
54 ['description', 'task'],
55 ['module', 'module'],
56 ['modules', 'module'],
57 ['file', 'module'],
58 ['files', 'module'],
59 ['path', 'module'],
60 ['reference', 'reference'],
61 ['references', 'reference'],
62 ['ref', 'reference'],
63 ['refs', 'reference'],
64 ['spec', 'reference'],
65 ['doc', 'reference'],
66 ['docs', 'reference'],
67]);
68
69/** Cell values that mean "no module / no reference". */
70const PLACEHOLDERS = new Set(['', '—', '–', '-']);
71
72/** `true` when a line looks like a table row (starts with `|`). */
73export function isTableRow(line: SourceLine): boolean {
74 return line.text.trimStart().startsWith('|');
75}
76
77/**
78 * Mark which characters of `s` sit inside an inline code span. A backtick run
79 * opens a span only when a run of the same length closes it later on the line;
80 * otherwise the backticks are literal.
81 */
82function codeSpanMask(s: string): boolean[] {
83 const mask: boolean[] = new Array<boolean>(s.length).fill(false);
84 const runLength = (at: number): number => {
85 let n = 0;
86 while (s[at + n] === '`') n++;
87 return n;
88 };
89 let i = 0;
90 while (i < s.length) {
91 if (s[i] === '\\') {
92 i += 2;
93 continue;
94 }
95 if (s[i] !== '`') {
96 i++;
97 continue;
98 }
99 const open = runLength(i);
100 let j = i + open;
101 let close = -1;
102 while (j < s.length) {
103 if (s[j] === '`') {
104 const n = runLength(j);
105 if (n === open) {
106 close = j;
107 break;
108 }
109 j += n;
110 } else {
111 j++;
112 }
113 }
114 if (close < 0) {
115 i += open;
116 continue;
117 }
118 for (let k = i; k < close + open; k++) mask[k] = true;
119 i = close + open;
120 }
121 return mask;
122}
123
124/**
125 * Split a markdown table row into cells. Escaped pipes (`\|`) and pipes inside
126 * inline code never split a cell; `\|` is unescaped to `|` in the cell text.
127 */
128export function splitRow(text: string): SplitRow {
129 let s = text.trim();
130 if (s.startsWith('|')) s = s.slice(1);
131 const code = codeSpanMask(s);
132 const cells: string[] = [];
133 let cur = '';
134 let closed = false;
135 for (let i = 0; i < s.length; i++) {
136 const ch = s[i]!;
137 if (ch === '\\' && s[i + 1] === '|') {
138 cur += '|';
139 i++;
140 continue;
141 }
142 if (ch === '|' && !code[i]) {
143 cells.push(cur);
144 cur = '';
145 if (i === s.length - 1) closed = true;
146 continue;
147 }
148 cur += ch;
149 }
150 if (!closed) cells.push(cur);
151 return { cells, closed };
152}
153
154/** `true` for a header separator row such as `|---|:--:|---|`. */
155export function isSeparatorRow(cells: string[]): boolean {
156 return cells.length > 0 && cells.every((c) => /^\s*:?-+:?\s*$/.test(c));
157}
158
159/**
160 * `true` when a cell looks like a document rather than code: it ends in
161 * `.md`, `.markdown`, `.html`, `.htm` or `.txt` (ignoring a `#fragment`), or
162 * lives under `docs/`.
163 */
164export function looksLikeDoc(cell: string): boolean {
165 const p = cell
166 .trim()
167 .replace(/^`+|`+$/g, '')
168 .trim()
169 .replace(/^\.\//, '');
170 const path = p.split('#')[0] ?? '';
171 return /^docs\//i.test(p) || /\.(md|markdown|html?|txt)$/i.test(path);
172}
173
174function normalizeHeader(cell: string): string {
175 return cell
176 .replace(/[*_`]/g, '')
177 .trim()
178 .replace(/\.$/, '')
179 .toLowerCase();
180}
181
182function pathCell(cell: string | null): string | null {
183 return cell === null || PLACEHOLDERS.has(cell) ? null : cell;
184}
185
186/**
187 * Parse the first markdown table in a `### Tasks` section. Lines before the
188 * table are ignored; the table ends at the first line that isn't a row.
189 * Returns no tasks (and no warnings) when the section has no table.
190 */
191export function parseTaskTable(lines: SourceLine[], file: string): ParseResult<TaskTable> {
192 const warnings: Warning[] = [];
193 const tasks: Task[] = [];
194 const start = lines.findIndex(isTableRow);
195 if (start < 0) return { value: { tasks, legacyTable: false }, warnings };
196
197 const headerLine = lines[start]!;
198 const header = splitRow(headerLine.text);
199 let columns: readonly (TaskColumn | null)[];
200 let i = start + 1;
201
202 if (isSeparatorRow(header.cells)) {
203 warnings.push(warningAt(file, headerLine, 'Task table has no header row; columns read in the canonical order'));
204 columns = CANONICAL;
205 } else {
206 const mapped = header.cells.map((c) => HEADER_NAMES.get(normalizeHeader(c)) ?? null);
207 if (mapped.includes('task')) {
208 columns = mapped;
209 } else {
210 warnings.push(
211 warningAt(file, headerLine, 'Task table header has no Task column; columns read in the canonical order'),
212 );
213 columns = CANONICAL;
214 }
215 const sep = lines[i];
216 if (sep && isTableRow(sep) && isSeparatorRow(splitRow(sep.text).cells)) {
217 i++;
218 } else {
219 warnings.push(warningAt(file, headerLine, 'Task table header is not followed by a separator row'));
220 }
221 }
222
223 const legacyTable = !columns.includes('status');
224 const idx = (col: TaskColumn): number => columns.indexOf(col);
225 const moduleIdx = idx('module');
226 const referenceIdx = idx('reference');
227
228 for (; i < lines.length; i++) {
229 const line = lines[i]!;
230 if (!isTableRow(line)) {
231 if (!isBlank(line)) {
232 warnings.push(
233 warningAt(file, line, 'Text directly after the task table with no blank line (markdown reads it as a table row)'),
234 );
235 }
236 break;
237 }
238
239 const row = splitRow(line.text);
240 const cells = row.cells.map((c) => c.trim());
241 if (isSeparatorRow(row.cells) || cells.every((c) => c === '')) continue;
242
243 if (!row.closed) warnings.push(warningAt(file, line, 'Table row has no closing pipe'));
244 if (cells.length > columns.length) {
245 warnings.push(
246 warningAt(
247 file,
248 line,
249 `Table row has ${cells.length} cells but the header has ${columns.length}; extra cells ignored`,
250 ),
251 );
252 }
253
254 const get = (col: TaskColumn): string | null => {
255 const at = idx(col);
256 return at >= 0 && at < cells.length ? (cells[at] ?? null) : null;
257 };
258
259 let status: TaskStatus = 'todo';
260 let rawStatus: string | null = null;
261 if (!legacyTable) {
262 rawStatus = get('status') ?? '';
263 const normalized = normalizeStatus(rawStatus);
264 status = normalized.status;
265 if (!normalized.recognized) {
266 warnings.push(warningAt(file, line, `Unrecognized status "${rawStatus}"; task kept with status unknown`));
267 }
268 }
269
270 const numberCell = get('number');
271 const text = get('task');
272 if (text === null) warnings.push(warningAt(file, line, 'Table row has no Task cell'));
273
274 let moduleCell = get('module');
275 let referenceCell = get('reference');
276 // Sparse 4-cell form: the row stops at the Module column, so its last cell
277 // may really be a Reference. Decide by shape.
278 if (
279 moduleIdx >= 0 &&
280 referenceIdx === moduleIdx + 1 &&
281 cells.length === referenceIdx &&
282 moduleCell !== null &&
283 looksLikeDoc(moduleCell)
284 ) {
285 referenceCell = moduleCell;
286 moduleCell = null;
287 }
288
289 tasks.push({
290 number: numberCell !== null && /^\d+$/.test(numberCell) ? Number(numberCell) : null,
291 text: text ?? '',
292 status,
293 rawStatus,
294 legacy: legacyTable,
295 module: pathCell(moduleCell),
296 reference: pathCell(referenceCell),
297 line: line.line,
298 });
299 }
300
301 return { value: { tasks, legacyTable }, warnings };
302}
303hooks/core/parser/verification.ts 224 lines1// Copied from viewer/src/core/parser/verification.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Parser for a sprint's `### Verification` block: the `- key: value` bullets
5 * phase-builder reads to decide which checks and browser tests to run (see
6 * phase-planner's SKILL.md, "Verification section").
7 *
8 * | Key | Model field | Parsing |
9 * |-------------|--------------|---------|
10 * | `cli` | `cli` | the value, trimmed; a value wrapped in backticks is unwrapped |
11 * | `ui` | `ui` | split on commas, trimmed, backticks unwrapped |
12 * | `skills` | `skills` | split on commas, trimmed, backticks unwrapped |
13 * | `viewports` | `viewports` | split on commas or spaces; `375` and `375px` read as 375; anything else dropped with a warning |
14 * | `skip-ui` | `skipUi` | `true` / `false` (also `yes` / `no`), any case; anything else is a warning |
15 * | `assert` | `assert` | one entry per `assert:` bullet; repeated keys collect |
16 * | other | `extra` | kept as written under the normalized key; a repeated key keeps its last value |
17 *
18 * Keys are case-insensitive; spaces and underscores in a key read as hyphens
19 * (`Skip UI`, `skip_ui` → `skip-ui`), and emphasis or code around the key is
20 * ignored (`**cli:**`, `` `cli`: ``). Only the first colon splits key from
21 * value, so values may contain colons. A few singular / plural slips are
22 * accepted (`viewport`, `skill`, `asserts`).
23 *
24 * Repeated `ui`, `skills` and `viewports` keys collect like `assert`; a
25 * repeated `cli` or `skip-ui` keeps its last value.
26 *
27 * Nested bullets under a key are more values for it:
28 *
29 * ```markdown
30 * - assert:
31 * - Grid shows every book
32 * - No layout break at 375px
33 * ```
34 *
35 * Indented continuation lines join onto the value with a space. Pure and
36 * never throws.
37 */
38import type { ParseResult, VerificationConfig, Warning } from '../model.js';
39import { warningAt, type SourceLine } from './lines.js';
40import { parseListItems, type ListItem } from './sections.js';
41
42/**
43 * The config of a sprint with no `### Verification` block: every list empty,
44 * no `cli` or `skipUi`, and `line` `0`.
45 */
46export function emptyVerification(line = 0): VerificationConfig {
47 return { ui: [], skills: [], viewports: [], assert: [], extra: {}, line };
48}
49
50const KEY = /^[a-z][a-z0-9-]{0,39}$/;
51
52const KEY_ALIASES = new Map<string, string>([
53 ['viewport', 'viewports'],
54 ['skill', 'skills'],
55 ['asserts', 'assert'],
56 ['skipui', 'skip-ui'],
57]);
58
59/** A `key: value` item plus any nested items under it. */
60interface Entry {
61 key: string;
62 value: string;
63 item: ListItem;
64 children: ListItem[];
65}
66
67/** One piece of value text and the line it came from. */
68interface ValuePart {
69 text: string;
70 line: SourceLine;
71}
72
73/** Drop emphasis or code around a leading key: `**cli:** x`, `**cli**: x`, `` `cli`: x `` → `cli: x`. */
74function unwrapKey(text: string): string {
75 return text.replace(/^(\*\*|__|\*|_|`)([^*_`:]+?)(:?)\1(:?)/, '$2$3$4');
76}
77
78/** Normalize a key: lowercase, emphasis dropped, spaces and underscores as hyphens, aliases applied. */
79export function normalizeVerificationKey(raw: string): string {
80 const key = raw
81 .replace(/[*`]/g, '')
82 .trim()
83 .toLowerCase()
84 .replace(/[\s_]+/g, '-');
85 return KEY_ALIASES.get(key) ?? key;
86}
87
88/** Split `key: value` on the first colon. `null` when the text has no plausible key. */
89function splitPair(text: string): { key: string; value: string } | null {
90 const t = unwrapKey(text.trim());
91 const colon = t.indexOf(':');
92 if (colon <= 0) return null;
93 const key = normalizeVerificationKey(t.slice(0, colon));
94 if (!KEY.test(key)) return null;
95 return { key, value: t.slice(colon + 1).trim() };
96}
97
98/** A whole value wrapped in one pair of backticks, unwrapped. */
99function unwrapCode(text: string): string {
100 const t = text.trim();
101 const m = /^`([^`]*)`$/.exec(t);
102 return m ? (m[1] ?? '').trim() : t;
103}
104
105function parseBool(text: string): boolean | null {
106 const t = unwrapCode(text).toLowerCase();
107 if (t === 'true' || t === 'yes') return true;
108 if (t === 'false' || t === 'no') return false;
109 return null;
110}
111
112/** Value text of an entry: its inline value first, then each nested item. Empty parts dropped. */
113function valueParts(entry: Entry): ValuePart[] {
114 const parts: ValuePart[] = [{ text: entry.value, line: entry.item.line }];
115 for (const child of entry.children) parts.push({ text: child.text, line: child.line });
116 return parts.filter((p) => p.text.trim() !== '');
117}
118
119function commaList(parts: ValuePart[]): string[] {
120 return parts.flatMap((p) => p.text.split(',').map(unwrapCode)).filter((s) => s !== '');
121}
122
123/**
124 * Parse the body of a `### Verification` section.
125 *
126 * Warnings (on the offending line): a line that isn't a `key: value` pair, a
127 * known key with no value, a viewport that isn't a number, a `skip-ui` that
128 * isn't a boolean.
129 *
130 * @param lines The section body (the lines after the heading).
131 * @param headingLine 1-based line of the `### Verification` heading, stored as {@link VerificationConfig.line}.
132 * @param file File path for warnings.
133 */
134export function parseVerification(lines: SourceLine[], headingLine: number, file: string): ParseResult<VerificationConfig> {
135 const warnings: Warning[] = [];
136 const config = emptyVerification(headingLine);
137
138 // Group items: a `key: value` item owns the more-indented items after it.
139 const entries: Entry[] = [];
140 let parent: Entry | null = null;
141 for (const item of parseListItems(lines)) {
142 if (parent !== null && item.indent > parent.item.indent) {
143 parent.children.push(item);
144 continue;
145 }
146 const pair = splitPair(item.text);
147 if (!pair) {
148 parent = null;
149 warnings.push(warningAt(file, item.line, 'Verification line is not a "key: value" pair; ignored'));
150 continue;
151 }
152 parent = { ...pair, item, children: [] };
153 entries.push(parent);
154 }
155
156 const noValue = (entry: Entry): void => {
157 warnings.push(warningAt(file, entry.item.line, `Verification key "${entry.key}" has no value; ignored`));
158 };
159
160 for (const entry of entries) {
161 const parts = valueParts(entry);
162 switch (entry.key) {
163 case 'cli': {
164 if (parts.length === 0) noValue(entry);
165 else config.cli = parts.map((p) => unwrapCode(p.text)).join('\n');
166 break;
167 }
168 case 'ui':
169 case 'skills': {
170 const values = commaList(parts);
171 const list = entry.key === 'ui' ? config.ui : config.skills;
172 if (values.length === 0) noValue(entry);
173 else list.push(...values);
174 break;
175 }
176 case 'viewports': {
177 if (parts.length === 0) noValue(entry);
178 for (const part of parts) {
179 const cells = part.text
180 .replace(/(\d)\s*px\b/gi, '$1')
181 .split(/[,\s]+/)
182 .map(unwrapCode)
183 .filter((s) => s !== '');
184 for (const cell of cells) {
185 if (/^\d+$/.test(cell)) config.viewports.push(Number(cell));
186 else warnings.push(warningAt(file, part.line, `Viewport "${cell}" is not a number; dropped`));
187 }
188 }
189 break;
190 }
191 case 'skip-ui': {
192 if (parts.length === 0) {
193 noValue(entry);
194 break;
195 }
196 const text = parts.map((p) => p.text).join(' ');
197 const bool = parseBool(text);
198 if (bool === null) {
199 warnings.push(warningAt(file, entry.item.line, `skip-ui value "${text}" is not true or false; ignored`));
200 } else {
201 config.skipUi = bool;
202 }
203 break;
204 }
205 case 'assert': {
206 if (parts.length === 0) noValue(entry);
207 for (const part of parts) config.assert.push(part.text.trim());
208 break;
209 }
210 default: {
211 // defineProperty so no key can ever reach the object's prototype.
212 Object.defineProperty(config.extra, entry.key, {
213 value: parts.map((p) => p.text.trim()).join('\n'),
214 enumerable: true,
215 writable: true,
216 configurable: true,
217 });
218 }
219 }
220 }
221
222 return { value: config, warnings };
223}
224hooks/core/parser/status.ts 50 lines1// Copied from viewer/src/core/parser/status.ts by hud/scripts/sync-core.mjs.
2// Do not edit this file: change the source, then run the script again.
3/**
4 * Status-cell normalization for task tables.
5 *
6 * Canonical cells are `—` (todo), `~` (active), `x` (done), `BLOCKED`,
7 * `MANUAL`, `CUT` and `DEFERRED`. Hand edits drift, so common variants are
8 * accepted too:
9 *
10 * | Written | Status |
11 * |-----------------------------------------|------------|
12 * | `—`, `–`, `-`, empty, `[ ]` | `todo` |
13 * | `~` | `active` |
14 * | `x`, `X`, `[x]`, `[X]` | `done` |
15 * | `blocked` / `manual` / `cut` / `deferred`, any case | same word |
16 * | anything else | `unknown` (caller warns) |
17 */
18import type { TaskStatus } from '../model.js';
19
20/** The result of normalizing one Status cell. */
21export interface NormalizedStatus {
22 /** The normalized status; `unknown` when the cell wasn't recognized. */
23 status: TaskStatus;
24 /** `false` when the cell matched nothing and the caller should warn. */
25 recognized: boolean;
26}
27
28const WORDS = new Map<string, TaskStatus>([
29 ['', 'todo'],
30 ['—', 'todo'],
31 ['–', 'todo'],
32 ['-', 'todo'],
33 ['~', 'active'],
34 ['x', 'done'],
35 ['blocked', 'blocked'],
36 ['manual', 'manual'],
37 ['cut', 'cut'],
38 ['deferred', 'deferred'],
39]);
40
41/** Normalize a Status cell. Never throws. */
42export function normalizeStatus(raw: string): NormalizedStatus {
43 const cell = raw.trim().toLowerCase();
44 const word = WORDS.get(cell);
45 if (word) return { status: word, recognized: true };
46 if (/^\[\s*\]$/.test(cell)) return { status: 'todo', recognized: true };
47 if (/^\[\s*x\s*\]$/.test(cell)) return { status: 'done', recognized: true };
48 return { status: 'unknown', recognized: false };
49}
50