create-pmos 0.132.0 → 0.134.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -2
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/notes.md +19 -1
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/notes-audio.js +28 -9
- package/workspace/lib/notes-capture.js +128 -0
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-whisper.js +132 -16
- package/workspace/lib/notes.js +4 -2
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +60 -8
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/calendar-views.js +25 -12
- package/workspace/public/pm/components.css +280 -0
- package/workspace/public/pm/console3.css +19 -0
- package/workspace/public/pm/shell-recording.js +122 -12
- package/workspace/public/pm/view-notes.js +7 -2
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +2 -2
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/meeting-notes.json +5 -3
- 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 };
|