create-pmos 0.11.0 → 0.12.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,878 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /*
4
+ * PMOS workflow state — the derivation engine.
5
+ *
6
+ * node scripts/workflow-state.js the board: every initiative, stage + blocker
7
+ * node scripts/workflow-state.js <id> the ladder for one initiative
8
+ * node scripts/workflow-state.js <id> --json the same, machine-readable
9
+ * node scripts/workflow-state.js --gate <id> CI: exit 1 on a BLOCKING order violation
10
+ * node scripts/workflow-state.js --open the board, open initiatives only
11
+ * --root <dir> operate on another repo root (fixtures, tenant workspaces)
12
+ *
13
+ * WHY THIS EXISTS. The Initiative Gate asserts that a lane's spec artifacts EXIST when CI runs. It
14
+ * has never asserted that they came FIRST. A PRD written after the implementation, in the same PR,
15
+ * satisfies it byte-for-byte identically to one written before — so "eval-driven PM" (D13),
16
+ * "prototype before PRD" (D60) and "explain the surface before you spec it" were norms with nothing
17
+ * behind them. See planning/prd/workflow-enforcement-prd.md §2 for the seven holes this closes.
18
+ *
19
+ * TWO DESIGN RULES, both load-bearing:
20
+ *
21
+ * 1. DERIVED, NEVER DECLARED. There is deliberately no `stage:` key. Stage and step status are
22
+ * computed from the record's frontmatter, the files it points at, the append-only rows in
23
+ * planning/runs/*.jsonl, and git's own first-appearance history. A status field nobody updates
24
+ * is a board that lies, and a board that lies is a watermelon generator — worse than no board.
25
+ *
26
+ * 2. ORDER COMES FROM GIT, NOT FROM TIMESTAMPS. Author/commit dates are rewritten by rebases and
27
+ * can tie within a second. So ordering compares POSITION IN THE COMMIT RANGE (oldest = 0),
28
+ * never clock time. A path introduced before the range is index -1 (it already existed); a path
29
+ * on disk that no commit has added yet is Infinity (uncommitted).
30
+ *
31
+ * HONESTY BY CONSTRUCTION. Every step carries one of: done · current · blocked · pending · n/a ·
32
+ * unknown. `unknown` is its own state and never collapses into `done` or `n/a` — a shallow clone or
33
+ * an unreadable input must be visibly distinct from a satisfied step. A gate that cannot see its
34
+ * inputs reports that it cannot see them; it never reports a pass.
35
+ *
36
+ * Zero dependencies, stock Node. It runs in CI, in a terminal, and inside the workspace server.
37
+ */
38
+
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+ const { execFileSync } = require('child_process');
42
+ /*
43
+ * ONE resolver, shared with the workspace (spec R-6 / contract C-7). Two readers that disagreed
44
+ * about whether an anchor resolves would reproduce the exact defect this work removes — the ladder
45
+ * and the OKR surface previously held separate, incompatible opinions about the same field.
46
+ */
47
+ const anchors = require('./anchor-resolve');
48
+
49
+ // The OKR documents and the budget registry are read once per root, not once per initiative: the
50
+ // board derives ~100 ladders in a single pass and re-reading the same three files each time is the
51
+ // same waste the shared commit-range exists to avoid.
52
+ const _okrCache = new Map();
53
+ const _budCache = new Map();
54
+ function okrIndexFor(root) {
55
+ if (!_okrCache.has(root)) _okrCache.set(root, anchors.parseOkrIndex(root));
56
+ return _okrCache.get(root);
57
+ }
58
+ function budgetsFor(root, L) {
59
+ if (!_budCache.has(root)) {
60
+ try { _budCache.set(root, { ok: true, text: fs.readFileSync(L.budgets, 'utf8'), path: path.relative(root, L.budgets) }); }
61
+ catch { _budCache.set(root, { ok: false, text: '', path: path.relative(root, L.budgets) }); }
62
+ }
63
+ return _budCache.get(root);
64
+ }
65
+
66
+ /*
67
+ * ── Enforcement boundary ──────────────────────────────────────────────────────────────────────
68
+ * An initiative is LEGACY when its record already existed before the change under review — i.e. its
69
+ * record first-appeared outside this commit range. Legacy records are reported and never failed by
70
+ * the rules introduced here (R0/O3/O4/O5/O6/O7); the original artifact-existence contract (R1/R2)
71
+ * binds everywhere, because it always did.
72
+ *
73
+ * The boundary is DERIVED FROM GIT, not from the record's own `timestamp:` frontmatter. A
74
+ * self-declared date is a field the record controls, so backdating it would grandfather you out of
75
+ * your own gate — the one thing a compliance boundary must not permit. Git's add-date is written by
76
+ * the commit, not by the author of the file's contents.
77
+ *
78
+ * `ENFORCED_FROM` is only the FALLBACK, used when git cannot answer (no history, a fresh fixture
79
+ * repo, a shallow clone). It is deliberately not the primary test.
80
+ *
81
+ * The 92 records that predate this are NOT backfilled. Inventing a specimen-test answer for work
82
+ * already shipped would be fabricating a judgement nobody made, and a gate whose history is
83
+ * fabricated is worth less than no gate at all.
84
+ */
85
+ const ENFORCED_FROM = '2026-07-28';
86
+
87
+ // A path counts as BUILD when it is neither a planning artifact nor documentation. `prototype/` is
88
+ // build only in the prototype lane, where the specimen IS the deliverable (D60); in every other lane
89
+ // a prototype directory is somebody else's leg-A work and must not count as this initiative's build.
90
+ function isBuildPath(p, lane) {
91
+ if (!p) return false;
92
+ if (p.startsWith('planning/') || p.startsWith('okf/') || p.startsWith('pmos/state/')) return false;
93
+ if (p.startsWith('prototype/')) return lane === 'prototype';
94
+ if (p.endsWith('.md')) return false; // docs, incl. root governance files
95
+ return true;
96
+ }
97
+
98
+ // ── Frontmatter ────────────────────────────────────────────────────────────────────────────────
99
+ // The LEADING block only — body text must never be able to fake a declaration. Mirrors
100
+ // workspace/lib/file-mode-parse.js and kit/scripts/metrics.py, plus the surrounding-quote strip the
101
+ // bash gate does, so all four readers agree on what a record declared.
102
+ function parseLeadingFrontmatter(text) {
103
+ const fm = {};
104
+ for (const line of String(text == null ? '' : text).split('\n')) {
105
+ const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
106
+ if (m) {
107
+ if (!(m[1] in fm)) fm[m[1]] = m[2].replace(/^"(.*)"$/s, '$1');
108
+ continue;
109
+ }
110
+ if (line.trim() && !line.startsWith('---')) break;
111
+ }
112
+ return fm;
113
+ }
114
+ function readFrontmatter(file) {
115
+ try { return parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')); } catch { return null; }
116
+ }
117
+ function readJsonl(file) {
118
+ let text;
119
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
120
+ const out = [];
121
+ for (const line of text.split('\n')) {
122
+ const t = line.trim();
123
+ if (!t) continue;
124
+ try { out.push(JSON.parse(t)); } catch { /* torn line — dropped, never guessed */ }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // ── Repo layout — pmos-self and the kit disagree on where state lives, so detect rather than assume
130
+ function layout(root) {
131
+ const selfDir = path.join(root, 'planning', 'initiatives');
132
+ const kitDir = path.join(root, 'pmos', 'state', 'initiatives');
133
+ const isKit = !fs.existsSync(selfDir) && fs.existsSync(kitDir);
134
+ return {
135
+ root,
136
+ kit: isKit,
137
+ initiatives: isKit ? kitDir : selfDir,
138
+ runsDir: isKit ? path.join(root, 'pmos', 'state') : path.join(root, 'planning', 'runs'),
139
+ runsFile: isKit ? 'runs.jsonl' : 'agent-runs.jsonl',
140
+ explainers: isKit
141
+ ? path.join(root, 'pmos', 'state', 'explainers')
142
+ : path.join(root, 'planning', 'evals', 'explainers'),
143
+ budgets: isKit
144
+ ? path.join(root, 'pmos', 'state', 'health-budgets.md')
145
+ : path.join(root, 'planning', 'okrs', 'health-budgets.md'),
146
+ };
147
+ }
148
+
149
+ // ── Git ────────────────────────────────────────────────────────────────────────────────────────
150
+ function git(root, args) {
151
+ try {
152
+ return execFileSync('git', ['-C', root, ...args], {
153
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
154
+ });
155
+ } catch { return null; }
156
+ }
157
+
158
+ /*
159
+ * The commit range this initiative's work lives in, oldest first, as sha -> index.
160
+ *
161
+ * In CI that is base..HEAD for the PR. Locally it is the merge-base with the default branch. When
162
+ * neither resolves — a shallow clone, a detached checkout with no base — the range is NULL and every
163
+ * order check downgrades to `unknown`. It does not downgrade to `pass`: an order rule that cannot be
164
+ * evaluated has not been satisfied, and saying so is the whole point of the honesty rule above.
165
+ */
166
+ function commitRange(root) {
167
+ if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
168
+ return { ok: false, reason: 'shallow clone — no history to order commits by (CI needs fetch-depth: 0)' };
169
+ }
170
+ const candidates = [];
171
+ if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
172
+ candidates.push('origin/main', 'main', 'origin/master', 'master');
173
+ let base = null;
174
+ for (const c of candidates) {
175
+ if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
176
+ const mb = git(root, ['merge-base', c, 'HEAD']);
177
+ if (mb && mb.trim()) { base = mb.trim(); break; }
178
+ }
179
+ const spec = base ? `${base}..HEAD` : 'HEAD';
180
+ const log = git(root, ['log', '--reverse', '--format=%H', spec]);
181
+ if (log == null) return { ok: false, reason: 'git history unreadable' };
182
+ const shas = log.split('\n').map((s) => s.trim()).filter(Boolean);
183
+ const index = new Map();
184
+ shas.forEach((sha, i) => index.set(sha, i));
185
+ return { ok: true, base, index, shas, wholeHistory: !base };
186
+ }
187
+
188
+ /*
189
+ * Where a path FIRST APPEARED, as an index into the range.
190
+ * -1 it already existed before the range began
191
+ * 0..n the position of the commit that added it
192
+ * Infinity it exists on disk but no commit has added it yet (uncommitted)
193
+ * undefined it exists, but its position is unknowable (no usable history)
194
+ * null it does not exist at all
195
+ *
196
+ * `undefined` and `-1` are kept apart on purpose. "Already existed" is a fact that satisfies an
197
+ * ordering rule; "I cannot tell" is not, and collapsing the second into the first is exactly how a
198
+ * shallow clone would turn every order check into a silent pass.
199
+ */
200
+ function firstAppearance(root, range, rel) {
201
+ const onDisk = fs.existsSync(path.join(root, rel));
202
+ if (!range.ok) return onDisk ? undefined : null;
203
+ const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
204
+ const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
205
+ if (!sha) return onDisk ? Infinity : null;
206
+ return range.index.has(sha) ? range.index.get(sha) : -1;
207
+ }
208
+
209
+ // The earliest position in the range at which this initiative's implementation was touched, plus a
210
+ // count. A docs-only PR has no build commits, which makes every "before the build" rule vacuous —
211
+ // reported as n/a rather than as a pass it did not earn.
212
+ function buildCommits(root, range, lane) {
213
+ if (!range.ok) return { unknown: true };
214
+ let first = null; let count = 0; let sample = null;
215
+ for (let i = 0; i < range.shas.length; i++) {
216
+ const files = git(root, ['show', '--name-only', '--format=', range.shas[i]]);
217
+ if (!files) continue;
218
+ const hit = files.split('\n').map((s) => s.trim()).filter(Boolean).find((f) => isBuildPath(f, lane));
219
+ if (!hit) continue;
220
+ count++;
221
+ if (first === null) { first = i; sample = hit; }
222
+ }
223
+ return { first, count, sample };
224
+ }
225
+
226
+ // ── Declaration readers ────────────────────────────────────────────────────────────────────────
227
+ // Both keys are a LEADING TOKEN from a closed set followed by free prose. The token is what the gate
228
+ // reads; the prose is the agent's reasoning for the PM. Closed-enum-only was rejected: D60's 2026-07-24
229
+ // amendment widened the lane past UI work precisely because the interesting part is the reasoning.
230
+ function specimenRoute(v) {
231
+ const t = String(v || '').trim().toLowerCase();
232
+ if (!t) return 'undeclared';
233
+ if (t.startsWith('needs-instance')) return 'instance';
234
+ if (t.startsWith('description-judgeable')) return 'description';
235
+ return 'malformed';
236
+ }
237
+ // The `priors` citation on a run record — the episodic read AGENTS.md step 3 mandates, written down.
238
+ // Two shapes are accepted deliberately: the bare list (`priors: ["run-a"]`) for the common case, and
239
+ // the object form (`priors: {consulted: [...], note: "…"}`) when the run needs to say WHY it consulted
240
+ // nothing. Anything else returns `malformed` rather than falling through to "absent" — a field that is
241
+ // present but unreadable must never be scored the same as a field nobody wrote, because the two call
242
+ // for opposite fixes.
243
+ function priorsCitation(run) {
244
+ if (!run || !('priors' in run) || run.priors === null) return { declared: false };
245
+ const p = run.priors;
246
+ if (Array.isArray(p)) return { declared: true, consulted: p.map((x) => String(x).trim()), note: '' };
247
+ if (typeof p === 'object') {
248
+ if (!('consulted' in p)) return { declared: true, malformed: 'priors object has no `consulted` key' };
249
+ if (!Array.isArray(p.consulted)) return { declared: true, malformed: '`priors.consulted` is not a list of run ids' };
250
+ return {
251
+ declared: true,
252
+ consulted: p.consulted.map((x) => String(x).trim()),
253
+ note: String(p.note == null ? '' : p.note).trim(),
254
+ };
255
+ }
256
+ return { declared: true, malformed: 'priors is neither a list of run ids nor {consulted, note}' };
257
+ }
258
+
259
+ function surfaceRoute(v) {
260
+ const t = String(v || '').trim().toLowerCase();
261
+ if (!t) return 'undeclared';
262
+ if (t.startsWith('existing')) return 'existing';
263
+ if (t.startsWith('greenfield')) return 'greenfield';
264
+ return 'malformed';
265
+ }
266
+
267
+ // ── Step model ─────────────────────────────────────────────────────────────────────────────────
268
+ // status ∈ done | current | blocked | pending | na | unknown
269
+ // `blocking` marks a step whose failure fails CI. O5/O6/O7 are deliberately NOT blocking: none can
270
+ // be true before the diff exists, and a rule enforced at a moment it cannot be satisfied only
271
+ // teaches people to reach for the escape hatch.
272
+ function step(key, label, status, evidence, next, opts) {
273
+ return {
274
+ key, label, status, evidence: evidence || null, next: next || null,
275
+ blocking: !!(opts && opts.blocking), rule: (opts && opts.rule) || null,
276
+ };
277
+ }
278
+
279
+ function computeWorkflow(id, opts) {
280
+ const root = path.resolve((opts && opts.root) || process.cwd());
281
+ const L = layout(root);
282
+ const recRel = path.relative(root, path.join(L.initiatives, `${id}.md`));
283
+ const recAbs = path.join(L.initiatives, `${id}.md`);
284
+ if (!fs.existsSync(recAbs)) {
285
+ return { ok: false, id, error: `no initiative record at ${recRel} — an initiative must exist before its work` };
286
+ }
287
+ const fm = readFrontmatter(recAbs) || {};
288
+ const lane = (fm.lane || 'feature').trim() || 'feature';
289
+ const knownLane = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'].includes(lane);
290
+ const effLane = knownLane ? lane : 'feature'; // unknown lane = strictest, as the bash gate does
291
+ const range = (opts && opts.range) || commitRange(root);
292
+ // See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
293
+ const recIdx = firstAppearance(root, range, recRel);
294
+ const legacy = (recIdx === undefined || recIdx === null)
295
+ ? String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM // fallback only — see ENFORCED_FROM
296
+ : recIdx === -1;
297
+ /*
298
+ * `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
299
+ * question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
300
+ * board and the workspace panel both read "0 in flight" against 93 initiatives — the surface was
301
+ * blank at exactly the moment someone opens it to ask where they are.
302
+ *
303
+ * The two axes below are what the board actually orders by. `inChange` is precise (git says so);
304
+ * `lastActivity` is the honest fallback for everything else, because "when was this last worked
305
+ * on" is a fact the repo already holds and nobody has to maintain.
306
+ */
307
+ const inChange = recIdx !== -1 && recIdx !== null && recIdx !== undefined;
308
+ const lastActivity = (() => {
309
+ const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
310
+ const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
311
+ const t = out && parseInt(out.trim(), 10);
312
+ return Number.isFinite(t) ? t : null;
313
+ })();
314
+ const steps = [];
315
+
316
+ // ── route ─ the lane is chosen and the two intake questions are answered ─────────────────────
317
+ const sRoute = specimenRoute(fm.specimen_test);
318
+ const fRoute = surfaceRoute(fm.surface_check);
319
+ {
320
+ const missing = [];
321
+ if (sRoute === 'undeclared' || sRoute === 'malformed') missing.push('specimen_test');
322
+ if (fRoute === 'undeclared' || fRoute === 'malformed') missing.push('surface_check');
323
+ if (!missing.length) {
324
+ steps.push(step('route', 'route', 'done',
325
+ `${lane} lane · specimen test ${sRoute} · surface ${fRoute}`));
326
+ } else if (legacy) {
327
+ steps.push(step('route', 'route', 'unknown',
328
+ `${lane} lane · ${missing.join(' + ')} not declared (record predates ${ENFORCED_FROM})`));
329
+ } else {
330
+ const how = missing.map((k) => k === 'specimen_test'
331
+ ? 'specimen_test: "description-judgeable — …" or "needs-instance — …"'
332
+ : 'surface_check: "greenfield — …" or "existing — …"').join('; ');
333
+ steps.push(step('route', 'route', 'blocked',
334
+ `${missing.join(' + ')} ${missing.length > 1 ? 'are' : 'is'} ${sRoute === 'malformed' || fRoute === 'malformed' ? 'malformed' : 'not declared'}`,
335
+ `run the pmos-router skill and record its answers in ${recRel} — ${how}`,
336
+ { blocking: true, rule: 'R0' }));
337
+ }
338
+ }
339
+
340
+ // ── anchor ─ every initiative anchors to a KR or a declared health budget (D59) ──────────────
341
+ {
342
+ const anchor = String(fm.parent_kr || '').trim();
343
+ const r = anchors.resolveAnchor(anchor, okrIndexFor(root), budgetsFor(root, L));
344
+ const budRel = path.relative(root, L.budgets);
345
+ if (r.kind === 'empty') {
346
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
347
+ 'parent_kr is empty — unanchored work is rejected (D59)',
348
+ `set parent_kr in ${recRel} to a KR, or to health:<id> from ${budRel}`,
349
+ { blocking: !legacy, rule: 'R1' }));
350
+ } else if (r.status === 'unknown') {
351
+ steps.push(step('anchor', 'anchor', 'unknown', `${anchor} — ${r.reason}`));
352
+ } else if (r.kind === 'health') {
353
+ if (r.status === 'resolved') {
354
+ steps.push(step('anchor', 'anchor', 'done', `health:${r.budget} — declared in ${budRel}`));
355
+ } else {
356
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
357
+ `health:${r.budget} is not a declared budget`,
358
+ `only the PM adds a budget — use an existing id from ${budRel} or anchor to a KR`,
359
+ { blocking: !legacy, rule: 'R1' }));
360
+ }
361
+ } else if (r.status === 'resolved') {
362
+ steps.push(step('anchor', 'anchor', 'done',
363
+ `${anchors.canonical(r, (okrIndexFor(root) || {}).period) || anchor} — ${r.reason}`));
364
+ } else {
365
+ // REPORTED, NEVER CI-BLOCKING (spec assumption A1). The anchor names an objective or KR the
366
+ // OKR documents do not declare — which is exactly the class that went unnoticed for two days
367
+ // (`q3-2026:integrity:kr-a` and `:kr-b` were anchored by three initiatives and written in no
368
+ // file). It is shown as a failed step so it cannot hide, and deliberately does NOT fail CI:
369
+ // making it blocking would turn one typo into a hard failure on every unrelated PR, and the
370
+ // recorded intent put the RED in the surface, not the gate. Raising it to blocking is a
371
+ // separate PM decision (spec Open questions), not a thing to slip in with a resolver.
372
+ steps.push(step('anchor', 'anchor', 'blocked',
373
+ `${anchor} — ${r.reason}`,
374
+ `write an anchor that names something the OKR documents declare, or add the KR (a PM act) — see ${budRel} for the health form`,
375
+ { blocking: false, rule: 'R1' }));
376
+ }
377
+ }
378
+
379
+ const prd = String(fm.prd_path || '').trim();
380
+ const rubric = String(fm.rubric_path || '').trim();
381
+ const prdIdx = prd ? firstAppearance(root, range, prd) : null;
382
+ const build = buildCommits(root, range, effLane);
383
+
384
+ // Shared helper: "A must not come after B", stated as positions, never as clock time.
385
+ function ordered(aIdx, bIdx) {
386
+ // null = absent (a different step's problem); undefined = position unknowable. Neither is a pass.
387
+ if (aIdx == null || bIdx == null) return null;
388
+ return aIdx <= bIdx;
389
+ }
390
+
391
+ // ── prototype ─ O3: a "needs-instance" answer obliges a prototype BEFORE the PRD (D60) ───────
392
+ {
393
+ const pred = String(fm.predecessor || '').trim();
394
+ if (sRoute !== 'instance') {
395
+ steps.push(step('prototype', 'prototype', 'na',
396
+ sRoute === 'description' ? 'specimen test says description-judgeable — routed straight to spec, untaxed'
397
+ : 'specimen test not declared'));
398
+ } else if (!pred) {
399
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
400
+ 'specimen_test says needs-instance but no predecessor is named',
401
+ 'run the prototype lane first (skills/core/prototype.skill), then set predecessor: <prototype-id>',
402
+ { blocking: !legacy, rule: 'O3' }));
403
+ } else {
404
+ const predAbs = path.join(L.initiatives, `${pred}.md`);
405
+ const predFm = readFrontmatter(predAbs);
406
+ if (!predFm) {
407
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
408
+ `predecessor '${pred}' has no initiative record at ${path.relative(root, predAbs)}`,
409
+ 'name a prototype-lane initiative that exists, or correct predecessor:',
410
+ { blocking: !legacy, rule: 'O3' }));
411
+ } else if ((predFm.lane || '').trim() !== 'prototype') {
412
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
413
+ `predecessor '${pred}' is lane '${predFm.lane || 'feature'}', not prototype`,
414
+ 'the specimen test is answered by a prototype leg — point predecessor: at one',
415
+ { blocking: !legacy, rule: 'O3' }));
416
+ } else {
417
+ const brief = String(predFm.prd_path || '').trim();
418
+ const briefIdx = brief ? firstAppearance(root, range, brief) : null;
419
+ const ok = ordered(briefIdx, prdIdx);
420
+ if (ok === null) {
421
+ steps.push(step('prototype', 'prototype', 'unknown',
422
+ `predecessor ${pred} present; order undecidable (${range.ok ? 'a path is missing' : range.reason})`));
423
+ } else if (ok) {
424
+ steps.push(step('prototype', 'prototype', 'done', `${pred} — brief ${brief} first-appeared before ${prd}`));
425
+ } else {
426
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
427
+ `ORDER VIOLATION — the PRD (${prd}) first-appeared BEFORE the prototype brief (${brief})`,
428
+ 'the prototype is what validates the direction the PRD then specifies — it cannot follow it (D60)',
429
+ { blocking: !legacy, rule: 'O3' }));
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ // ── understand ─ O4: an existing surface is explained BEFORE it is specified ─────────────────
436
+ {
437
+ const expl = String(fm.explainer_path || '').trim();
438
+ if (fRoute !== 'existing') {
439
+ steps.push(step('understand', 'understand', 'na',
440
+ fRoute === 'greenfield' ? 'greenfield — nothing existing to explain first' : 'surface check not declared'));
441
+ } else if (!expl) {
442
+ steps.push(step('understand', 'understand', legacy ? 'unknown' : 'blocked',
443
+ 'surface_check says existing but no explainer_path is set',
444
+ 'run build-skills/explain-surface.skill in a FRESH context (not the agent that will write the spec), then set explainer_path:',
445
+ { blocking: !legacy, rule: 'O4' }));
446
+ } else if (!fs.existsSync(path.join(root, expl))) {
447
+ steps.push(step('understand', 'understand', legacy ? 'unknown' : 'blocked',
448
+ `explainer_path '${expl}' does not exist in the repo`,
449
+ 'set explainer_path to a real explain-surface output',
450
+ { blocking: !legacy, rule: 'O4' }));
451
+ } else {
452
+ const eIdx = firstAppearance(root, range, expl);
453
+ const ok = ordered(eIdx, prdIdx);
454
+ if (ok === null) {
455
+ steps.push(step('understand', 'understand', 'unknown',
456
+ `${expl} present; order undecidable (${range.ok ? 'no spec artifact to order against' : range.reason})`));
457
+ } else if (ok) {
458
+ steps.push(step('understand', 'understand', 'done', `${expl} — first-appeared before ${prd}`));
459
+ } else {
460
+ steps.push(step('understand', 'understand', legacy ? 'unknown' : 'blocked',
461
+ `ORDER VIOLATION — the spec (${prd}) first-appeared BEFORE the explainer (${expl})`,
462
+ 'without the pass first, the AGENT acquires the understanding and the PM approves a spec built on facts they never saw',
463
+ { blocking: !legacy, rule: 'O4' }));
464
+ }
465
+ }
466
+ }
467
+
468
+ // ── spec ─ the lane's artifacts, and O1/O2: they precede the build ───────────────────────────
469
+ const needsRubric = effLane === 'feature';
470
+ {
471
+ const wants = {
472
+ feature: 'a PRD', discovery: 'a spike brief', incident: 'an incident record',
473
+ content: 'a content brief', prototype: 'a quick prototype brief',
474
+ }[effLane];
475
+ if (effLane === 'chore') {
476
+ const intent = String(fm.status || '').trim();
477
+ steps.push(intent
478
+ ? step('spec', 'spec', 'done', 'chore lane — intent stated in the record\'s status')
479
+ : step('spec', 'spec', 'blocked', 'chore lane requires a one-paragraph intent in status:',
480
+ `write the intent in ${recRel} — anchor-free trivia takes the no-initiative label instead`,
481
+ { blocking: true, rule: 'R2' }));
482
+ } else if (!prd) {
483
+ steps.push(step('spec', 'spec', 'blocked', `no prd_path — the ${effLane} lane needs ${wants}`,
484
+ effLane === 'feature'
485
+ ? 'author the PRD (skills/core/prd.skill) and the rubric alongside it (D13), then set prd_path + rubric_path'
486
+ : `author ${wants} and set prd_path in ${recRel}`,
487
+ { blocking: true, rule: 'R2' }));
488
+ } else if (prdIdx === null) {
489
+ steps.push(step('spec', 'spec', 'blocked', `prd_path '${prd}' does not exist in the repo`,
490
+ 'create the file the record points at, or correct prd_path',
491
+ { blocking: true, rule: 'R2' }));
492
+ } else if (needsRubric && !rubric) {
493
+ steps.push(step('spec', 'spec', 'blocked', 'no rubric_path — the feature lane writes the eval before the build (D13)',
494
+ 'author the rubric (skills/core/eval-rubric.skill) and set rubric_path',
495
+ { blocking: true, rule: 'R2' }));
496
+ } else if (rubric && !fs.existsSync(path.join(root, rubric))) {
497
+ steps.push(step('spec', 'spec', 'blocked', `rubric_path '${rubric}' does not exist in the repo`,
498
+ 'optional never means dangling — point rubric_path at a real file or clear it',
499
+ { blocking: true, rule: 'R2' }));
500
+ } else {
501
+ // Artifacts are present. Now the order rules the old gate could not see.
502
+ const rubricIdx = rubric ? firstAppearance(root, range, rubric) : null;
503
+ const specIdx = rubricIdx == null ? prdIdx : Math.max(prdIdx, rubricIdx);
504
+ const specNames = rubric ? `${prd} + ${rubric}` : prd;
505
+ if (specIdx === undefined || (rubricIdx === undefined)) {
506
+ steps.push(step('spec', 'spec', 'unknown', `${specNames} present — order vs the build undecidable (${range.reason || 'no usable history'})`));
507
+ } else if (build.unknown) {
508
+ steps.push(step('spec', 'spec', 'unknown', `${specNames} present — order vs the build undecidable (${range.reason})`));
509
+ } else if (build.first === null) {
510
+ steps.push(step('spec', 'spec', 'done', `${specNames} present — no implementation commits in this range, so nothing can precede them`));
511
+ } else if (specIdx <= build.first) {
512
+ steps.push(step('spec', 'spec', 'done', `${specNames} — first-appeared at or before the first build commit`));
513
+ } else {
514
+ const late = rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? rubric : prd;
515
+ steps.push(step('spec', 'spec', 'blocked',
516
+ `ORDER VIOLATION — implementation (${build.sample}) was committed BEFORE ${late} first appeared`,
517
+ 'the rubric is the spec (D13). Land the spec artifacts in an earlier commit than the build, or split this PR so the spec lands first',
518
+ { blocking: true, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
519
+ }
520
+ }
521
+ }
522
+
523
+ const runs = readJsonl(path.join(L.runsDir, L.runsFile)).filter((r) => r && r.initiative_id === id);
524
+ const runsRel = path.relative(root, path.join(L.runsDir, L.runsFile));
525
+
526
+ // ── priors ─ O7: the episodic read is CITED, and the citation is checked ─────────────────────
527
+ // AGENTS.md step 3 mandates reading this initiative's earlier runs BEFORE acting — the one kernel
528
+ // invariant that was still asserted only in prose. Nothing can derive that a file was READ, and
529
+ // this rule does not pretend to: what it derives is the three things that ARE decidable from the
530
+ // record, and together they are what separates it from a checkbox.
531
+ // 1. whether earlier runs existed at all — the rule is vacuous on a first run, and says so
532
+ // 2. whether the run declared what it consulted
533
+ // 3. whether the ids it named are REAL and actually EARLIER
534
+ // (3) is the part that matters. O5's `PM_approved: true` is a bare boolean, satisfiable by typing
535
+ // it; a fabricated `priors` list fails against the corpus it claims to have read. The rule is
536
+ // NON-BLOCKING for the same reason O5/O6 are: it cannot be true before a run exists, and a rule
537
+ // enforced at a moment it cannot be satisfied only teaches people to reach for the escape hatch.
538
+ {
539
+ const latest = runs.length ? runs[runs.length - 1] : null;
540
+ const earlier = runs.slice(0, -1);
541
+ const cite = priorsCitation(latest);
542
+ const how = `consult this initiative's earlier runs BEFORE acting (AGENTS.md step 3) and cite them as \`priors\` on the run in ${runsRel}`;
543
+ if (!latest) {
544
+ steps.push(step('priors', 'priors', 'pending', 'no run logged for this initiative yet', how, { rule: 'O7' }));
545
+ } else if (!earlier.length) {
546
+ // Not a pass — there was nothing to pass. A tick here would be the watermelon this engine exists to prevent.
547
+ steps.push(step('priors', 'priors', 'na',
548
+ `${latest.run_id} is the first run on this initiative — no priors to consult`));
549
+ } else if (cite.malformed) {
550
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
551
+ `${latest.run_id} has a malformed priors citation — ${cite.malformed}`,
552
+ `use \`priors: ["<run_id>", …]\` or \`priors: {consulted: [...], note: "…"}\` in ${runsRel}`,
553
+ { rule: 'O7' }));
554
+ } else if (!cite.declared) {
555
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
556
+ `${latest.run_id} follows ${earlier.length} earlier run(s) on this initiative and cites no priors`,
557
+ how, { rule: 'O7' }));
558
+ } else {
559
+ const byId = new Map(runs.map((r) => [String(r.run_id), r]));
560
+ const earlierIds = new Set(earlier.map((r) => String(r.run_id)));
561
+ const missing = cite.consulted.filter((x) => !byId.has(x));
562
+ const notEarlier = cite.consulted.filter((x) => byId.has(x) && !earlierIds.has(x));
563
+ if (missing.length) {
564
+ // The check a declared field would otherwise never get. A citation naming a run that does not
565
+ // exist reports a read that cannot have happened — worse than an absent citation, because it
566
+ // reads as compliance on the board.
567
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
568
+ `${latest.run_id} cites prior run(s) that do not exist on this initiative: ${missing.join(', ')}`,
569
+ `cite run_ids that are actually in ${runsRel} — a citation naming nothing reports a read that cannot have happened`,
570
+ { rule: 'O7' }));
571
+ } else if (notEarlier.length) {
572
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
573
+ `${latest.run_id} cites ${notEarlier.join(', ')}, which ${notEarlier.length > 1 ? 'are' : 'is'} not EARLIER than it`,
574
+ 'a run cannot consult itself or a run that came after it — cite only runs logged before this one',
575
+ { rule: 'O7' }));
576
+ } else if (!cite.consulted.length && !cite.note) {
577
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
578
+ `${latest.run_id} declares an empty priors list with no note, over ${earlier.length} earlier run(s)`,
579
+ 'an empty list with no reason is indistinguishable from not looking — say which priors were read, or say in `note` why none applied',
580
+ { rule: 'O7' }));
581
+ } else if (!cite.consulted.length) {
582
+ // Allowed, and deliberately not silent: the machine cannot judge whether the priors were
583
+ // genuinely irrelevant, so it surfaces the claim verbatim for the PM instead of ticking.
584
+ steps.push(step('priors', 'priors', 'done',
585
+ `${latest.run_id} consulted none of ${earlier.length} earlier run(s) — declared irrelevant: ${cite.note}`));
586
+ } else {
587
+ steps.push(step('priors', 'priors', 'done',
588
+ `${latest.run_id} cites ${cite.consulted.length} prior run(s): ${cite.consulted.join(', ')}${cite.note ? ` — ${cite.note}` : ''}`));
589
+ }
590
+ }
591
+ }
592
+
593
+ // ── contract ─ O5: PM-approved done-criteria before the build. Reported, never CI-blocking ────
594
+ const approved = runs.find((r) => r.pre_run_contract && r.pre_run_contract.PM_approved === true);
595
+ {
596
+ if (approved) {
597
+ steps.push(step('contract', 'contract', 'done',
598
+ `PM-approved pre_run_contract on run ${approved.run_id}${approved.pre_run_contract.approved_by ? ` (by ${approved.pre_run_contract.approved_by})` : ''}`));
599
+ } else if (runs.length) {
600
+ steps.push(step('contract', 'contract', legacy ? 'pending' : 'blocked',
601
+ `${runs.length} run(s) logged, none with pre_run_contract.PM_approved: true`,
602
+ `agree numbered done_criteria + test_method with the PM and record the approval in ${runsRel}`,
603
+ { rule: 'O5' }));
604
+ } else {
605
+ steps.push(step('contract', 'contract', 'pending',
606
+ 'no run logged for this initiative yet',
607
+ `agree done_criteria + test_method BEFORE building and append the run to ${runsRel} with PM_approved: true`,
608
+ { rule: 'O5' }));
609
+ }
610
+ }
611
+
612
+ // ── build ────────────────────────────────────────────────────────────────────────────────────
613
+ steps.push(build.unknown
614
+ ? step('build', 'build', 'unknown', range.reason)
615
+ : build.first === null
616
+ ? step('build', 'build', 'pending', 'no implementation commits in this range')
617
+ : step('build', 'build', 'done', `${build.count} commit(s) on implementation paths`));
618
+
619
+ // ── quality ─ CI's verdict, never this engine's ──────────────────────────────────────────────
620
+ steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
621
+
622
+ // ── review ─ the Review Gate's verdict, where the lane has a rubric ──────────────────────────
623
+ const evals = readJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
624
+ {
625
+ if (effLane === 'chore' || effLane === 'prototype') {
626
+ steps.push(step('review', 'review', 'na', effLane === 'prototype'
627
+ ? 'leg A is grilled as dialogue, never scored (D60)'
628
+ : 'chore lane carries no rubric (D56)'));
629
+ } else if (evals.length) {
630
+ const last = evals[evals.length - 1];
631
+ steps.push(step('review', 'review', 'done',
632
+ `${last.verdict}${last.score != null ? ` (${last.score})` : ''} — advisory until calibrated per evaluator model (D40)`));
633
+ } else {
634
+ steps.push(step('review', 'review', 'pending', 'no Review-Gate verdict recorded',
635
+ 'score the build against the rubric (build-skills/evaluator.skill), then append to evals.jsonl',
636
+ { rule: 'R3' }));
637
+ }
638
+ }
639
+
640
+ // ── explain ─ O6: the run is explained AFTER the build and BEFORE acceptance ─────────────────
641
+ {
642
+ let found = null;
643
+ for (const r of runs) {
644
+ const p = path.join(L.explainers, `${r.run_id}-explainer.md`);
645
+ if (fs.existsSync(p)) { found = path.relative(root, p); break; }
646
+ }
647
+ if (found) {
648
+ steps.push(step('explain', 'explain', 'done', found));
649
+ } else if (!runs.length) {
650
+ steps.push(step('explain', 'explain', 'pending', 'no run to explain yet',
651
+ 'log the run first, then run build-skills/explain-run.skill against its diff', { rule: 'O6' }));
652
+ } else {
653
+ steps.push(step('explain', 'explain', legacy ? 'pending' : 'blocked',
654
+ `no explainer at ${path.relative(root, L.explainers)}/${runs[runs.length - 1].run_id}-explainer.md`,
655
+ 'run build-skills/explain-run.skill — raw-transcript vigilance degrades within ~30 min, so the explainer is what the PM actually reviews',
656
+ { rule: 'O6' }));
657
+ }
658
+ }
659
+
660
+ // ── accept ─ the human PM, final and non-delegable (D12) ─────────────────────────────────────
661
+ const accepted = readJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
662
+ .filter((a) => a && a.initiative_id === id && a.accepted === true);
663
+ {
664
+ const blockers = steps.filter((s) => s.status === 'blocked').map((s) => s.key);
665
+ if (accepted.length) {
666
+ const last = accepted[accepted.length - 1];
667
+ steps.push(step('accept', 'accept', 'done', `accepted by ${last.pm || 'PM'}${last.at ? ` on ${String(last.at).slice(0, 10)}` : ''}`));
668
+ } else if (blockers.length) {
669
+ steps.push(step('accept', 'accept', 'blocked', `blocked by: ${blockers.join(', ')}`,
670
+ 'the Acceptance Gate is the PM\'s (D12) — but it does not open over an unfinished ladder'));
671
+ } else {
672
+ steps.push(step('accept', 'accept', 'pending', 'PM Acceptance Gate open — non-delegable (D12)',
673
+ 'the PM names one thing verified BY HAND in the acceptance note, then appends to acceptances.jsonl'));
674
+ }
675
+ }
676
+
677
+ // ── stage, derived from what the steps found ─────────────────────────────────────────────────
678
+ const by = Object.fromEntries(steps.map((s) => [s.key, s]));
679
+ let stage = 'intake';
680
+ if (by.spec.status === 'done') stage = 'spec';
681
+ if (by.contract.status === 'done') stage = 'contract';
682
+ if (by.build.status === 'done') stage = 'build';
683
+ if (evals.length || runs.length) stage = 'eval';
684
+ if (accepted.length) stage = 'accepted';
685
+
686
+ const blockers = steps.filter((s) => s.status === 'blocked');
687
+ const gateFailures = blockers.filter((s) => s.blocking);
688
+ const next = steps.find((s) => s.status === 'blocked')
689
+ || steps.find((s) => s.status === 'pending' && s.next)
690
+ || null;
691
+
692
+ return {
693
+ ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
694
+ last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
695
+ title: fm.title || id, anchor: fm.parent_kr || null,
696
+ steps, blockers: blockers.map((s) => s.key), gate_failures: gateFailures.map((s) => s.rule || s.key),
697
+ next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
698
+ range: range.ok ? { base: range.base, commits: range.shas.length, whole_history: !!range.wholeHistory } : { error: range.reason },
699
+ enforced_from: ENFORCED_FROM,
700
+ };
701
+ }
702
+
703
+ // ── Rendering ──────────────────────────────────────────────────────────────────────────────────
704
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
705
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
706
+ const GLYPH = {
707
+ done: ['✓', 32], current: ['●', 36], blocked: ['✗', 31],
708
+ pending: ['·', 90], na: ['─', 90], unknown: ['?', 33],
709
+ };
710
+ function mark(status) {
711
+ const [g, col] = GLYPH[status] || ['?', 33];
712
+ return c(col, g);
713
+ }
714
+ // Record prose is deliberately long — the reasoning is for the PM to read in the record, not to
715
+ // wrap a terminal. The ladder shows the declaration, then stops.
716
+ function brief(s, n) {
717
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
718
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
719
+ }
720
+
721
+ function renderLadder(w) {
722
+ if (!w.ok) return c(31, `✗ ${w.error}`);
723
+ const out = [];
724
+ const head = `${c(1, w.id)} · ${w.lane}${w.laneKnown ? '' : c(33, ' (unknown lane — enforcing feature)')} · ${w.anchor ? brief(w.anchor, 46) : c(31, 'unanchored')}`;
725
+ out.push('');
726
+ out.push(` ${head}`);
727
+ out.push(` ${c(90, 'stage')} ${c(1, w.stage)}${w.legacy ? c(33, ` legacy — record predates ${w.enforced_from}, order rules report but do not fail`) : ''}`);
728
+ out.push(` ${c(90, '─'.repeat(78))}`);
729
+ for (const s of w.steps) {
730
+ const label = s.label.padEnd(11);
731
+ out.push(` ${mark(s.status)} ${s.status === 'na' || s.status === 'pending' ? c(90, label) : label} ${brief(s.evidence, 110)}`);
732
+ if (s.next) out.push(` ${c(90, '→')} ${c(s.status === 'blocked' ? 33 : 90, brief(s.next, 150))}`);
733
+ }
734
+ out.push('');
735
+ if (w.next) out.push(` ${c(1, 'next:')} ${w.next.action}`);
736
+ else out.push(` ${c(32, 'nothing blocking — the ladder is complete')}`);
737
+ if (w.range.error) out.push(` ${c(33, `order checks degraded: ${w.range.error}`)}`);
738
+ out.push('');
739
+ return out.join('\n');
740
+ }
741
+
742
+ function renderBoard(rows, opts) {
743
+ const out = [''];
744
+ const acceptedN = rows.filter((r) => r.ok && r.accepted).length;
745
+ const { shown, hidden, total } = inFlight(rows, { cap: opts.all ? Infinity : BOARD_CAP });
746
+ const blocked = shown.filter((r) => r.ok && r.blockers.length).length;
747
+ // "Open" is stated as what it is DERIVED FROM — no recorded acceptance — not as "in flight". The
748
+ // difference matters here: this repo has 10 acceptances against 93 initiatives, so most of these
749
+ // are finished work whose acceptance was never logged. Calling that "in flight" would be the
750
+ // board inventing a fact; naming the gap is the board doing its job.
751
+ out.push(` ${c(1, 'PMOS workflow board')} · ${total} without a recorded acceptance · ${blocked ? c(31, `${blocked} blocked`) : c(32, 'none blocked')}${acceptedN ? c(90, ` · ${acceptedN} accepted`) : ''}`);
752
+ out.push(` ${c(90, 'most recently worked on first · ✗ blocked · ● in this change')}`);
753
+ out.push(` ${c(90, '─'.repeat(78))}`);
754
+ if (!shown.length) { out.push(` ${c(32, 'every initiative here has a recorded acceptance.')}`); out.push(''); return out.join('\n'); }
755
+ const w1 = Math.min(34, Math.max(...shown.map((r) => r.id.length), 4));
756
+ for (const r of shown) {
757
+ if (!r.ok) { out.push(` ${c(31, '✗')} ${r.id.padEnd(w1)} ${c(31, r.error)}`); continue; }
758
+ const status = r.blockers.length ? 'blocked' : r.in_change ? 'current' : 'pending';
759
+ // The board shows WHICH step and WHY in a few words; the full next action lives on the ladder,
760
+ // where there is room for it. Repeating a 90-character instruction on every row taught nothing.
761
+ const why = r.next ? `${r.next.step} — ${brief(r.next.why, 62)}` : 'nothing blocking';
762
+ const note = r.blockers.length ? c(31, why) : c(90, why);
763
+ const tag = r.in_change ? c(36, ' ·this change') : '';
764
+ out.push(` ${mark(status)} ${r.id.padEnd(w1)} ${r.lane.padEnd(9)} ${r.stage.padEnd(9)} ${note}${tag}`);
765
+ }
766
+ out.push('');
767
+ // Never a silent cap — a truncated list that does not say so reads as "that is everything".
768
+ if (hidden) out.push(` ${c(33, `+ ${hidden} more without a recorded acceptance, not shown`)} ${c(90, `— node ${opts.self} --all`)}`);
769
+ out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
770
+ out.push('');
771
+ return out.join('\n');
772
+ }
773
+
774
+ /*
775
+ * What "in flight" means, in one place, so the CLI, the gate and the workspace panel cannot drift.
776
+ *
777
+ * NOT `!legacy` — that was the bug this replaced. Legacy is about whether a rule may fail a PR;
778
+ * being old has nothing to do with whether work is live. In flight = **not accepted**, ordered so
779
+ * the rows worth looking at come first:
780
+ * 1. anything blocked — the reason you opened the surface
781
+ * 2. anything this change carries — precise, git says so
782
+ * 3. most recently worked on — the honest fallback, and the repo already knows it
783
+ *
784
+ * The cap is REPORTED, never silent: a list quietly truncated to 12 reads as "that's everything",
785
+ * which is the same class of lie as a false green.
786
+ */
787
+ const BOARD_CAP = 12;
788
+ function inFlight(rows, opts) {
789
+ const cap = (opts && opts.cap) || BOARD_CAP;
790
+ const live = rows.filter((r) => !r.ok || !r.accepted);
791
+ const rank = (r) => (!r.ok ? 0 : r.blockers.length ? 1 : r.in_change ? 2 : 3);
792
+ const sorted = [...live].sort((a, b) =>
793
+ rank(a) - rank(b)
794
+ || (b.last_activity || 0) - (a.last_activity || 0)
795
+ || String(a.id).localeCompare(String(b.id)));
796
+ return { shown: sorted.slice(0, cap), hidden: Math.max(0, sorted.length - cap), total: sorted.length };
797
+ }
798
+
799
+ function listInitiatives(root) {
800
+ const L = layout(root);
801
+ try {
802
+ return fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && f !== 'README.md' && f !== 'index.md')
803
+ .map((f) => f.replace(/\.md$/, '')).sort();
804
+ } catch { return []; }
805
+ }
806
+
807
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────────────
808
+ function main(argv) {
809
+ const args = argv.slice(2);
810
+ let root = process.cwd(); let json = false; let gate = false; let all = false;
811
+ const rest = [];
812
+ for (let i = 0; i < args.length; i++) {
813
+ const a = args[i];
814
+ if (a === '--root') { root = args[++i]; continue; }
815
+ if (a === '--json') { json = true; continue; }
816
+ if (a === '--gate') { gate = true; continue; }
817
+ if (a === '--all') { all = true; continue; }
818
+ if (a === '--open') { continue; } // the board is in-flight-only by default now
819
+ if (a === '--help' || a === '-h') {
820
+ process.stdout.write([
821
+ '', ' node scripts/workflow-state.js [<initiative-id>] [--json] [--gate] [--all] [--root <dir>]', '',
822
+ ' (no id) the board — what is in flight, blocked first, then this change, then recency',
823
+ ' <id> the ladder for one initiative: where you are, what is next, what blocks',
824
+ ' --gate <id> CI mode — exit 1 on a blocking order violation, with the next action',
825
+ ' --all the board without the 12-row cap',
826
+ ' --json machine-readable (what an agent reads at session start)', '',
827
+ ].join('\n') + '\n');
828
+ return 0;
829
+ }
830
+ rest.push(a);
831
+ }
832
+ const self = path.relative(root, __filename) || 'scripts/workflow-state.js';
833
+ const id = rest[0];
834
+
835
+ if (id) {
836
+ const w = computeWorkflow(id, { root });
837
+ if (json) { process.stdout.write(JSON.stringify(w, null, 2) + '\n'); }
838
+ else { process.stdout.write(renderLadder(w) + '\n'); }
839
+ if (!gate) return w.ok ? 0 : 1;
840
+
841
+ // ── gate mode ──────────────────────────────────────────────────────────────────────────────
842
+ if (!w.ok) { process.stdout.write(`::error::workflow-gate: ${w.error}\n`); return 1; }
843
+ const fails = w.steps.filter((s) => s.status === 'blocked' && s.blocking);
844
+ if (fails.length) {
845
+ for (const f of fails) {
846
+ process.stdout.write(`::error::workflow-gate [${f.rule || f.key}]: ${f.evidence}\n`);
847
+ process.stdout.write(`❌ ${f.key} — ${f.evidence}\n → ${f.next}\n`);
848
+ }
849
+ return 1;
850
+ }
851
+ const reported = w.steps.filter((s) => s.status === 'blocked' && !s.blocking);
852
+ for (const r of reported) {
853
+ // Reported, not failed: O5/O6 cannot be true before the diff exists. Saying WHY at the point
854
+ // of report is what stops this reading as a gate that was quietly skipped.
855
+ process.stdout.write(`::notice::workflow-gate [${r.rule || r.key}]: ${r.evidence} — reported, not failed: this rule closes after the diff exists, and blocks the Acceptance step instead.\n`);
856
+ process.stdout.write(`⚠️ ${r.key} — ${r.evidence}\n → ${r.next}\n`);
857
+ }
858
+ if (w.legacy) process.stdout.write(`ℹ️ '${w.id}' is LEGACY (record predates ${ENFORCED_FROM}) — order rules reported, not enforced.\n`);
859
+ if (w.range.error) process.stdout.write(`⚠️ order checks degraded — ${w.range.error}\n`);
860
+ process.stdout.write(`✅ workflow gate PASSED — '${w.id}' (${w.lane}) at stage ${w.stage}.\n`);
861
+ return 0;
862
+ }
863
+
864
+ const range = commitRange(root);
865
+ const rows = listInitiatives(root).map((i) => computeWorkflow(i, { root, range }));
866
+ if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
867
+ if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
868
+ process.stdout.write(renderBoard(rows, { self, all }));
869
+ return 0;
870
+ }
871
+
872
+ module.exports = {
873
+ computeWorkflow, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
874
+ parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
875
+ ENFORCED_FROM,
876
+ };
877
+
878
+ if (require.main === module) process.exit(main(process.argv));