create-pmos 0.11.0 → 0.11.1

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