create-pmos 0.131.0 → 0.133.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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/pmos.js +18 -2
  7. package/workspace/guide/data-freshness.md +40 -0
  8. package/workspace/guide/index.md +2 -0
  9. package/workspace/guide/welcome.md +38 -0
  10. package/workspace/lib/calendar-cli.js +84 -0
  11. package/workspace/lib/facts-async.js +144 -35
  12. package/workspace/lib/freshness.js +167 -0
  13. package/workspace/lib/import-overlay.js +32 -0
  14. package/workspace/lib/offloop.js +94 -0
  15. package/workspace/lib/pmos-verbs.js +3 -0
  16. package/workspace/lib/route-modules.js +2 -0
  17. package/workspace/lib/routes-board.js +3 -1
  18. package/workspace/lib/routes-my-work.js +27 -9
  19. package/workspace/lib/routes-onboarding.js +215 -0
  20. package/workspace/lib/routes-sources.js +64 -0
  21. package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
  22. package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
  23. package/workspace/lib/vendor/scripts/intake.js +78 -0
  24. package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
  25. package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
  26. package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
  27. package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
  28. package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
  29. package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
  30. package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
  31. package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
  32. package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
  33. package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
  34. package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
  35. package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
  36. package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
  37. package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
  38. package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
  39. package/workspace/lib/vendor/scripts/status-facts.js +214 -0
  40. package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
  41. package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
  42. package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
  43. package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
  44. package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
  45. package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
  46. package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
  47. package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
  48. package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
  49. package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
  50. package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
  51. package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
  52. package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
  53. package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
  54. package/workspace/package-lock.json +2 -2
  55. package/workspace/package.json +1 -1
  56. package/workspace/public/pm/c3-shell.js +4 -1
  57. package/workspace/public/pm/components.css +313 -0
  58. package/workspace/public/pm/dest-settings.js +10 -1
  59. package/workspace/public/pm/freshness-line.js +123 -0
  60. package/workspace/public/pm/welcome.js +283 -0
  61. package/workspace/public/welcome.html +20 -0
  62. package/workspace/server.js +41 -4
  63. package/workspace/skills/calendar-sync.skill +30 -0
  64. package/workspace/test-lists/onboarding-firstrun.json +15 -0
  65. package/workspace/test-lists/workspace-data-freshness.json +6 -0
@@ -0,0 +1,1888 @@
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 { nineStages } = require('./nine-stages');
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const { execFileSync } = require('child_process');
43
+ /*
44
+ * ONE resolver, shared with the workspace (spec R-6 / contract C-7). Two readers that disagreed
45
+ * about whether an anchor resolves would reproduce the exact defect this work removes — the ladder
46
+ * and the OKR surface previously held separate, incompatible opinions about the same field.
47
+ */
48
+ const anchors = require('./anchor-resolve');
49
+ /*
50
+ * ONE reader for delivery too (workflow-status-sweep R-1). `buildCommits` used to answer "is there a
51
+ * build commit in this range" — a question about a RANGE — while both of its callers asked a
52
+ * question about an INITIATIVE. Over a pull-request range those coincide, because the range IS one
53
+ * initiative's work; over the trunk they do not, and every initiative read `done` with the same 488
54
+ * commits of evidence. The attribution signal already existed, module-private, inside the workspace
55
+ * server. It is shared now, for the same reason `anchor-resolve` is.
56
+ */
57
+ const delivery = require('./delivery-index');
58
+ /*
59
+ * Three flags the ladder cannot see (status-from-artifacts, Run 1): a spec file changed after an
60
+ * approval, requirement ids the rubric never cites, and `status:` prose the ladder contradicts. They
61
+ * sit BESIDE the steps. They never change a step, the stage, a colour or a count, and they write
62
+ * nothing. See scripts/status-facts.js.
63
+ */
64
+ const statusFacts = require('./status-facts');
65
+
66
+ // The OKR documents and the budget registry are read once per root, not once per initiative: the
67
+ // board derives ~100 ladders in a single pass and re-reading the same three files each time is the
68
+ // same waste the shared commit-range exists to avoid.
69
+ //
70
+ // KEYED BY WHAT THE FILES ARE, never by where they live alone. The workspace server requires this
71
+ // module in-process and holds it for days, so a root-only key kept serving the ladder yesterday's
72
+ // anchor index after the PM fixed the OKR document — the OKR panel (which reads fresh) and the
73
+ // anchor rung disagreed about one field until a restart, the exact two-readers defect
74
+ // anchor-resolve was extracted to end. The stamp is mtime+size over the documents and their
75
+ // directories (the directory catches a file added or removed); the stats cost microseconds against
76
+ // the parse they guard. delivery-index keys its cache on the trunk head sha for the same reason.
77
+ // One stated limit: an `okrs/` directory born at a location no prior read has seen is picked up on
78
+ // the next change to a watched path or the next process, not instantly.
79
+ const _okrCache = new Map(); // root -> { stamp, files, index }
80
+ const _budCache = new Map(); // root -> { stamp, value }
81
+ function statStamp(p) {
82
+ try { const s = fs.statSync(p); return `${s.mtimeMs}:${s.size}`; } catch { return 'absent'; }
83
+ }
84
+ function okrStamp(files) {
85
+ const dirs = [...new Set(files.map((f) => path.dirname(f)))];
86
+ return files.concat(dirs).map((p) => `${p}=${statStamp(p)}`).join('|');
87
+ }
88
+ function okrIndexFor(root) {
89
+ const hit = _okrCache.get(root);
90
+ if (hit && hit.files.length && okrStamp(hit.files) === hit.stamp) return hit.index;
91
+ const files = anchors.listOkrFiles(root);
92
+ const entry = { stamp: okrStamp(files), files, index: anchors.parseOkrIndex(root) };
93
+ _okrCache.set(root, entry);
94
+ return entry.index;
95
+ }
96
+ function budgetsFor(root, L) {
97
+ const stamp = statStamp(L.budgets);
98
+ const hit = _budCache.get(root);
99
+ if (!hit || hit.stamp !== stamp) {
100
+ let value;
101
+ try { value = { ok: true, text: fs.readFileSync(L.budgets, 'utf8'), path: path.relative(root, L.budgets) }; }
102
+ catch { value = { ok: false, text: '', path: path.relative(root, L.budgets) }; }
103
+ _budCache.set(root, { stamp, value });
104
+ }
105
+ // D67: the domain registry rides on the budgets object, read fresh (a small, PM-edited file)
106
+ return anchors.withDomains(_budCache.get(root).value, path.dirname(L.budgets), root);
107
+ }
108
+
109
+ /*
110
+ * ── Enforcement boundary ──────────────────────────────────────────────────────────────────────
111
+ * An initiative is LEGACY when its record already existed before the change under review — i.e. its
112
+ * record first-appeared outside this commit range. Legacy records are reported and never failed by
113
+ * the rules introduced here (R0/O3/O4/O5/O6/O7); the original artifact-existence contract (R1/R2)
114
+ * binds everywhere, because it always did.
115
+ *
116
+ * The boundary is DERIVED FROM GIT, not from the record's own `timestamp:` frontmatter. A
117
+ * self-declared date is a field the record controls, so backdating it would grandfather you out of
118
+ * your own gate — the one thing a compliance boundary must not permit. Git's add-date is written by
119
+ * the commit, not by the author of the file's contents.
120
+ *
121
+ * `ENFORCED_FROM` is only the FALLBACK, used when git cannot answer (no history, a fresh fixture
122
+ * repo, a shallow clone). It is deliberately not the primary test.
123
+ *
124
+ * The 92 records that predate this are NOT backfilled. Inventing a specimen-test answer for work
125
+ * already shipped would be fabricating a judgement nobody made, and a gate whose history is
126
+ * fabricated is worth less than no gate at all.
127
+ */
128
+ const ENFORCED_FROM = '2026-07-28';
129
+
130
+ // A path counts as BUILD when it is neither a planning artifact nor documentation. `prototype/` is
131
+ // build only in the prototype lane, where the specimen IS the deliverable (D60); in every other lane
132
+ // a prototype directory is somebody else's leg-A work and must not count as this initiative's build.
133
+ function isBuildPath(p, lane) {
134
+ if (!p) return false;
135
+ if (p.startsWith('planning/') || p.startsWith('okf/') || p.startsWith('pmos/state/')) return false;
136
+ if (p.startsWith('prototype/')) return lane === 'prototype';
137
+ if (p.endsWith('.md')) return false; // docs, incl. root governance files
138
+ return true;
139
+ }
140
+
141
+ // ── Frontmatter ────────────────────────────────────────────────────────────────────────────────
142
+ // The LEADING block only — body text must never be able to fake a declaration. The closing ---
143
+ // fence is TERMINAL, never skipped: the fence used to be stepped over, so a key-shaped FIRST body
144
+ // line was absorbed into the block and could fake a declaration at the fence boundary (the one
145
+ // shape the gamma prose-then-keys fixture never exercised). Fenceless files keep parsing from line
146
+ // 1 and stop at the first non-key line — the kit's smoke fixtures carry no fences and must keep
147
+ // parsing. Mirrors workspace/lib/file-mode-parse.js, kit/scripts/metrics.py and the two bash gates'
148
+ // readers — first value wins, one pair of surrounding double quotes stripped — so all the readers
149
+ // agree on what a record declared (kit/fixtures/test_parsers.py + workspace/fixture-parity.test.mjs
150
+ // hold the agreement).
151
+ function parseLeadingFrontmatter(text) {
152
+ const fm = {};
153
+ let fenceOpen = false;
154
+ for (const line of String(text == null ? '' : text).split('\n')) {
155
+ if (line.startsWith('---')) {
156
+ // Only the FIRST ---, before any key, opens the block; every later --- ends it.
157
+ if (fenceOpen || Object.keys(fm).length) break;
158
+ fenceOpen = true;
159
+ continue;
160
+ }
161
+ const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
162
+ if (m) {
163
+ if (!(m[1] in fm)) fm[m[1]] = m[2].replace(/^"(.*)"$/s, '$1');
164
+ continue;
165
+ }
166
+ if (line.trim()) break;
167
+ }
168
+ return fm;
169
+ }
170
+ function readFrontmatter(file) {
171
+ try { return parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')); } catch { return null; }
172
+ }
173
+ /*
174
+ * The ledger reader, and the one place a torn line is REPORTED rather than merely dropped (R-4).
175
+ *
176
+ * A line that does not parse is still never guessed at — that part was always right. What was wrong
177
+ * was the silence: a truncated acceptance row did not make the ladder say "a row here is
178
+ * unreadable", it made the ladder say the initiative was never accepted. The file's own honesty rule
179
+ * ("a gate that cannot see its inputs reports that it cannot see them") held for git ranges and not
180
+ * for ledger lines.
181
+ *
182
+ * The channel is out-of-band — an optional `defects` array the caller passes in — rather than a
183
+ * changed return type, because every existing call site wants the rows and only the sweep wants the
184
+ * damage. Changing the shape would have made ~six callers handle a case none of them can act on.
185
+ */
186
+ // The implementation lives in scripts/read-jsonl.js now — this contract became the shared one after
187
+ // six hand-rolled readers each re-decided the torn-line question (and two got it wrong). The thin
188
+ // wrapper keeps this file's call sites and the out-of-band defects channel unchanged.
189
+ const jsonl = require('./read-jsonl');
190
+ function readJsonl(file, defects) {
191
+ const r = jsonl.readJsonl(file);
192
+ // Dropped, never guessed — and said out loud to anyone who asked to hear it.
193
+ if (Array.isArray(defects)) defects.push(...r.defects);
194
+ return r.rows;
195
+ }
196
+
197
+ // The ledgers are read once per VERSION OF THE FILE, not once per initiative — the same per-root
198
+ // rule the OKR cache above states, applied to the other four per-root inputs it was skipping. The
199
+ // board filters ~665 KB of parsed rows once per ladder; re-reading and re-parsing the files each
200
+ // time was ~145 MB of work per board. The stamp is mtime+size for the same reason as the OKR
201
+ // cache's: the workspace server holds this module in-process, and a run or acceptance appended
202
+ // between requests must be visible on the next derivation, not after a restart. Callers filter the
203
+ // shared rows and never mutate them.
204
+ const _ledgerCache = new Map(); // absolute path -> { stamp, rows }
205
+ function cachedJsonl(file) {
206
+ const stamp = statStamp(file);
207
+ const hit = _ledgerCache.get(file);
208
+ if (hit && hit.stamp === stamp) return hit.rows;
209
+ const rows = readJsonl(file);
210
+ _ledgerCache.set(file, { stamp, rows });
211
+ return rows;
212
+ }
213
+
214
+ // ── Repo layout — pmos-self and the kit disagree on where state lives, so detect rather than assume
215
+ function layout(root) {
216
+ const selfDir = path.join(root, 'planning', 'initiatives');
217
+ const kitDir = path.join(root, 'pmos', 'state', 'initiatives');
218
+ const isKit = !fs.existsSync(selfDir) && fs.existsSync(kitDir);
219
+ return {
220
+ root,
221
+ kit: isKit,
222
+ initiatives: isKit ? kitDir : selfDir,
223
+ runsDir: isKit ? path.join(root, 'pmos', 'state') : path.join(root, 'planning', 'runs'),
224
+ runsFile: isKit ? 'runs.jsonl' : 'agent-runs.jsonl',
225
+ explainers: isKit
226
+ ? path.join(root, 'pmos', 'state', 'explainers')
227
+ : path.join(root, 'planning', 'evals', 'explainers'),
228
+ budgets: isKit
229
+ ? path.join(root, 'pmos', 'state', 'health-budgets.md')
230
+ : path.join(root, 'planning', 'okrs', 'health-budgets.md'),
231
+ };
232
+ }
233
+
234
+ // ── Git ────────────────────────────────────────────────────────────────────────────────────────
235
+ function git(root, args) {
236
+ try {
237
+ return execFileSync('git', ['-C', root, ...args], {
238
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
239
+ });
240
+ } catch { return null; }
241
+ }
242
+
243
+ /*
244
+ * The commit range this initiative's work lives in, oldest first, as sha -> index.
245
+ *
246
+ * In CI that is base..HEAD for the PR. Locally it is the merge-base with the default branch. When
247
+ * neither resolves — a shallow clone, a detached checkout with no base — the range is NULL and every
248
+ * order check downgrades to `unknown`. It does not downgrade to `pass`: an order rule that cannot be
249
+ * evaluated has not been satisfied, and saying so is the whole point of the honesty rule above.
250
+ */
251
+ function commitRange(root) {
252
+ return buildRange(root, { spec: null });
253
+ }
254
+
255
+ /*
256
+ * The BOARD's range: the whole trunk, not the pull request.
257
+ *
258
+ * These are two different questions and they were sharing an answer. The gate asks "inside this
259
+ * diff, did the spec precede the build?" — only decidable within the PR. The board asks "where does
260
+ * this initiative stand?", which the PR range cannot answer at all: on a branch level with `main`
261
+ * the range is EMPTY, so every initiative reported `no implementation commits in this range` and
262
+ * collapsed to stage `spec`. A delivered initiative rendered identically to one never started.
263
+ *
264
+ * Splitting them means a rule can read `done` on the board and still be evaluated inside the gate.
265
+ * That is intended and is stated in the board's own footer, so it never reads as a discrepancy.
266
+ */
267
+ function historyRange(root) {
268
+ const trunk = ['origin/main', 'main', 'origin/master', 'master']
269
+ .find((c) => git(root, ['rev-parse', '--verify', '--quiet', c]));
270
+ return buildRange(root, { spec: trunk || 'HEAD', whole: true });
271
+ }
272
+
273
+ /*
274
+ * ONE pass over the range, not one git call per question.
275
+ *
276
+ * Two `git log` invocations build every index the ladder needs: which paths each commit CHANGED
277
+ * (for buildCommits) and where each path was ADDED (for firstAppearance). Previously firstAppearance
278
+ * shelled out once per path per initiative and buildCommits ran `git show` once per commit per
279
+ * initiative — 330+ processes for the board on an empty range (23 s), and O(commits × initiatives)
280
+ * on a full one, which is what made full history look impossible rather than merely unimplemented.
281
+ */
282
+ function buildRange(root, { spec, whole }) {
283
+ if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
284
+ return { ok: false, reason: 'shallow clone — no history to order commits by (CI needs fetch-depth: 0)' };
285
+ }
286
+ let base = null;
287
+ let range = spec;
288
+ if (!range) {
289
+ const candidates = [];
290
+ if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
291
+ candidates.push('origin/main', 'main', 'origin/master', 'master');
292
+ for (const c of candidates) {
293
+ if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
294
+ const mb = git(root, ['merge-base', c, 'HEAD']);
295
+ if (mb && mb.trim()) { base = mb.trim(); break; }
296
+ }
297
+ range = base ? `${base}..HEAD` : 'HEAD';
298
+ }
299
+ // %x1e separates commits; %x1f separates the header from the message and the message from
300
+ // --name-only's file list. The MESSAGE is captured in this same pass because attribution over the
301
+ // trunk needs it (R-2) — a second `git log` per initiative is exactly the O(commits × initiatives)
302
+ // cost this single-pass rewrite existed to remove.
303
+ const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct%x1f%s %b%x1f', '--name-only', range]);
304
+ if (log == null) return { ok: false, reason: 'git history unreadable' };
305
+
306
+ const shas = [];
307
+ const changed = []; // index -> string[] of paths touched
308
+ const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
309
+ const subjects = []; // index -> subject + body, one line, for attribution
310
+ for (const chunk of log.split('\x1e')) {
311
+ if (!chunk.trim()) continue;
312
+ const parts = chunk.split('\x1f');
313
+ const head = (parts[0] || '').trim();
314
+ const [sha, ct] = head.split(/\s+/);
315
+ if (!sha) continue;
316
+ shas.push(sha);
317
+ dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
318
+ subjects.push(String(parts[1] || '').replace(/\s+/g, ' ').trim());
319
+ changed.push(String(parts[2] || '').split('\n').map((s) => s.trim()).filter(Boolean));
320
+ }
321
+ const index = new Map();
322
+ shas.forEach((sha, i) => index.set(sha, i));
323
+
324
+ // Where each path was ADDED, as an index into the same range. `--diff-filter=A` in one pass.
325
+ const added = new Map();
326
+ const addLog = git(root, ['log', '--reverse', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]);
327
+ if (addLog != null) {
328
+ for (const chunk of addLog.split('\x1e')) {
329
+ if (!chunk.trim()) continue;
330
+ const lines = chunk.split('\n');
331
+ const sha = (lines.shift() || '').trim();
332
+ const at = index.has(sha) ? index.get(sha) : null;
333
+ if (at == null) continue;
334
+ for (const f of lines.map((s) => s.trim()).filter(Boolean)) {
335
+ if (!added.has(f)) added.set(f, at);
336
+ }
337
+ }
338
+ }
339
+ return { ok: true, base, index, shas, changed, dates, subjects, added, wholeHistory: whole ? true : !base };
340
+ }
341
+
342
+ /*
343
+ * Where a path FIRST APPEARED, as an index into the range.
344
+ * -1 it already existed before the range began
345
+ * 0..n the position of the commit that added it
346
+ * Infinity it exists on disk but no commit has added it yet (uncommitted)
347
+ * undefined it exists, but its position is unknowable (no usable history)
348
+ * null it does not exist at all
349
+ *
350
+ * `undefined` and `-1` are kept apart on purpose. "Already existed" is a fact that satisfies an
351
+ * ordering rule; "I cannot tell" is not, and collapsing the second into the first is exactly how a
352
+ * shallow clone would turn every order check into a silent pass.
353
+ */
354
+ function firstAppearance(root, range, rel) {
355
+ const onDisk = fs.existsSync(path.join(root, rel));
356
+ if (!range.ok) return onDisk ? undefined : null;
357
+ // The single-pass index answers first, and over the whole trunk it answers everything that has
358
+ // ever been committed — so the fallback below only runs for a PR range, where "added before the
359
+ // range" and "never committed" are genuinely different answers and only git can tell them apart.
360
+ if (range.added && range.added.has(rel)) return range.added.get(rel);
361
+ const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
362
+ const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
363
+ if (!sha) return onDisk ? Infinity : null;
364
+ return range.index.has(sha) ? range.index.get(sha) : -1;
365
+ }
366
+
367
+ /*
368
+ * When was each path last touched, answered from the range's OWN single pass. A `git log -1
369
+ * --format=%ct -- <paths>` per initiative was one subprocess per board row — ~85% of the board's
370
+ * runtime spent re-reading history buildRange had already captured (see the single-pass note
371
+ * above). Built lazily, memoised on the range object, so every ladder on the board shares one map.
372
+ */
373
+ function touchIndex(range) {
374
+ if (!range._touch) {
375
+ const m = new Map();
376
+ for (let i = 0; i < range.shas.length; i++) {
377
+ const t = range.dates[i];
378
+ if (!Number.isFinite(t)) continue;
379
+ for (const f of (range.changed[i] || [])) {
380
+ const prev = m.get(f);
381
+ if (prev === undefined || t > prev) m.set(f, t);
382
+ }
383
+ }
384
+ range._touch = m;
385
+ }
386
+ return range._touch;
387
+ }
388
+
389
+ // The set of paths git tracks, one `ls-files` shared across the board via the range object. Used
390
+ // only where a range is EMPTY — see the note at the chgIdx computation. `null` = git could not
391
+ // answer, which callers must treat as "fall back", never as "nothing is tracked".
392
+ function trackedIn(root, range) {
393
+ if (range._tracked === undefined) {
394
+ const out = git(root, ['ls-files', '-z']);
395
+ range._tracked = out == null ? null : new Set(out.split('\0').filter(Boolean));
396
+ }
397
+ return range._tracked;
398
+ }
399
+
400
+ /*
401
+ * The earliest position in the range at which this initiative's implementation was touched, plus a
402
+ * count. A docs-only PR has no build commits, which makes every "before the build" rule vacuous —
403
+ * reported as n/a rather than as a pass it did not earn.
404
+ *
405
+ * ATTRIBUTION (R-2). This function used to take no id, so it answered a question about the RANGE
406
+ * while both of its callers asked one about an INITIATIVE. Which of those is right depends entirely
407
+ * on what the range is, and the two cases are answered differently on purpose:
408
+ *
409
+ * · A PULL-REQUEST RANGE (`base..HEAD`) — the range IS this initiative's work, by the branch
410
+ * convention the Initiative Gate already enforces. Any build commit in it is therefore this
411
+ * initiative's build commit, and the O1/O2 order rules depend on exactly that reading. This path
412
+ * is UNCHANGED, deliberately: the gate's verdicts must not move for a reporting fix.
413
+ *
414
+ * · THE WHOLE TRUNK (`range.wholeHistory`) — the range is everyone's work. Attribution comes from
415
+ * `delivery-index`: a merged `initiative/<id>` branch, or a commit that names the id and touches
416
+ * a build path. Without it every initiative reported `done` with the identical count, which is
417
+ * the tell that a per-initiative fact was being read from a repository-wide one.
418
+ *
419
+ * `signal` travels back with the answer so the rung can say WHICH evidence decided it. A bare count
420
+ * is what let 488 stand as an explanation for 177 different initiatives.
421
+ */
422
+ function buildCommits(root, range, lane, opts) {
423
+ if (!range.ok) return { unknown: true };
424
+ const id = opts && opts.id;
425
+ const recordIdx = opts && opts.recordIdx;
426
+ const whole = !!range.wholeHistory;
427
+
428
+ // Over the trunk, the id must be attributable before any commit counts. The index is built once
429
+ // per trunk head by the shared reader and passed in by the board; a caller that did not supply one
430
+ // gets it built here (single-ladder invocations, where one extra `git log` is the whole cost).
431
+ let signal = null;
432
+ let declared = null;
433
+ if (whole && id) {
434
+ // The PM's declaration is consulted FIRST, and its failure is reported before anything else
435
+ // (R-2b). A `delivered_by` that does not check out must not be able to hide behind a convention
436
+ // signal that happens to succeed: a broken declaration is a defect in the record either way, and
437
+ // silently ignoring it would leave a false pointer sitting in the file forever.
438
+ declared = delivery.declaredDelivery(root, opts && opts.declaredDelivery);
439
+ if (declared && declared.declared && !declared.ok) {
440
+ return { unknown: false, failedDeclaration: declared, first: null, count: 0, sample: null, signal: null, attributed: true };
441
+ }
442
+ const index = (opts && opts.deliveryIndex) || delivery.deliveryIndex(root, listInitiatives(root), { recordsDir: layout(root).initiatives });
443
+ signal = delivery.attribution(index, id);
444
+ if (declared && declared.ok) {
445
+ // A verified declaration attributes the work regardless of what the conventions found. It is
446
+ // the PM naming the commit, checked against git — strictly better evidence than a heuristic.
447
+ return { first: null, count: 0, sample: null, signal: 'declared', declaredSha: declared.sha, attributed: true, mergedOnly: true };
448
+ }
449
+ if (signal === 'unknown') return { unknown: true, reason: delivery.attributionEvidence('unknown', id) };
450
+ if (!signal) return { first: null, count: 0, sample: null, signal: null, attributed: true };
451
+ }
452
+
453
+ let first = null; let count = 0; let sample = null;
454
+ for (let i = 0; i < range.shas.length; i++) {
455
+ // range.changed came from the range's own single pass; the per-commit `git show` this replaced
456
+ // ran once per commit PER INITIATIVE, which is why the board took 23 s before it read anything.
457
+ const files = (range.changed && range.changed[i]) || [];
458
+ const hit = files.find((f) => isBuildPath(f, lane));
459
+ if (!hit) continue;
460
+ // Over the trunk, only commits that carry the id themselves are this initiative's. The branch
461
+ // signal is a MERGE subject, so the merge commit is the one that names it — which makes the
462
+ // merge position the honest "when did this land", and keeps a squashed or rebased history
463
+ // answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
464
+ // initiative is counted from its merge subject below rather than from this per-file loop.
465
+ if (whole && id && !commitNamesId(range, i, id)) continue;
466
+ /* Inside a PR RANGE the id filter above does not apply, because a pull request was assumed to
467
+ * carry one initiative and everything in it to be that initiative's build. A branch carrying
468
+ * several breaks that: PR #287 held five, and the FIRST commit on it — a D01 propagation touching
469
+ * supabase/functions/mcp/index.ts — was attributed to `sink-discipline-backport`, whose record did
470
+ * not yet exist. The gate reported ORDER VIOLATION, spec-after-build, for an initiative whose spec
471
+ * demonstrably preceded its build by three commits.
472
+ *
473
+ * A commit made BEFORE an initiative's record first appeared cannot be that initiative's
474
+ * implementation — the initiative did not exist to be built. That is the whole rule, and it is
475
+ * narrow on purpose: an implementation commit landing AFTER the record but BEFORE the PRD still
476
+ * fails, which is the case D13 actually exists to catch. This removes a false violation without
477
+ * removing a true one. */
478
+ if (!whole && recordIdx != null && recordIdx !== undefined && recordIdx >= 0 && i < recordIdx) continue;
479
+ count++;
480
+ if (first === null) { first = i; sample = hit; }
481
+ }
482
+ if (whole && id && (signal === 'branch' || signal === 'change') && first === null) {
483
+ // The branch merged and every commit it carried was written on the branch, so no trunk commit
484
+ // subject names the id. The merge itself is the evidence, and saying "0 commits" over a merged
485
+ // branch would be the under-claim in a new place.
486
+ return { first: null, count: 0, sample: null, signal, attributed: true, mergedOnly: true };
487
+ }
488
+ return { first, count, sample, signal, attributed: whole && !!id };
489
+ }
490
+
491
+ // Whether the commit at `i` names this initiative in its own subject or body. The subjects come from
492
+ // the range's own single pass, so this costs nothing per initiative.
493
+ function commitNamesId(range, i, id) {
494
+ const msg = (range.subjects && range.subjects[i]) || '';
495
+ return msg.includes(id);
496
+ }
497
+
498
+ // ── Declaration readers ────────────────────────────────────────────────────────────────────────
499
+ // Both keys are a LEADING TOKEN from a closed set followed by free prose. The token is what the gate
500
+ // reads; the prose is the agent's reasoning for the PM. Closed-enum-only was rejected: D60's 2026-07-24
501
+ // amendment widened the lane past UI work precisely because the interesting part is the reasoning.
502
+ function specimenRoute(v) {
503
+ const t = String(v || '').trim().toLowerCase();
504
+ if (!t) return 'undeclared';
505
+ if (t.startsWith('needs-instance')) return 'instance';
506
+ if (t.startsWith('description-judgeable')) return 'description';
507
+ return 'malformed';
508
+ }
509
+ // The `priors` citation on a run record — the episodic read AGENTS.md step 3 mandates, written down.
510
+ // Two shapes are accepted deliberately: the bare list (`priors: ["run-a"]`) for the common case, and
511
+ // the object form (`priors: {consulted: [...], note: "…"}`) when the run needs to say WHY it consulted
512
+ // nothing. Anything else returns `malformed` rather than falling through to "absent" — a field that is
513
+ // present but unreadable must never be scored the same as a field nobody wrote, because the two call
514
+ // for opposite fixes.
515
+ function priorsCitation(run) {
516
+ if (!run || !('priors' in run) || run.priors === null) return { declared: false };
517
+ const p = run.priors;
518
+ if (Array.isArray(p)) return { declared: true, consulted: p.map((x) => String(x).trim()), note: '' };
519
+ if (typeof p === 'object') {
520
+ if (!('consulted' in p)) return { declared: true, malformed: 'priors object has no `consulted` key' };
521
+ if (!Array.isArray(p.consulted)) return { declared: true, malformed: '`priors.consulted` is not a list of run ids' };
522
+ return {
523
+ declared: true,
524
+ consulted: p.consulted.map((x) => String(x).trim()),
525
+ note: String(p.note == null ? '' : p.note).trim(),
526
+ };
527
+ }
528
+ return { declared: true, malformed: 'priors is neither a list of run ids nor {consulted, note}' };
529
+ }
530
+
531
+ function surfaceRoute(v) {
532
+ const t = String(v || '').trim().toLowerCase();
533
+ if (!t) return 'undeclared';
534
+ if (t.startsWith('existing')) return 'existing';
535
+ if (t.startsWith('greenfield')) return 'greenfield';
536
+ return 'malformed';
537
+ }
538
+
539
+ // ── Repository profile (D68) ──────────────────────────────────────────────────────────────────
540
+ // A repository declares `pm-artifacts` (a PM's documents, strategy and specifications — no code) or
541
+ // `product-code` (PMOS-self and code repositories) in a one-line `.pmos-profile` file at its root.
542
+ // No file, or anything else in it, is `product-code`: every existing repository behaves as before.
543
+ // D77 — the lane lists, tracks and rung sets are DATA in scripts/lane-defs.js. This file reads them;
544
+ // it holds no lane list of its own, so editing a definition changes the ladder with no edit here.
545
+ const laneDefs = require('./lane-defs');
546
+ function profileOf(root) {
547
+ try {
548
+ const line = fs.readFileSync(path.join(root, '.pmos-profile'), 'utf8')
549
+ .split('\n').map((l) => l.replace(/#.*$/, '').trim()).find(Boolean);
550
+ return line === 'pm-artifacts' ? 'pm-artifacts' : 'product-code';
551
+ } catch { return 'product-code'; }
552
+ }
553
+
554
+ /** The lanes a repository's initiatives may declare — what the lane picker offers and the lane write accepts. */
555
+ function laneNames(profile) { return laneDefs.laneNames(profile === 'pm-artifacts' ? 'pm-artifacts' : 'product-code'); }
556
+ function lanesFor(root) {
557
+ const profile = profileOf(path.resolve(root || process.cwd()));
558
+ return { profile, lanes: laneNames(profile), defs: laneDefs.defsFor(profile) };
559
+ }
560
+
561
+ // ── Step model ─────────────────────────────────────────────────────────────────────────────────
562
+ // status ∈ done | current | blocked | pending | na | unknown
563
+ // `blocking` marks a step whose failure fails CI. O5/O6/O7 are deliberately NOT blocking: none can
564
+ // be true before the diff exists, and a rule enforced at a moment it cannot be satisfied only
565
+ // teaches people to reach for the escape hatch.
566
+ function step(key, label, status, evidence, next, opts) {
567
+ return {
568
+ key, label, status, evidence: evidence || null, next: next || null,
569
+ blocking: !!(opts && opts.blocking), rule: (opts && opts.rule) || null,
570
+ };
571
+ }
572
+
573
+ /*
574
+ * D77 — the basis of every step: the rule that decides it, and what it read.
575
+ *
576
+ * `rule` is one sentence from lane-defs.js (the engine's test, restated for a PM). `file` is a
577
+ * repository path the step read, and is set ONLY when that file exists — a path the engine did not
578
+ * find is never offered as a basis. `source` says what kind of thing decided the step: a `file`, a
579
+ * `ledger` (a .jsonl row), `git` (commit history) or `none`. `first_seen` is the date the file first
580
+ * appeared, read from the range's own index where it holds one; it is null rather than guessed.
581
+ */
582
+ function attachBasis(steps, c) {
583
+ const { profile, root, L, fm, recRel, range, hasEvals, hasAcceptance } = c;
584
+ const rel = (abs) => path.relative(root, abs);
585
+ const runs = rel(path.join(L.runsDir, L.runsFile));
586
+ const evalsLedger = rel(path.join(L.runsDir, 'evals.jsonl'));
587
+ const acceptLedger = rel(path.join(L.runsDir, 'acceptances.jsonl'));
588
+ const outcomeLedger = rel(path.join(L.runsDir, 'outcome_checks.jsonl'));
589
+ const exists = (p) => !!p && fs.existsSync(path.join(root, p));
590
+ const inEvidence = (st) => {
591
+ const m = String(st.evidence || '').match(/[\w./-]+\.(?:md|jsonl)\b/);
592
+ return m && exists(m[0]) ? m[0] : null;
593
+ };
594
+ const prd = String(fm.prd_path || '').trim();
595
+ const pick = {
596
+ 'pm-artifacts': {
597
+ route: [recRel], anchor: [recRel],
598
+ brief: [String(fm.brief_path || fm.prd_path || '').trim()], draft: [String(fm.doc_path || '').trim()],
599
+ review: [hasEvals ? evalsLedger : recRel], approved: [acceptLedger], handoff: [recRel],
600
+ },
601
+ 'product-code': {
602
+ route: [recRel], anchor: [recRel], prototype: [prd], understand: [String(fm.explainer_path || '').trim()],
603
+ spec: [prd, fm.lane === 'chore' ? recRel : null], priors: [runs], contract: [runs], build: [], quality: [],
604
+ review: [hasEvals ? evalsLedger : null], explain: [], accept: [hasAcceptance ? acceptLedger : null],
605
+ outcome: [outcomeLedger],
606
+ },
607
+ }[profile] || {};
608
+ for (const st of steps) {
609
+ let file = null;
610
+ for (const cand of (pick[st.key] || [])) if (exists(cand)) { file = cand; break; }
611
+ if (!file && st.status === 'done') file = inEvidence(st);
612
+ if (st.status === 'na') file = null;
613
+ let source = 'none';
614
+ if (file) source = file.endsWith('.jsonl') ? 'ledger' : 'file';
615
+ else if (st.key === 'build' && st.status === 'done') source = 'git';
616
+ let firstSeen = null;
617
+ if (file && source === 'file' && range && range.ok && range.dates) {
618
+ const idx = firstAppearance(root, range, file);
619
+ const t = typeof idx === 'number' && idx >= 0 ? range.dates[idx] : null;
620
+ if (t) firstSeen = new Date(t * 1000).toISOString().slice(0, 10);
621
+ }
622
+ st.basis = { rule: laneDefs.rungRule(profile, st.key), file, source, first_seen: firstSeen };
623
+ }
624
+ return steps;
625
+ }
626
+
627
+ // D74 — `generated: {by: x, at: "2026-10-02T01:00:00Z"}` on one line (the only form PMOS writes). Returns the `at`, or ''.
628
+ function generatedAt(v) {
629
+ const m = /\bat:\s*["']?([^"',}\s]+)/.exec(String(v || ''));
630
+ return m ? m[1] : '';
631
+ }
632
+ function computeWorkflowDerived(id, opts) {
633
+ const root = path.resolve((opts && opts.root) || process.cwd());
634
+ const L = layout(root);
635
+ const recRel = path.relative(root, path.join(L.initiatives, `${id}.md`));
636
+ const recAbs = path.join(L.initiatives, `${id}.md`);
637
+ if (!fs.existsSync(recAbs)) {
638
+ return { ok: false, id, error: `no initiative record at ${recRel} — an initiative must exist before its work` };
639
+ }
640
+ const fm = readFrontmatter(recAbs) || {};
641
+ const profile = profileOf(root);
642
+ const pmProfile = profile === 'pm-artifacts';
643
+ const lane = (fm.lane || (pmProfile ? 'prd' : 'feature')).trim() || (pmProfile ? 'prd' : 'feature');
644
+ const knownLane = pmProfile ? laneNames('pm-artifacts').includes(lane)
645
+ : laneNames('product-code').includes(lane);
646
+ // unknown lane = the profile's strictest (feature in product-code, a governed prd in pm-artifacts)
647
+ const effLane = knownLane ? lane : (pmProfile ? 'prd' : 'feature');
648
+ const range = (opts && opts.range) || commitRange(root);
649
+ // See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
650
+ //
651
+ // `recIdx === -1` — "the record predates this range" — is the right test for a PR range and a
652
+ // MEANINGLESS one over the whole trunk, where nothing predates the range and every legacy record
653
+ // would suddenly be enforced. So the commit DATE decides whenever the range can supply it, and
654
+ // -1 keeps its meaning for the gate's range. Same question, asked of evidence that survives both.
655
+ const recIdx = firstAppearance(root, range, recRel);
656
+ const recAt = (range.dates && typeof recIdx === 'number' && recIdx >= 0) ? range.dates[recIdx] : null;
657
+ /*
658
+ * `reconstructed_from` — the merge date of work whose record was written afterwards.
659
+ *
660
+ * A record backfilled today for a PR that merged in June has TODAY's commit date, so the date
661
+ * test would enforce today's rules on work that predates them and report a violation nobody
662
+ * could ever have avoided. The declared merge date decides instead.
663
+ *
664
+ * This is not an escape hatch: it must name a date, that date is checkable against the merge it
665
+ * claims, and declaring one AFTER `ENFORCED_FROM` makes the record non-legacy — so work that
666
+ * genuinely skipped the loop still reports blocked, which is the point of writing it down.
667
+ */
668
+ const recon = String(fm.reconstructed_from || '').trim().slice(0, 10);
669
+ const legacy = recIdx === -1 ? true
670
+ : /^\d{4}-\d{2}-\d{2}$/.test(recon) ? recon < ENFORCED_FROM
671
+ : recAt ? new Date(recAt * 1000).toISOString().slice(0, 10) < ENFORCED_FROM
672
+ : String(fm.timestamp || generatedAt(fm.generated) || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM; D74: OKF v0.2 `generated.at` stands in for `timestamp`
673
+ /*
674
+ * `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
675
+ * question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
676
+ * board and the workspace panel both read "0 in flight" against 93 initiatives — the surface was
677
+ * blank at exactly the moment someone opens it to ask where they are.
678
+ *
679
+ * The two axes below are what the board actually orders by. `inChange` is precise (git says so);
680
+ * `lastActivity` is the honest fallback for everything else, because "when was this last worked
681
+ * on" is a fact the repo already holds and nobody has to maintain.
682
+ */
683
+ // `● in this change` must keep meaning "the diff under review touches this". Over the whole trunk
684
+ // that is true of everything, which makes the marker noise — so the board passes the GATE's range
685
+ // separately and the marker is answered from that, or dropped when there is no such range.
686
+ const changeRange = (opts && opts.changeRange) || (range.wholeHistory ? null : range);
687
+ // A ZERO-COMMIT range contains nothing, so firstAppearance's per-path fallback — a full-history
688
+ // `git log --diff-filter=A`, once per initiative on any full clone whose base..HEAD is empty
689
+ // (HEAD level with main) — can only conclude what one shared `ls-files` already says: a tracked
690
+ // record predates the range (-1), an on-disk-untracked one is uncommitted (Infinity). The gate's
691
+ // range — non-empty — keeps the fallback, which is what tells "added before the range" from
692
+ // "never committed" for O1/O2.
693
+ const emptyChange = !!changeRange && changeRange.ok && changeRange.shas.length === 0;
694
+ const tracked = emptyChange ? trackedIn(root, changeRange) : null;
695
+ const chgIdx = !changeRange ? null
696
+ : tracked ? (tracked.has(recRel) ? -1 : fs.existsSync(recAbs) ? Infinity : null)
697
+ : firstAppearance(root, changeRange, recRel);
698
+ const inChange = !!changeRange && chgIdx !== -1 && chgIdx !== null && chgIdx !== undefined;
699
+ const lastActivity = (() => {
700
+ const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
701
+ // The board supplies the trunk range, whose single pass already holds every commit's paths and
702
+ // dates — so the answer is a map lookup, not a subprocess. The trunk alone would demote
703
+ // branch-fresh work (`git log -1` reads from HEAD, which sees branch-local commits the trunk
704
+ // range excludes), so the gate's base..HEAD range is overlaid, max date per path. The
705
+ // subprocess survives only where no whole-history range was supplied — the single-ladder call.
706
+ // One deliberate difference from the per-path subprocess: a true merge lists no files in the
707
+ // single pass, so a merge that merely reconciles histories no longer counts as activity on the
708
+ // paths it carried — the date it stamped was the sync's, not the work's.
709
+ if (opts && opts.range && range.ok && range.wholeHistory) {
710
+ const trunk = touchIndex(range);
711
+ const branch = changeRange && changeRange.ok ? touchIndex(changeRange) : null;
712
+ let best = null;
713
+ for (const p of paths) {
714
+ const t = Math.max(trunk.get(p) || 0, (branch && branch.get(p)) || 0);
715
+ if (t > 0 && (best === null || t > best)) best = t;
716
+ }
717
+ return best;
718
+ }
719
+ const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
720
+ const t = out && parseInt(out.trim(), 10);
721
+ return Number.isFinite(t) ? t : null;
722
+ })();
723
+ const steps = [];
724
+ // Steps the PM cut or made optional on 2026-10-07 (planning/initiatives/sdlc-step-cuts.md).
725
+ const CUT_NOTE = 'cut by the PM 2026-10-07 (sdlc-step-cuts)';
726
+
727
+ // ── route ─ the lane is chosen and the two intake questions are answered ─────────────────────
728
+ const sRoute = specimenRoute(fm.specimen_test);
729
+ const fRoute = surfaceRoute(fm.surface_check);
730
+ {
731
+ const missing = [];
732
+ if (sRoute === 'undeclared' || sRoute === 'malformed') missing.push('specimen_test');
733
+ if (fRoute === 'undeclared' || fRoute === 'malformed') missing.push('surface_check');
734
+ if (!missing.length) {
735
+ steps.push(step('route', 'route', 'done',
736
+ `${lane} lane · specimen test ${sRoute} · surface ${fRoute}`));
737
+ } else if (effLane !== 'feature') {
738
+ // The routing questions are asked of feature work only.
739
+ steps.push(step('route', 'route', 'done', `${lane} lane — routing questions are for features only (${CUT_NOTE})`));
740
+ } else if (legacy) {
741
+ steps.push(step('route', 'route', 'unknown',
742
+ `${lane} lane · ${missing.join(' + ')} not declared (record predates ${ENFORCED_FROM})`));
743
+ } else {
744
+ const how = missing.map((k) => k === 'specimen_test'
745
+ ? 'specimen_test: "description-judgeable — …" or "needs-instance — …"'
746
+ : 'surface_check: "greenfield — …" or "existing — …"').join('; ');
747
+ steps.push(step('route', 'route', 'blocked',
748
+ `${missing.join(' + ')} ${missing.length > 1 ? 'are' : 'is'} ${sRoute === 'malformed' || fRoute === 'malformed' ? 'malformed' : 'not declared'}`,
749
+ `run the pmos-router skill and record its answers in ${recRel} — ${how}`,
750
+ { blocking: true, rule: 'R0' }));
751
+ }
752
+ }
753
+
754
+ // ── anchor ─ every initiative anchors to a KR or a declared health budget (D59) ──────────────
755
+ {
756
+ const anchor = String(fm.parent_kr || '').trim();
757
+ const r = anchors.resolveAnchor(anchor, okrIndexFor(root), budgetsFor(root, L));
758
+ const budRel = path.relative(root, L.budgets);
759
+ if (r.kind === 'empty') {
760
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
761
+ 'parent_kr is empty — unanchored work is rejected (D59)',
762
+ `set parent_kr in ${recRel} to a KR, or to health:<id> from ${budRel}`,
763
+ { blocking: !legacy, rule: 'R1' }));
764
+ } else if (r.status === 'unknown') {
765
+ steps.push(step('anchor', 'anchor', 'unknown', `${anchor} — ${r.reason}`));
766
+ } else if (r.kind === 'health') {
767
+ if (r.status === 'resolved') {
768
+ steps.push(step('anchor', 'anchor', 'done', `health:${r.budget} — declared in ${budRel}`));
769
+ } else {
770
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
771
+ `health:${r.budget} is not a declared budget`,
772
+ `only the PM adds a budget — use an existing id from ${budRel} or anchor to a KR`,
773
+ { blocking: !legacy, rule: 'R1' }));
774
+ }
775
+ } else if (r.kind === 'domain') {
776
+ // D67 — the domain's standing goal: resolved like a health budget, rejected like one when the
777
+ // id is not in the PM-edited registry.
778
+ const domRel = (budgetsFor(root, L).domains || {}).path || 'planning/okrs/domains.md';
779
+ if (r.status === 'resolved') {
780
+ steps.push(step('anchor', 'anchor', 'done', `domain:${r.domain} — ${r.domain_goal || 'standing goal not written'} (no OKR, D67)`));
781
+ } else {
782
+ steps.push(step('anchor', 'anchor', legacy ? 'unknown' : 'blocked',
783
+ `domain:${r.domain} is not a declared domain`,
784
+ `only the PM adds a domain — use an id from ${domRel}, or anchor to a KR or a health budget`,
785
+ { blocking: !legacy, rule: 'R1' }));
786
+ }
787
+ } else if (r.status === 'resolved') {
788
+ steps.push(step('anchor', 'anchor', 'done',
789
+ `${anchors.canonical(r, (okrIndexFor(root) || {}).period) || anchor} — ${r.reason}`));
790
+ } else {
791
+ // REPORTED, NEVER CI-BLOCKING (spec assumption A1). The anchor names an objective or KR the
792
+ // OKR documents do not declare — which is exactly the class that went unnoticed for two days
793
+ // (`q3-2026:integrity:kr-a` and `:kr-b` were anchored by three initiatives and written in no
794
+ // file). It is shown as a failed step so it cannot hide, and deliberately does NOT fail CI:
795
+ // making it blocking would turn one typo into a hard failure on every unrelated PR, and the
796
+ // recorded intent put the RED in the surface, not the gate. Raising it to blocking is a
797
+ // separate PM decision (spec Open questions), not a thing to slip in with a resolver.
798
+ steps.push(step('anchor', 'anchor', 'blocked',
799
+ `${anchor} — ${r.reason}`,
800
+ `write an anchor that names something the OKR documents declare, or add the KR (a PM act) — see ${budRel} for the health form`,
801
+ { blocking: false, rule: 'R1' }));
802
+ }
803
+ }
804
+
805
+ // ── D68: the pm-artifacts ladder replaces everything after the anchor ─────────────────────────
806
+ if (pmProfile) {
807
+ return pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, recRel, range, inChange, lastActivity, opts });
808
+ }
809
+
810
+ const prd = String(fm.prd_path || '').trim();
811
+ const rubric = String(fm.rubric_path || '').trim();
812
+ const prdIdx = prd ? firstAppearance(root, range, prd) : null;
813
+ const build = buildCommits(root, range, effLane, {
814
+ id,
815
+ // Where this initiative's own record first appears in the range — the earliest commit that could
816
+ // possibly be its build. Already computed above as `recIdx`, from identical arguments.
817
+ // See the note in buildCommits' attribution loop.
818
+ recordIdx: recIdx,
819
+ deliveryIndex: opts && opts.deliveryIndex,
820
+ declaredDelivery: fm.delivered_by,
821
+ });
822
+
823
+ // Shared helper: "A must not come after B", stated as positions, never as clock time.
824
+ function ordered(aIdx, bIdx) {
825
+ // null = absent (a different step's problem); undefined = position unknowable. Neither is a pass.
826
+ if (aIdx == null || bIdx == null) return null;
827
+ return aIdx <= bIdx;
828
+ }
829
+
830
+ // ── prototype ─ O3: a "needs-instance" answer obliges a prototype BEFORE the PRD (D60) ───────
831
+ {
832
+ const pred = String(fm.predecessor || '').trim();
833
+ // The lane is part of the question, not just the answer. O3 asks "does a lookable instance have
834
+ // to precede this spec?" — and for a PROTOTYPE-lane record the instance IS what this record
835
+ // produces (D60). Demanding a predecessor of the leg that answers the specimen test is not
836
+ // merely wrong, it is unsatisfiable: a prototype leg has no leg before it. The file already
837
+ // holds this same fact twice — `isBuildPath` (:93) and the `review` step both read
838
+ // `lane === 'prototype'` — so this is the third place, not a new idea.
839
+ // The other three O3 branches are untouched: a FEATURE record still owes its prototype, named,
840
+ // of the right lane, and briefed before its PRD.
841
+ if (sRoute !== 'instance' || effLane === 'prototype') {
842
+ steps.push(step('prototype', 'prototype', 'na',
843
+ effLane === 'prototype'
844
+ ? 'prototype lane — this record IS the leg the specimen test asks for (D60), so nothing precedes it'
845
+ : sRoute === 'description' ? 'specimen test says description-judgeable — routed straight to spec, untaxed'
846
+ : 'specimen test not declared'));
847
+ } else if (!pred) {
848
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
849
+ 'specimen_test says needs-instance but no predecessor is named',
850
+ 'run the prototype lane first (skills/core/prototype.skill), then set predecessor: <prototype-id>',
851
+ { blocking: !legacy, rule: 'O3' }));
852
+ } else {
853
+ const predAbs = path.join(L.initiatives, `${pred}.md`);
854
+ const predFm = readFrontmatter(predAbs);
855
+ if (!predFm) {
856
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
857
+ `predecessor '${pred}' has no initiative record at ${path.relative(root, predAbs)}`,
858
+ 'name a prototype-lane initiative that exists, or correct predecessor:',
859
+ { blocking: !legacy, rule: 'O3' }));
860
+ } else if ((predFm.lane || '').trim() !== 'prototype') {
861
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
862
+ `predecessor '${pred}' is lane '${predFm.lane || 'feature'}', not prototype`,
863
+ 'the specimen test is answered by a prototype leg — point predecessor: at one',
864
+ { blocking: !legacy, rule: 'O3' }));
865
+ } else {
866
+ const brief = String(predFm.prd_path || '').trim();
867
+ const briefIdx = brief ? firstAppearance(root, range, brief) : null;
868
+ const ok = ordered(briefIdx, prdIdx);
869
+ if (ok === null) {
870
+ steps.push(step('prototype', 'prototype', 'unknown',
871
+ `predecessor ${pred} present; order undecidable (${range.ok ? 'a path is missing' : range.reason})`));
872
+ } else if (ok) {
873
+ steps.push(step('prototype', 'prototype', 'done', `${pred} — brief ${brief} first-appeared before ${prd}`));
874
+ } else {
875
+ steps.push(step('prototype', 'prototype', legacy ? 'unknown' : 'blocked',
876
+ `ORDER VIOLATION — the PRD (${prd}) first-appeared BEFORE the prototype brief (${brief})`,
877
+ 'the prototype is what validates the direction the PRD then specifies — it cannot follow it (D60)',
878
+ { blocking: !legacy, rule: 'O3' }));
879
+ }
880
+ }
881
+ }
882
+ }
883
+
884
+ // ── understand ─ O4, made optional (sdlc-step-cuts) ───────────────────────────────────────
885
+ // An explain-surface pass that exists is shown; a missing one never blocks.
886
+ {
887
+ const expl = String(fm.explainer_path || '').trim();
888
+ if (expl && fs.existsSync(path.join(root, expl))) {
889
+ steps.push(step('understand', 'understand', 'done', `${expl} — optional surface explainer`));
890
+ } else {
891
+ steps.push(step('understand', 'understand', 'na', `the surface explainer is optional — ${CUT_NOTE}`));
892
+ }
893
+ }
894
+
895
+ // ── spec ─ the lane's artifacts, and O1/O2: they precede the build ───────────────────────────
896
+ // A feature PRD's done-criteria section can serve as its rubric (sdlc-step-cuts), so a separate
897
+ // rubric file is optional in every lane. One that is declared must still exist.
898
+ const needsRubric = false;
899
+ {
900
+ const wants = {
901
+ feature: 'a PRD', discovery: 'a spike brief', incident: 'an incident record',
902
+ content: 'a content brief', prototype: 'a quick prototype brief',
903
+ }[effLane];
904
+ if (effLane === 'chore') {
905
+ const intent = String(fm.status || '').trim();
906
+ steps.push(intent
907
+ ? step('spec', 'spec', 'done', 'chore lane — intent stated in the record\'s status')
908
+ : step('spec', 'spec', 'blocked', 'chore lane requires a one-paragraph intent in status:',
909
+ `write the intent in ${recRel} — anchor-free trivia takes the no-initiative label instead`,
910
+ { blocking: true, rule: 'R2' }));
911
+ } else if (!prd) {
912
+ steps.push(step('spec', 'spec', 'blocked', `no prd_path — the ${effLane} lane needs ${wants}`,
913
+ effLane === 'feature'
914
+ ? 'author the PRD (skills/core/prd.skill) with its done-criteria (D13), then set prd_path'
915
+ : `author ${wants} and set prd_path in ${recRel}`,
916
+ { blocking: true, rule: 'R2' }));
917
+ } else if (prdIdx === null) {
918
+ steps.push(step('spec', 'spec', 'blocked', `prd_path '${prd}' does not exist in the repo`,
919
+ 'create the file the record points at, or correct prd_path',
920
+ { blocking: true, rule: 'R2' }));
921
+ } else if (needsRubric && !rubric) {
922
+ steps.push(step('spec', 'spec', 'blocked', 'no rubric_path — the feature lane writes the eval before the build (D13)',
923
+ 'author the rubric (skills/core/eval-rubric.skill) and set rubric_path',
924
+ { blocking: true, rule: 'R2' }));
925
+ } else if (rubric && !fs.existsSync(path.join(root, rubric))) {
926
+ steps.push(step('spec', 'spec', 'blocked', `rubric_path '${rubric}' does not exist in the repo`,
927
+ 'optional never means dangling — point rubric_path at a real file or clear it',
928
+ { blocking: true, rule: 'R2' }));
929
+ } else {
930
+ // Artifacts are present. Now the order rules the old gate could not see.
931
+ const rubricIdx = rubric ? firstAppearance(root, range, rubric) : null;
932
+ const specIdx = rubricIdx == null ? prdIdx : Math.max(prdIdx, rubricIdx);
933
+ const specNames = rubric ? `${prd} + ${rubric}` : prd;
934
+ if (specIdx === undefined || (rubricIdx === undefined)) {
935
+ steps.push(step('spec', 'spec', 'unknown', `${specNames} present — order vs the build undecidable (${range.reason || 'no usable history'})`));
936
+ } else if (build.unknown) {
937
+ steps.push(step('spec', 'spec', 'unknown', `${specNames} present — order vs the build undecidable (${range.reason})`));
938
+ } else if (build.first === null) {
939
+ steps.push(step('spec', 'spec', 'done', `${specNames} present — no implementation commits in this range, so nothing can precede them`));
940
+ } else if (specIdx <= build.first) {
941
+ steps.push(step('spec', 'spec', 'done', `${specNames} — first-appeared at or before the first build commit`));
942
+ } else {
943
+ const late = rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? rubric : prd;
944
+ // `legacy` applies here exactly as it does to O4. It always should have — the rule simply
945
+ // never fired for a legacy record while the board shared the gate's PR range, because an
946
+ // empty range has no build commit to be out of order with. Reading the trunk exposed it:
947
+ // 81 records that predate enforcement were suddenly failing a rule written after them.
948
+ steps.push(step('spec', 'spec', legacy ? 'unknown' : 'blocked',
949
+ `ORDER VIOLATION — implementation (${build.sample}) was committed BEFORE ${late} first appeared`,
950
+ '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',
951
+ { blocking: !legacy, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
952
+ }
953
+ }
954
+ }
955
+
956
+ const runs = cachedJsonl(path.join(L.runsDir, L.runsFile)).filter((r) => r && r.initiative_id === id);
957
+ const runsRel = path.relative(root, path.join(L.runsDir, L.runsFile));
958
+
959
+ // ── priors ─ O7, cut (sdlc-step-cuts) ───────────────────────────────────────────────────────
960
+ // Reading earlier runs is still good practice; citing them is no longer checked.
961
+ steps.push(step('priors', 'priors', 'na', `prior-run citation is not required — ${CUT_NOTE}`));
962
+
963
+ // ── contract ─ O5: PM-approved done-criteria. Reported, never blocking ─────────────────────
964
+ // Since sdlc-step-cuts only the feature lane asks for it, and a missing sign-off is pending, not
965
+ // blocked: the PM's approval of the PRD and the acceptance at the end are the sign-offs that count.
966
+ const approved = runs.find((r) => r.pre_run_contract && r.pre_run_contract.PM_approved === true);
967
+ {
968
+ if (approved) {
969
+ steps.push(step('contract', 'contract', 'done',
970
+ `PM-approved pre_run_contract on run ${approved.run_id}${approved.pre_run_contract.approved_by ? ` (by ${approved.pre_run_contract.approved_by})` : ''}`));
971
+ } else if (effLane !== 'feature') {
972
+ steps.push(step('contract', 'contract', 'na', `${effLane} lane — the PM's acceptance stands in for a contract sign-off (${CUT_NOTE})`));
973
+ } else {
974
+ steps.push(step('contract', 'contract', 'pending',
975
+ runs.length ? `${runs.length} run(s) logged, none with pre_run_contract.PM_approved: true` : 'no run logged for this initiative yet',
976
+ `agree done_criteria + test_method with the PM (approving the PRD counts) and record it in ${runsRel}`,
977
+ { rule: 'O5' }));
978
+ }
979
+ }
980
+
981
+ // ── build ────────────────────────────────────────────────────────────────────────────────────
982
+ /*
983
+ * The evidence names WHICH signal decided, never a bare count (R-2). `488 commit(s) on
984
+ * implementation paths` was true of the repository and said nothing about the initiative it was
985
+ * printed under — the identical number on every row was the tell.
986
+ *
987
+ * The three pending sentences below are deliberately different from each other. "No commit on the
988
+ * trunk is attributable to this initiative" is a statement about the INITIATIVE; "no implementation
989
+ * commits in this range" is a statement about the RANGE, and on a branch level with `main` that
990
+ * range is legitimately empty. A reader who cannot tell those apart calls the second one "never
991
+ * built", which is the console's half of this defect.
992
+ *
993
+ * THE BUILD RULE (PRD R6, pm-os-absorb Run 4). Items never complete a stage, with one exception: when plan items
994
+ * under Build were materialized (a D65 `declared` line names them as `plan_item`, D86 point 1), the step is done
995
+ * when every one of those work items is integrated, and pending until then, whatever the commits say. With no
996
+ * materialized item the commits decide, as above. The rule reads both ledgers through plan-items-stage.js, the
997
+ * engine's own pure fold (X29: the engine cannot require workspace/lib). A broken delivered_by stays blocked first.
998
+ */
999
+ const buildRule = require('./plan-items-stage').buildRuleFor(root, id);
1000
+ const ruleStep = build.failedDeclaration || !(buildRule.applies || buildRule.unknown) ? null
1001
+ : buildRule.unknown
1002
+ ? step('build', 'build', 'unknown', buildRule.reason)
1003
+ : buildRule.pending.length === 0
1004
+ ? step('build', 'build', 'done',
1005
+ `every materialized item under Build is integrated (${buildRule.integrated.length} of ${buildRule.materialized.length} in planning/work-items.jsonl)`)
1006
+ : step('build', 'build', 'pending',
1007
+ `${buildRule.integrated.length} of ${buildRule.materialized.length} materialized item(s) under Build integrated — waiting on ${buildRule.pending.join(', ')}`,
1008
+ 'merge each work item with a `Work-Item: <id>` trailer on the trunk, then run `pmos verify-work`');
1009
+ steps.push(ruleStep || (build.unknown
1010
+ ? step('build', 'build', 'unknown', build.reason || range.reason)
1011
+ : build.failedDeclaration
1012
+ // REPORTED, never CI-blocking — the same stance R1 takes on an unresolvable anchor. Making a
1013
+ // record's own broken pointer fail every unrelated PR would turn one typo into a repo-wide
1014
+ // outage; the RED belongs in the surface. `blocked` still closes the accept step below, which
1015
+ // is where a false delivery claim actually needs to stop something.
1016
+ ? step('build', 'build', 'blocked', build.failedDeclaration.reason,
1017
+ `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`,
1018
+ { blocking: false, rule: 'R-2b' })
1019
+ : build.mergedOnly
1020
+ ? step('build', 'build', 'done',
1021
+ delivery.attributionEvidence(build.signal || 'branch', id, build.declaredSha))
1022
+ : build.first === null
1023
+ ? step('build', 'build', 'pending', build.attributed
1024
+ ? delivery.attributionEvidence(null, id)
1025
+ : 'no implementation commits in this range')
1026
+ : step('build', 'build', 'done', build.signal
1027
+ ? `${build.count} commit(s) on implementation paths — ${delivery.attributionEvidence(build.signal, id, build.declaredSha)}`
1028
+ : `${build.count} commit(s) on implementation paths`)));
1029
+
1030
+ // ── quality ─ CI's verdict, never this engine's ──────────────────────────────────────────────
1031
+ steps.push(step('quality', 'quality', 'unknown', 'CI decides on the PR — this engine never claims a test result it did not run'));
1032
+
1033
+ // ── review ─ the Review Gate's verdict, where the lane has a rubric ──────────────────────────
1034
+ const evals = cachedJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
1035
+ {
1036
+ /* The rung reads the RUBRIC, not the lane.
1037
+ *
1038
+ * It keyed on `lane` alone, so a chore that deliberately carries a rubric could never surface
1039
+ * its Review Gate — the ladder said "chore lane carries no rubric (D56)" while a SKIPPED verdict
1040
+ * sat in evals.jsonl unread. That is how `guard-and-ledger-hardening`, a chore whose own record
1041
+ * argues "blast radius overrides lane" and which carried a rubric on purpose, reached `accept`
1042
+ * with the gate never run. The board could not show what the ledger already knew.
1043
+ *
1044
+ * A prototype is still never scored — D60 grills leg A as dialogue, and that is a property of
1045
+ * the lane, not of whether someone attached a rubric. Every other lane defers to the artifact. */
1046
+ const declaresRubric = !!String(fm.rubric_path || '').trim();
1047
+ /* EVERY lane defers to the artifact — the first version said so in the comment and applied it
1048
+ * only to `chore`, so a discovery or incident initiative with no rubric (legitimate under D56)
1049
+ * showed a permanent `no Review-Gate verdict recorded`: a rung it could never satisfy. The lanes
1050
+ * that carry no rubric by design are named once, here. */
1051
+ const RUBRIC_OPTIONAL = ['chore', 'discovery', 'incident', 'content'];
1052
+ if (effLane === 'prototype') {
1053
+ steps.push(step('review', 'review', 'na', 'leg A is grilled as dialogue, never scored (D60)'));
1054
+ } else if (RUBRIC_OPTIONAL.includes(effLane) && !declaresRubric && !evals.length) {
1055
+ steps.push(step('review', 'review', 'na', `${effLane} lane carries no rubric (D56)`));
1056
+ } else if (evals.length) {
1057
+ const last = evals[evals.length - 1];
1058
+ const verdict = String(last.verdict || '').toUpperCase();
1059
+ const label = `${last.verdict}${last.score != null ? ` (${last.score})` : ''} — advisory until calibrated per evaluator model (D40)`;
1060
+ /* A SKIPPED gate is NOT a satisfied gate, and a FAIL is emphatically not one.
1061
+ *
1062
+ * This rung rendered both as a green ✓, which is the watermelon this repo names as its own
1063
+ * failure mode: the board said the Review Gate was done over a row that says judging never
1064
+ * happened. Thirteen initiatives currently render `✓ review` over a SKIPPED verdict, and the
1065
+ * fix that made a chore's gate visible ADDED one. The module's own rule, repeated in three
1066
+ * files — "a skip is an honest RECORD that verification did not happen; it is not
1067
+ * verification" — now applies to the surface that reports it. */
1068
+ // The Review Gate runs on request since sdlc-step-cuts: a skip is n/a, and a FAIL is reported
1069
+ // for the PM to weigh at acceptance rather than holding the gate shut.
1070
+ if (verdict === 'SKIPPED') {
1071
+ steps.push(step('review', 'review', 'na', `${label} — skipped; the Review Gate runs on request (${CUT_NOTE})`));
1072
+ } else if (verdict === 'FAIL') {
1073
+ steps.push(step('review', 'review', 'pending', label,
1074
+ 'the Review Gate FAILED — weigh the findings at acceptance, or remediate and record a new verdict', { rule: 'R3' }));
1075
+ } else {
1076
+ steps.push(step('review', 'review', 'done', label));
1077
+ }
1078
+ } else {
1079
+ steps.push(step('review', 'review', 'na', `no Review-Gate verdict — it runs on request (${CUT_NOTE})`));
1080
+ }
1081
+ }
1082
+
1083
+ // ── explain ─ O6, made optional (sdlc-step-cuts) ────────────────────────────────────────────
1084
+ // A run explainer that exists is shown; a missing one never blocks acceptance.
1085
+ {
1086
+ let found = null;
1087
+ for (const r of runs) {
1088
+ const p = path.join(L.explainers, `${r.run_id}-explainer.md`);
1089
+ if (fs.existsSync(p)) { found = path.relative(root, p); break; }
1090
+ }
1091
+ steps.push(found
1092
+ ? step('explain', 'explain', 'done', found)
1093
+ : step('explain', 'explain', 'na', `the run explainer is optional — ${CUT_NOTE}`));
1094
+ }
1095
+
1096
+ // ── accept ─ the human PM, final and non-delegable (D12) ─────────────────────────────────────
1097
+ const accepted = cachedJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
1098
+ .filter((a) => a && a.initiative_id === id && a.accepted === true);
1099
+ {
1100
+ const blockers = steps.filter((s) => s.status === 'blocked').map((s) => s.key);
1101
+ if (accepted.length) {
1102
+ const last = accepted[accepted.length - 1];
1103
+ steps.push(step('accept', 'accept', 'done', `accepted by ${last.pm || 'PM'}${last.at ? ` on ${String(last.at).slice(0, 10)}` : ''}`));
1104
+ } else if (blockers.length) {
1105
+ steps.push(step('accept', 'accept', 'blocked', `blocked by: ${blockers.join(', ')}`,
1106
+ 'the Acceptance Gate is the PM\'s (D12) — but it does not open over an unfinished ladder'));
1107
+ } else {
1108
+ steps.push(step('accept', 'accept', 'pending', 'no acceptance record needed (D94)',
1109
+ 'merge once the required checks are green and the PM has not returned the run'));
1110
+ }
1111
+ }
1112
+
1113
+ /* ── outcome ─ D57: did the bet pay off? A SECOND verdict, at a second time ───────────────────
1114
+ *
1115
+ * This rung did not exist, and its absence is why the loop never ran: `outcome_metric` is optional
1116
+ * in D57, nothing ever asked for it, and 0 of ~170 records declared one. An optional field with no
1117
+ * prompt and no surface is a field that does not exist.
1118
+ *
1119
+ * `na` is a first-class, non-failing state on purpose. Most work is not a bet, and a rung that
1120
+ * renders red for every initiative without a metric becomes noise and gets ignored — which is how
1121
+ * you end up with a board nobody reads. */
1122
+ {
1123
+ const metric = String(fm.outcome_metric || '').trim();
1124
+ const horizon = String(fm.outcome_horizon || '').trim();
1125
+ const checks = cachedJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
1126
+ .filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
1127
+ const latest = checks.length ? checks[checks.length - 1] : null;
1128
+ const today = new Date().toISOString().slice(0, 10);
1129
+
1130
+ if (!metric) {
1131
+ steps.push(step('outcome', 'outcome', 'na', 'no outcome declared — most work is not a bet (D57)'));
1132
+ } else if (!horizon) {
1133
+ /* A metric with no horizon can never come due, so it reads as diligence for ever while
1134
+ * committing to nothing. That is worse than declaring nothing at all. */
1135
+ steps.push(step('outcome', 'outcome', 'blocked',
1136
+ `outcome_metric declared with NO outcome_horizon — a bet that can never come due`,
1137
+ 'add outcome_horizon: YYYY-MM-DD to the initiative record, or remove the metric', { rule: 'D57' }));
1138
+ } else if (latest) {
1139
+ /* A refuted bet is a COMPLETED check and an UNSUCCESSFUL bet. The rung says both — rendering
1140
+ * it as a plain green tick is the same watermelon the review rung was fixed for. */
1141
+ const done = latest.disposition === 'refuted' ? 'blocked' : 'done';
1142
+ steps.push(step('outcome', 'outcome', done,
1143
+ `${latest.disposition}${latest.evidence ? ` — ${String(latest.evidence).slice(0, 60)}` : ''}`,
1144
+ latest.disposition === 'refuted'
1145
+ ? 'the check is complete and the bet did not pay off — that is a result, not a failure to record'
1146
+ : undefined));
1147
+ } else if (horizon <= today) {
1148
+ steps.push(step('outcome', 'outcome', 'blocked',
1149
+ `outcome check DUE — horizon ${horizon} has passed and no disposition is recorded`,
1150
+ 'dispose it: node scripts/record-outcome.js (validated | refuted | inconclusive, evidence required)',
1151
+ { rule: 'D57' }));
1152
+ } else {
1153
+ steps.push(step('outcome', 'outcome', 'pending', `outcome pending — horizon ${horizon}`));
1154
+ }
1155
+ }
1156
+
1157
+ attachBasis(steps, { profile: 'product-code', root, L, fm, recRel, range, hasEvals: evals.length > 0, hasAcceptance: accepted.length > 0 });
1158
+
1159
+ // ── stage, derived from what the steps found ─────────────────────────────────────────────────
1160
+ const by = Object.fromEntries(steps.map((s) => [s.key, s]));
1161
+ let stage = 'intake';
1162
+ if (by.spec.status === 'done') stage = 'spec';
1163
+ if (by.contract.status === 'done') stage = 'contract';
1164
+ if (by.build.status === 'done') stage = 'build';
1165
+ if (evals.length || runs.length) stage = 'eval';
1166
+ if (accepted.length) stage = 'accepted';
1167
+
1168
+ const blockers = steps.filter((s) => s.status === 'blocked');
1169
+ const gateFailures = blockers.filter((s) => s.blocking);
1170
+ const next = steps.find((s) => s.status === 'blocked')
1171
+ || steps.find((s) => s.status === 'pending' && s.next)
1172
+ || null;
1173
+
1174
+ // `opts.facts === false` exists so a test can prove the flags change nothing else in the ladder.
1175
+ const facts = (opts && opts.facts === false) ? {}
1176
+ : statusFacts.compute({ root, L, id, fm, lane: effLane, stage, runs, accepted });
1177
+
1178
+ return {
1179
+ ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
1180
+ last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
1181
+ title: fm.title || id, anchor: fm.parent_kr || null, profile: 'product-code',
1182
+ nine_stages: nineStages(effLane, { stage, steps }),
1183
+ ...facts,
1184
+ steps, blockers: blockers.map((s) => s.key), gate_failures: gateFailures.map((s) => s.rule || s.key),
1185
+ next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
1186
+ range: range.ok ? { base: range.base, commits: range.shas.length, whole_history: !!range.wholeHistory } : { error: range.reason },
1187
+ enforced_from: ENFORCED_FROM,
1188
+ };
1189
+ }
1190
+
1191
+ // ── The document scan (scan-documents S1–S4) — what the repository holds for each initiative ─────
1192
+ // A pm-artifacts record often names none of its documents: the PRD, the evaluation and the Jira
1193
+ // tickets sit in the repository and the record was never told. The ladder used to read the record
1194
+ // only, so that work read as not started. This reads the repository once per root (tracked and
1195
+ // untracked, not ignored), joins each document to an initiative by its frontmatter or by the id in
1196
+ // its path, and gives it a role from its name. It is evidence a named path outranks, never a
1197
+ // blocker, and nothing is written back. The cache holds SCAN_TTL_MS; `forgetDocumentScan` (the
1198
+ // workspace's Scan repository) drops it at once.
1199
+ const SCAN_TTL_MS = 30000;
1200
+ const SCAN_MAX_FILES = 20000;
1201
+ const SCAN_EXT = /\.(md|markdown|mdx|txt|docx|pdf|html?)$/i;
1202
+ const SCAN_TEXT = /\.(md|markdown|mdx|txt)$/i;
1203
+ const SCAN_ROLES = [ // first match wins, in this order (S2)
1204
+ ['review', ['eval', 'evals', 'evaluation', 'evaluations', 'review', 'reviews', 'verdict', 'scorecard']],
1205
+ ['handoff', ['handoff', 'handover', 'hand-off']],
1206
+ ['brief', ['brief', 'briefs', 'problem', 'idea', 'ideas', 'intake', 'one-pager', 'onepager', 'bug-report']],
1207
+ ['draft', ['prd', 'prds', 'spec', 'specs', 'specification', 'requirements', 'findings', 'plan', 'strategy', 'report', 'decision']],
1208
+ ];
1209
+ const SCAN_NOT_EVIDENCE = ['rubric', 'rubrics', 'template', 'templates'];
1210
+ const JIRA_KEY = /^[A-Z][A-Z0-9_]+-\d+$/;
1211
+ const _scanCache = new Map(); // root -> { at, index: Map(id -> { brief:[], draft:[], review:[], handoff:[] }) }
1212
+ function forgetDocumentScan(root) { if (root) _scanCache.delete(path.resolve(root)); else _scanCache.clear(); }
1213
+
1214
+ function scanWords(s) {
1215
+ const lower = String(s || '').toLowerCase();
1216
+ const words = lower.split(/[^a-z0-9]+/).filter(Boolean);
1217
+ // The hyphenated role words are matched as joined pairs too: "hand-off", "one-pager", "bug-report".
1218
+ const n = words.length;
1219
+ for (let i = 0; i + 1 < n; i++) words.push(`${words[i]}-${words[i + 1]}`);
1220
+ return words;
1221
+ }
1222
+ function roleOfWords(words, extra) {
1223
+ const set = new Set(words);
1224
+ for (const [role, list] of SCAN_ROLES) {
1225
+ if (list.some((w) => set.has(w))) return role;
1226
+ if (role === 'draft' && extra && extra.some((w) => set.has(w))) return role;
1227
+ }
1228
+ return null;
1229
+ }
1230
+ /** A name as a slug: lower case, every run of other characters one dash. "Checkout Redesign PRD.docx" → "checkout-redesign-prd-docx". */
1231
+ const scanSlug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
1232
+ /**
1233
+ * Does the slug `name` carry the key? Equal, or the key leads it ("checkout-prd"). A key of two words or more, or of
1234
+ * six characters or more, may also stand anywhere between dashes ("prd-checkout-redesign-v2"): a short one-word key
1235
+ * such as "app" would otherwise claim every file with that word in it.
1236
+ */
1237
+ function slugHasKey(name, key) {
1238
+ if (!name || !key) return false;
1239
+ if (name === key || name.startsWith(key + '-')) return true;
1240
+ if (!key.includes('-') && key.length < 6) return false;
1241
+ return name.endsWith('-' + key) || name.includes('-' + key + '-');
1242
+ }
1243
+ /**
1244
+ * The initiatives a document belongs to (S1): its frontmatter names them; else the record whose key — its id, or its
1245
+ * title as a slug — is a folder in the path, leads or stands in the file name, or stands in the document's own title.
1246
+ * The longest key wins, so "checkout-redesign" takes a file that "checkout" would also match.
1247
+ */
1248
+ function documentOwners(rel, fm, idSet, keys) {
1249
+ const named = [fm.initiative, fm.initiative_id, fm.initiatives].filter(Boolean)
1250
+ .flatMap((v) => String(v).replace(/^\[|\]$/g, '').split(',')).map((s) => s.trim().replace(/^["']|["']$/g, ''))
1251
+ .filter((s) => idSet.has(s));
1252
+ if (named.length) return { ids: [...new Set(named)], key: null };
1253
+ const parts = rel.split('/');
1254
+ const base = scanSlug(parts[parts.length - 1].replace(/\.[^.]+$/, ''));
1255
+ const dirs = parts.slice(0, -1).map(scanSlug);
1256
+ const title = scanSlug(fm.title);
1257
+ for (const { id, key } of keys) {
1258
+ if (dirs.includes(key) || slugHasKey(base, key) || slugHasKey(title, key)) return { ids: [id], key };
1259
+ }
1260
+ return { ids: [], key: null };
1261
+ }
1262
+ /** The role of a found document (S2): its file name, then its frontmatter `type:`, then its folders. */
1263
+ function documentRole(rel, type, key) {
1264
+ const parts = rel.split('/');
1265
+ const base = parts[parts.length - 1].replace(/\.[^.]+$/, '');
1266
+ // A rubric or a template says what good looks like; it is not evidence that the work was judged or done.
1267
+ if (scanWords(base).some((x) => SCAN_NOT_EVIDENCE.includes(x))) return null;
1268
+ // The initiative's own name is not a role word: "problem-reporting-prd" is the PRD of the initiative problem-reporting.
1269
+ const strip = (s) => { const slug = scanSlug(s); return key ? slug.split(key).join('-') : slug; };
1270
+ return roleOfWords(scanWords(strip(base))) || roleOfWords(scanWords(type))
1271
+ || roleOfWords(scanWords(strip(parts.slice(0, -1).join('/'))));
1272
+ }
1273
+ /** Every record: its id and the keys a document may carry — the id, and the title as a slug when it is not too short. */
1274
+ function recordKeys(L) {
1275
+ let files = [];
1276
+ try { files = fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && !/^(README|index|log)\.md$/i.test(f)); }
1277
+ catch { return []; }
1278
+ const out = [];
1279
+ for (const f of files) {
1280
+ const id = f.slice(0, -3);
1281
+ const fm = readFrontmatter(path.join(L.initiatives, f)) || {};
1282
+ const keys = new Set([scanSlug(id)]);
1283
+ const t = scanSlug(fm.title);
1284
+ if (t && (t.includes('-') || t.length >= 6)) keys.add(t);
1285
+ for (const key of keys) if (key.length >= 3) out.push({ id, key });
1286
+ }
1287
+ return out.sort((a, b) => b.key.length - a.key.length);
1288
+ }
1289
+ /** The repository's files: git's list (tracked, and untracked but not ignored); a walk of the folder when it is not a git repository. */
1290
+ function scanFiles(root) {
1291
+ const out = git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
1292
+ if (out != null) return [...new Set(out.split('\0').filter(Boolean))].slice(0, SCAN_MAX_FILES);
1293
+ const files = [];
1294
+ const walk = (dir, rel) => {
1295
+ let ents = [];
1296
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
1297
+ for (const e of ents) {
1298
+ if (files.length >= SCAN_MAX_FILES) return;
1299
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue;
1300
+ const r = rel ? `${rel}/${e.name}` : e.name;
1301
+ if (e.isDirectory()) walk(path.join(dir, e.name), r);
1302
+ else if (e.isFile()) files.push(r);
1303
+ }
1304
+ };
1305
+ walk(root, '');
1306
+ return files;
1307
+ }
1308
+ /** Where the tidy (S10) moves a duplicate, repository-relative: `<state>/archive/duplicates/`. The scan does not read it. */
1309
+ function duplicatesArchive(root, L) {
1310
+ return path.relative(root, path.join(path.dirname(L.initiatives), 'archive', 'duplicates')).split(path.sep).join('/') + '/';
1311
+ }
1312
+ function scanRepository(root, L) { return scanDetail(root, L).index; }
1313
+ /** The kinds the PM confirmed (S11): `<state>/document-kinds.jsonl`, the latest line for a path. A kind is a role, or `none`. */
1314
+ const DOCUMENT_KINDS = ['brief', 'draft', 'review', 'handoff', 'none'];
1315
+ function documentKinds(L) {
1316
+ const out = new Map();
1317
+ let text = '';
1318
+ try { text = fs.readFileSync(path.join(path.dirname(L.initiatives), 'document-kinds.jsonl'), 'utf8'); } catch { return out; }
1319
+ for (const line of text.split('\n')) {
1320
+ if (!line.trim()) continue;
1321
+ try {
1322
+ const o = JSON.parse(line);
1323
+ if (o && typeof o.path === 'string' && DOCUMENT_KINDS.includes(o.kind)) out.set(o.path, o.kind);
1324
+ } catch { /* a torn line is skipped */ }
1325
+ }
1326
+ return out;
1327
+ }
1328
+ /** The scan's index, and how many initiatives each found document belongs to (`owners`, for the tidy). */
1329
+ function scanDetail(root, L) {
1330
+ const key = path.resolve(root);
1331
+ const hit = _scanCache.get(key);
1332
+ if (hit && Date.now() - hit.at < SCAN_TTL_MS) return hit;
1333
+ const index = new Map();
1334
+ const owners = new Map();
1335
+ const owned = []; // every document tied to an initiative, with or without a role (the Jev check reads them, S11)
1336
+ const kinds = documentKinds(L);
1337
+ const keys = recordKeys(L);
1338
+ const idSet = new Set(keys.map((k) => k.id));
1339
+ const recDir = path.relative(root, L.initiatives).split(path.sep).join('/') + '/';
1340
+ const archDir = duplicatesArchive(root, L);
1341
+ for (const rel of scanFiles(root)) {
1342
+ if (!SCAN_EXT.test(rel) || rel.startsWith(recDir) || rel.startsWith(archDir) || /(^|\/)(node_modules|\.git)\//.test(rel)) continue;
1343
+ let fm = {};
1344
+ if (SCAN_TEXT.test(rel)) {
1345
+ try {
1346
+ const fd = fs.openSync(path.join(root, rel), 'r');
1347
+ const buf = Buffer.alloc(4096);
1348
+ const n = fs.readSync(fd, buf, 0, 4096, 0);
1349
+ fs.closeSync(fd);
1350
+ fm = parseLeadingFrontmatter(buf.slice(0, n).toString('utf8')) || {};
1351
+ } catch { continue; }
1352
+ }
1353
+ const owner = documentOwners(rel, fm, idSet, keys);
1354
+ if (!owner.ids.length) continue;
1355
+ // git's index still lists a tracked file that was moved or deleted and not yet staged
1356
+ if (!SCAN_TEXT.test(rel) && !fs.existsSync(path.join(root, rel))) continue;
1357
+ const local = documentRole(rel, fm.type, owner.key);
1358
+ const kind = kinds.get(rel) || null; // a kind the PM confirmed outranks the name (S11)
1359
+ const role = kind ? (kind === 'none' ? null : kind) : local;
1360
+ owned.push({ path: rel, ids: owner.ids, role, local, kind, title: fm.title ? String(fm.title) : null });
1361
+ if (!role) continue;
1362
+ owners.set(rel, owner.ids.length);
1363
+ for (const id of owner.ids) {
1364
+ if (!index.has(id)) index.set(id, { brief: [], draft: [], review: [], handoff: [] });
1365
+ index.get(id)[role].push(rel);
1366
+ }
1367
+ }
1368
+ for (const v of index.values()) for (const k of Object.keys(v)) v[k].sort();
1369
+ const out = { at: Date.now(), index, owners, owned };
1370
+ _scanCache.set(key, out);
1371
+ return out;
1372
+ }
1373
+ /** When a document last changed, in seconds: its last commit, else the file's own time. */
1374
+ function documentChangedAt(root, rel) {
1375
+ const t = Number(String(git(root, ['log', '-1', '--format=%ct', '--', rel]) || '').trim());
1376
+ if (t > 0) return t;
1377
+ try { return Math.floor(fs.statSync(path.join(root, rel)).mtimeMs / 1000); } catch { return 0; }
1378
+ }
1379
+ /**
1380
+ * Every document the scan ties to an initiative, for the Jev check (S11): { ok, profile, kinds, documents: [{ path, ids,
1381
+ * role, local, kind, title, text }] }. `local` is the role the name gives, `kind` the one the PM confirmed, `role` the one
1382
+ * the scan uses, and `text` says whether the file is Markdown or text (only those can be read by Jev). Nothing is written.
1383
+ */
1384
+ function documentCandidates(root, opts) {
1385
+ const L = layout(root);
1386
+ const profile = profileOf(root);
1387
+ const kinds = path.relative(root, path.join(path.dirname(L.initiatives), 'document-kinds.jsonl')).split(path.sep).join('/');
1388
+ if (profile !== 'pm-artifacts') return { ok: true, profile, kinds, documents: [] };
1389
+ if (opts && opts.fresh) forgetDocumentScan(root);
1390
+ const { owned } = scanDetail(root, L);
1391
+ const titles = {};
1392
+ try {
1393
+ for (const f of fs.readdirSync(L.initiatives)) {
1394
+ if (!f.endsWith('.md') || /^(README|index|log)\.md$/i.test(f)) continue;
1395
+ titles[f.slice(0, -3)] = String((readFrontmatter(path.join(L.initiatives, f)) || {}).title || f.slice(0, -3));
1396
+ }
1397
+ } catch { /* no records */ }
1398
+ return { ok: true, profile, kinds, titles, documents: owned.map((d) => ({ ...d, text: SCAN_TEXT.test(d.path), changed: documentChangedAt(root, d.path) })) };
1399
+ }
1400
+ /**
1401
+ * The duplicates the tidy offers (S10): for each initiative, each role with more than one document. The group keeps the
1402
+ * file the record names, else the most recently changed, else the shortest path. A document that belongs to more than one
1403
+ * initiative is listed as `shared` and is never one to move. Only a pm-artifacts repository has groups. Nothing is written.
1404
+ * { ok, profile, archive, groups: [{ id, title, role, keep, files: [{ path, changed }], shared: [path] }] }
1405
+ */
1406
+ function documentDuplicates(root, opts) {
1407
+ const L = layout(root);
1408
+ const profile = profileOf(root);
1409
+ const archive = duplicatesArchive(root, L);
1410
+ if (profile !== 'pm-artifacts') return { ok: true, profile, archive, groups: [] };
1411
+ if (opts && opts.fresh) forgetDocumentScan(root);
1412
+ const { index, owners } = scanDetail(root, L);
1413
+ const norm = (p) => { const v = String(p || '').trim(); return v ? path.relative(root, path.resolve(root, v)).split(path.sep).join('/') : ''; };
1414
+ const groups = [];
1415
+ for (const [id, roles] of [...index].sort((a, b) => a[0].localeCompare(b[0]))) {
1416
+ const fm = readFrontmatter(path.join(L.initiatives, `${id}.md`)) || {};
1417
+ const named = { brief: norm(fm.brief_path || fm.prd_path), draft: norm(fm.doc_path) };
1418
+ for (const role of ['brief', 'draft', 'review', 'handoff']) {
1419
+ const own = roles[role].filter((f) => (owners.get(f) || 0) <= 1);
1420
+ const shared = roles[role].filter((f) => (owners.get(f) || 0) > 1);
1421
+ const pin = named[role] && fs.existsSync(path.join(root, named[role])) ? named[role] : '';
1422
+ if (pin && !own.includes(pin) && !shared.includes(pin)) own.push(pin);
1423
+ if (own.length < 2) continue;
1424
+ const files = own.map((f) => ({ path: f, changed: documentChangedAt(root, f) }));
1425
+ files.sort((a, b) => (b.path === pin) - (a.path === pin) || b.changed - a.changed
1426
+ || a.path.length - b.path.length || a.path.localeCompare(b.path));
1427
+ groups.push({ id, title: fm.title || id, role, keep: files[0].path, files, shared, named: !!pin && files[0].path === pin });
1428
+ }
1429
+ }
1430
+ return { ok: true, profile, archive, groups };
1431
+ }
1432
+ /**
1433
+ * Jira keys the repository holds for an initiative (S4): the Jira tab's draft; the record's `jira:` keys; in the
1434
+ * record's text, a Jira link (`…/browse/KEY`) or a key on a line that says Jira; and a Jira link in its found
1435
+ * documents. A bare key elsewhere is not read: "UTF-8" and "ISO-8601" have the same shape.
1436
+ */
1437
+ function jiraKeysOf(root, L, id, fm, found) {
1438
+ const keys = [];
1439
+ const draft = path.join(path.dirname(L.initiatives), 'tracker', 'jira', `${id}-tickets.md`);
1440
+ try {
1441
+ for (const line of fs.readFileSync(draft, 'utf8').split('\n')) {
1442
+ const m = /^- jira:\s*(\S+)\s*$/.exec(line);
1443
+ if (m && JIRA_KEY.test(m[1].toUpperCase())) keys.push(m[1].toUpperCase());
1444
+ }
1445
+ } catch { /* no draft */ }
1446
+ for (const k of ['jira', 'jira_key', 'jira_keys', 'jira_epic']) {
1447
+ for (const m of String(fm[k] || '').toUpperCase().matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1448
+ }
1449
+ const fromText = (text, lines) => {
1450
+ for (const m of String(text).matchAll(/\/browse\/([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1451
+ if (lines) for (const line of String(text).split('\n')) {
1452
+ if (!/jira/i.test(line)) continue;
1453
+ for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]+-\d+)\b/g)) keys.push(m[1]);
1454
+ }
1455
+ };
1456
+ try { fromText(fs.readFileSync(path.join(L.initiatives, `${id}.md`), 'utf8'), true); } catch { /* no record text */ }
1457
+ for (const rel of found ? [].concat(found.brief, found.draft, found.review, found.handoff) : []) {
1458
+ if (!SCAN_TEXT.test(rel)) continue;
1459
+ try { fromText(fs.readFileSync(path.join(root, rel), 'utf8').slice(0, 200000), false); } catch { /* unreadable */ }
1460
+ }
1461
+ return [...new Set(keys)];
1462
+ }
1463
+
1464
+ /**
1465
+ * D68 — the pm-artifacts ladder: route → anchor → brief → draft → review → approved → handed off.
1466
+ *
1467
+ * Route and anchor are computed by the shared code above and keep their rules (anchoring holds in
1468
+ * every profile — D59/D67); route is re-stated here because its product-code form asks the
1469
+ * specimen/surface questions, which a PM document does not need. What follows is evidence-derived
1470
+ * like every other rung — nothing is declared done:
1471
+ * brief — the brief file (`brief_path`, or `prd_path`) exists
1472
+ * draft — the deliverable (`doc_path`) exists, and — governed only — did not appear before the brief
1473
+ * review — an evals.jsonl verdict for this id, or a named `reviewed_by:` (a rubric, an agent, a human)
1474
+ * approved — the PM's acceptance in acceptances.jsonl (D12, never automatic)
1475
+ * handoff — `handed_off:` names where it went; n/a for decision records, reports and chores
1476
+ * When the record names no file for brief, draft or review, or names one that does not exist, a
1477
+ * document the repository scan found for the id completes the step (scan-documents S3); Jira keys
1478
+ * in the repository complete the handoff (S4). Found evidence never blocks and never fails CI.
1479
+ * Track: `light` (guidance — only route and anchor can fail CI) or `governed` (brief and draft
1480
+ * existence and their order fail CI for a non-legacy record). Review, approval and handoff are never
1481
+ * CI-blocking in either track: none can be true before the PR exists.
1482
+ */
1483
+ function pmLadder({ id, fm, lane, effLane, knownLane, legacy, steps, root, L, recRel, range, inChange, lastActivity, opts }) {
1484
+ const declared = String(fm.track || '').trim().toLowerCase();
1485
+ const track = declared === 'light' || declared === 'governed' ? declared
1486
+ : (laneDefs.laneDef('pm-artifacts', effLane) || laneDefs.laneDef('pm-artifacts', 'prd')).track;
1487
+ const enforce = track === 'governed' && !legacy;
1488
+ const exists = (p) => !!p && fs.existsSync(path.join(root, p));
1489
+ // D77 — the lane's own definition decides which rungs it passes and what it calls them.
1490
+ const def = laneDefs.laneDef('pm-artifacts', effLane) || laneDefs.laneDef('pm-artifacts', 'prd');
1491
+ const flows = (k) => def.rungs.includes(k);
1492
+ const skipped = (key, label) => step(key, label, 'na', `${effLane} lane — ${label} is not part of this flow`);
1493
+
1494
+ // route — the lane is a pm-artifacts lane
1495
+ steps[0] = knownLane
1496
+ ? step('route', 'route', 'done', `${lane} lane · ${track} track (pm-artifacts profile, D68)`)
1497
+ : step('route', 'route', 'done', `unknown lane '${lane}' — treated as a governed prd, the profile's strictest (D68)`,
1498
+ `set lane: to one of ${laneNames('pm-artifacts').join(', ')}`);
1499
+
1500
+ const found = (opts && opts.scan === false) ? null : (scanRepository(root, L).get(id) || null);
1501
+ const foundOf = (role) => (found && found[role].length ? found[role][0] : null);
1502
+ const briefPath = String(fm.brief_path || fm.prd_path || '').trim();
1503
+ const docPath = String(fm.doc_path || '').trim();
1504
+ const foundBrief = exists(briefPath) ? null : foundOf('brief');
1505
+ const foundDraft = exists(docPath) ? null : foundOf('draft');
1506
+ const briefIdx = briefPath ? firstAppearance(root, range, briefPath) : null;
1507
+ const docIdx = docPath ? firstAppearance(root, range, docPath) : null;
1508
+
1509
+ if (!flows('brief')) steps.push(skipped('brief', def.brief_label));
1510
+ else if (foundBrief) steps.push(step('brief', def.brief_label, 'done', `${foundBrief} — found by the repository scan`));
1511
+ else if (exists(briefPath)) steps.push(step('brief', def.brief_label, 'done', `${briefPath} present`));
1512
+ else steps.push(step('brief', def.brief_label, enforce ? 'blocked' : 'pending',
1513
+ briefPath ? `brief_path '${briefPath}' does not exist` : `no ${def.brief_label} yet`,
1514
+ `write the ${def.brief_label} (${def.input}) and set brief_path in ${recRel}`,
1515
+ { blocking: enforce, rule: 'P1' }));
1516
+
1517
+ if (!flows('draft')) steps.push(skipped('draft', def.draft_label));
1518
+ else if (foundDraft) steps.push(step('draft', def.draft_label, 'done', `${foundDraft} — found by the repository scan`));
1519
+ else if (exists(docPath)) {
1520
+ const inOrder = briefIdx == null || docIdx == null ? null : docIdx >= briefIdx;
1521
+ if (enforce && inOrder === false) {
1522
+ steps.push(step('draft', def.draft_label, 'blocked', `${docPath} appeared before its brief ${briefPath}`,
1523
+ 'a governed document is briefed before it is drafted — the brief must land first', { blocking: true, rule: 'P2' }));
1524
+ } else steps.push(step('draft', def.draft_label, 'done', `${docPath} present`));
1525
+ } else steps.push(step('draft', def.draft_label, enforce && docPath ? 'blocked' : 'pending',
1526
+ docPath ? `doc_path '${docPath}' does not exist` : `no ${def.draft_label} yet`,
1527
+ `write the ${def.draft_label} (${def.output}) and set doc_path in ${recRel}`, { blocking: enforce && !!docPath, rule: 'P2' }));
1528
+
1529
+ const evals = cachedJsonl(path.join(L.runsDir, 'evals.jsonl')).filter((e) => e && e.initiative_id === id);
1530
+ const reviewer = String(fm.reviewed_by || '').trim();
1531
+ if (!flows('review')) steps.push(skipped('review', 'review'));
1532
+ else if (evals.length) steps.push(step('review', 'review', 'done', `${evals[evals.length - 1].verdict || 'verdict'} recorded in evals.jsonl`));
1533
+ else if (reviewer) steps.push(step('review', 'review', 'done', `reviewed by ${reviewer}`));
1534
+ else if (foundOf('review')) steps.push(step('review', 'review', 'done', `${foundOf('review')} — evaluation found by the repository scan`));
1535
+ else steps.push(step('review', 'review', 'pending', 'not reviewed yet',
1536
+ 'name the reviewer in reviewed_by: (a person, an agent, or a rubric verdict in evals.jsonl)'));
1537
+
1538
+ const accepted = cachedJsonl(path.join(L.runsDir, 'acceptances.jsonl'))
1539
+ .filter((a) => a && a.initiative_id === id && a.accepted === true);
1540
+ const acceptedOn = accepted.length ? (accepted[accepted.length - 1].accepted_at || accepted[accepted.length - 1].at) : null;
1541
+ // D86 point 3 — a report is approved when the PM issues it: an `issued` line in issues.jsonl naming its doc_path.
1542
+ // Its own minimal parse (X29: the vendored engine never requires workspace/lib); a line counts only if the PM wrote it.
1543
+ const norm = (p) => String(p || '').trim().replace(/^\.\//, '');
1544
+ const issued = effLane !== 'report' || !docPath ? []
1545
+ : cachedJsonl(path.join(L.runsDir, 'issues.jsonl')).filter((r) => r && r.ev === 'issued' && norm(r.path) === norm(docPath)
1546
+ && /^pm:/.test(String(r.by || '')) && (r.via === 'ui' || r.via === 'cli') && /^[0-9a-f]{40}$/.test(String(r.sha || '')));
1547
+ steps.push(!flows('approved') ? skipped('approved', 'approved')
1548
+ : issued.length
1549
+ ? step('approved', 'approved', 'done', `issued by the PM on ${String(issued[0].at || '').slice(0, 10)} (issues.jsonl)`)
1550
+ : accepted.length
1551
+ ? step('approved', 'approved', 'done', `accepted by the PM${acceptedOn ? ' on ' + String(acceptedOn).slice(0, 10) : ''}`)
1552
+ : effLane === 'report'
1553
+ ? step('approved', 'approved', 'pending', 'awaiting the PM\'s Issue (D86 point 3)', 'the PM issues the report (pmos report issue, or Issue on Reports) — never automatic')
1554
+ : step('approved', 'approved', 'pending', 'awaiting the PM\'s acceptance (D12)', 'the PM accepts the document — never automatic'));
1555
+
1556
+ if (!flows('handoff')) steps.push(step('handoff', 'handed off', 'na', `${effLane} — nothing to hand off`));
1557
+ else {
1558
+ const to = String(fm.handed_off || '').trim();
1559
+ const keys = to ? [] : jiraKeysOf(root, L, id, fm, found);
1560
+ steps.push(to ? step('handoff', 'handed off', 'done', `handed off — ${to}`)
1561
+ : keys.length ? step('handoff', 'handed off', 'done', `handed off to Jira — ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ` and ${keys.length - 5} more` : ''}`)
1562
+ : foundOf('handoff') ? step('handoff', 'handed off', 'done', `${foundOf('handoff')} — handoff found by the repository scan`)
1563
+ : step('handoff', 'handed off', 'pending', 'not handed off yet',
1564
+ 'record where it went in handed_off: (a tracker link, the team, or "accepted as the deliverable")'));
1565
+ }
1566
+
1567
+ attachBasis(steps, { profile: 'pm-artifacts', root, L, fm, recRel, range, hasEvals: evals.length > 0, hasAcceptance: accepted.length > 0 });
1568
+ const by = Object.fromEntries(steps.map((x) => [x.key, x]));
1569
+ let stage = 'intake';
1570
+ if (by.brief.status === 'done') stage = 'brief';
1571
+ if (by.draft.status === 'done') stage = 'draft';
1572
+ if (by.review.status === 'done') stage = 'review';
1573
+ if (by.handoff && by.handoff.status === 'done') stage = 'handoff';
1574
+ if (accepted.length) stage = 'accepted';
1575
+ const blockers = steps.filter((x) => x.status === 'blocked');
1576
+ const gateFailures = blockers.filter((x) => x.blocking);
1577
+ const next = steps.find((x) => x.status === 'blocked') || steps.find((x) => x.status === 'pending' && x.next) || null;
1578
+ // status-from-artifacts: a document repository gets the one fact that applies to it — the brief or the deliverable
1579
+ // changed after the PM accepted it. Coverage and the status-line check are product-code facts and stay null here.
1580
+ const facts = (opts && opts.facts === false) ? {}
1581
+ : statusFacts.computeArtifacts({ root, L, fm, accepted });
1582
+ return {
1583
+ ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
1584
+ last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
1585
+ title: fm.title || id, anchor: fm.parent_kr || null, profile: 'pm-artifacts', track,
1586
+ found: found ? { brief: found.brief.slice(), draft: found.draft.slice(), review: found.review.slice(), handoff: found.handoff.slice() } : null,
1587
+ ...facts,
1588
+ steps, blockers: blockers.map((x) => x.key), gate_failures: gateFailures.map((x) => x.rule || x.key),
1589
+ next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
1590
+ range: range.ok ? { base: range.base, commits: range.shas.length, whole_history: !!range.wholeHistory } : { error: range.reason },
1591
+ enforced_from: ENFORCED_FROM,
1592
+ };
1593
+ }
1594
+
1595
+ // ── Lifecycle (D70) — a stopped initiative is a ledger line, never a record field ───────────────
1596
+ // `lifecycle.jsonl` beside the other ledgers: {initiative_id, state, reason, pm, at}. The latest line
1597
+ // for an id wins. `active` reopens. The same reader exists in workspace/lib/lifecycle.js; a parity
1598
+ // fixture keeps the two in step. A lifecycle line is never an acceptance and never writes one.
1599
+ const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
1600
+ function readLifecycle(root) {
1601
+ const L = layout(root);
1602
+ const latest = new Map();
1603
+ for (const r of cachedJsonl(path.join(L.runsDir, 'lifecycle.jsonl'))) {
1604
+ if (!r || typeof r.initiative_id !== 'string') continue;
1605
+ if (r.state !== 'active' && !STOPPED_STATES.includes(r.state)) continue;
1606
+ latest.set(r.initiative_id, { state: r.state, reason: r.reason || '', pm: r.pm || '', at: r.at || '' });
1607
+ }
1608
+ return latest;
1609
+ }
1610
+ function computeWorkflow(id, opts) {
1611
+ const w = computeWorkflowDerived(id, opts);
1612
+ if (!w.ok) return w;
1613
+ const lc = readLifecycle(path.resolve((opts && opts.root) || process.cwd())).get(id);
1614
+ if (!lc || lc.state === 'active') return w;
1615
+ // Stopped: leaves the board and stops gating. The ladder is kept so a reopen shows where it was.
1616
+ return { ...w, stopped: lc, blockers: [], gate_failures: [], next: null,
1617
+ steps: w.steps.map((x) => (x.status === 'blocked' ? { ...x, status: 'pending', blocking: false } : x)) };
1618
+ }
1619
+
1620
+ // ── Rendering ──────────────────────────────────────────────────────────────────────────────────
1621
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
1622
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
1623
+ const GLYPH = {
1624
+ done: ['✓', 32], current: ['●', 36], blocked: ['✗', 31],
1625
+ pending: ['·', 90], na: ['─', 90], unknown: ['?', 33],
1626
+ };
1627
+ function mark(status) {
1628
+ const [g, col] = GLYPH[status] || ['?', 33];
1629
+ return c(col, g);
1630
+ }
1631
+ // Record prose is deliberately long — the reasoning is for the PM to read in the record, not to
1632
+ // wrap a terminal. The ladder shows the declaration, then stops.
1633
+ function brief(s, n) {
1634
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
1635
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
1636
+ }
1637
+
1638
+ /**
1639
+ * The three status-from-artifacts lines, one plain sentence each with its basis. A flag, never a
1640
+ * step: nothing here is a state, and a missing basis prints nothing (never a pass).
1641
+ */
1642
+ function renderFacts(w) {
1643
+ const out = [];
1644
+ const sa = w.since_approval;
1645
+ if (sa) {
1646
+ if (sa.status === 'unknown') out.push(` ${c(90, '?')} since approval ${c(90, `cannot tell — ${sa.reason}`)}`);
1647
+ else {
1648
+ for (const a of sa.approvals) {
1649
+ const what = a.kind === 'contract' ? 'the contract was approved' : 'the PM accepted';
1650
+ if (a.status === 'unknown') out.push(` ${c(90, '?')} since approval ${c(90, `${what} (${a.run_id || 'no run id'}) — ${a.reason}`)}`);
1651
+ else if (!a.changed.length) out.push(` ${c(90, '·')} since approval ${c(90, `no spec file changed since ${what} (${a.approved_sha})`)}`);
1652
+ else {
1653
+ const files = [...new Set(a.changed.map((x) => x.file))].join(', ');
1654
+ const last = a.changed[a.changed.length - 1];
1655
+ out.push(` ${c(33, '!')} since approval ${files} changed after ${what} (${a.approved_sha}); ${a.changed_total} commit(s), latest ${last.sha} ${last.date || ''}`);
1656
+ }
1657
+ }
1658
+ }
1659
+ }
1660
+ const cv = w.coverage;
1661
+ if (cv) {
1662
+ if (cv.status === 'unknown') out.push(` ${c(90, '?')} coverage ${c(90, `cannot tell — ${cv.reason}`)}`);
1663
+ else if (!cv.uncited.length && !cv.orphan_citations.length) out.push(` ${c(90, '·')} coverage ${c(90, `all ${cv.requirements} requirement ids are cited in the rubric`)}`);
1664
+ else {
1665
+ const bits = [];
1666
+ if (cv.uncited.length) bits.push(`${cv.uncited.join(', ')} not cited in the rubric`);
1667
+ if (cv.orphan_citations.length) bits.push(`${cv.orphan_citations.join(', ')} cited but not in the PRD`);
1668
+ out.push(` ${c(33, '!')} coverage ${bits.join('; ')} (cited is not graded)`);
1669
+ }
1670
+ }
1671
+ const rs = w.record_says;
1672
+ if (rs) out.push(` ${c(33, '!')} record says status: ${rs.reason}`);
1673
+ return out;
1674
+ }
1675
+
1676
+ function renderLadder(w) {
1677
+ if (!w.ok) return c(31, `✗ ${w.error}`);
1678
+ const out = [];
1679
+ 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')}`;
1680
+ out.push('');
1681
+ out.push(` ${head}`);
1682
+ 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`) : ''}`);
1683
+ out.push(` ${c(90, '─'.repeat(78))}`);
1684
+ for (const s of w.steps) {
1685
+ const label = s.label.padEnd(11);
1686
+ out.push(` ${mark(s.status)} ${s.status === 'na' || s.status === 'pending' ? c(90, label) : label} ${brief(s.evidence, 110)}`);
1687
+ if (s.next) out.push(` ${c(90, '→')} ${c(s.status === 'blocked' ? 33 : 90, brief(s.next, 150))}`);
1688
+ }
1689
+ const flags = renderFacts(w);
1690
+ if (flags.length) { out.push(` ${c(90, '─'.repeat(78))}`); out.push(...flags); }
1691
+ out.push('');
1692
+ if (w.next) out.push(` ${c(1, 'next:')} ${w.next.action}`);
1693
+ else out.push(` ${c(32, 'nothing blocking — the ladder is complete')}`);
1694
+ if (w.range.error) out.push(` ${c(33, `order checks degraded: ${w.range.error}`)}`);
1695
+ out.push('');
1696
+ return out.join('\n');
1697
+ }
1698
+
1699
+ function renderBoard(rows, opts) {
1700
+ const out = [''];
1701
+ const acceptedN = rows.filter((r) => r.ok && r.accepted).length;
1702
+ const { shown, hidden, total } = inFlight(rows, { cap: opts.all ? Infinity : BOARD_CAP });
1703
+ const blocked = shown.filter((r) => r.ok && r.blockers.length).length;
1704
+ // "Open" is stated as what it is DERIVED FROM — no recorded acceptance — not as "in flight". The
1705
+ // difference matters here: this repo has 10 acceptances against 93 initiatives, so most of these
1706
+ // are finished work whose acceptance was never logged. Calling that "in flight" would be the
1707
+ // board inventing a fact; naming the gap is the board doing its job.
1708
+ 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`) : ''}`);
1709
+ out.push(` ${c(90, 'most recently worked on first · ✗ blocked · ● in this change')}`);
1710
+ out.push(` ${c(90, '─'.repeat(78))}`);
1711
+ const stoppedList = () => {
1712
+ const stopped = rows.filter((r) => r.ok && r.stopped);
1713
+ if (stopped.length) {
1714
+ out.push(` ${c(90, `stopped (${stopped.length}) — off the board, not gating:`)}`);
1715
+ for (const r of stopped) out.push(` ${c(90, `─ ${r.id} ${r.stopped.state}${r.stopped.at ? ' ' + String(r.stopped.at).slice(0, 10) : ''}${r.stopped.reason ? ' — ' + brief(r.stopped.reason, 60) : ''}`)}`);
1716
+ out.push('');
1717
+ }
1718
+ };
1719
+ if (!shown.length) { out.push(` ${c(32, 'every initiative here has a recorded acceptance.')}`); out.push(''); stoppedList(); return out.join('\n'); }
1720
+ const w1 = Math.min(34, Math.max(...shown.map((r) => r.id.length), 4));
1721
+ for (const r of shown) {
1722
+ if (!r.ok) { out.push(` ${c(31, '✗')} ${r.id.padEnd(w1)} ${c(31, r.error)}`); continue; }
1723
+ const status = r.blockers.length ? 'blocked' : r.in_change ? 'current' : 'pending';
1724
+ // The board shows WHICH step and WHY in a few words; the full next action lives on the ladder,
1725
+ // where there is room for it. Repeating a 90-character instruction on every row taught nothing.
1726
+ const why = r.next ? `${r.next.step} — ${brief(r.next.why, 62)}` : 'nothing blocking';
1727
+ const note = r.blockers.length ? c(31, why) : c(90, why);
1728
+ const tag = r.in_change ? c(36, ' ·this change') : '';
1729
+ out.push(` ${mark(status)} ${r.id.padEnd(w1)} ${r.lane.padEnd(9)} ${r.stage.padEnd(9)} ${note}${tag}`);
1730
+ }
1731
+ out.push('');
1732
+ // Never a silent cap — a truncated list that does not say so reads as "that is everything".
1733
+ if (hidden) out.push(` ${c(33, `+ ${hidden} more without a recorded acceptance, not shown`)} ${c(90, `— node ${opts.self} --all`)}`);
1734
+ // The two ranges, said out loud. The board reads the whole trunk so a delivered initiative does
1735
+ // not render as unstarted; the gate reads the PR so "spec before build" stays decidable. A rule
1736
+ // can therefore read done here and still be evaluated on the PR — intended, and not a discrepancy.
1737
+ stoppedList();
1738
+ out.push(` ${c(90, 'derived from the whole trunk history; the CI gate evaluates order within the PR only')}`);
1739
+ out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
1740
+ out.push('');
1741
+ return out.join('\n');
1742
+ }
1743
+
1744
+ /*
1745
+ * What "in flight" means, in one place, so the CLI, the gate and the workspace panel cannot drift.
1746
+ *
1747
+ * NOT `!legacy` — that was the bug this replaced. Legacy is about whether a rule may fail a PR;
1748
+ * being old has nothing to do with whether work is live. In flight = **not accepted**, ordered so
1749
+ * the rows worth looking at come first:
1750
+ * 1. anything blocked — the reason you opened the surface
1751
+ * 2. anything this change carries — precise, git says so
1752
+ * 3. most recently worked on — the honest fallback, and the repo already knows it
1753
+ *
1754
+ * The cap is REPORTED, never silent: a list quietly truncated to 12 reads as "that's everything",
1755
+ * which is the same class of lie as a false green.
1756
+ */
1757
+ const BOARD_CAP = 12;
1758
+ function inFlight(rows, opts) {
1759
+ const cap = (opts && opts.cap) || BOARD_CAP;
1760
+ const live = rows.filter((r) => !r.ok || (!r.accepted && !r.stopped));
1761
+ const rank = (r) => (!r.ok ? 0 : r.blockers.length ? 1 : r.in_change ? 2 : 3);
1762
+ const sorted = [...live].sort((a, b) =>
1763
+ rank(a) - rank(b)
1764
+ || (b.last_activity || 0) - (a.last_activity || 0)
1765
+ || String(a.id).localeCompare(String(b.id)));
1766
+ return { shown: sorted.slice(0, cap), hidden: Math.max(0, sorted.length - cap), total: sorted.length };
1767
+ }
1768
+
1769
+ function listInitiatives(root) {
1770
+ const L = layout(root);
1771
+ try {
1772
+ return fs.readdirSync(L.initiatives).filter((f) => f.endsWith('.md') && f !== 'README.md' && f !== 'index.md')
1773
+ .map((f) => f.replace(/\.md$/, '')).sort();
1774
+ } catch { return []; }
1775
+ }
1776
+
1777
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────────────
1778
+ function main(argv) {
1779
+ const args = argv.slice(2);
1780
+ let root = process.cwd(); let json = false; let gate = false; let all = false; let lanes = false;
1781
+ const rest = [];
1782
+ for (let i = 0; i < args.length; i++) {
1783
+ const a = args[i];
1784
+ if (a === '--root') { root = args[++i]; continue; }
1785
+ if (a === '--json') { json = true; continue; }
1786
+ if (a === '--gate') { gate = true; continue; }
1787
+ if (a === '--all') { all = true; continue; }
1788
+ if (a === '--lanes') { lanes = true; continue; }
1789
+ if (a === '--open') { continue; } // the board is in-flight-only by default now
1790
+ if (a === '--help' || a === '-h') {
1791
+ process.stdout.write([
1792
+ '', ' node scripts/workflow-state.js [<initiative-id>] [--json] [--gate] [--all] [--root <dir>]', '',
1793
+ ' (no id) the board — what is in flight, blocked first, then this change, then recency',
1794
+ ' <id> the ladder for one initiative: where you are, what is next, what blocks',
1795
+ ' --gate <id> CI mode — exit 1 on a blocking order violation, with the next action',
1796
+ ' --all the board without the 12-row cap',
1797
+ ' --lanes every lane this repository offers: when to use it, input, output, done',
1798
+ ' --json machine-readable (what an agent reads at session start)', '',
1799
+ ].join('\n') + '\n');
1800
+ return 0;
1801
+ }
1802
+ rest.push(a);
1803
+ }
1804
+ if (lanes) {
1805
+ const info = lanesFor(root);
1806
+ if (json) { process.stdout.write(JSON.stringify(info, null, 2) + '\n'); return 0; }
1807
+ const out = ['', ` ${info.profile} — ${info.lanes.length} lanes`, ''];
1808
+ for (const name of info.lanes) {
1809
+ const d = info.defs[name];
1810
+ out.push(` ${c(1, name)}${d.label ? ` — ${d.label}` : ''}${d.track ? c(90, ` [${d.track} track]`) : ''}`);
1811
+ out.push(` when ${d.when}`);
1812
+ out.push(` input ${d.requires || d.input}`);
1813
+ out.push(` output ${d.output}`);
1814
+ out.push(` done ${d.done}`, '');
1815
+ }
1816
+ process.stdout.write(out.join('\n') + '\n');
1817
+ return 0;
1818
+ }
1819
+ const self = path.relative(root, __filename) || 'scripts/workflow-state.js';
1820
+ const id = rest[0];
1821
+
1822
+ if (id) {
1823
+ const w = computeWorkflow(id, { root });
1824
+ if (json) { process.stdout.write(JSON.stringify(w, null, 2) + '\n'); }
1825
+ else { process.stdout.write(renderLadder(w) + '\n'); }
1826
+ if (!gate) return w.ok ? 0 : 1;
1827
+
1828
+ // ── gate mode ──────────────────────────────────────────────────────────────────────────────
1829
+ if (!w.ok) { process.stdout.write(`::error::workflow-gate: ${w.error}\n`); return 1; }
1830
+ if (w.stopped) { process.stdout.write(`ℹ️ '${w.id}' is ${w.stopped.state} — stopped initiatives do not gate (D70).\n✅ workflow gate PASSED — '${w.id}' is ${w.stopped.state}.\n`); return 0; }
1831
+ const fails = w.steps.filter((s) => s.status === 'blocked' && s.blocking);
1832
+ if (fails.length) {
1833
+ for (const f of fails) {
1834
+ process.stdout.write(`::error::workflow-gate [${f.rule || f.key}]: ${f.evidence}\n`);
1835
+ process.stdout.write(`❌ ${f.key} — ${f.evidence}\n → ${f.next}\n`);
1836
+ }
1837
+ return 1;
1838
+ }
1839
+ const reported = w.steps.filter((s) => s.status === 'blocked' && !s.blocking);
1840
+ for (const r of reported) {
1841
+ // Reported, not failed: O5/O6 cannot be true before the diff exists. Saying WHY at the point
1842
+ // of report is what stops this reading as a gate that was quietly skipped.
1843
+ 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`);
1844
+ process.stdout.write(`⚠️ ${r.key} — ${r.evidence}\n → ${r.next}\n`);
1845
+ }
1846
+ if (w.legacy) process.stdout.write(`ℹ️ '${w.id}' is LEGACY (record predates ${ENFORCED_FROM}) — order rules reported, not enforced.\n`);
1847
+ if (w.range.error) process.stdout.write(`⚠️ order checks degraded — ${w.range.error}\n`);
1848
+ process.stdout.write(`✅ workflow gate PASSED — '${w.id}' (${w.lane}) at stage ${w.stage}.\n`);
1849
+ return 0;
1850
+ }
1851
+
1852
+ // R-7: the BOARD reads the trunk, not the pull request. The gate path above keeps commitRange().
1853
+ // The PR range is still computed and passed alongside — it is what `● in this change` means, and
1854
+ // it is the one thing the trunk range genuinely cannot answer.
1855
+ const range = historyRange(root);
1856
+ const changeRange = commitRange(root);
1857
+ const ids = listInitiatives(root);
1858
+ // ONE delivery index for the whole board. Built here rather than inside each ladder for the same
1859
+ // reason the commit range is: `deliveryIndex` memoises per trunk head, so a per-initiative build
1860
+ // would be cheap but the intent — one read, shared — is what keeps the rows consistent with each
1861
+ // other even if the trunk moved mid-run.
1862
+ const deliveryIdx = delivery.deliveryIndex(root, ids, { recordsDir: layout(root).initiatives });
1863
+ const rows = ids.map((i) => computeWorkflow(i, { root, range, changeRange, deliveryIndex: deliveryIdx }));
1864
+ if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
1865
+ if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
1866
+ process.stdout.write(renderBoard(rows, { self, all }));
1867
+ return 0;
1868
+ }
1869
+
1870
+ module.exports = {
1871
+ computeWorkflow, readLifecycle, STOPPED_STATES, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
1872
+ parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
1873
+ // the board's trunk range, exported so the suite can assert the split: the same record is legacy
1874
+ // under BOTH ranges, and `in this change` is false under the trunk one.
1875
+ historyRange,
1876
+ ENFORCED_FROM,
1877
+ lanesFor, laneNames,
1878
+ forgetDocumentScan, documentDuplicates, documentCandidates, DOCUMENT_KINDS,
1879
+ };
1880
+
1881
+ /*
1882
+ * `exitCode`, not `process.exit()`. `process.exit` tears the process down without waiting for stdout
1883
+ * to drain, and when stdout is a PIPE — every CI invocation, every `| jq`, every test harness —
1884
+ * writes larger than the pipe buffer are asynchronous. `--json` on this repository is ~65 KB, so it
1885
+ * arrived TRUNCATED MID-STRING and parsed as invalid JSON, while an interactive run to a TTY looked
1886
+ * perfect. Setting the code and letting the process end naturally flushes first.
1887
+ */
1888
+ if (require.main === module) process.exitCode = main(process.argv);