create-pmos 0.22.0 → 0.23.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,1139 @@
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
+ * ONE reader for delivery too (workflow-status-sweep R-1). `buildCommits` used to answer "is there a
50
+ * build commit in this range" — a question about a RANGE — while both of its callers asked a
51
+ * question about an INITIATIVE. Over a pull-request range those coincide, because the range IS one
52
+ * initiative's work; over the trunk they do not, and every initiative read `done` with the same 488
53
+ * commits of evidence. The attribution signal already existed, module-private, inside the workspace
54
+ * server. It is shared now, for the same reason `anchor-resolve` is.
55
+ */
56
+ const delivery = require('./delivery-index');
57
+
58
+ // The OKR documents and the budget registry are read once per root, not once per initiative: the
59
+ // board derives ~100 ladders in a single pass and re-reading the same three files each time is the
60
+ // same waste the shared commit-range exists to avoid.
61
+ const _okrCache = new Map();
62
+ const _budCache = new Map();
63
+ function okrIndexFor(root) {
64
+ if (!_okrCache.has(root)) _okrCache.set(root, anchors.parseOkrIndex(root));
65
+ return _okrCache.get(root);
66
+ }
67
+ function budgetsFor(root, L) {
68
+ if (!_budCache.has(root)) {
69
+ try { _budCache.set(root, { ok: true, text: fs.readFileSync(L.budgets, 'utf8'), path: path.relative(root, L.budgets) }); }
70
+ catch { _budCache.set(root, { ok: false, text: '', path: path.relative(root, L.budgets) }); }
71
+ }
72
+ return _budCache.get(root);
73
+ }
74
+
75
+ /*
76
+ * ── Enforcement boundary ──────────────────────────────────────────────────────────────────────
77
+ * An initiative is LEGACY when its record already existed before the change under review — i.e. its
78
+ * record first-appeared outside this commit range. Legacy records are reported and never failed by
79
+ * the rules introduced here (R0/O3/O4/O5/O6/O7); the original artifact-existence contract (R1/R2)
80
+ * binds everywhere, because it always did.
81
+ *
82
+ * The boundary is DERIVED FROM GIT, not from the record's own `timestamp:` frontmatter. A
83
+ * self-declared date is a field the record controls, so backdating it would grandfather you out of
84
+ * your own gate — the one thing a compliance boundary must not permit. Git's add-date is written by
85
+ * the commit, not by the author of the file's contents.
86
+ *
87
+ * `ENFORCED_FROM` is only the FALLBACK, used when git cannot answer (no history, a fresh fixture
88
+ * repo, a shallow clone). It is deliberately not the primary test.
89
+ *
90
+ * The 92 records that predate this are NOT backfilled. Inventing a specimen-test answer for work
91
+ * already shipped would be fabricating a judgement nobody made, and a gate whose history is
92
+ * fabricated is worth less than no gate at all.
93
+ */
94
+ const ENFORCED_FROM = '2026-07-28';
95
+
96
+ // A path counts as BUILD when it is neither a planning artifact nor documentation. `prototype/` is
97
+ // build only in the prototype lane, where the specimen IS the deliverable (D60); in every other lane
98
+ // a prototype directory is somebody else's leg-A work and must not count as this initiative's build.
99
+ function isBuildPath(p, lane) {
100
+ if (!p) return false;
101
+ if (p.startsWith('planning/') || p.startsWith('okf/') || p.startsWith('pmos/state/')) return false;
102
+ if (p.startsWith('prototype/')) return lane === 'prototype';
103
+ if (p.endsWith('.md')) return false; // docs, incl. root governance files
104
+ return true;
105
+ }
106
+
107
+ // ── Frontmatter ────────────────────────────────────────────────────────────────────────────────
108
+ // The LEADING block only — body text must never be able to fake a declaration. Mirrors
109
+ // workspace/lib/file-mode-parse.js and kit/scripts/metrics.py, plus the surrounding-quote strip the
110
+ // bash gate does, so all four readers agree on what a record declared.
111
+ function parseLeadingFrontmatter(text) {
112
+ const fm = {};
113
+ for (const line of String(text == null ? '' : text).split('\n')) {
114
+ const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
115
+ if (m) {
116
+ if (!(m[1] in fm)) fm[m[1]] = m[2].replace(/^"(.*)"$/s, '$1');
117
+ continue;
118
+ }
119
+ if (line.trim() && !line.startsWith('---')) break;
120
+ }
121
+ return fm;
122
+ }
123
+ function readFrontmatter(file) {
124
+ try { return parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')); } catch { return null; }
125
+ }
126
+ /*
127
+ * The ledger reader, and the one place a torn line is REPORTED rather than merely dropped (R-4).
128
+ *
129
+ * A line that does not parse is still never guessed at — that part was always right. What was wrong
130
+ * was the silence: a truncated acceptance row did not make the ladder say "a row here is
131
+ * unreadable", it made the ladder say the initiative was never accepted. The file's own honesty rule
132
+ * ("a gate that cannot see its inputs reports that it cannot see them") held for git ranges and not
133
+ * for ledger lines.
134
+ *
135
+ * The channel is out-of-band — an optional `defects` array the caller passes in — rather than a
136
+ * changed return type, because every existing call site wants the rows and only the sweep wants the
137
+ * damage. Changing the shape would have made ~six callers handle a case none of them can act on.
138
+ */
139
+ function readJsonl(file, defects) {
140
+ let text;
141
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
142
+ const out = [];
143
+ const lines = text.split('\n');
144
+ for (let i = 0; i < lines.length; i++) {
145
+ const t = lines[i].trim();
146
+ if (!t) continue;
147
+ try { out.push(JSON.parse(t)); }
148
+ catch (e) {
149
+ // Dropped, never guessed — and now said out loud to anyone who asked to hear it.
150
+ if (Array.isArray(defects)) {
151
+ defects.push({ file, line: i + 1, reason: String((e && e.message) || 'not valid JSON').slice(0, 120), text: t.slice(0, 120) });
152
+ }
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ // ── Repo layout — pmos-self and the kit disagree on where state lives, so detect rather than assume
159
+ function layout(root) {
160
+ const selfDir = path.join(root, 'planning', 'initiatives');
161
+ const kitDir = path.join(root, 'pmos', 'state', 'initiatives');
162
+ const isKit = !fs.existsSync(selfDir) && fs.existsSync(kitDir);
163
+ return {
164
+ root,
165
+ kit: isKit,
166
+ initiatives: isKit ? kitDir : selfDir,
167
+ runsDir: isKit ? path.join(root, 'pmos', 'state') : path.join(root, 'planning', 'runs'),
168
+ runsFile: isKit ? 'runs.jsonl' : 'agent-runs.jsonl',
169
+ explainers: isKit
170
+ ? path.join(root, 'pmos', 'state', 'explainers')
171
+ : path.join(root, 'planning', 'evals', 'explainers'),
172
+ budgets: isKit
173
+ ? path.join(root, 'pmos', 'state', 'health-budgets.md')
174
+ : path.join(root, 'planning', 'okrs', 'health-budgets.md'),
175
+ };
176
+ }
177
+
178
+ // ── Git ────────────────────────────────────────────────────────────────────────────────────────
179
+ function git(root, args) {
180
+ try {
181
+ return execFileSync('git', ['-C', root, ...args], {
182
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
183
+ });
184
+ } catch { return null; }
185
+ }
186
+
187
+ /*
188
+ * The commit range this initiative's work lives in, oldest first, as sha -> index.
189
+ *
190
+ * In CI that is base..HEAD for the PR. Locally it is the merge-base with the default branch. When
191
+ * neither resolves — a shallow clone, a detached checkout with no base — the range is NULL and every
192
+ * order check downgrades to `unknown`. It does not downgrade to `pass`: an order rule that cannot be
193
+ * evaluated has not been satisfied, and saying so is the whole point of the honesty rule above.
194
+ */
195
+ function commitRange(root) {
196
+ return buildRange(root, { spec: null });
197
+ }
198
+
199
+ /*
200
+ * The BOARD's range: the whole trunk, not the pull request.
201
+ *
202
+ * These are two different questions and they were sharing an answer. The gate asks "inside this
203
+ * diff, did the spec precede the build?" — only decidable within the PR. The board asks "where does
204
+ * this initiative stand?", which the PR range cannot answer at all: on a branch level with `main`
205
+ * the range is EMPTY, so every initiative reported `no implementation commits in this range` and
206
+ * collapsed to stage `spec`. A delivered initiative rendered identically to one never started.
207
+ *
208
+ * Splitting them means a rule can read `done` on the board and still be evaluated inside the gate.
209
+ * That is intended and is stated in the board's own footer, so it never reads as a discrepancy.
210
+ */
211
+ function historyRange(root) {
212
+ const trunk = ['origin/main', 'main', 'origin/master', 'master']
213
+ .find((c) => git(root, ['rev-parse', '--verify', '--quiet', c]));
214
+ return buildRange(root, { spec: trunk || 'HEAD', whole: true });
215
+ }
216
+
217
+ /*
218
+ * ONE pass over the range, not one git call per question.
219
+ *
220
+ * Two `git log` invocations build every index the ladder needs: which paths each commit CHANGED
221
+ * (for buildCommits) and where each path was ADDED (for firstAppearance). Previously firstAppearance
222
+ * shelled out once per path per initiative and buildCommits ran `git show` once per commit per
223
+ * initiative — 330+ processes for the board on an empty range (23 s), and O(commits × initiatives)
224
+ * on a full one, which is what made full history look impossible rather than merely unimplemented.
225
+ */
226
+ function buildRange(root, { spec, whole }) {
227
+ if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
228
+ return { ok: false, reason: 'shallow clone — no history to order commits by (CI needs fetch-depth: 0)' };
229
+ }
230
+ let base = null;
231
+ let range = spec;
232
+ if (!range) {
233
+ const candidates = [];
234
+ if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
235
+ candidates.push('origin/main', 'main', 'origin/master', 'master');
236
+ for (const c of candidates) {
237
+ if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
238
+ const mb = git(root, ['merge-base', c, 'HEAD']);
239
+ if (mb && mb.trim()) { base = mb.trim(); break; }
240
+ }
241
+ range = base ? `${base}..HEAD` : 'HEAD';
242
+ }
243
+ // %x1e separates commits; %x1f separates the header from the message and the message from
244
+ // --name-only's file list. The MESSAGE is captured in this same pass because attribution over the
245
+ // trunk needs it (R-2) — a second `git log` per initiative is exactly the O(commits × initiatives)
246
+ // cost this single-pass rewrite existed to remove.
247
+ const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct%x1f%s %b%x1f', '--name-only', range]);
248
+ if (log == null) return { ok: false, reason: 'git history unreadable' };
249
+
250
+ const shas = [];
251
+ const changed = []; // index -> string[] of paths touched
252
+ const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
253
+ const subjects = []; // index -> subject + body, one line, for attribution
254
+ for (const chunk of log.split('\x1e')) {
255
+ if (!chunk.trim()) continue;
256
+ const parts = chunk.split('\x1f');
257
+ const head = (parts[0] || '').trim();
258
+ const [sha, ct] = head.split(/\s+/);
259
+ if (!sha) continue;
260
+ shas.push(sha);
261
+ dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
262
+ subjects.push(String(parts[1] || '').replace(/\s+/g, ' ').trim());
263
+ changed.push(String(parts[2] || '').split('\n').map((s) => s.trim()).filter(Boolean));
264
+ }
265
+ const index = new Map();
266
+ shas.forEach((sha, i) => index.set(sha, i));
267
+
268
+ // Where each path was ADDED, as an index into the same range. `--diff-filter=A` in one pass.
269
+ const added = new Map();
270
+ const addLog = git(root, ['log', '--reverse', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]);
271
+ if (addLog != null) {
272
+ for (const chunk of addLog.split('\x1e')) {
273
+ if (!chunk.trim()) continue;
274
+ const lines = chunk.split('\n');
275
+ const sha = (lines.shift() || '').trim();
276
+ const at = index.has(sha) ? index.get(sha) : null;
277
+ if (at == null) continue;
278
+ for (const f of lines.map((s) => s.trim()).filter(Boolean)) {
279
+ if (!added.has(f)) added.set(f, at);
280
+ }
281
+ }
282
+ }
283
+ return { ok: true, base, index, shas, changed, dates, subjects, added, wholeHistory: whole ? true : !base };
284
+ }
285
+
286
+ /*
287
+ * Where a path FIRST APPEARED, as an index into the range.
288
+ * -1 it already existed before the range began
289
+ * 0..n the position of the commit that added it
290
+ * Infinity it exists on disk but no commit has added it yet (uncommitted)
291
+ * undefined it exists, but its position is unknowable (no usable history)
292
+ * null it does not exist at all
293
+ *
294
+ * `undefined` and `-1` are kept apart on purpose. "Already existed" is a fact that satisfies an
295
+ * ordering rule; "I cannot tell" is not, and collapsing the second into the first is exactly how a
296
+ * shallow clone would turn every order check into a silent pass.
297
+ */
298
+ function firstAppearance(root, range, rel) {
299
+ const onDisk = fs.existsSync(path.join(root, rel));
300
+ if (!range.ok) return onDisk ? undefined : null;
301
+ // The single-pass index answers first, and over the whole trunk it answers everything that has
302
+ // ever been committed — so the fallback below only runs for a PR range, where "added before the
303
+ // range" and "never committed" are genuinely different answers and only git can tell them apart.
304
+ if (range.added && range.added.has(rel)) return range.added.get(rel);
305
+ const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
306
+ const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
307
+ if (!sha) return onDisk ? Infinity : null;
308
+ return range.index.has(sha) ? range.index.get(sha) : -1;
309
+ }
310
+
311
+ /*
312
+ * The earliest position in the range at which this initiative's implementation was touched, plus a
313
+ * count. A docs-only PR has no build commits, which makes every "before the build" rule vacuous —
314
+ * reported as n/a rather than as a pass it did not earn.
315
+ *
316
+ * ATTRIBUTION (R-2). This function used to take no id, so it answered a question about the RANGE
317
+ * while both of its callers asked one about an INITIATIVE. Which of those is right depends entirely
318
+ * on what the range is, and the two cases are answered differently on purpose:
319
+ *
320
+ * · A PULL-REQUEST RANGE (`base..HEAD`) — the range IS this initiative's work, by the branch
321
+ * convention the Initiative Gate already enforces. Any build commit in it is therefore this
322
+ * initiative's build commit, and the O1/O2 order rules depend on exactly that reading. This path
323
+ * is UNCHANGED, deliberately: the gate's verdicts must not move for a reporting fix.
324
+ *
325
+ * · THE WHOLE TRUNK (`range.wholeHistory`) — the range is everyone's work. Attribution comes from
326
+ * `delivery-index`: a merged `initiative/<id>` branch, or a commit that names the id and touches
327
+ * a build path. Without it every initiative reported `done` with the identical count, which is
328
+ * the tell that a per-initiative fact was being read from a repository-wide one.
329
+ *
330
+ * `signal` travels back with the answer so the rung can say WHICH evidence decided it. A bare count
331
+ * is what let 488 stand as an explanation for 177 different initiatives.
332
+ */
333
+ function buildCommits(root, range, lane, opts) {
334
+ if (!range.ok) return { unknown: true };
335
+ const id = opts && opts.id;
336
+ const whole = !!range.wholeHistory;
337
+
338
+ // Over the trunk, the id must be attributable before any commit counts. The index is built once
339
+ // per trunk head by the shared reader and passed in by the board; a caller that did not supply one
340
+ // gets it built here (single-ladder invocations, where one extra `git log` is the whole cost).
341
+ let signal = null;
342
+ let declared = null;
343
+ if (whole && id) {
344
+ // The PM's declaration is consulted FIRST, and its failure is reported before anything else
345
+ // (R-2b). A `delivered_by` that does not check out must not be able to hide behind a convention
346
+ // signal that happens to succeed: a broken declaration is a defect in the record either way, and
347
+ // silently ignoring it would leave a false pointer sitting in the file forever.
348
+ declared = delivery.declaredDelivery(root, opts && opts.declaredDelivery);
349
+ if (declared && declared.declared && !declared.ok) {
350
+ return { unknown: false, failedDeclaration: declared, first: null, count: 0, sample: null, signal: null, attributed: true };
351
+ }
352
+ const index = (opts && opts.deliveryIndex) || delivery.deliveryIndex(root, listInitiatives(root));
353
+ signal = delivery.attribution(index, id);
354
+ if (declared && declared.ok) {
355
+ // A verified declaration attributes the work regardless of what the conventions found. It is
356
+ // the PM naming the commit, checked against git — strictly better evidence than a heuristic.
357
+ return { first: null, count: 0, sample: null, signal: 'declared', declaredSha: declared.sha, attributed: true, mergedOnly: true };
358
+ }
359
+ if (signal === 'unknown') return { unknown: true, reason: delivery.attributionEvidence('unknown', id) };
360
+ if (!signal) return { first: null, count: 0, sample: null, signal: null, attributed: true };
361
+ }
362
+
363
+ let first = null; let count = 0; let sample = null;
364
+ for (let i = 0; i < range.shas.length; i++) {
365
+ // range.changed came from the range's own single pass; the per-commit `git show` this replaced
366
+ // ran once per commit PER INITIATIVE, which is why the board took 23 s before it read anything.
367
+ const files = (range.changed && range.changed[i]) || [];
368
+ const hit = files.find((f) => isBuildPath(f, lane));
369
+ if (!hit) continue;
370
+ // Over the trunk, only commits that carry the id themselves are this initiative's. The branch
371
+ // signal is a MERGE subject, so the merge commit is the one that names it — which makes the
372
+ // merge position the honest "when did this land", and keeps a squashed or rebased history
373
+ // answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
374
+ // initiative is counted from its merge subject below rather than from this per-file loop.
375
+ if (whole && id && !commitNamesId(range, i, id)) continue;
376
+ count++;
377
+ if (first === null) { first = i; sample = hit; }
378
+ }
379
+ if (whole && id && signal === 'branch' && first === null) {
380
+ // The branch merged and every commit it carried was written on the branch, so no trunk commit
381
+ // subject names the id. The merge itself is the evidence, and saying "0 commits" over a merged
382
+ // branch would be the under-claim in a new place.
383
+ return { first: null, count: 0, sample: null, signal, attributed: true, mergedOnly: true };
384
+ }
385
+ return { first, count, sample, signal, attributed: whole && !!id };
386
+ }
387
+
388
+ // Whether the commit at `i` names this initiative in its own subject or body. The subjects come from
389
+ // the range's own single pass, so this costs nothing per initiative.
390
+ function commitNamesId(range, i, id) {
391
+ const msg = (range.subjects && range.subjects[i]) || '';
392
+ return msg.includes(id);
393
+ }
394
+
395
+ // ── Declaration readers ────────────────────────────────────────────────────────────────────────
396
+ // Both keys are a LEADING TOKEN from a closed set followed by free prose. The token is what the gate
397
+ // reads; the prose is the agent's reasoning for the PM. Closed-enum-only was rejected: D60's 2026-07-24
398
+ // amendment widened the lane past UI work precisely because the interesting part is the reasoning.
399
+ function specimenRoute(v) {
400
+ const t = String(v || '').trim().toLowerCase();
401
+ if (!t) return 'undeclared';
402
+ if (t.startsWith('needs-instance')) return 'instance';
403
+ if (t.startsWith('description-judgeable')) return 'description';
404
+ return 'malformed';
405
+ }
406
+ // The `priors` citation on a run record — the episodic read AGENTS.md step 3 mandates, written down.
407
+ // Two shapes are accepted deliberately: the bare list (`priors: ["run-a"]`) for the common case, and
408
+ // the object form (`priors: {consulted: [...], note: "…"}`) when the run needs to say WHY it consulted
409
+ // nothing. Anything else returns `malformed` rather than falling through to "absent" — a field that is
410
+ // present but unreadable must never be scored the same as a field nobody wrote, because the two call
411
+ // for opposite fixes.
412
+ function priorsCitation(run) {
413
+ if (!run || !('priors' in run) || run.priors === null) return { declared: false };
414
+ const p = run.priors;
415
+ if (Array.isArray(p)) return { declared: true, consulted: p.map((x) => String(x).trim()), note: '' };
416
+ if (typeof p === 'object') {
417
+ if (!('consulted' in p)) return { declared: true, malformed: 'priors object has no `consulted` key' };
418
+ if (!Array.isArray(p.consulted)) return { declared: true, malformed: '`priors.consulted` is not a list of run ids' };
419
+ return {
420
+ declared: true,
421
+ consulted: p.consulted.map((x) => String(x).trim()),
422
+ note: String(p.note == null ? '' : p.note).trim(),
423
+ };
424
+ }
425
+ return { declared: true, malformed: 'priors is neither a list of run ids nor {consulted, note}' };
426
+ }
427
+
428
+ function surfaceRoute(v) {
429
+ const t = String(v || '').trim().toLowerCase();
430
+ if (!t) return 'undeclared';
431
+ if (t.startsWith('existing')) return 'existing';
432
+ if (t.startsWith('greenfield')) return 'greenfield';
433
+ return 'malformed';
434
+ }
435
+
436
+ // ── Step model ─────────────────────────────────────────────────────────────────────────────────
437
+ // status ∈ done | current | blocked | pending | na | unknown
438
+ // `blocking` marks a step whose failure fails CI. O5/O6/O7 are deliberately NOT blocking: none can
439
+ // be true before the diff exists, and a rule enforced at a moment it cannot be satisfied only
440
+ // teaches people to reach for the escape hatch.
441
+ function step(key, label, status, evidence, next, opts) {
442
+ return {
443
+ key, label, status, evidence: evidence || null, next: next || null,
444
+ blocking: !!(opts && opts.blocking), rule: (opts && opts.rule) || null,
445
+ };
446
+ }
447
+
448
+ function computeWorkflow(id, opts) {
449
+ const root = path.resolve((opts && opts.root) || process.cwd());
450
+ const L = layout(root);
451
+ const recRel = path.relative(root, path.join(L.initiatives, `${id}.md`));
452
+ const recAbs = path.join(L.initiatives, `${id}.md`);
453
+ if (!fs.existsSync(recAbs)) {
454
+ return { ok: false, id, error: `no initiative record at ${recRel} — an initiative must exist before its work` };
455
+ }
456
+ const fm = readFrontmatter(recAbs) || {};
457
+ const lane = (fm.lane || 'feature').trim() || 'feature';
458
+ const knownLane = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'].includes(lane);
459
+ const effLane = knownLane ? lane : 'feature'; // unknown lane = strictest, as the bash gate does
460
+ const range = (opts && opts.range) || commitRange(root);
461
+ // See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
462
+ //
463
+ // `recIdx === -1` — "the record predates this range" — is the right test for a PR range and a
464
+ // MEANINGLESS one over the whole trunk, where nothing predates the range and every legacy record
465
+ // would suddenly be enforced. So the commit DATE decides whenever the range can supply it, and
466
+ // -1 keeps its meaning for the gate's range. Same question, asked of evidence that survives both.
467
+ const recIdx = firstAppearance(root, range, recRel);
468
+ const recAt = (range.dates && typeof recIdx === 'number' && recIdx >= 0) ? range.dates[recIdx] : null;
469
+ /*
470
+ * `reconstructed_from` — the merge date of work whose record was written afterwards.
471
+ *
472
+ * A record backfilled today for a PR that merged in June has TODAY's commit date, so the date
473
+ * test would enforce today's rules on work that predates them and report a violation nobody
474
+ * could ever have avoided. The declared merge date decides instead.
475
+ *
476
+ * This is not an escape hatch: it must name a date, that date is checkable against the merge it
477
+ * claims, and declaring one AFTER `ENFORCED_FROM` makes the record non-legacy — so work that
478
+ * genuinely skipped the loop still reports blocked, which is the point of writing it down.
479
+ */
480
+ const recon = String(fm.reconstructed_from || '').trim().slice(0, 10);
481
+ const legacy = recIdx === -1 ? true
482
+ : /^\d{4}-\d{2}-\d{2}$/.test(recon) ? recon < ENFORCED_FROM
483
+ : recAt ? new Date(recAt * 1000).toISOString().slice(0, 10) < ENFORCED_FROM
484
+ : String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM
485
+ /*
486
+ * `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
487
+ * question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
488
+ * board and the workspace panel both read "0 in flight" against 93 initiatives — the surface was
489
+ * blank at exactly the moment someone opens it to ask where they are.
490
+ *
491
+ * The two axes below are what the board actually orders by. `inChange` is precise (git says so);
492
+ * `lastActivity` is the honest fallback for everything else, because "when was this last worked
493
+ * on" is a fact the repo already holds and nobody has to maintain.
494
+ */
495
+ // `● in this change` must keep meaning "the diff under review touches this". Over the whole trunk
496
+ // that is true of everything, which makes the marker noise — so the board passes the GATE's range
497
+ // separately and the marker is answered from that, or dropped when there is no such range.
498
+ const changeRange = (opts && opts.changeRange) || (range.wholeHistory ? null : range);
499
+ const chgIdx = changeRange ? firstAppearance(root, changeRange, recRel) : null;
500
+ const inChange = !!changeRange && chgIdx !== -1 && chgIdx !== null && chgIdx !== undefined;
501
+ const lastActivity = (() => {
502
+ const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
503
+ const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
504
+ const t = out && parseInt(out.trim(), 10);
505
+ return Number.isFinite(t) ? t : null;
506
+ })();
507
+ const steps = [];
508
+
509
+ // ── route ─ the lane is chosen and the two intake questions are answered ─────────────────────
510
+ const sRoute = specimenRoute(fm.specimen_test);
511
+ const fRoute = surfaceRoute(fm.surface_check);
512
+ {
513
+ const missing = [];
514
+ if (sRoute === 'undeclared' || sRoute === 'malformed') missing.push('specimen_test');
515
+ if (fRoute === 'undeclared' || fRoute === 'malformed') missing.push('surface_check');
516
+ if (!missing.length) {
517
+ steps.push(step('route', 'route', 'done',
518
+ `${lane} lane · specimen test ${sRoute} · surface ${fRoute}`));
519
+ } else if (legacy) {
520
+ steps.push(step('route', 'route', 'unknown',
521
+ `${lane} lane · ${missing.join(' + ')} not declared (record predates ${ENFORCED_FROM})`));
522
+ } else {
523
+ const how = missing.map((k) => k === 'specimen_test'
524
+ ? 'specimen_test: "description-judgeable — …" or "needs-instance — …"'
525
+ : 'surface_check: "greenfield — …" or "existing — …"').join('; ');
526
+ steps.push(step('route', 'route', 'blocked',
527
+ `${missing.join(' + ')} ${missing.length > 1 ? 'are' : 'is'} ${sRoute === 'malformed' || fRoute === 'malformed' ? 'malformed' : 'not declared'}`,
528
+ `run the pmos-router skill and record its answers in ${recRel} — ${how}`,
529
+ { blocking: true, rule: 'R0' }));
530
+ }
531
+ }
532
+
533
+ // ── anchor ─ every initiative anchors to a KR or a declared health budget (D59) ──────────────
534
+ {
535
+ const anchor = String(fm.parent_kr || '').trim();
536
+ const r = anchors.resolveAnchor(anchor, okrIndexFor(root), budgetsFor(root, L));
537
+ const budRel = path.relative(root, L.budgets);
538
+ if (r.kind === 'empty') {
539
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
540
+ 'parent_kr is empty — unanchored work is rejected (D59)',
541
+ `set parent_kr in ${recRel} to a KR, or to health:<id> from ${budRel}`,
542
+ { blocking: !legacy, rule: 'R1' }));
543
+ } else if (r.status === 'unknown') {
544
+ steps.push(step('anchor', 'anchor', 'unknown', `${anchor} — ${r.reason}`));
545
+ } else if (r.kind === 'health') {
546
+ if (r.status === 'resolved') {
547
+ steps.push(step('anchor', 'anchor', 'done', `health:${r.budget} — declared in ${budRel}`));
548
+ } else {
549
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
550
+ `health:${r.budget} is not a declared budget`,
551
+ `only the PM adds a budget — use an existing id from ${budRel} or anchor to a KR`,
552
+ { blocking: !legacy, rule: 'R1' }));
553
+ }
554
+ } else if (r.status === 'resolved') {
555
+ steps.push(step('anchor', 'anchor', 'done',
556
+ `${anchors.canonical(r, (okrIndexFor(root) || {}).period) || anchor} — ${r.reason}`));
557
+ } else {
558
+ // REPORTED, NEVER CI-BLOCKING (spec assumption A1). The anchor names an objective or KR the
559
+ // OKR documents do not declare — which is exactly the class that went unnoticed for two days
560
+ // (`q3-2026:integrity:kr-a` and `:kr-b` were anchored by three initiatives and written in no
561
+ // file). It is shown as a failed step so it cannot hide, and deliberately does NOT fail CI:
562
+ // making it blocking would turn one typo into a hard failure on every unrelated PR, and the
563
+ // recorded intent put the RED in the surface, not the gate. Raising it to blocking is a
564
+ // separate PM decision (spec Open questions), not a thing to slip in with a resolver.
565
+ steps.push(step('anchor', 'anchor', 'blocked',
566
+ `${anchor} — ${r.reason}`,
567
+ `write an anchor that names something the OKR documents declare, or add the KR (a PM act) — see ${budRel} for the health form`,
568
+ { blocking: false, rule: 'R1' }));
569
+ }
570
+ }
571
+
572
+ const prd = String(fm.prd_path || '').trim();
573
+ const rubric = String(fm.rubric_path || '').trim();
574
+ const prdIdx = prd ? firstAppearance(root, range, prd) : null;
575
+ const build = buildCommits(root, range, effLane, {
576
+ id,
577
+ deliveryIndex: opts && opts.deliveryIndex,
578
+ declaredDelivery: fm.delivered_by,
579
+ });
580
+
581
+ // Shared helper: "A must not come after B", stated as positions, never as clock time.
582
+ function ordered(aIdx, bIdx) {
583
+ // null = absent (a different step's problem); undefined = position unknowable. Neither is a pass.
584
+ if (aIdx == null || bIdx == null) return null;
585
+ return aIdx <= bIdx;
586
+ }
587
+
588
+ // ── prototype ─ O3: a "needs-instance" answer obliges a prototype BEFORE the PRD (D60) ───────
589
+ {
590
+ const pred = String(fm.predecessor || '').trim();
591
+ // The lane is part of the question, not just the answer. O3 asks "does a lookable instance have
592
+ // to precede this spec?" — and for a PROTOTYPE-lane record the instance IS what this record
593
+ // produces (D60). Demanding a predecessor of the leg that answers the specimen test is not
594
+ // merely wrong, it is unsatisfiable: a prototype leg has no leg before it. The file already
595
+ // holds this same fact twice — `isBuildPath` (:93) and the `review` step both read
596
+ // `lane === 'prototype'` — so this is the third place, not a new idea.
597
+ // The other three O3 branches are untouched: a FEATURE record still owes its prototype, named,
598
+ // of the right lane, and briefed before its PRD.
599
+ if (sRoute !== 'instance' || effLane === 'prototype') {
600
+ steps.push(step('prototype', 'prototype', 'na',
601
+ effLane === 'prototype'
602
+ ? 'prototype lane — this record IS the leg the specimen test asks for (D60), so nothing precedes it'
603
+ : sRoute === 'description' ? 'specimen test says description-judgeable — routed straight to spec, untaxed'
604
+ : 'specimen test not declared'));
605
+ } else if (!pred) {
606
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
607
+ 'specimen_test says needs-instance but no predecessor is named',
608
+ 'run the prototype lane first (skills/core/prototype.skill), then set predecessor: <prototype-id>',
609
+ { blocking: !legacy, rule: 'O3' }));
610
+ } else {
611
+ const predAbs = path.join(L.initiatives, `${pred}.md`);
612
+ const predFm = readFrontmatter(predAbs);
613
+ if (!predFm) {
614
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
615
+ `predecessor '${pred}' has no initiative record at ${path.relative(root, predAbs)}`,
616
+ 'name a prototype-lane initiative that exists, or correct predecessor:',
617
+ { blocking: !legacy, rule: 'O3' }));
618
+ } else if ((predFm.lane || '').trim() !== 'prototype') {
619
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
620
+ `predecessor '${pred}' is lane '${predFm.lane || 'feature'}', not prototype`,
621
+ 'the specimen test is answered by a prototype leg — point predecessor: at one',
622
+ { blocking: !legacy, rule: 'O3' }));
623
+ } else {
624
+ const brief = String(predFm.prd_path || '').trim();
625
+ const briefIdx = brief ? firstAppearance(root, range, brief) : null;
626
+ const ok = ordered(briefIdx, prdIdx);
627
+ if (ok === null) {
628
+ steps.push(step('prototype', 'prototype', 'unknown',
629
+ `predecessor ${pred} present; order undecidable (${range.ok ? 'a path is missing' : range.reason})`));
630
+ } else if (ok) {
631
+ steps.push(step('prototype', 'prototype', 'done', `${pred} — brief ${brief} first-appeared before ${prd}`));
632
+ } else {
633
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
634
+ `ORDER VIOLATION — the PRD (${prd}) first-appeared BEFORE the prototype brief (${brief})`,
635
+ 'the prototype is what validates the direction the PRD then specifies — it cannot follow it (D60)',
636
+ { blocking: !legacy, rule: 'O3' }));
637
+ }
638
+ }
639
+ }
640
+ }
641
+
642
+ // ── understand ─ O4: an existing surface is explained BEFORE it is specified ─────────────────
643
+ {
644
+ const expl = String(fm.explainer_path || '').trim();
645
+ if (fRoute !== 'existing') {
646
+ steps.push(step('understand', 'understand', 'na',
647
+ fRoute === 'greenfield' ? 'greenfield — nothing existing to explain first' : 'surface check not declared'));
648
+ } else if (!expl) {
649
+ steps.push(step('understand', 'understand', legacy ? 'unknown' : 'blocked',
650
+ 'surface_check says existing but no explainer_path is set',
651
+ 'run build-skills/explain-surface.skill in a FRESH context (not the agent that will write the spec), then set explainer_path:',
652
+ { blocking: !legacy, rule: 'O4' }));
653
+ } else if (!fs.existsSync(path.join(root, expl))) {
654
+ steps.push(step('understand', 'understand', legacy ? 'unknown' : 'blocked',
655
+ `explainer_path '${expl}' does not exist in the repo`,
656
+ 'set explainer_path to a real explain-surface output',
657
+ { blocking: !legacy, rule: 'O4' }));
658
+ } else {
659
+ const eIdx = firstAppearance(root, range, expl);
660
+ const ok = ordered(eIdx, prdIdx);
661
+ if (ok === null) {
662
+ steps.push(step('understand', 'understand', 'unknown',
663
+ `${expl} present; order undecidable (${range.ok ? 'no spec artifact to order against' : range.reason})`));
664
+ } else if (ok) {
665
+ steps.push(step('understand', 'understand', 'done', `${expl} — first-appeared before ${prd}`));
666
+ } else {
667
+ steps.push(step('understand', 'understand', legacy ? 'unknown' : 'blocked',
668
+ `ORDER VIOLATION — the spec (${prd}) first-appeared BEFORE the explainer (${expl})`,
669
+ 'without the pass first, the AGENT acquires the understanding and the PM approves a spec built on facts they never saw',
670
+ { blocking: !legacy, rule: 'O4' }));
671
+ }
672
+ }
673
+ }
674
+
675
+ // ── spec ─ the lane's artifacts, and O1/O2: they precede the build ───────────────────────────
676
+ const needsRubric = effLane === 'feature';
677
+ {
678
+ const wants = {
679
+ feature: 'a PRD', discovery: 'a spike brief', incident: 'an incident record',
680
+ content: 'a content brief', prototype: 'a quick prototype brief',
681
+ }[effLane];
682
+ if (effLane === 'chore') {
683
+ const intent = String(fm.status || '').trim();
684
+ steps.push(intent
685
+ ? step('spec', 'spec', 'done', 'chore lane — intent stated in the record\'s status')
686
+ : step('spec', 'spec', 'blocked', 'chore lane requires a one-paragraph intent in status:',
687
+ `write the intent in ${recRel} — anchor-free trivia takes the no-initiative label instead`,
688
+ { blocking: true, rule: 'R2' }));
689
+ } else if (!prd) {
690
+ steps.push(step('spec', 'spec', 'blocked', `no prd_path — the ${effLane} lane needs ${wants}`,
691
+ effLane === 'feature'
692
+ ? 'author the PRD (skills/core/prd.skill) and the rubric alongside it (D13), then set prd_path + rubric_path'
693
+ : `author ${wants} and set prd_path in ${recRel}`,
694
+ { blocking: true, rule: 'R2' }));
695
+ } else if (prdIdx === null) {
696
+ steps.push(step('spec', 'spec', 'blocked', `prd_path '${prd}' does not exist in the repo`,
697
+ 'create the file the record points at, or correct prd_path',
698
+ { blocking: true, rule: 'R2' }));
699
+ } else if (needsRubric && !rubric) {
700
+ steps.push(step('spec', 'spec', 'blocked', 'no rubric_path — the feature lane writes the eval before the build (D13)',
701
+ 'author the rubric (skills/core/eval-rubric.skill) and set rubric_path',
702
+ { blocking: true, rule: 'R2' }));
703
+ } else if (rubric && !fs.existsSync(path.join(root, rubric))) {
704
+ steps.push(step('spec', 'spec', 'blocked', `rubric_path '${rubric}' does not exist in the repo`,
705
+ 'optional never means dangling — point rubric_path at a real file or clear it',
706
+ { blocking: true, rule: 'R2' }));
707
+ } else {
708
+ // Artifacts are present. Now the order rules the old gate could not see.
709
+ const rubricIdx = rubric ? firstAppearance(root, range, rubric) : null;
710
+ const specIdx = rubricIdx == null ? prdIdx : Math.max(prdIdx, rubricIdx);
711
+ const specNames = rubric ? `${prd} + ${rubric}` : prd;
712
+ if (specIdx === undefined || (rubricIdx === undefined)) {
713
+ steps.push(step('spec', 'spec', 'unknown', `${specNames} present — order vs the build undecidable (${range.reason || 'no usable history'})`));
714
+ } else if (build.unknown) {
715
+ steps.push(step('spec', 'spec', 'unknown', `${specNames} present — order vs the build undecidable (${range.reason})`));
716
+ } else if (build.first === null) {
717
+ steps.push(step('spec', 'spec', 'done', `${specNames} present — no implementation commits in this range, so nothing can precede them`));
718
+ } else if (specIdx <= build.first) {
719
+ steps.push(step('spec', 'spec', 'done', `${specNames} — first-appeared at or before the first build commit`));
720
+ } else {
721
+ const late = rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? rubric : prd;
722
+ // `legacy` applies here exactly as it does to O4. It always should have — the rule simply
723
+ // never fired for a legacy record while the board shared the gate's PR range, because an
724
+ // empty range has no build commit to be out of order with. Reading the trunk exposed it:
725
+ // 81 records that predate enforcement were suddenly failing a rule written after them.
726
+ steps.push(step('spec', 'spec', legacy ? 'unknown' : 'blocked',
727
+ `ORDER VIOLATION — implementation (${build.sample}) was committed BEFORE ${late} first appeared`,
728
+ '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',
729
+ { blocking: !legacy, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
730
+ }
731
+ }
732
+ }
733
+
734
+ const runs = readJsonl(path.join(L.runsDir, L.runsFile)).filter((r) => r && r.initiative_id === id);
735
+ const runsRel = path.relative(root, path.join(L.runsDir, L.runsFile));
736
+
737
+ // ── priors ─ O7: the episodic read is CITED, and the citation is checked ─────────────────────
738
+ // AGENTS.md step 3 mandates reading this initiative's earlier runs BEFORE acting — the one kernel
739
+ // invariant that was still asserted only in prose. Nothing can derive that a file was READ, and
740
+ // this rule does not pretend to: what it derives is the three things that ARE decidable from the
741
+ // record, and together they are what separates it from a checkbox.
742
+ // 1. whether earlier runs existed at all — the rule is vacuous on a first run, and says so
743
+ // 2. whether the run declared what it consulted
744
+ // 3. whether the ids it named are REAL and actually EARLIER
745
+ // (3) is the part that matters. O5's `PM_approved: true` is a bare boolean, satisfiable by typing
746
+ // it; a fabricated `priors` list fails against the corpus it claims to have read. The rule is
747
+ // NON-BLOCKING for the same reason O5/O6 are: it cannot be true before a run exists, and a rule
748
+ // enforced at a moment it cannot be satisfied only teaches people to reach for the escape hatch.
749
+ {
750
+ const latest = runs.length ? runs[runs.length - 1] : null;
751
+ const earlier = runs.slice(0, -1);
752
+ const cite = priorsCitation(latest);
753
+ const how = `consult this initiative's earlier runs BEFORE acting (AGENTS.md step 3) and cite them as \`priors\` on the run in ${runsRel}`;
754
+ if (!latest) {
755
+ steps.push(step('priors', 'priors', 'pending', 'no run logged for this initiative yet', how, { rule: 'O7' }));
756
+ } else if (!earlier.length) {
757
+ // Not a pass — there was nothing to pass. A tick here would be the watermelon this engine exists to prevent.
758
+ steps.push(step('priors', 'priors', 'na',
759
+ `${latest.run_id} is the first run on this initiative — no priors to consult`));
760
+ } else if (cite.malformed) {
761
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
762
+ `${latest.run_id} has a malformed priors citation — ${cite.malformed}`,
763
+ `use \`priors: ["<run_id>", …]\` or \`priors: {consulted: [...], note: "…"}\` in ${runsRel}`,
764
+ { rule: 'O7' }));
765
+ } else if (!cite.declared) {
766
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
767
+ `${latest.run_id} follows ${earlier.length} earlier run(s) on this initiative and cites no priors`,
768
+ how, { rule: 'O7' }));
769
+ } else {
770
+ const byId = new Map(runs.map((r) => [String(r.run_id), r]));
771
+ const earlierIds = new Set(earlier.map((r) => String(r.run_id)));
772
+ const missing = cite.consulted.filter((x) => !byId.has(x));
773
+ const notEarlier = cite.consulted.filter((x) => byId.has(x) && !earlierIds.has(x));
774
+ if (missing.length) {
775
+ // The check a declared field would otherwise never get. A citation naming a run that does not
776
+ // exist reports a read that cannot have happened — worse than an absent citation, because it
777
+ // reads as compliance on the board.
778
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
779
+ `${latest.run_id} cites prior run(s) that do not exist on this initiative: ${missing.join(', ')}`,
780
+ `cite run_ids that are actually in ${runsRel} — a citation naming nothing reports a read that cannot have happened`,
781
+ { rule: 'O7' }));
782
+ } else if (notEarlier.length) {
783
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
784
+ `${latest.run_id} cites ${notEarlier.join(', ')}, which ${notEarlier.length > 1 ? 'are' : 'is'} not EARLIER than it`,
785
+ 'a run cannot consult itself or a run that came after it — cite only runs logged before this one',
786
+ { rule: 'O7' }));
787
+ } else if (!cite.consulted.length && !cite.note) {
788
+ steps.push(step('priors', 'priors', legacy ? 'pending' : 'blocked',
789
+ `${latest.run_id} declares an empty priors list with no note, over ${earlier.length} earlier run(s)`,
790
+ 'an empty list with no reason is indistinguishable from not looking — say which priors were read, or say in `note` why none applied',
791
+ { rule: 'O7' }));
792
+ } else if (!cite.consulted.length) {
793
+ // Allowed, and deliberately not silent: the machine cannot judge whether the priors were
794
+ // genuinely irrelevant, so it surfaces the claim verbatim for the PM instead of ticking.
795
+ steps.push(step('priors', 'priors', 'done',
796
+ `${latest.run_id} consulted none of ${earlier.length} earlier run(s) — declared irrelevant: ${cite.note}`));
797
+ } else {
798
+ steps.push(step('priors', 'priors', 'done',
799
+ `${latest.run_id} cites ${cite.consulted.length} prior run(s): ${cite.consulted.join(', ')}${cite.note ? ` — ${cite.note}` : ''}`));
800
+ }
801
+ }
802
+ }
803
+
804
+ // ── contract ─ O5: PM-approved done-criteria before the build. Reported, never CI-blocking ────
805
+ const approved = runs.find((r) => r.pre_run_contract && r.pre_run_contract.PM_approved === true);
806
+ {
807
+ if (approved) {
808
+ steps.push(step('contract', 'contract', 'done',
809
+ `PM-approved pre_run_contract on run ${approved.run_id}${approved.pre_run_contract.approved_by ? ` (by ${approved.pre_run_contract.approved_by})` : ''}`));
810
+ } else if (runs.length) {
811
+ steps.push(step('contract', 'contract', legacy ? 'pending' : 'blocked',
812
+ `${runs.length} run(s) logged, none with pre_run_contract.PM_approved: true`,
813
+ `agree numbered done_criteria + test_method with the PM and record the approval in ${runsRel}`,
814
+ { rule: 'O5' }));
815
+ } else {
816
+ steps.push(step('contract', 'contract', 'pending',
817
+ 'no run logged for this initiative yet',
818
+ `agree done_criteria + test_method BEFORE building and append the run to ${runsRel} with PM_approved: true`,
819
+ { rule: 'O5' }));
820
+ }
821
+ }
822
+
823
+ // ── build ────────────────────────────────────────────────────────────────────────────────────
824
+ /*
825
+ * The evidence names WHICH signal decided, never a bare count (R-2). `488 commit(s) on
826
+ * implementation paths` was true of the repository and said nothing about the initiative it was
827
+ * printed under — the identical number on every row was the tell.
828
+ *
829
+ * The three pending sentences below are deliberately different from each other. "No commit on the
830
+ * trunk is attributable to this initiative" is a statement about the INITIATIVE; "no implementation
831
+ * commits in this range" is a statement about the RANGE, and on a branch level with `main` that
832
+ * range is legitimately empty. A reader who cannot tell those apart calls the second one "never
833
+ * built", which is the console's half of this defect.
834
+ */
835
+ steps.push(build.unknown
836
+ ? step('build', 'build', 'unknown', build.reason || range.reason)
837
+ : build.failedDeclaration
838
+ // REPORTED, never CI-blocking — the same stance R1 takes on an unresolvable anchor. Making a
839
+ // record's own broken pointer fail every unrelated PR would turn one typo into a repo-wide
840
+ // outage; the RED belongs in the surface. `blocked` still closes the accept step below, which
841
+ // is where a false delivery claim actually needs to stop something.
842
+ ? step('build', 'build', 'blocked', build.failedDeclaration.reason,
843
+ `point delivered_by at a commit that is reachable from the trunk and changes a build path in ${recRel}, or remove the key — a declaration that fails its own check reads as evidence`,
844
+ { blocking: false, rule: 'R-2b' })
845
+ : build.mergedOnly
846
+ ? step('build', 'build', 'done',
847
+ delivery.attributionEvidence(build.signal || 'branch', id, build.declaredSha))
848
+ : build.first === null
849
+ ? step('build', 'build', 'pending', build.attributed
850
+ ? delivery.attributionEvidence(null, id)
851
+ : 'no implementation commits in this range')
852
+ : step('build', 'build', 'done', build.signal
853
+ ? `${build.count} commit(s) on implementation paths — ${delivery.attributionEvidence(build.signal, id, build.declaredSha)}`
854
+ : `${build.count} commit(s) on implementation paths`));
855
+
856
+ // ── quality ─ CI's verdict, never this engine's ──────────────────────────────────────────────
857
+ steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
858
+
859
+ // ── review ─ the Review Gate's verdict, where the lane has a rubric ──────────────────────────
860
+ const evals = readJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
861
+ {
862
+ if (effLane === 'chore' || effLane === 'prototype') {
863
+ steps.push(step('review', 'review', 'na', effLane === 'prototype'
864
+ ? 'leg A is grilled as dialogue, never scored (D60)'
865
+ : 'chore lane carries no rubric (D56)'));
866
+ } else if (evals.length) {
867
+ const last = evals[evals.length - 1];
868
+ steps.push(step('review', 'review', 'done',
869
+ `${last.verdict}${last.score != null ? ` (${last.score})` : ''} — advisory until calibrated per evaluator model (D40)`));
870
+ } else {
871
+ steps.push(step('review', 'review', 'pending', 'no Review-Gate verdict recorded',
872
+ 'score the build against the rubric (build-skills/evaluator.skill), then append to evals.jsonl',
873
+ { rule: 'R3' }));
874
+ }
875
+ }
876
+
877
+ // ── explain ─ O6: the run is explained AFTER the build and BEFORE acceptance ─────────────────
878
+ {
879
+ let found = null;
880
+ for (const r of runs) {
881
+ const p = path.join(L.explainers, `${r.run_id}-explainer.md`);
882
+ if (fs.existsSync(p)) { found = path.relative(root, p); break; }
883
+ }
884
+ if (found) {
885
+ steps.push(step('explain', 'explain', 'done', found));
886
+ } else if (!runs.length) {
887
+ steps.push(step('explain', 'explain', 'pending', 'no run to explain yet',
888
+ 'log the run first, then run build-skills/explain-run.skill against its diff', { rule: 'O6' }));
889
+ } else {
890
+ steps.push(step('explain', 'explain', legacy ? 'pending' : 'blocked',
891
+ `no explainer at ${path.relative(root, L.explainers)}/${runs[runs.length - 1].run_id}-explainer.md`,
892
+ 'run build-skills/explain-run.skill — raw-transcript vigilance degrades within ~30 min, so the explainer is what the PM actually reviews',
893
+ { rule: 'O6' }));
894
+ }
895
+ }
896
+
897
+ // ── accept ─ the human PM, final and non-delegable (D12) ─────────────────────────────────────
898
+ const accepted = readJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
899
+ .filter((a) => a && a.initiative_id === id && a.accepted === true);
900
+ {
901
+ const blockers = steps.filter((s) => s.status === 'blocked').map((s) => s.key);
902
+ if (accepted.length) {
903
+ const last = accepted[accepted.length - 1];
904
+ steps.push(step('accept', 'accept', 'done', `accepted by ${last.pm || 'PM'}${last.at ? ` on ${String(last.at).slice(0, 10)}` : ''}`));
905
+ } else if (blockers.length) {
906
+ steps.push(step('accept', 'accept', 'blocked', `blocked by: ${blockers.join(', ')}`,
907
+ 'the Acceptance Gate is the PM\'s (D12) — but it does not open over an unfinished ladder'));
908
+ } else {
909
+ steps.push(step('accept', 'accept', 'pending', 'PM Acceptance Gate open — non-delegable (D12)',
910
+ 'the PM names one thing verified BY HAND in the acceptance note, then appends to acceptances.jsonl'));
911
+ }
912
+ }
913
+
914
+ // ── stage, derived from what the steps found ─────────────────────────────────────────────────
915
+ const by = Object.fromEntries(steps.map((s) => [s.key, s]));
916
+ let stage = 'intake';
917
+ if (by.spec.status === 'done') stage = 'spec';
918
+ if (by.contract.status === 'done') stage = 'contract';
919
+ if (by.build.status === 'done') stage = 'build';
920
+ if (evals.length || runs.length) stage = 'eval';
921
+ if (accepted.length) stage = 'accepted';
922
+
923
+ const blockers = steps.filter((s) => s.status === 'blocked');
924
+ const gateFailures = blockers.filter((s) => s.blocking);
925
+ const next = steps.find((s) => s.status === 'blocked')
926
+ || steps.find((s) => s.status === 'pending' && s.next)
927
+ || null;
928
+
929
+ return {
930
+ ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
931
+ last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
932
+ title: fm.title || id, anchor: fm.parent_kr || null,
933
+ steps, blockers: blockers.map((s) => s.key), gate_failures: gateFailures.map((s) => s.rule || s.key),
934
+ next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
935
+ range: range.ok ? { base: range.base, commits: range.shas.length, whole_history: !!range.wholeHistory } : { error: range.reason },
936
+ enforced_from: ENFORCED_FROM,
937
+ };
938
+ }
939
+
940
+ // ── Rendering ──────────────────────────────────────────────────────────────────────────────────
941
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
942
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
943
+ const GLYPH = {
944
+ done: ['✓', 32], current: ['●', 36], blocked: ['✗', 31],
945
+ pending: ['·', 90], na: ['─', 90], unknown: ['?', 33],
946
+ };
947
+ function mark(status) {
948
+ const [g, col] = GLYPH[status] || ['?', 33];
949
+ return c(col, g);
950
+ }
951
+ // Record prose is deliberately long — the reasoning is for the PM to read in the record, not to
952
+ // wrap a terminal. The ladder shows the declaration, then stops.
953
+ function brief(s, n) {
954
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
955
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
956
+ }
957
+
958
+ function renderLadder(w) {
959
+ if (!w.ok) return c(31, `✗ ${w.error}`);
960
+ const out = [];
961
+ 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')}`;
962
+ out.push('');
963
+ out.push(` ${head}`);
964
+ 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`) : ''}`);
965
+ out.push(` ${c(90, '─'.repeat(78))}`);
966
+ for (const s of w.steps) {
967
+ const label = s.label.padEnd(11);
968
+ out.push(` ${mark(s.status)} ${s.status === 'na' || s.status === 'pending' ? c(90, label) : label} ${brief(s.evidence, 110)}`);
969
+ if (s.next) out.push(` ${c(90, '→')} ${c(s.status === 'blocked' ? 33 : 90, brief(s.next, 150))}`);
970
+ }
971
+ out.push('');
972
+ if (w.next) out.push(` ${c(1, 'next:')} ${w.next.action}`);
973
+ else out.push(` ${c(32, 'nothing blocking — the ladder is complete')}`);
974
+ if (w.range.error) out.push(` ${c(33, `order checks degraded: ${w.range.error}`)}`);
975
+ out.push('');
976
+ return out.join('\n');
977
+ }
978
+
979
+ function renderBoard(rows, opts) {
980
+ const out = [''];
981
+ const acceptedN = rows.filter((r) => r.ok && r.accepted).length;
982
+ const { shown, hidden, total } = inFlight(rows, { cap: opts.all ? Infinity : BOARD_CAP });
983
+ const blocked = shown.filter((r) => r.ok && r.blockers.length).length;
984
+ // "Open" is stated as what it is DERIVED FROM — no recorded acceptance — not as "in flight". The
985
+ // difference matters here: this repo has 10 acceptances against 93 initiatives, so most of these
986
+ // are finished work whose acceptance was never logged. Calling that "in flight" would be the
987
+ // board inventing a fact; naming the gap is the board doing its job.
988
+ 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`) : ''}`);
989
+ out.push(` ${c(90, 'most recently worked on first · ✗ blocked · ● in this change')}`);
990
+ out.push(` ${c(90, '─'.repeat(78))}`);
991
+ if (!shown.length) { out.push(` ${c(32, 'every initiative here has a recorded acceptance.')}`); out.push(''); return out.join('\n'); }
992
+ const w1 = Math.min(34, Math.max(...shown.map((r) => r.id.length), 4));
993
+ for (const r of shown) {
994
+ if (!r.ok) { out.push(` ${c(31, '✗')} ${r.id.padEnd(w1)} ${c(31, r.error)}`); continue; }
995
+ const status = r.blockers.length ? 'blocked' : r.in_change ? 'current' : 'pending';
996
+ // The board shows WHICH step and WHY in a few words; the full next action lives on the ladder,
997
+ // where there is room for it. Repeating a 90-character instruction on every row taught nothing.
998
+ const why = r.next ? `${r.next.step} — ${brief(r.next.why, 62)}` : 'nothing blocking';
999
+ const note = r.blockers.length ? c(31, why) : c(90, why);
1000
+ const tag = r.in_change ? c(36, ' ·this change') : '';
1001
+ out.push(` ${mark(status)} ${r.id.padEnd(w1)} ${r.lane.padEnd(9)} ${r.stage.padEnd(9)} ${note}${tag}`);
1002
+ }
1003
+ out.push('');
1004
+ // Never a silent cap — a truncated list that does not say so reads as "that is everything".
1005
+ if (hidden) out.push(` ${c(33, `+ ${hidden} more without a recorded acceptance, not shown`)} ${c(90, `— node ${opts.self} --all`)}`);
1006
+ // The two ranges, said out loud. The board reads the whole trunk so a delivered initiative does
1007
+ // not render as unstarted; the gate reads the PR so "spec before build" stays decidable. A rule
1008
+ // can therefore read done here and still be evaluated on the PR — intended, and not a discrepancy.
1009
+ out.push(` ${c(90, 'derived from the whole trunk history; the CI gate evaluates order within the PR only')}`);
1010
+ out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
1011
+ out.push('');
1012
+ return out.join('\n');
1013
+ }
1014
+
1015
+ /*
1016
+ * What "in flight" means, in one place, so the CLI, the gate and the workspace panel cannot drift.
1017
+ *
1018
+ * NOT `!legacy` — that was the bug this replaced. Legacy is about whether a rule may fail a PR;
1019
+ * being old has nothing to do with whether work is live. In flight = **not accepted**, ordered so
1020
+ * the rows worth looking at come first:
1021
+ * 1. anything blocked — the reason you opened the surface
1022
+ * 2. anything this change carries — precise, git says so
1023
+ * 3. most recently worked on — the honest fallback, and the repo already knows it
1024
+ *
1025
+ * The cap is REPORTED, never silent: a list quietly truncated to 12 reads as "that's everything",
1026
+ * which is the same class of lie as a false green.
1027
+ */
1028
+ const BOARD_CAP = 12;
1029
+ function inFlight(rows, opts) {
1030
+ const cap = (opts && opts.cap) || BOARD_CAP;
1031
+ const live = rows.filter((r) => !r.ok || !r.accepted);
1032
+ const rank = (r) => (!r.ok ? 0 : r.blockers.length ? 1 : r.in_change ? 2 : 3);
1033
+ const sorted = [...live].sort((a, b) =>
1034
+ rank(a) - rank(b)
1035
+ || (b.last_activity || 0) - (a.last_activity || 0)
1036
+ || String(a.id).localeCompare(String(b.id)));
1037
+ return { shown: sorted.slice(0, cap), hidden: Math.max(0, sorted.length - cap), total: sorted.length };
1038
+ }
1039
+
1040
+ function listInitiatives(root) {
1041
+ const L = layout(root);
1042
+ try {
1043
+ return fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && f !== 'README.md' && f !== 'index.md')
1044
+ .map((f) => f.replace(/\.md$/, '')).sort();
1045
+ } catch { return []; }
1046
+ }
1047
+
1048
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────────────
1049
+ function main(argv) {
1050
+ const args = argv.slice(2);
1051
+ let root = process.cwd(); let json = false; let gate = false; let all = false;
1052
+ const rest = [];
1053
+ for (let i = 0; i < args.length; i++) {
1054
+ const a = args[i];
1055
+ if (a === '--root') { root = args[++i]; continue; }
1056
+ if (a === '--json') { json = true; continue; }
1057
+ if (a === '--gate') { gate = true; continue; }
1058
+ if (a === '--all') { all = true; continue; }
1059
+ if (a === '--open') { continue; } // the board is in-flight-only by default now
1060
+ if (a === '--help' || a === '-h') {
1061
+ process.stdout.write([
1062
+ '', ' node scripts/workflow-state.js [<initiative-id>] [--json] [--gate] [--all] [--root <dir>]', '',
1063
+ ' (no id) the board — what is in flight, blocked first, then this change, then recency',
1064
+ ' <id> the ladder for one initiative: where you are, what is next, what blocks',
1065
+ ' --gate <id> CI mode — exit 1 on a blocking order violation, with the next action',
1066
+ ' --all the board without the 12-row cap',
1067
+ ' --json machine-readable (what an agent reads at session start)', '',
1068
+ ].join('\n') + '\n');
1069
+ return 0;
1070
+ }
1071
+ rest.push(a);
1072
+ }
1073
+ const self = path.relative(root, __filename) || 'scripts/workflow-state.js';
1074
+ const id = rest[0];
1075
+
1076
+ if (id) {
1077
+ const w = computeWorkflow(id, { root });
1078
+ if (json) { process.stdout.write(JSON.stringify(w, null, 2) + '\n'); }
1079
+ else { process.stdout.write(renderLadder(w) + '\n'); }
1080
+ if (!gate) return w.ok ? 0 : 1;
1081
+
1082
+ // ── gate mode ──────────────────────────────────────────────────────────────────────────────
1083
+ if (!w.ok) { process.stdout.write(`::error::workflow-gate: ${w.error}\n`); return 1; }
1084
+ const fails = w.steps.filter((s) => s.status === 'blocked' && s.blocking);
1085
+ if (fails.length) {
1086
+ for (const f of fails) {
1087
+ process.stdout.write(`::error::workflow-gate [${f.rule || f.key}]: ${f.evidence}\n`);
1088
+ process.stdout.write(`❌ ${f.key} — ${f.evidence}\n → ${f.next}\n`);
1089
+ }
1090
+ return 1;
1091
+ }
1092
+ const reported = w.steps.filter((s) => s.status === 'blocked' && !s.blocking);
1093
+ for (const r of reported) {
1094
+ // Reported, not failed: O5/O6 cannot be true before the diff exists. Saying WHY at the point
1095
+ // of report is what stops this reading as a gate that was quietly skipped.
1096
+ 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`);
1097
+ process.stdout.write(`⚠️ ${r.key} — ${r.evidence}\n → ${r.next}\n`);
1098
+ }
1099
+ if (w.legacy) process.stdout.write(`ℹ️ '${w.id}' is LEGACY (record predates ${ENFORCED_FROM}) — order rules reported, not enforced.\n`);
1100
+ if (w.range.error) process.stdout.write(`⚠️ order checks degraded — ${w.range.error}\n`);
1101
+ process.stdout.write(`✅ workflow gate PASSED — '${w.id}' (${w.lane}) at stage ${w.stage}.\n`);
1102
+ return 0;
1103
+ }
1104
+
1105
+ // R-7: the BOARD reads the trunk, not the pull request. The gate path above keeps commitRange().
1106
+ // The PR range is still computed and passed alongside — it is what `● in this change` means, and
1107
+ // it is the one thing the trunk range genuinely cannot answer.
1108
+ const range = historyRange(root);
1109
+ const changeRange = commitRange(root);
1110
+ const ids = listInitiatives(root);
1111
+ // ONE delivery index for the whole board. Built here rather than inside each ladder for the same
1112
+ // reason the commit range is: `deliveryIndex` memoises per trunk head, so a per-initiative build
1113
+ // would be cheap but the intent — one read, shared — is what keeps the rows consistent with each
1114
+ // other even if the trunk moved mid-run.
1115
+ const deliveryIdx = delivery.deliveryIndex(root, ids);
1116
+ const rows = ids.map((i) => computeWorkflow(i, { root, range, changeRange, deliveryIndex: deliveryIdx }));
1117
+ if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
1118
+ if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
1119
+ process.stdout.write(renderBoard(rows, { self, all }));
1120
+ return 0;
1121
+ }
1122
+
1123
+ module.exports = {
1124
+ computeWorkflow, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
1125
+ parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
1126
+ // the board's trunk range, exported so the suite can assert the split: the same record is legacy
1127
+ // under BOTH ranges, and `in this change` is false under the trunk one.
1128
+ historyRange,
1129
+ ENFORCED_FROM,
1130
+ };
1131
+
1132
+ /*
1133
+ * `exitCode`, not `process.exit()`. `process.exit` tears the process down without waiting for stdout
1134
+ * to drain, and when stdout is a PIPE — every CI invocation, every `| jq`, every test harness —
1135
+ * writes larger than the pipe buffer are asynchronous. `--json` on this repository is ~65 KB, so it
1136
+ * arrived TRUNCATED MID-STRING and parsed as invalid JSON, while an interactive run to a TTY looked
1137
+ * perfect. Setting the code and letting the process end naturally flushes first.
1138
+ */
1139
+ if (require.main === module) process.exitCode = main(process.argv);