create-pmos 0.132.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 (51) 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/index.md +1 -0
  8. package/workspace/guide/welcome.md +38 -0
  9. package/workspace/lib/import-overlay.js +32 -0
  10. package/workspace/lib/route-modules.js +1 -0
  11. package/workspace/lib/routes-onboarding.js +215 -0
  12. package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
  13. package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
  14. package/workspace/lib/vendor/scripts/intake.js +78 -0
  15. package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
  16. package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
  17. package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
  18. package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
  19. package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
  20. package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
  21. package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
  22. package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
  23. package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
  24. package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
  25. package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
  26. package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
  27. package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
  28. package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
  29. package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
  30. package/workspace/lib/vendor/scripts/status-facts.js +214 -0
  31. package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
  32. package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
  33. package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
  34. package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
  35. package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
  36. package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
  37. package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
  38. package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
  39. package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
  40. package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
  41. package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
  42. package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
  43. package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
  44. package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
  45. package/workspace/package-lock.json +2 -2
  46. package/workspace/package.json +1 -1
  47. package/workspace/public/pm/components.css +280 -0
  48. package/workspace/public/pm/welcome.js +283 -0
  49. package/workspace/public/welcome.html +20 -0
  50. package/workspace/server.js +2 -2
  51. package/workspace/test-lists/onboarding-firstrun.json +15 -0
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+ /* actor.js — who a write is, read from the CALL PATH, never from a field (pm-os-absorb, PRD R23 and R25).
3
+ *
4
+ * fromUi() → { by: 'pm:wawan', via: 'ui' } the page, after the route's own checks
5
+ * fromCli({isTTY, env}) → { by, via: 'cli' } a bound agent session, else a terminal (the PM), else unknown
6
+ * isPm(actor) · isAgent(actor) · sessionOf(actor)
7
+ * pageName(by) → 'agent:sess-<n>' for 'agent:sess-<n>.<8hex>', else `by` unchanged
8
+ *
9
+ * F5: a ledger line's `by` carries the session token; the page must never see it. Every GET route and every needs
10
+ * provider maps each `by` it answers through pageName (proposals, moves, item owners, report attributions).
11
+ *
12
+ * The placements, measurements and issues writers take their actor from here, so the four ledgers attribute one way.
13
+ * A caller never passes `by` or `via`: a writer that accepted them would make attribution something a caller chooses.
14
+ *
15
+ * THE SESSION-TOKEN MAP (Run 2, slice 2A). server.js gives each agent session a token, `sess-<n>.<8 hex>`, in its PTY
16
+ * environment as PMOS_SESSION. The token itself is never written to disk or sent to the page (D08, F5). server.js
17
+ * persists its SHA-256 (`tokenHash`) in the session's own meta file, so after a restart the map still answers which
18
+ * session a token belongs to and which initiative that session is bound to. The map is read from the files each time
19
+ * (no cache, D33), so a CLI with no server reads the same answer as the server.
20
+ *
21
+ * tokenHash(token) → 64 hex characters, or null for a value that is not a token
22
+ * sessionForToken(token, { sessDir }) → { session, initiative } | null
23
+ *
24
+ * HONEST LIMIT. A token is an attribution, not a credential: a process that unsets PMOS_SESSION is no longer an agent
25
+ * to fromCli. pm-guard.js requirePmCli asks such a caller to type the id of a PM-only act (X12); CI detection is the
26
+ * backstop for the rest.
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+
32
+ const PM_SLUG = 'wawan';
33
+ const SESSION_RE = /^sess-\d+\.[0-9a-f]{8}$/;
34
+ const BY_RE = /^(?:pm:[a-z0-9_-]{1,40}|agent:unknown|agent:sess-\d+\.[0-9a-f]{8})$/;
35
+ const VIA = ['ui', 'cli'];
36
+
37
+ const PM = Object.freeze({ by: `pm:${PM_SLUG}`, via: 'ui' });
38
+
39
+ /** The page. Call it only after the route has run its checks (pm-guard.js requirePmPress for a PM-only act). */
40
+ function fromUi() { return { by: PM.by, via: 'ui' }; }
41
+
42
+ /**
43
+ * A `pmos <verb>` call. A bound agent session FIRST, by its token: an agent that runs its commands in a PTY (or wraps
44
+ * them with `script`) still has a terminal, and a terminal must not turn its writes into the PM's. Only agent sessions
45
+ * get PMOS_SESSION (server.js spawnSession), so the PM's own shell is a terminal with no token: the PM. Else unknown.
46
+ */
47
+ function fromCli({ isTTY = !!(process.stdin.isTTY && process.stdout.isTTY), env = process.env } = {}) {
48
+ const tok = env && env.PMOS_SESSION;
49
+ if (typeof tok === 'string' && SESSION_RE.test(tok)) return { by: `agent:${tok}`, via: 'cli' };
50
+ if (isTTY) return { by: `pm:${PM_SLUG}`, via: 'cli' };
51
+ return { by: 'agent:unknown', via: 'cli' };
52
+ }
53
+
54
+ const valid = (a) => !!a && typeof a.by === 'string' && BY_RE.test(a.by) && VIA.includes(a.via);
55
+ const isPm = (a) => valid(a) && a.by.startsWith('pm:');
56
+ const isAgent = (a) => valid(a) && a.by.startsWith('agent:');
57
+ /** The session token of an agent actor, or null. */
58
+ const sessionOf = (a) => (isAgent(a) && a.by !== 'agent:unknown' ? a.by.slice('agent:'.length) : null);
59
+
60
+ /** The name the page may show. Strips the session token's secret suffix; anything else passes through. */
61
+ function pageName(by) {
62
+ if (typeof by !== 'string') return by;
63
+ const m = /^(agent:sess-\d+)\.[0-9a-f]{8}$/.exec(by);
64
+ return m ? m[1] : by;
65
+ }
66
+
67
+ /** The SHA-256 of a session token. The map stores this, never the token. */
68
+ function tokenHash(token) {
69
+ if (typeof token !== 'string' || !SESSION_RE.test(token)) return null;
70
+ return crypto.createHash('sha256').update(token).digest('hex');
71
+ }
72
+
73
+ /**
74
+ * The session a token belongs to, read from the session meta files server.js persists (`<sessDir>/<id>.json`). The id
75
+ * part of the token names the file, and the stored hash must match: a token with the right id and a wrong suffix is not
76
+ * that session. Null when there is no such session or no hash on record. `plan_item` is the item Hand to agent gave the
77
+ * session (null for any other session).
78
+ */
79
+ function sessionForToken(token, { sessDir } = {}) {
80
+ const h = tokenHash(token);
81
+ if (!h || typeof sessDir !== 'string' || !sessDir) return null;
82
+ const id = token.slice(0, token.indexOf('.'));
83
+ let meta;
84
+ try { meta = JSON.parse(fs.readFileSync(path.join(sessDir, id + '.json'), 'utf8')); } catch { return null; }
85
+ if (!meta || meta.id !== id || typeof meta.tokenHash !== 'string' || meta.tokenHash.length !== h.length) return null;
86
+ if (!crypto.timingSafeEqual(Buffer.from(meta.tokenHash), Buffer.from(h))) return null;
87
+ const str = (v) => (typeof v === 'string' && v ? v : null);
88
+ return { session: id, initiative: str(meta.initiative), plan_item: str(meta.plan_item) };
89
+ }
90
+
91
+ module.exports = { tokenHash, sessionForToken, PM_SLUG, SESSION_RE, BY_RE, VIA, fromUi, fromCli, valid, isPm, isAgent, sessionOf, pageName };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+ /*
3
+ * board-cache.js — the workflow board, cached and derived OFF the event loop.
4
+ *
5
+ * WHY THIS EXISTS. `console-state.workflow()` runs `scripts/workflow-state.js` over every
6
+ * initiative record, and the engine resolves each artifact's FIRST APPEARANCE by shelling `git`.
7
+ * Measured on the PMOS repo, 2026-07-29: ~260 ms per initiative × 109 records = **~30 seconds**, and
8
+ * every millisecond of it is SYNCHRONOUS. Today that cost is paid only when the PM opens the
9
+ * `workflow` lens, where it freezes the server — and with it every PTY WebSocket — for half a minute.
10
+ *
11
+ * PRD R4 puts the same board behind EVERY sidebar row, so an uncached in-process derivation would
12
+ * turn a 30-second freeze into a freeze on every render. Two rules follow, and they are the whole
13
+ * design:
14
+ *
15
+ * 1. NEVER derive on the request path. A miss spawns a CHILD PROCESS and returns immediately.
16
+ * The event loop is never blocked, however cold the cache is.
17
+ * 2. NEVER serve a stale board as if it were fresh. Every hit carries `as_of` and `stale`, and a
18
+ * cold cache returns `pending: true` rather than an empty board. A surface that cannot say how
19
+ * old its answer is has no business showing a stage — that is R5's rule applied to time
20
+ * instead of to branch.
21
+ *
22
+ * The signature is HEAD + the initiative records' own mtimes, so editing a record refreshes the
23
+ * board without a commit, and switching branch refreshes it without an edit.
24
+ */
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { spawnSync, spawn } = require('child_process');
28
+
29
+ const MAX_AGE_MS = 10 * 60 * 1000; // a board older than this is refreshed even if the signature holds
30
+ const REFRESH_TIMEOUT_MS = 120000; // a derivation that cannot finish in 2 minutes is reported, not awaited
31
+
32
+ const cache = new Map(); // root -> { sig, at, value, error }
33
+ const inflight = new Map(); // root -> true
34
+
35
+ // headSha runs on every signature, and the needs poll asks for a signature per registered tree several times per
36
+ // request, so a `git rev-parse` each time held the server for seconds with twenty trees registered. The answer is
37
+ // kept per root while the files a commit, checkout or reset touches (HEAD, its ref, packed-refs, the HEAD reflog)
38
+ // are unchanged; anything unreadable falls back to asking git.
39
+ const headMemo = new Map(); // root -> { key, sha }
40
+ function gitDirOf(root) {
41
+ const dotgit = path.join(root, '.git');
42
+ const st = fs.statSync(dotgit);
43
+ if (st.isDirectory()) return { dir: dotgit, common: dotgit };
44
+ const m = /^gitdir:\s*(.+)\s*$/m.exec(fs.readFileSync(dotgit, 'utf8'));
45
+ if (!m) throw new Error('no gitdir');
46
+ const dir = path.resolve(root, m[1].trim());
47
+ let common = dir;
48
+ try { common = path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()); } catch { /* not a linked worktree */ }
49
+ return { dir, common };
50
+ }
51
+ function headKey(root) {
52
+ const { dir, common } = gitDirOf(root);
53
+ const head = fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim();
54
+ const stat = (f) => { try { const st = fs.statSync(f); return `${st.size}:${st.mtimeMs}:${st.ino}`; } catch { return '-'; } };
55
+ const ref = /^ref:\s*(.+)$/.exec(head);
56
+ return [head, ref ? stat(path.join(common, ref[1])) : '', stat(path.join(common, 'packed-refs')), stat(path.join(dir, 'logs', 'HEAD'))].join('|');
57
+ }
58
+ function headSha(root) {
59
+ let key = null;
60
+ try { key = headKey(root); } catch { key = null; }
61
+ const hit = headMemo.get(root);
62
+ if (key && hit && hit.key === key) return hit.sha;
63
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 });
64
+ const sha = r.status === 0 && r.stdout ? r.stdout.trim() : 'no-head';
65
+ if (key) headMemo.set(root, { key, sha });
66
+ return sha;
67
+ }
68
+
69
+ // The records' own state, so an UNCOMMITTED edit still invalidates. Cheap: one readdir + one stat
70
+ // per record, no git, no file contents.
71
+ function recordsSig(root) {
72
+ // Both layouts (pmos-self `planning/`, a PM repository's `pmos/state/`), and the ledgers the
73
+ // board's rows depend on: an uncommitted lifecycle or acceptance line must not wait out the TTL (D70).
74
+ // pm-os-absorb: plan-items.jsonl (Run 4's Build rule reads items) and issues.jsonl (Run 5's report lane reads issues).
75
+ const dirs = [path.join(root, 'planning', 'initiatives'), path.join(root, 'pmos', 'state', 'initiatives')];
76
+ const ledgers = ['lifecycle.jsonl', 'acceptances.jsonl', 'plan-items.jsonl', 'issues.jsonl'].flatMap((f) => [
77
+ path.join(root, 'planning', 'runs', f), path.join(root, 'pmos', 'state', f)]);
78
+ let total = 0; let newest = 0; let any = false;
79
+ for (const dir of dirs) {
80
+ let names = [];
81
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort(); } catch { continue; }
82
+ any = true; total += names.length;
83
+ for (const n of names) {
84
+ try { const st = fs.statSync(path.join(dir, n)); if (st.mtimeMs > newest) newest = st.mtimeMs; }
85
+ catch { /* a record that vanished mid-scan just does not raise the watermark */ }
86
+ }
87
+ }
88
+ if (!any) return 'none';
89
+ let led = 0;
90
+ for (const f of ledgers) { try { const st = fs.statSync(f); led = Math.max(led, Math.round(st.mtimeMs)) + st.size; } catch { /* absent */ } }
91
+ return `${total}:${Math.round(newest)}:${led}`;
92
+ }
93
+
94
+ function signature(root) { return `${headSha(root)}|${recordsSig(root)}`; }
95
+
96
+ // The child runs the SAME reader the lens uses — no second implementation of the board to drift.
97
+ function refresh(root, sig) {
98
+ if (inflight.get(root)) return;
99
+ inflight.set(root, true);
100
+ const script = `
101
+ const cs = require(${JSON.stringify(path.join(__dirname, 'console-state.js'))});
102
+ process.stdout.write(JSON.stringify(cs.workflow({ root: ${JSON.stringify(root)} })));
103
+ `;
104
+ let out = '';
105
+ let child;
106
+ try { child = spawn(process.execPath, ['-e', script], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }); }
107
+ catch (e) {
108
+ inflight.delete(root);
109
+ cache.set(root, { sig, at: Date.now(), value: null, error: 'could not start the board derivation: ' + (e.code || e.message) });
110
+ return;
111
+ }
112
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, REFRESH_TIMEOUT_MS);
113
+ if (timer.unref) timer.unref();
114
+ child.stdout.on('data', (c) => { out += c; });
115
+ child.on('error', (e) => {
116
+ clearTimeout(timer); inflight.delete(root);
117
+ cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation failed to run: ' + (e.code || e.message) });
118
+ });
119
+ child.on('close', (code, signal) => {
120
+ clearTimeout(timer); inflight.delete(root);
121
+ if (signal === 'SIGKILL') {
122
+ cache.set(root, { sig, at: Date.now(), value: null, error: `the board took longer than ${Math.round(REFRESH_TIMEOUT_MS / 1000)}s to derive and was stopped` });
123
+ return;
124
+ }
125
+ if (code !== 0 || !out) {
126
+ cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation exited without a result' });
127
+ return;
128
+ }
129
+ try { cache.set(root, { sig, at: Date.now(), value: JSON.parse(out), error: null }); }
130
+ catch { cache.set(root, { sig, at: Date.now(), value: null, error: 'the board derivation returned unreadable output' }); }
131
+ });
132
+ }
133
+
134
+ /*
135
+ * The read the request path uses. Always returns immediately.
136
+ * { pending: true } nothing cached yet; a derivation was started
137
+ * { ok:false, error, as_of, stale } the derivation failed — render RED, never a calm empty
138
+ * { ...workflowPayload, as_of, stale } a board; `stale` means a refresh is running behind it
139
+ */
140
+ function board(root, { refreshNow = false } = {}) {
141
+ if (!root) return { ok: false, error: 'no workspace root resolved for the workflow board', as_of: null, stale: false };
142
+ let sig;
143
+ try { sig = signature(root); } catch { sig = 'unreadable'; }
144
+ const hit = cache.get(root);
145
+ const fresh = hit && hit.sig === sig && (Date.now() - hit.at) < MAX_AGE_MS;
146
+ if (!fresh || refreshNow) refresh(root, sig);
147
+ if (!hit) return { pending: true, refreshing: true, as_of: null, stale: false };
148
+ const envelope = { as_of: new Date(hit.at).toISOString(), stale: !fresh, refreshing: !!inflight.get(root) };
149
+ if (hit.error) return { ok: false, error: hit.error, ...envelope };
150
+ return { ...hit.value, ...envelope };
151
+ }
152
+
153
+ // Test/maintenance seam: drop everything, or one root.
154
+ function invalidate(root) { if (root) { cache.delete(root); inflight.delete(root); } else { cache.clear(); inflight.clear(); } }
155
+
156
+ module.exports = { board, invalidate, signature, MAX_AGE_MS, REFRESH_TIMEOUT_MS };
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+ /*
3
+ * Read-only console state readers (D64 / pivot-workspace-first, Track B).
4
+ *
5
+ * The workspace console surfaces — the "needs you" queue, a radar-style health summary, and a
6
+ * metrics view — derive ONLY from local file-mode state: the kit's `pmos/state/*.jsonl` and, as a
7
+ * seed/demo source when no live state exists yet, the committed hosted snapshot under
8
+ * `planning/data-export/<date>-hosted-snapshot/*.json`. Live file-mode state is ALWAYS preferred.
9
+ *
10
+ * HONESTY BY CONSTRUCTION (the anti-watermelon rule): every reader returns a discriminated result —
11
+ * { ok:true, empty:false, source, data } data present
12
+ * { ok:true, empty:true, source } genuinely nothing (calm empty state on the client)
13
+ * { ok:false, error } a missing source / parse failure — the client MUST
14
+ * render this RED, never a green "all clear".
15
+ * A missing DATA source (no live file AND no snapshot) is an ERROR, not an empty — a health surface
16
+ * that cannot read its inputs is broken, and a broken honesty surface must read red.
17
+ */
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const reviewGate = require('./review-gate');
21
+ const gitInfo = require('./git-info'); // R5: the ladder is branch-scoped — name the branch it came from
22
+
23
+ function loadJson(file) {
24
+ let text;
25
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
26
+ try { return { ok: true, data: JSON.parse(text) }; }
27
+ catch { return { ok: false, error: `${path.basename(file)} is not valid JSON — the file-mode source is corrupt, not empty` }; }
28
+ }
29
+ function loadJsonl(file) {
30
+ let text;
31
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
32
+ const rows = []; let bad = 0;
33
+ for (const line of text.split('\n')) {
34
+ const t = line.trim(); if (!t) continue;
35
+ try { rows.push(JSON.parse(t)); } catch { bad++; }
36
+ }
37
+ // A file that is entirely unparseable is corrupt state — surface it red rather than call it empty.
38
+ if (bad > 0 && rows.length === 0) return { ok: false, error: `every line in ${path.basename(file)} is malformed — corrupt file-mode state` };
39
+ return { ok: true, data: rows, bad };
40
+ }
41
+
42
+ function computeMetricsFromRuns(runs) {
43
+ const corr = runs.map((r) => Number(r.human_corrections) || 0);
44
+ const total = corr.reduce((a, b) => a + b, 0);
45
+ const needing = corr.filter((c) => c > 0).length;
46
+ const n = runs.length;
47
+ return {
48
+ total_runs: n,
49
+ avg_corrections_per_run: n ? +(total / n).toFixed(3) : 0,
50
+ runs_needing_correction: needing,
51
+ share_runs_corrected: n ? +(needing / n).toFixed(3) : 0,
52
+ per_initiative: [],
53
+ };
54
+ }
55
+
56
+ // ── metrics: prefer live pmos/state/runs.jsonl; fall back to the snapshot's metrics.json ──
57
+ function metrics({ stateDir, snapshotDir }) {
58
+ if (stateDir) {
59
+ const live = loadJsonl(path.join(stateDir, 'runs.jsonl'));
60
+ if (live.ok) {
61
+ if (!live.data.length) return { ok: true, empty: true, source: 'file-mode' };
62
+ return { ok: true, empty: false, source: 'file-mode', data: computeMetricsFromRuns(live.data) };
63
+ }
64
+ if (live.ok === false) return { ok: false, error: live.error };
65
+ // live.missing → fall through to the snapshot
66
+ }
67
+ if (!snapshotDir) return { ok: false, error: 'no metrics source — no live runs.jsonl and no hosted snapshot for this workspace' };
68
+ const snap = loadJson(path.join(snapshotDir, 'metrics.json'));
69
+ if (snap.missing) return { ok: false, error: 'metrics source missing — neither live runs.jsonl nor the hosted snapshot is present' };
70
+ if (!snap.ok) return { ok: false, error: snap.error };
71
+ const m = snap.data || {};
72
+ return { ok: true, empty: false, source: 'snapshot', data: {
73
+ total_runs: m.total_runs, avg_corrections_per_run: m.avg_corrections_per_run,
74
+ runs_needing_correction: m.runs_needing_correction, share_runs_corrected: m.share_runs_corrected,
75
+ per_initiative: Array.isArray(m.per_initiative) ? m.per_initiative.slice(0, 12) : [],
76
+ } };
77
+ }
78
+
79
+ // ── radar: prefer a live pmos/state/radar.json; fall back to the snapshot's radar_all_sections.json ──
80
+ function radar({ stateDir, snapshotDir }) {
81
+ let src = null, source = null;
82
+ if (stateDir) {
83
+ const live = loadJson(path.join(stateDir, 'radar.json'));
84
+ if (live.ok) { src = live.data; source = 'file-mode'; }
85
+ else if (live.ok === false) return { ok: false, error: live.error };
86
+ }
87
+ if (!src) {
88
+ if (!snapshotDir) return { ok: false, error: 'no radar source — no live radar.json and no hosted snapshot for this workspace' };
89
+ const snap = loadJson(path.join(snapshotDir, 'radar_all_sections.json'));
90
+ if (snap.missing) return { ok: false, error: 'radar source missing — neither live radar.json nor the hosted snapshot is present' };
91
+ if (!snap.ok) return { ok: false, error: snap.error };
92
+ src = snap.data; source = 'snapshot';
93
+ }
94
+ const d = src || {};
95
+ const gates = Array.isArray(d.eval_gates) ? d.eval_gates : [];
96
+ const passing = gates.filter((g) => reviewGate.isReviewPass(g.verdict, g.score)).length;
97
+ const wms = (Array.isArray(d.watermelons) ? d.watermelons : []).map((w) => ({
98
+ id: w.id, title: w.title, rag: w.rag,
99
+ verdict: w.latest_eval_verdict, score: w.latest_eval_score, corrections: w.avg_human_corrections,
100
+ }));
101
+ const calRaw = Array.isArray(d.calibration) ? d.calibration[0] : d.calibration;
102
+ const calibration = calRaw ? {
103
+ n: calRaw.n, kappa: calRaw.kappa, tnr: calRaw.tnr, status: calRaw.status,
104
+ meets_d40: !!calRaw.meets_d40_kappa, caveat: calRaw.leakage_caveat || null,
105
+ } : null;
106
+ const okr = (Array.isArray(d.okr_pulse) ? d.okr_pulse : []).slice(0, 8).map((o) => ({
107
+ objective: o.objective, kr: o.kr, progress: o.progress_pct, pulse: o.pulse,
108
+ }));
109
+ return { ok: true, empty: false, source, data: {
110
+ // watermelon flags = anything not green (amber/red) leads; the whole point is to surface drift
111
+ watermelons: wms,
112
+ flags: wms.filter((w) => w.rag && String(w.rag).toLowerCase() !== 'green').length,
113
+ eval_gate: { total: gates.length, passing, pass_rate: gates.length ? +(passing / gates.length).toFixed(3) : null },
114
+ calibration, okr_pulse: okr,
115
+ } };
116
+ }
117
+
118
+ /*
119
+ * ── workflow: where each initiative is, what is next, what is blocking ────────────────────────
120
+ *
121
+ * The one surface that answers "where am I in the loop" without the PM reconstructing it from
122
+ * files. It renders the SAME ladder the CLI and the Initiative Gate print, from the same engine —
123
+ * `scripts/workflow-state.js` — so the panel can never disagree with what CI is about to say.
124
+ *
125
+ * The engine is the one BUNDLED WITH THE WORKSPACE, pointed at the opened repo via its `root`
126
+ * option. It is deliberately not `require`d out of the opened directory: the engine only reads
127
+ * files and runs `git -C <root> log`, so no code from a workspace the PM merely opened is ever
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.
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
+ }
163
+ function workflow({ root }) {
164
+ if (!root) return { ok: false, error: 'no workspace root resolved for the workflow surface' };
165
+ const loaded = loadEngine();
166
+ if (!loaded.ok) return { ok: false, error: loaded.error };
167
+ const engine = loaded.engine;
168
+ let ids;
169
+ try { ids = engine.listInitiatives(root); }
170
+ catch (e) { return { ok: false, error: 'could not list initiative records: ' + String((e && e.message) || 'error').slice(0, 120) }; }
171
+ if (!ids.length) return { ok: true, empty: true, source: 'file-mode' };
172
+
173
+ /*
174
+ * THE TRUNK, not the pull request (workflow-status-sweep R-3).
175
+ *
176
+ * This used to be `engine.commitRange(root)` — merge-base(origin/main, HEAD)..HEAD — and the trap
177
+ * is documented three lines below in words that were written and then not acted on: on `main`, or
178
+ * on a branch level with it, that range is EMPTY with `range.ok === true`. Nothing reads as
179
+ * degraded, every `build` rung legitimately reports `pending`, and the four-panel fold rolls
180
+ * BUILD/EVAL/ACCEPTED to 0/3 for every initiative in the repository — including ones that shipped
181
+ * months ago. The PM opens the panel to ask what is delivered and is told nothing is.
182
+ *
183
+ * So the panel asks the question the CLI board already asks — "where does this initiative stand?",
184
+ * which only the trunk can answer — and passes the PR range alongside as `changeRange`, which is
185
+ * what `● in this change` means and the one thing the trunk range genuinely cannot answer. Same
186
+ * split, same reasons, stated in `historyRange`'s own header. The `build` rung is attributed per
187
+ * initiative now (R-2), so a trunk-wide range no longer means a trunk-wide answer.
188
+ */
189
+ const range = engine.historyRange(root);
190
+ const changeRange = engine.commitRange(root);
191
+ // ONE delivery index for the board, built once and shared, exactly as the CLI board does.
192
+ let deliveryIndex = null;
193
+ try { deliveryIndex = require(path.join(path.dirname(loaded.from), 'delivery-index.js')).deliveryIndex(root, ids, { recordsDir: engine.layout(root).initiatives }); }
194
+ catch { deliveryIndex = null; } // absent → each ladder builds its own; the engine handles it
195
+ const bi = gitInfo.branchInfo(root);
196
+ const scope = {
197
+ branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
198
+ branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
199
+ commits: range.ok ? range.shas.length : null,
200
+ whole_history: range.ok ? !!range.wholeHistory : null,
201
+ // Kept, and now describing `changeRange` rather than the derivation range. It is no longer the
202
+ // trap it names — the derivation cannot land in it — but a consumer that wants to say "nothing
203
+ // in this branch touches these initiatives" still needs the fact, and deleting a field because
204
+ // the bug it documented was fixed is how the next reader loses the reason.
205
+ empty_range: changeRange.ok ? changeRange.shas.length === 0 : null,
206
+ change_commits: changeRange.ok ? changeRange.shas.length : null,
207
+ };
208
+ const rows = ids.map((id) => {
209
+ try { return engine.computeWorkflow(id, { root, range, changeRange, deliveryIndex }); }
210
+ catch (e) { return { ok: false, id, error: String((e && e.message) || 'derivation failed').slice(0, 160) }; }
211
+ });
212
+ // Same `inFlight` the CLI and the gate use — the panel must not invent its own idea of what is
213
+ // open. Filtering by `legacy` here was a real bug: on main every record is legacy, so the panel
214
+ // read "0 in flight" against 93 initiatives, blank at exactly the moment someone opens it.
215
+ const { shown, hidden, total } = engine.inFlight(rows);
216
+ const shape = (r) => (r.ok ? {
217
+ id: r.id, title: r.title, lane: r.lane, stage: r.stage, legacy: r.legacy,
218
+ in_change: r.in_change, anchor: r.anchor,
219
+ record: r.record, blockers: r.blockers, next: r.next,
220
+ steps: r.steps.map((s) => ({ key: s.key, status: s.status, evidence: s.evidence, next: s.next, blocking: s.blocking, rule: s.rule })),
221
+ } : { id: r.id, error: r.error });
222
+ return {
223
+ ok: true, empty: false, source: 'file-mode',
224
+ data: {
225
+ live: shown.map(shape),
226
+ hidden, // reported on the client — a silent cap reads as "that is all"
227
+ total,
228
+ accepted: rows.filter((r) => r.ok && r.accepted).length,
229
+ // D70 — stopped initiatives are off the board and not gating, but listed, never dropped silently.
230
+ stopped: rows.filter((r) => r.ok && r.stopped).map((r) => ({ id: r.id, title: r.title, state: r.stopped.state, reason: r.stopped.reason, at: r.stopped.at, pm: r.stopped.pm })),
231
+ unreadable: rows.filter((r) => !r.ok).map(shape),
232
+ blocked: shown.filter((r) => r.ok && r.blockers.length).length,
233
+ degraded: range.ok ? null : range.reason,
234
+ scope, // R5 — the branch this ladder was derived from; never omit it
235
+ enforced_from: engine.ENFORCED_FROM,
236
+ },
237
+ };
238
+ }
239
+
240
+ // ENGINE_PATHS/loadEngine are exported for the packaging guard in workspace/test.js: the incident
241
+ // that made them a list was invisible to every unit test precisely because resolution was inline.
242
+ /* ONE initiative's ladder, derived on demand.
243
+ *
244
+ * WHY THIS IS SEPARATE FROM `workflow`. The board caps what it returns (`inFlight`) and reports the
245
+ * remainder as `hidden` — 12 shown against 93 hidden in this repository. That cap is right for a
246
+ * board, which is a list of what is in flight. It is wrong for a PANEL, which is asked about one
247
+ * initiative the PM picked by name: reading the capped list and finding nothing there means "not in
248
+ * the top twelve", and a panel that renders that as "no initiative record" states something false
249
+ * about 93 of 105 records. This derives the one that was asked for, so the answer is about the
250
+ * initiative rather than about its rank.
251
+ *
252
+ * It is safe on the request path for the same reason the board is not: this shells git once for the
253
+ * commit range and runs the engine ONCE, rather than 105 times. `workflow` still owns the board.
254
+ */
255
+ function ladder({ root, id }) {
256
+ if (!root) return { ok: false, error: 'no workspace root resolved for the ladder' };
257
+ if (!id) return { ok: false, error: 'no initiative id given' };
258
+ const loaded = loadEngine();
259
+ if (!loaded.ok) return { ok: false, error: loaded.error };
260
+ const engine = loaded.engine;
261
+ // ABSENT and BROKEN are different facts and must not share an answer. The engine returns
262
+ // `ok:false` for BOTH "there is no record with this id" and "the record is there and unreadable",
263
+ // and collapsing them makes the panel paint a red "read failed" over an initiative that simply
264
+ // does not exist — overstating a missing file as a fault. The initiative list is the arbiter:
265
+ // an id the repository does not know is an empty read, and only an id it DOES know can fail one.
266
+ let known = null;
267
+ try { known = engine.listInitiatives(root); } catch { known = null; }
268
+ if (Array.isArray(known) && !known.includes(id)) return { ok: true, empty: true, row: null };
269
+ // The trunk, for the reason `workflow` above states at length: this is asked about ONE initiative
270
+ // the PM named, and "where does it stand" is a question the pull-request range cannot answer — on
271
+ // main it is empty, so the panel reported `BUILD 0/3` on delivered work (R-3).
272
+ let range, changeRange, row;
273
+ try { range = engine.historyRange(root); }
274
+ catch (e) { return { ok: false, error: 'could not resolve the commit range: ' + String((e && e.message) || 'error').slice(0, 120) }; }
275
+ try { changeRange = engine.commitRange(root); } catch { changeRange = { ok: false }; }
276
+ try { row = engine.computeWorkflow(id, { root, range, changeRange }); }
277
+ catch (e) { return { ok: false, error: `could not derive the ladder for ${id}: ` + String((e && e.message) || 'error').slice(0, 160) }; }
278
+ if (row && row.ok === false) return { ok: false, error: row.error || `the record for ${id} could not be read` };
279
+ const bi = gitInfo.branchInfo(root);
280
+ return {
281
+ ok: true,
282
+ empty: !row, // no such record — distinct from a read that failed
283
+ row: row || null,
284
+ scope: {
285
+ branch: bi.ok ? (bi.current || 'a detached HEAD') : null,
286
+ branch_error: bi.ok ? null : (bi.error || 'branch could not be read'),
287
+ commits: range.ok ? range.shas.length : null,
288
+ whole_history: range.ok ? !!range.wholeHistory : null,
289
+ empty_range: changeRange.ok ? changeRange.shas.length === 0 : null,
290
+ change_commits: changeRange.ok ? changeRange.shas.length : null,
291
+ },
292
+ };
293
+ }
294
+
295
+ module.exports = { metrics, radar, workflow, ladder, computeMetricsFromRuns, loadJson, loadJsonl, ENGINE_PATHS, loadEngine };