@golden-frijoles/kit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,716 @@
1
+ #!/usr/bin/env node
2
+ // build-state.mjs — ONE resolver for "what is being built right now" (build-visualization-claude-mods S3).
3
+ //
4
+ // node scripts/build-state.mjs # the build view, as the five lines the CLI mod renders
5
+ // node scripts/build-state.mjs --json # everything, as JSON (the mod, standup, anything else)
6
+ // node scripts/build-state.mjs --offline # never call `gh` (git + files only) — what the mod uses (D4)
7
+ // node scripts/build-state.mjs --repo-root <dir> # default: this script's repo
8
+ //
9
+ // It reads EXISTING artefacts only — the epic/sprint frontmatter contract (lib/roadmap-contract.mjs), git,
10
+ // the session journal and, unless --offline, one `gh` call. It never writes anything and never becomes a
11
+ // second source of truth: every field it prints is either a frontmatter value or a fact git/gh reports.
12
+ //
13
+ // ── Which epic, which story, which status ────────────────────────────────────────────────────────────
14
+ // Epic — the branch: `feat|fix|chore|spike|bug|docs/<slug>`, optionally `-s<N>` / `-sprint-<N>` for a
15
+ // stacked sprint, and optionally any `-<words>` after it (`feat/foo-s4-licences`, `docs/foo-close`).
16
+ // The LONGEST leading run of the branch's words that names an epic wins, so `feat/aws-s3` is the
17
+ // `aws-s3` epic when one exists and sprint 3 of `aws` when it doesn't. A branch naming no epic but
18
+ // a seed (`Roadmap/00-ideas/seeds/<slug>.md`) is that seed's work: a bug, chore or spike with no
19
+ // epic, or — when the seed carries `epic:` — that epic. A branch naming neither is "nothing in
20
+ // flight here", said plainly. So are the default branch and a detached HEAD.
21
+ // Elsewhere — builders usually work in their own `git worktree` while the session sits on `main`, and
22
+ // worktrees share one set of refs. So when this checkout has nothing in flight, the view lists
23
+ // the other worktrees that do (resolved exactly like this one, offline) and, failing those, the
24
+ // epics whose WRITTEN `status:` is in-progress. It reads; it never guesses which one you meant.
25
+ // Story — D2: the newest commit on the branch (base..HEAD) whose subject names `S<n>.<m>` / `Story n.m`,
26
+ // else the newest session-journal entry naming one AND this epic's slug, else `unknown`. A named
27
+ // story that no sprint of this epic lists is `unknown` too — a confident wrong story is the
28
+ // failure this exists to prevent. The journal is read from the LOCAL refs (as last fetched): this runs every turn, and a
29
+ // network fetch has no place there.
30
+ // Status — D7: the WRITTEN `phase:` of the sprint in flight (else the epic's). Evidence may only ADVANCE
31
+ // it on the two rungs the cadence makes directly observable: story commits on the branch lift
32
+ // anything below Building to Building (git), and an open PR lifts to In review (gh). Locking
33
+ // architecture, Verifying and Shipped are never inferred. `phase_written` and `status_source` are
34
+ // always in the JSON, so a disagreement is visible rather than smoothed over.
35
+
36
+ import { execFileSync } from 'node:child_process';
37
+
38
+ // The repo is `--repo-root`, never an inherited GIT_DIR: git exports GIT_DIR (and friends) into hooks, and
39
+ // they override `cwd` — so run from a hook, this resolver would describe whichever repository the hook
40
+ // belongs to instead of the one it was asked about (found 2026-09-23 with the fixture leak in its spec).
41
+ const GIT_ENV_TO_CLEAR = [
42
+ 'GIT_DIR',
43
+ 'GIT_INDEX_FILE',
44
+ 'GIT_WORK_TREE',
45
+ 'GIT_COMMON_DIR',
46
+ 'GIT_OBJECT_DIRECTORY',
47
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
48
+ 'GIT_CEILING_DIRECTORIES',
49
+ 'GIT_PREFIX',
50
+ ];
51
+ function repoEnv() {
52
+ const env = { ...process.env };
53
+ for (const k of GIT_ENV_TO_CLEAR) delete env[k];
54
+ return env;
55
+ }
56
+ import { readFileSync, readdirSync, existsSync, statSync, realpathSync } from 'node:fs';
57
+ import { fileURLToPath } from 'node:url';
58
+ import { dirname, join, resolve } from 'node:path';
59
+ import { PHASES, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
60
+ import { parseJournal, JOURNAL_BRANCH, JOURNAL_PATH } from './lib/session-journal.mjs';
61
+
62
+ const __dirname = dirname(fileURLToPath(import.meta.url));
63
+
64
+ const BRANCH_PREFIX_RE = /^(?:feat|fix|chore|spike|bug|docs)\/(.+)$/;
65
+ const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*$/;
66
+ const SEEDS_DIR = ['Roadmap', '00-ideas', 'seeds'];
67
+ const MAX_WORKTREES = 8; // resolved per refresh — a pile of stale agent worktrees must not stall the view
68
+ const MAX_ELSEWHERE_LINES = 4;
69
+ const STORY_IN_TEXT_RE = /\b(?:S|Story\s+)(\d+)\.(\d+)\b/g;
70
+ const rank = (phase) => PHASES.indexOf(phase);
71
+
72
+ /** Every story id a piece of text names, in order ("S1.1–S1.4 …" → ['S1.1', 'S1.4']). */
73
+ export function storyIdsIn(text) {
74
+ return [...String(text).matchAll(STORY_IN_TEXT_RE)].map((m) => `S${Number(m[1])}.${Number(m[2])}`);
75
+ }
76
+
77
+ /** A sprint token right after the slug: `s3`, `sprint3`, or `sprint` `3` → 3; else null. */
78
+ function sprintAt(rest) {
79
+ const m = String(rest[0] ?? '').match(/^s(?:print)?(\d+)$/);
80
+ if (m) return Number(m[1]);
81
+ return rest[0] === 'sprint' && /^\d+$/.test(rest[1] ?? '') ? Number(rest[1]) : null;
82
+ }
83
+
84
+ /**
85
+ * Every reading of a work branch as `<slug>[-s<N>][-anything]`, LONGEST slug first:
86
+ * `feat/foo-s4-licences` → foo-s4-licences, foo-s4, foo (sprint 4). The resolver takes the first reading
87
+ * whose slug names an epic (or a seed) on disk. [] for a branch that isn't a work branch.
88
+ */
89
+ export function branchCandidates(branch) {
90
+ const m = String(branch || '').match(BRANCH_PREFIX_RE);
91
+ if (!m || !SLUG_RE.test(m[1]) || m[1].includes('..')) return [];
92
+ const tokens = m[1].split('-');
93
+ const out = [];
94
+ for (let i = tokens.length; i >= 1; i--) {
95
+ out.push({
96
+ slug: tokens.slice(0, i).join('-'),
97
+ sprint: sprintAt(tokens.slice(i)),
98
+ exact: i === tokens.length,
99
+ });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * The syntactic reading alone, no disk: `feat/foo-s3` → { slug: 'foo', sprint: 3 },
106
+ * `feat/foo-s3-extra` → { slug: 'foo', sprint: 3 }; null for a branch that is not a work branch.
107
+ */
108
+ export function parseBranch(branch) {
109
+ const cands = branchCandidates(branch);
110
+ if (!cands.length) return null;
111
+ const withSprint = cands.find((c) => c.sprint !== null);
112
+ return withSprint
113
+ ? { slug: withSprint.slug, sprint: withSprint.sprint }
114
+ : { slug: cands[0].slug, sprint: null };
115
+ }
116
+
117
+ function makeGit(root) {
118
+ return (args) =>
119
+ execFileSync('git', args, {
120
+ cwd: root,
121
+ encoding: 'utf8',
122
+ stdio: ['ignore', 'pipe', 'ignore'],
123
+ env: repoEnv(),
124
+ }).trim();
125
+ }
126
+
127
+ function tryGit(git, args) {
128
+ try {
129
+ return git(args);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function findEpic(root, slug) {
136
+ const roadmap = join(root, 'Roadmap');
137
+ if (!existsSync(roadmap)) return null;
138
+ for (const macro of readdirSync(roadmap).sort()) {
139
+ if (!/^\d{2}-/.test(macro)) continue;
140
+ const dir = join(roadmap, macro, slug);
141
+ if (existsSync(join(dir, 'README.md')) && statSync(dir).isDirectory()) return { macro, dir };
142
+ }
143
+ return null;
144
+ }
145
+
146
+ function readEpic(root, slug) {
147
+ const found = findEpic(root, slug);
148
+ if (!found) return null;
149
+ const readme = parseDocFrontmatter(readFileSync(join(found.dir, 'README.md'), 'utf8'));
150
+ const sprints = readdirSync(found.dir)
151
+ .filter((f) => /^sprint-\d+\.md$/.test(f))
152
+ .map((f) => ({ n: Number(f.match(/\d+/)[0]), file: f }))
153
+ .sort((a, b) => a.n - b.n)
154
+ .map(({ n, file }) => {
155
+ const p = parseDocFrontmatter(readFileSync(join(found.dir, file), 'utf8'));
156
+ return {
157
+ n,
158
+ file,
159
+ title: p.data.title ?? null,
160
+ phase: p.data.phase ?? null,
161
+ stories: Array.isArray(p.data.stories) ? p.data.stories : [],
162
+ contract: p.hasFrontmatter && !p.error,
163
+ };
164
+ });
165
+ return {
166
+ slug,
167
+ path: `Roadmap/${found.macro}/${slug}/README.md`,
168
+ title: readme.data.title ?? slug,
169
+ area: readme.data.area ?? found.macro,
170
+ risk: readme.data.risk ?? null,
171
+ phase: readme.data.phase ?? null,
172
+ lifecycle: readme.data.status ?? null,
173
+ contract: readme.hasFrontmatter && !readme.error && 'phase' in readme.data,
174
+ sprints,
175
+ };
176
+ }
177
+
178
+ const unquoteNull = (v) => (v === null || v === undefined || v === 'null' || v === '' ? null : v);
179
+
180
+ function readSeed(root, slug) {
181
+ const path = join(root, ...SEEDS_DIR, `${slug}.md`);
182
+ if (!existsSync(path)) return null;
183
+ const p = parseDocFrontmatter(readFileSync(path, 'utf8'));
184
+ if (!p.hasFrontmatter) return null;
185
+ const d = p.data;
186
+ return {
187
+ slug,
188
+ title: d.title ?? slug,
189
+ type: TYPES_KNOWN.includes(d.type) ? d.type : 'chore',
190
+ appetite: unquoteNull(d.appetite),
191
+ risk: unquoteNull(d.risk),
192
+ status: unquoteNull(d.status),
193
+ epic: unquoteNull(d.epic),
194
+ path: `${SEEDS_DIR.join('/')}/${slug}.md`,
195
+ };
196
+ }
197
+ const TYPES_KNOWN = ['feature', 'spike', 'bug', 'chore'];
198
+
199
+ /**
200
+ * What the branch names, longest reading first: an epic, else a seed (a seed that carries `epic:` is that
201
+ * epic — once scaffolded the seed is funnel-only), else null.
202
+ */
203
+ function resolveTarget(root, branch) {
204
+ for (const c of branchCandidates(branch)) {
205
+ const epic = readEpic(root, c.slug);
206
+ if (epic) return { kind: 'epic', epic, sprint: c.sprint, match: c.exact ? 'exact' : 'prefix' };
207
+ const seed = readSeed(root, c.slug);
208
+ if (seed) {
209
+ const epicSlug = seed.epic ? String(seed.epic).split('/').pop() : null;
210
+ const linked = epicSlug ? readEpic(root, epicSlug) : null;
211
+ if (linked) return { kind: 'epic', epic: linked, sprint: c.sprint, match: 'seed' };
212
+ return { kind: 'seed', seed, match: c.exact ? 'exact' : 'prefix' };
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /** Epics whose WRITTEN lifecycle is in-progress — the Roadmap's own answer to "what is open". */
219
+ function openEpics(root) {
220
+ const roadmap = join(root, 'Roadmap');
221
+ if (!existsSync(roadmap)) return [];
222
+ const out = [];
223
+ for (const macro of readdirSync(roadmap).sort()) {
224
+ if (!/^\d{2}-/.test(macro)) continue;
225
+ let dirs = [];
226
+ try {
227
+ dirs = readdirSync(join(roadmap, macro), { withFileTypes: true }).filter((d) => d.isDirectory());
228
+ } catch {
229
+ continue;
230
+ }
231
+ for (const d of dirs) {
232
+ const readme = join(roadmap, macro, d.name, 'README.md');
233
+ if (!existsSync(readme)) continue;
234
+ const p = parseDocFrontmatter(readFileSync(readme, 'utf8'));
235
+ if (p.data.status !== 'in-progress') continue;
236
+ out.push({
237
+ slug: d.name,
238
+ title: p.data.title ?? d.name,
239
+ area: p.data.area ?? macro,
240
+ phase: p.data.phase ?? null,
241
+ path: `Roadmap/${macro}/${d.name}/README.md`,
242
+ });
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /**
249
+ * The base to measure "this branch's commits" from: of every default-branch candidate that exists, the one
250
+ * whose merge-base with HEAD is the NEWEST. A stale `origin/main` (local `main` fetched since, or advanced)
251
+ * would otherwise put main's own commits inside base..HEAD and let them name the story (codex, on a
252
+ * consumer copy-in). Returns { ref, mergeBase } or nulls.
253
+ */
254
+ function baseRef(git) {
255
+ const head = tryGit(git, ['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']);
256
+ let best = { ref: null, mergeBase: null };
257
+ for (const ref of [head, 'origin/main', 'main', 'origin/master', 'master'].filter(Boolean)) {
258
+ if (!tryGit(git, ['rev-parse', '--verify', '-q', `${ref}^{commit}`])) continue;
259
+ const mb = tryGit(git, ['merge-base', ref, 'HEAD']);
260
+ if (!mb) continue;
261
+ // "Newest" by ANCESTRY, not by commit date: two candidates' merge-bases can share a timestamp to the
262
+ // second (they do in the tests, and in a fast scripted sequence), and a date comparison then keeps
263
+ // whichever came first in the list.
264
+ const advances =
265
+ !best.mergeBase || tryGit(git, ['merge-base', '--is-ancestor', best.mergeBase, mb]) !== null;
266
+ if (advances) best = { ref, mergeBase: mb };
267
+ }
268
+ return best;
269
+ }
270
+
271
+ function readJournalLocal(git) {
272
+ for (const ref of [JOURNAL_BRANCH, `origin/${JOURNAL_BRANCH}`]) {
273
+ const text = tryGit(git, ['show', `${ref}:${JOURNAL_PATH}`]);
274
+ if (text) return { entries: parseJournal(text), ref };
275
+ }
276
+ return { entries: [], ref: null };
277
+ }
278
+
279
+ function ghOpenPr(root, branch) {
280
+ try {
281
+ const out = execFileSync(
282
+ 'gh',
283
+ ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number,url', '--limit', '1'],
284
+ { cwd: root, encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }
285
+ );
286
+ const [pr] = JSON.parse(out);
287
+ return { ok: true, pr: pr || null };
288
+ } catch {
289
+ return { ok: false, pr: null };
290
+ }
291
+ }
292
+
293
+ const notInFlight = (reason, extra = {}) => ({ in_flight: false, reason, ...extra });
294
+
295
+ /** Does `text` name `slug` as a whole slug — `aws` in "aws S1.1", but not inside "aws-s3 S1.1"? */
296
+ export function namesSlug(text, slug) {
297
+ const esc = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
298
+ return new RegExp(`(^|[^A-Za-z0-9-])${esc}($|[^A-Za-z0-9-])`).test(String(text));
299
+ }
300
+
301
+ /**
302
+ * Resolve the build state. Every external read is injectable so the tests drive real fixture repos with
303
+ * a fake `gh`: { root, offline, git, gh }. It NEVER throws — it runs once per turn inside a CLI hook, and
304
+ * an exception there is a blank view: any failure is reported as "not in flight", with the reason.
305
+ * `elsewhere: false` skips the other-worktrees / open-epics scan (used when resolving those worktrees).
306
+ */
307
+ export function resolveBuildState(opts = {}) {
308
+ let state;
309
+ try {
310
+ state = resolve_(opts);
311
+ } catch (err) {
312
+ state = notInFlight(
313
+ `the resolver could not read this checkout (${err && err.message ? err.message : err})`
314
+ );
315
+ }
316
+ if (opts.elsewhere === false) return state;
317
+ try {
318
+ state.elsewhere = elsewhere_({ root: opts.root, git: opts.git || makeGit(opts.root), state });
319
+ } catch {
320
+ state.elsewhere = { worktrees: [], open_epics: [] };
321
+ }
322
+ return state;
323
+ }
324
+
325
+ /** `git worktree list --porcelain` → [{ path, branch, bare, prunable }]. Pure. */
326
+ export function parseWorktrees(porcelain) {
327
+ const out = [];
328
+ let cur = null;
329
+ for (const line of String(porcelain || '').split('\n')) {
330
+ if (line.startsWith('worktree ')) {
331
+ cur = { path: line.slice('worktree '.length), branch: null, bare: false, prunable: false };
332
+ out.push(cur);
333
+ } else if (!cur) continue;
334
+ else if (line.startsWith('branch '))
335
+ cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
336
+ else if (line === 'bare') cur.bare = true;
337
+ else if (line.startsWith('prunable')) cur.prunable = true;
338
+ }
339
+ return out;
340
+ }
341
+
342
+ function samePath(a, b) {
343
+ try {
344
+ return realpathSync(a) === realpathSync(b);
345
+ } catch {
346
+ return a === b;
347
+ }
348
+ }
349
+
350
+ const DONE_LIFECYCLES = ['shipped', 'archived'];
351
+
352
+ /**
353
+ * Work in flight OUTSIDE this checkout: every other worktree on a work branch, resolved exactly like this
354
+ * one (offline — no `gh` per worktree), and the epics the Roadmap itself says are in progress. Worktrees
355
+ * share this repo's refs, so this is local reads only.
356
+ */
357
+ function elsewhere_({ root, git, state }) {
358
+ const top = tryGit(git, ['rev-parse', '--show-toplevel']) || root;
359
+ const worktrees = [];
360
+ const seen = new Set();
361
+ let resolved = 0;
362
+ for (const w of parseWorktrees(tryGit(git, ['worktree', 'list', '--porcelain']))) {
363
+ if (!w.branch || w.bare || w.prunable || samePath(w.path, top) || !existsSync(w.path)) continue;
364
+ if (!branchCandidates(w.branch).length) continue; // main, release branches: nothing to resolve
365
+ if (resolved++ >= MAX_WORKTREES) break;
366
+ const s = resolveBuildState({ root: w.path, offline: true, elsewhere: false });
367
+ if (!s.in_flight || DONE_LIFECYCLES.includes(s.lifecycle)) continue;
368
+ const item = s.epic
369
+ ? { kind: 'epic', slug: s.epic.slug, title: s.epic.title }
370
+ : { kind: s.kind, slug: s.seed.slug, title: s.seed.title };
371
+ seen.add(item.slug);
372
+ worktrees.push({
373
+ ...item,
374
+ path: w.path,
375
+ branch: w.branch,
376
+ story: s.story ? s.story.id : null,
377
+ status: s.status ?? null,
378
+ });
379
+ }
380
+ const current = state.in_flight ? (state.epic?.slug ?? state.seed?.slug) : null;
381
+ const open_epics = openEpics(root).filter((e) => !seen.has(e.slug) && e.slug !== current);
382
+ return { worktrees, open_epics };
383
+ }
384
+
385
+ /** A branch naming a seed and no epic: a bug, chore, spike or unscaffolded feature. No stories, no sprints. */
386
+ function seedState({ root, git, gh, offline, branch, seed, match }) {
387
+ const { ref: base, mergeBase } = baseRef(git);
388
+ const subjects = mergeBase
389
+ ? (tryGit(git, ['log', '--format=%s', `${mergeBase}..HEAD`]) || '').split('\n').filter(Boolean)
390
+ : [];
391
+ let status = seed.status;
392
+ let statusSource = seed.status ? 'written' : 'unknown';
393
+ if (subjects.length) {
394
+ status = 'Building';
395
+ statusSource = 'git';
396
+ }
397
+ let pr = null;
398
+ let ghChecked = false;
399
+ if (!offline) {
400
+ const res = gh(root, branch);
401
+ ghChecked = res.ok;
402
+ pr = res.pr;
403
+ if (pr) {
404
+ status = 'In review';
405
+ statusSource = 'gh';
406
+ }
407
+ }
408
+ return {
409
+ in_flight: true,
410
+ kind: seed.type,
411
+ branch,
412
+ branch_match: match,
413
+ epic: null,
414
+ seed: {
415
+ slug: seed.slug,
416
+ title: seed.title,
417
+ type: seed.type,
418
+ appetite: seed.appetite,
419
+ risk: seed.risk,
420
+ path: seed.path,
421
+ },
422
+ sprint: null,
423
+ story: null,
424
+ story_source: 'n/a',
425
+ story_note: null,
426
+ progress: null,
427
+ lifecycle: seed.status,
428
+ status,
429
+ status_source: statusSource,
430
+ phase_written: null,
431
+ evidence: {
432
+ base,
433
+ merge_base: mergeBase,
434
+ commits: subjects.length,
435
+ pr,
436
+ gh: offline ? 'skipped (--offline)' : ghChecked ? 'ok' : 'unavailable',
437
+ },
438
+ };
439
+ }
440
+
441
+ function resolve_({ root, offline = false, git = makeGit(root), gh = ghOpenPr } = {}) {
442
+ if (tryGit(git, ['rev-parse', '--is-inside-work-tree']) !== 'true')
443
+ return notInFlight('not inside a git checkout (or git is not installed)');
444
+ const branch = tryGit(git, ['symbolic-ref', '-q', '--short', 'HEAD']);
445
+ if (!branch) return notInFlight('detached HEAD — no branch, so no epic in flight');
446
+ const cands = branchCandidates(branch);
447
+ if (!cands.length)
448
+ return notInFlight(`on ${branch} — not an epic branch (feat/<slug>…), so no epic in flight`, { branch });
449
+ // The LONGEST reading that names something wins: with both `aws` and `aws-s3` on the board, `feat/aws-s3`
450
+ // is the `aws-s3` epic, never sprint 3 of `aws` (codex, on a consumer copy-in).
451
+ const target = resolveTarget(root, branch);
452
+ if (!target) {
453
+ const slug = cands.at(-1).slug;
454
+ return notInFlight(
455
+ `${branch} names no epic under Roadmap/ (looked for */${cands[0].slug}/README.md … */${slug}/README.md) and no seed`,
456
+ { branch }
457
+ );
458
+ }
459
+ if (target.kind === 'seed')
460
+ return seedState({ root, git, gh, offline, branch, seed: target.seed, match: target.match });
461
+ const epic = target.epic;
462
+ const parsed = { slug: epic.slug, sprint: target.sprint };
463
+ if (!epic.contract)
464
+ return notInFlight(`${epic.path} predates the frontmatter contract — run scripts/roadmap-backfill.mjs`, {
465
+ branch,
466
+ });
467
+
468
+ // All stories of the epic, in build order — the ordinal is "Story X of Y".
469
+ const allStories = epic.sprints.flatMap((s) => s.stories.map((st) => ({ ...st, sprint: s.n })));
470
+ const byId = new Map(allStories.map((st, i) => [st.id, { ...st, ordinal: i + 1 }]));
471
+ // A story counts only if THIS epic lists it — and, on a stacked sprint branch (`-s4`), only if it is that
472
+ // sprint's: base..HEAD on a stacked branch still carries the previous sprint's commits, and reporting
473
+ // them would be a confident wrong answer (found by the fresh reviewer on #27).
474
+ const accepts = (id) => byId.has(id) && (parsed.sprint === null || byId.get(id).sprint === parsed.sprint);
475
+ const scope = parsed.sprint === null ? 'this epic' : `sprint ${parsed.sprint} of this epic`;
476
+
477
+ // D2 — commits first, then the journal, then unknown.
478
+ const { ref: base, mergeBase } = baseRef(git);
479
+ const range = mergeBase ? `${mergeBase}..HEAD` : null;
480
+ const subjects = range
481
+ ? (tryGit(git, ['log', '--format=%s', range]) || '').split('\n').filter(Boolean)
482
+ : [];
483
+ const storyCommits = subjects.filter((s) => storyIdsIn(s).some(accepts)).length;
484
+ const foreign = [...new Set(subjects.flatMap(storyIdsIn).filter((id) => !accepts(id)))];
485
+ let story = null;
486
+ let storySource = 'unknown';
487
+ let unlisted = null;
488
+ for (const subject of subjects) {
489
+ const ids = storyIdsIn(subject);
490
+ if (!ids.length) continue;
491
+ // An id this epic does not list AT ALL stops everything — including the journal fallback below: the
492
+ // newest story commit is the claim about what is in flight, so answering with an older commit, with
493
+ // the other id in the same subject, or with a journal entry would each be a guess (D2; codex on the
494
+ // consumer copy-ins). An id of ANOTHER SPRINT of this epic is different — a stacked branch inherits
495
+ // those — so it is skipped and the walk continues (the fresh reviewer's finding on #27).
496
+ const strangers = ids.filter((id) => !byId.has(id));
497
+ if (strangers.length) {
498
+ unlisted = strangers;
499
+ break;
500
+ }
501
+ const mine = ids.filter(accepts);
502
+ if (mine.length) {
503
+ story = byId.get(mine.at(-1));
504
+ storySource = 'commit';
505
+ break;
506
+ }
507
+ }
508
+ // The journal: only an entry that NAMES this epic's slug. Every epic has an S1.1, and the journal is
509
+ // shared by every session in the repo — parallel epics write to it at the same time — so neither an id
510
+ // nor a timestamp says which epic an entry meant (codex, on both consumer copy-ins).
511
+ // Journal a story as: node scripts/session-note.mjs --kind doing "<epic-slug> S2.1 — …"
512
+ let journalRef = null;
513
+ if (!story && !unlisted) {
514
+ const journal = readJournalLocal(git);
515
+ journalRef = journal.ref;
516
+ for (const entry of [...journal.entries].reverse()) {
517
+ // A journal line is only JSON — `refs` may be anything, and spreading a non-array threw, which the
518
+ // outer guard then reported as "not in flight" on a perfectly good branch (codex, consumer copy-in).
519
+ const refs = Array.isArray(entry.refs) ? entry.refs : [];
520
+ const text = [entry.text, ...refs].filter((v) => typeof v === 'string').join(' ');
521
+ const ids = namesSlug(text, epic.slug) ? storyIdsIn(text).filter(accepts) : [];
522
+ if (ids.length) {
523
+ story = byId.get(ids.at(-1));
524
+ storySource = 'journal';
525
+ break;
526
+ }
527
+ }
528
+ }
529
+ const storyNote = story
530
+ ? null
531
+ : unlisted
532
+ ? `the newest story commit names ${unlisted.join(', ')}, which no sprint of this epic lists`
533
+ : foreign.length
534
+ ? `commits here name ${foreign.join(', ')} — none of them ${scope}'s; no journal entry names one either`
535
+ : `no commit on this branch names a story of ${scope}, and the session journal names none either`;
536
+
537
+ // The sprint: the story's, else the branch's -s<N>, else none — never "the first unshipped one".
538
+ const sprintN = story ? story.sprint : parsed.sprint;
539
+ const sprint = epic.sprints.find((s) => s.n === sprintN) || null;
540
+ // A sprint whose frontmatter could not be read is not "a sprint with no stories": say so, rather than
541
+ // serving the epic's phase as if it were the sprint's (codex, consumer copy-in).
542
+ const unreadableSprint =
543
+ sprint && !sprint.contract ? `Roadmap/…/sprint-${sprint.n}.md frontmatter could not be read` : null;
544
+
545
+ // D7 — the written phase, advanced only by direct evidence.
546
+ // The sprint in flight owns the phase. When there IS a sprint but it carries no readable one, the epic's
547
+ // phase is NOT a stand-in — claiming it would state a sprint phase nobody wrote (codex, consumer copy-in).
548
+ // Status is then null ("unknown") unless evidence lifts it, which is the one honest answer.
549
+ const phaseWritten = sprint ? sprint.phase || null : epic.phase;
550
+ let status = phaseWritten;
551
+ let statusSource = phaseWritten ? 'written' : 'unknown';
552
+ if (storyCommits > 0 && rank(status) < rank('Building')) {
553
+ status = 'Building';
554
+ statusSource = 'git';
555
+ }
556
+ let pr = null;
557
+ let ghChecked = false;
558
+ if (!offline) {
559
+ const res = gh(root, branch);
560
+ ghChecked = res.ok;
561
+ pr = res.pr;
562
+ if (pr && rank(status) < rank('In review')) {
563
+ status = 'In review';
564
+ statusSource = 'gh';
565
+ }
566
+ }
567
+
568
+ return {
569
+ in_flight: true,
570
+ kind: 'epic',
571
+ branch,
572
+ branch_match: target.match,
573
+ lifecycle: epic.lifecycle,
574
+ epic: {
575
+ slug: epic.slug,
576
+ title: epic.title,
577
+ area: epic.area,
578
+ risk: epic.risk,
579
+ phase: epic.phase,
580
+ path: epic.path,
581
+ },
582
+ sprint: sprint ? { n: sprint.n, title: sprint.title, phase: sprint.phase } : null,
583
+ story: story
584
+ ? {
585
+ id: story.id,
586
+ title: story.title,
587
+ as_a: story.as_a,
588
+ i_want: story.i_want,
589
+ so_that: story.so_that,
590
+ status: story.status,
591
+ }
592
+ : null,
593
+ story_source: storySource,
594
+ story_note: unreadableSprint ? `${unreadableSprint}${storyNote ? ` — ${storyNote}` : ''}` : storyNote,
595
+ warning: unreadableSprint,
596
+ progress: {
597
+ story: story ? story.ordinal : null,
598
+ stories: allStories.length,
599
+ sprint: sprint ? epic.sprints.indexOf(sprint) + 1 : null,
600
+ sprints: epic.sprints.length,
601
+ },
602
+ status,
603
+ status_source: statusSource,
604
+ phase_written: phaseWritten,
605
+ evidence: {
606
+ base,
607
+ merge_base: mergeBase,
608
+ story_commits: storyCommits,
609
+ pr,
610
+ gh: offline ? 'skipped (--offline)' : ghChecked ? 'ok' : 'unavailable',
611
+ journal: journalRef,
612
+ },
613
+ };
614
+ }
615
+
616
+ const HEADINGS = {
617
+ bug: 'Currently fixing',
618
+ chore: 'Currently on a chore',
619
+ spike: 'Currently investigating',
620
+ feature: 'Currently shaping',
621
+ };
622
+ const clip = (t, n = 56) => (String(t).length > n ? `${String(t).slice(0, n - 1)}…` : String(t));
623
+
624
+ /** One line per piece of work elsewhere, worktrees first, capped — the JSON carries the full lists. */
625
+ function elsewhereLines(state, pad) {
626
+ const e = state.elsewhere || { worktrees: [], open_epics: [] };
627
+ const items = [
628
+ ...e.worktrees.map(
629
+ (w) =>
630
+ `${pad('Worktree')}${clip(w.title)}${w.story ? ` · ${w.story}` : ''}${w.status ? ` · ${w.status}` : ''} · ${w.branch}`
631
+ ),
632
+ ...e.open_epics.map((o) => `${pad('Open')}${clip(o.title)}${o.phase ? ` · ${o.phase}` : ''} · ${o.area}`),
633
+ ];
634
+ if (items.length <= MAX_ELSEWHERE_LINES) return items;
635
+ const shown = items.slice(0, MAX_ELSEWHERE_LINES - 1);
636
+ return [...shown, `${pad('')}+${items.length - shown.length} more — node scripts/build-state.mjs --json`];
637
+ }
638
+
639
+ /**
640
+ * The build view as the CLI shows it — a pure function of resolveBuildState's output (Sprint 4's mod
641
+ * renders exactly these lines and nothing else: D3). Five lines under the heading for an epic, no box;
642
+ * work in other worktrees appears as one `Also` line when this checkout is building, and as the list
643
+ * itself when it isn't.
644
+ */
645
+ export function renderLines(state) {
646
+ const pad = (label) => ` ${label.padEnd(9)}`;
647
+ if (!state.in_flight) return [`No epic in flight — ${state.reason}`, ...elsewhereLines(state, pad)];
648
+ const others = state.elsewhere?.worktrees?.length || 0;
649
+ const also = others
650
+ ? [
651
+ `${pad('Also')}${others} more in other worktrees: ${state.elsewhere.worktrees.map((w) => w.branch).join(', ')}`,
652
+ ]
653
+ : [];
654
+ if (state.seed) {
655
+ const { seed } = state;
656
+ const meta = [
657
+ seed.appetite && `appetite ${seed.appetite}`,
658
+ seed.risk && `risk ${String(seed.risk).toUpperCase()}`,
659
+ ]
660
+ .filter(Boolean)
661
+ .join(' · ');
662
+ const label = seed.type.charAt(0).toUpperCase() + seed.type.slice(1);
663
+ return [
664
+ HEADINGS[seed.type] || 'Currently working on',
665
+ `${pad(label)}${seed.title}${meta ? ` ${meta}` : ''}`,
666
+ `${pad('Seed')}${seed.path}`,
667
+ `${pad('Status')}${state.status || 'unknown'}`,
668
+ ...also,
669
+ ];
670
+ }
671
+ const { epic, story, progress } = state;
672
+ const cont = ' '.repeat(11);
673
+ const risk = epic.risk ? ` · risk ${epic.risk.toUpperCase()}` : '';
674
+ const lines = ['Currently building', `${pad('Epic')}${epic.title} ${epic.area}${risk}`];
675
+ if (story) {
676
+ lines.push(`${pad('Story')}${story.id}${story.title ? ` — ${story.title}` : ''}`);
677
+ lines.push(
678
+ story.as_a && story.i_want && story.so_that
679
+ ? `${cont}As ${story.as_a}, I want ${story.i_want}, so that ${story.so_that}.`
680
+ : `${cont}(the docs carry no user story for ${story.id})`
681
+ );
682
+ } else {
683
+ lines.push(`${pad('Story')}unknown`);
684
+ lines.push(`${cont}${state.story_note}`);
685
+ }
686
+ const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
687
+ const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
688
+ lines.push(`${pad('Progress')}${storyPart} · ${sprintPart}`);
689
+ lines.push(`${pad('Status')}${state.status || `unknown${state.warning ? ` — ${state.warning}` : ''}`}`);
690
+ return [...lines, ...also];
691
+ }
692
+
693
+ // realpath on both sides: a plugin or checkout reached through a symlink (macOS /tmp → /private/tmp) would
694
+ // otherwise never equal the module URL, and the CLI would print nothing and exit 0 — a blank build view.
695
+ const isMain = (() => {
696
+ try {
697
+ return (
698
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
699
+ );
700
+ } catch {
701
+ return false;
702
+ }
703
+ })();
704
+ if (isMain) {
705
+ const argv = process.argv.slice(2);
706
+ const i = argv.indexOf('--repo-root');
707
+ if (i !== -1 && (!argv[i + 1] || argv[i + 1].startsWith('--'))) {
708
+ process.stderr.write('build-state: --repo-root needs a directory\n');
709
+ process.exit(2);
710
+ }
711
+ const root = resolve(i === -1 ? join(__dirname, '..') : argv[i + 1]);
712
+ const state = resolveBuildState({ root, offline: argv.includes('--offline') });
713
+ if (argv.includes('--json'))
714
+ process.stdout.write(`${JSON.stringify({ ...state, lines: renderLines(state) }, null, 2)}\n`);
715
+ else process.stdout.write(`${renderLines(state).join('\n')}\n`);
716
+ }