@golden-frijoles/kit 0.5.4 → 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.
- package/dist/build-state.mjs +716 -0
- package/dist/cross-agent-doctor.mjs +617 -0
- package/dist/cross-review.mjs +857 -0
- package/dist/cross-review.prompt.md +100 -0
- package/dist/cross-review.security.prompt.md +92 -0
- package/dist/lib/cross-agent-cli.mjs +886 -143
- package/dist/lib/review-guard.mjs +478 -0
- package/dist/lib/secret-guard.mjs +212 -0
- package/dist/lib/session-journal.mjs +99 -0
- package/dist/review-config.json +25 -0
- package/dist/review-route.mjs +234 -0
- package/dist/session-note.mjs +127 -0
- package/dist/session-resume.mjs +953 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|