create-pmos 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,764 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /*
4
+ * PMOS workflow sweep — find the state that misreports, and repair only what is mechanical.
5
+ *
6
+ * node scripts/workflow-sweep.js the sweep, human-readable
7
+ * node scripts/workflow-sweep.js --json machine-readable
8
+ * node scripts/workflow-sweep.js --fix apply every repairable finding
9
+ * node scripts/workflow-sweep.js --fix --dry-run print what --fix would write, write nothing
10
+ * node scripts/workflow-sweep.js --gate exit 1 while any DEFECT remains
11
+ * node scripts/workflow-sweep.js --owed the PM worklist only
12
+ * node scripts/workflow-sweep.js --root <dir> sweep another repo (tenant, kit, fixture)
13
+ * node scripts/workflow-sweep.js <id> one initiative
14
+ *
15
+ * WHY THIS EXISTS. The workflow surfaces are derived, which makes them self-maintaining and does
16
+ * NOT make them self-correcting. A derivation is only as true as what it reads, and nothing audited
17
+ * what it reads. On this repository that meant four manufactured acceptance rows written by a test
18
+ * suite into the file that records the non-delegable D12 gate; two colliding `run_id`s in a ledger
19
+ * whose readers build id→row maps; a merged branch with no record; and twenty-four records whose
20
+ * `parent_kr` prose was truncated mid-write. Every one of those is invisible on every surface,
21
+ * because a surface derived from damaged inputs reports confidently from the damage.
22
+ *
23
+ * ── THE ONE DISTINCTION THIS TOOL IS BUILT AROUND ────────────────────────────────────────────
24
+ *
25
+ * A DEFECT is state that CONTRADICTS REALITY or cannot be read. The board is lying. This is the
26
+ * sweep's business, and where the repair is mechanical, its repair.
27
+ *
28
+ * An OWED finding is work that HONESTLY HAS NOT HAPPENED. 53 of this repository's 65 merged
29
+ * initiative branches have no run row and 49 have no acceptance. That is not corrupt data — it is
30
+ * an open loop, and it is the largest population here by an order of magnitude.
31
+ *
32
+ * They never share a bucket. Collapsing them fails in both directions at once: it buries four real
33
+ * defects inside fifty-three ordinary open items, and it licenses the tool to "fix" the fifty-three
34
+ * by writing the missing rows.
35
+ *
36
+ * ── WHAT THIS TOOL WILL NOT DO, EVER ─────────────────────────────────────────────────────────
37
+ *
38
+ * It will not write a run, an eval verdict, an acceptance, a `pre_run_contract` approval, a
39
+ * `specimen_test` answer, a `surface_check` answer, or a `delivered_by`. Those are judgements — the
40
+ * PM's or the evaluator's — and a tool that manufactures them produces a perfectly green board out
41
+ * of evidence nobody produced. That is not a hypothetical failure mode: it is the single most
42
+ * attractive shortcut available to this program, it would satisfy every count in the PRD, and it
43
+ * would be the most complete watermelon PMOS has ever shipped.
44
+ *
45
+ * What it repairs is POINTERS AND DAMAGED TEXT — facts about where a file is and what a truncated
46
+ * line said. A repair that requires choosing between two plausible candidates is not mechanical, and
47
+ * is reported rather than guessed. `--fix` writes only to initiative records; it never touches a
48
+ * `.jsonl`, because the ledgers are append-only and a sweep that edits history is not a sweep.
49
+ *
50
+ * Zero dependencies, stock Node. It runs in CI, in a terminal, and against a vendored kit.
51
+ */
52
+
53
+ const fs = require('fs');
54
+ const path = require('path');
55
+ const { execFileSync } = require('child_process');
56
+
57
+ const engine = require('./workflow-state');
58
+ const anchors = require('./anchor-resolve');
59
+ const delivery = require('./delivery-index');
60
+
61
+ // ── severity ───────────────────────────────────────────────────────────────────────────────────
62
+ const DEFECT = 'defect';
63
+ const OWED = 'owed';
64
+
65
+ /*
66
+ * The finding classes, in one table, so the split cannot drift into the code that produces them.
67
+ * `repairable` is a property of the CLASS, not of an individual finding: a class either has a
68
+ * mechanical repair or it does not, and adding one is a decision rather than a convenience.
69
+ */
70
+ const CLASSES = {
71
+ 'torn-ledger-line': { severity: DEFECT, repairable: false, why: 'a ledger line that does not parse is dropped by every reader — a truncated acceptance reads as "never accepted"' },
72
+ 'orphan-ledger-row': { severity: DEFECT, repairable: false, why: 'a row naming no record reports state for an initiative that does not exist' },
73
+ 'duplicate-run-id': { severity: DEFECT, repairable: false, why: 'both the contract and priors rungs build id→row maps; a collision silently loses a row, and append-only has no rule for which supersedes' },
74
+ 'dangling-path': { severity: DEFECT, repairable: true, why: 'the record points at a file that is not there, so the rung reading it reports on nothing' },
75
+ 'damaged-field': { severity: DEFECT, repairable: true, why: 'a frontmatter value truncated mid-write renders cut off wherever it is shown; only git can restore the lost text, and where it cannot this is a human\'s line to finish' },
76
+ 'missing-record': { severity: DEFECT, repairable: true, why: 'work merged with no record is invisible to every board and to the gate' },
77
+ 'unresolvable-anchor': { severity: DEFECT, repairable: false, why: 'the anchor names something the OKR documents do not declare (D59)' },
78
+ 'unverifiable-delivery': { severity: DEFECT, repairable: false, why: 'delivered_by names a commit the trunk cannot reach, or one that changes no build path (R-2b)' },
79
+ 'delivered-unlogged': { severity: OWED, repairable: false, why: 'the trunk attributes work to this initiative and no run was ever logged (AGENTS.md step 7)' },
80
+ 'delivered-unreviewed': { severity: OWED, repairable: false, why: 'delivered against a rubric with no Review-Gate verdict recorded — a skipped gate is recorded as skipped, never left absent' },
81
+ 'delivered-unaccepted': { severity: OWED, repairable: false, why: 'delivered with no acceptance row — the D12 gate is open' },
82
+ 'unexplained-run': { severity: OWED, repairable: false, why: 'a logged run with no explainer; raw-transcript vigilance degrades within ~30 min (O6)' },
83
+ };
84
+
85
+ function finding(cls, o) {
86
+ const meta = CLASSES[cls];
87
+ if (!meta) throw new Error(`workflow-sweep: unknown finding class '${cls}' — add it to CLASSES with its severity and whether it is repairable`);
88
+ return {
89
+ class: cls,
90
+ severity: meta.severity,
91
+ repairable: meta.repairable,
92
+ id: o.id || null,
93
+ file: o.file || null,
94
+ line: o.line == null ? null : o.line,
95
+ what: o.what,
96
+ evidence: o.evidence || null,
97
+ // Every finding says either how it is repaired or why it cannot be. A finding with neither is a
98
+ // dead end presented as information.
99
+ repair: o.repair || null,
100
+ hand: o.hand || (meta.repairable ? null : meta.why),
101
+ };
102
+ }
103
+
104
+ // ── reading ────────────────────────────────────────────────────────────────────────────────────
105
+
106
+ function git(root, args) {
107
+ try {
108
+ return execFileSync('git', ['-C', root, ...args], {
109
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
110
+ });
111
+ } catch { return null; }
112
+ }
113
+
114
+ /** Every non-blank line of a ledger, parsed or reported. Never both, never neither. */
115
+ function readLedger(abs) {
116
+ const rel = abs;
117
+ let text;
118
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { return { present: false, rows: [], torn: [] }; }
119
+ const rows = [];
120
+ const torn = [];
121
+ const lines = text.split('\n');
122
+ for (let i = 0; i < lines.length; i++) {
123
+ const t = lines[i].trim();
124
+ if (!t) continue;
125
+ try { rows.push({ row: JSON.parse(t), line: i + 1 }); }
126
+ catch (e) { torn.push({ file: rel, line: i + 1, reason: String((e && e.message) || 'not valid JSON').slice(0, 120), text: t.slice(0, 160) }); }
127
+ }
128
+ return { present: true, rows, torn };
129
+ }
130
+
131
+ /*
132
+ * A frontmatter value is DAMAGED when it was truncated mid-write: an opening bracket or quote that
133
+ * never closes on the same value.
134
+ *
135
+ * The test is deliberately narrow. Prose in these fields is normal and encouraged — the router's
136
+ * answers are reasoning, not enum tokens — so "looks unfinished" is not something to guess at. An
137
+ * unbalanced delimiter is different: it is a fact about the text, decidable without judgement, and
138
+ * it is what the twenty-four damaged records in this repository actually have.
139
+ */
140
+ function damagedValue(v) {
141
+ const s = String(v == null ? '' : v);
142
+ if (!s.trim()) return null;
143
+ const opens = (s.match(/\(/g) || []).length;
144
+ const closes = (s.match(/\)/g) || []).length;
145
+ if (opens > closes) return { kind: 'paren', missing: opens - closes };
146
+ // A value that opens with a quote and does not close it — the YAML would have swallowed the rest.
147
+ if (/^"/.test(s.trim()) && !/"\s*$/.test(s.trim())) return { kind: 'quote', missing: 1 };
148
+ return null;
149
+ }
150
+
151
+ /** The conventional locations a lane's spec artifact is written to, for the dangling-path repair. */
152
+ function candidatesFor(root, L, key, id) {
153
+ const kitish = L.kit;
154
+ const dirs = {
155
+ prd_path: kitish ? ['planning/prd'] : ['planning/prd', 'planning/briefs', 'planning/research'],
156
+ rubric_path: ['planning/evals'],
157
+ explainer_path: kitish ? ['pmos/state/explainers'] : ['planning/explainers', 'planning/evals/explainers'],
158
+ }[key] || [];
159
+ const names = {
160
+ prd_path: [`${id}-prd.md`, `${id}-spec.md`, `${id}-brief.md`, `${id}.md`],
161
+ rubric_path: [`${id}-eval-rubric.md`, `${id}-rubric.md`],
162
+ explainer_path: [`${id}-surface-explainer.md`, `${id}-explainer.md`],
163
+ }[key] || [];
164
+ const out = [];
165
+ for (const d of dirs) for (const n of names) {
166
+ const rel = `${d}/${n}`;
167
+ if (fs.existsSync(path.join(root, rel))) out.push(rel);
168
+ }
169
+ return [...new Set(out)];
170
+ }
171
+
172
+ // ── the sweep ──────────────────────────────────────────────────────────────────────────────────
173
+
174
+ function sweep(opts) {
175
+ const root = path.resolve((opts && opts.root) || process.cwd());
176
+ const L = engine.layout(root);
177
+ const only = opts && opts.id;
178
+ const findings = [];
179
+ const notes = [];
180
+
181
+ const ids = engine.listInitiatives(root);
182
+ /*
183
+ * ABSENT and EMPTY are different facts and must not share an answer — the same rule the workspace
184
+ * applies to a ladder read. A freshly vendored kit HAS a `state/initiatives/` directory with
185
+ * nothing in it yet, and telling that adopter "is this a PMOS repo?" on their first run is the
186
+ * tool being wrong about a repository that is perfectly correct. A missing directory is the real
187
+ * "you pointed me somewhere else" case, and only it gets the error.
188
+ */
189
+ if (!ids.length) {
190
+ if (!fs.existsSync(L.initiatives)) {
191
+ return { ok: false, root, error: `no initiatives directory at ${path.relative(root, L.initiatives) || L.initiatives} — this does not look like a PMOS repo (use --root to point at one)`, findings: [], notes: [] };
192
+ }
193
+ return {
194
+ ok: true, root, lane_layout: L.kit ? 'kit' : 'pmos-self', initiatives: 0, empty: true,
195
+ findings: [], defects: [], owed: [], repairable: 0, delivery_read: null,
196
+ notes: [`no initiative records yet under ${path.relative(root, L.initiatives)} — nothing to sweep, which is a clean result rather than a failed read`],
197
+ };
198
+ }
199
+ const idset = new Set(ids);
200
+
201
+ // ── the ledgers, read once ────────────────────────────────────────────────────────────────────
202
+ const ledgerFiles = {
203
+ runs: path.join(L.runsDir, L.runsFile),
204
+ evals: path.join(L.runsDir, 'evals.jsonl'),
205
+ acceptances: path.join(L.runsDir, 'acceptances.jsonl'),
206
+ };
207
+ const ledgers = {};
208
+ for (const [name, abs] of Object.entries(ledgerFiles)) {
209
+ const led = readLedger(abs);
210
+ ledgers[name] = led;
211
+ const rel = path.relative(root, abs);
212
+ if (!led.present) { notes.push(`${rel} is not present — no rows to sweep`); continue; }
213
+
214
+ for (const t of led.torn) {
215
+ findings.push(finding('torn-ledger-line', {
216
+ file: rel, line: t.line,
217
+ what: `${rel}:${t.line} does not parse and is silently dropped by every reader`,
218
+ evidence: `${t.reason} — ${t.text}`,
219
+ }));
220
+ }
221
+ // A row naming an initiative with no record. Reported, never removed: the ledgers are
222
+ // append-only, and a sweep that deletes rows to make its own output clean is the worst possible
223
+ // version of this tool.
224
+ for (const { row, line } of led.rows) {
225
+ const iid = row && row.initiative_id;
226
+ if (iid && !idset.has(iid)) {
227
+ findings.push(finding('orphan-ledger-row', {
228
+ id: iid, file: rel, line,
229
+ what: `${rel}:${line} records ${name === 'acceptances' ? 'an acceptance' : name === 'evals' ? 'an eval' : 'a run'} for '${iid}', which has no initiative record`,
230
+ evidence: JSON.stringify(row).slice(0, 200),
231
+ hand: name === 'acceptances'
232
+ ? 'an acceptance naming no initiative records the non-delegable D12 gate against nothing — the PM decides whether it is annotated or the record is created'
233
+ : CLASSES['orphan-ledger-row'].why,
234
+ }));
235
+ }
236
+ }
237
+ }
238
+ // Duplicate run ids — checked across the runs ledger only, where run_id is the identity every
239
+ // other rung dereferences.
240
+ {
241
+ const seen = new Map();
242
+ for (const { row, line } of ledgers.runs.rows) {
243
+ const rid = row && row.run_id;
244
+ if (!rid) continue;
245
+ if (seen.has(rid)) {
246
+ findings.push(finding('duplicate-run-id', {
247
+ id: row.initiative_id || null, file: path.relative(root, ledgerFiles.runs), line,
248
+ what: `run_id '${rid}' appears twice (lines ${seen.get(rid)} and ${line})`,
249
+ evidence: 'the contract and priors rungs dereference run_id through a Map — last write wins, so one of these rows is invisible to both',
250
+ }));
251
+ } else seen.set(rid, line);
252
+ }
253
+ }
254
+
255
+ const runsBy = new Map();
256
+ for (const { row } of ledgers.runs.rows) {
257
+ if (!row || !row.initiative_id) continue;
258
+ if (!runsBy.has(row.initiative_id)) runsBy.set(row.initiative_id, []);
259
+ runsBy.get(row.initiative_id).push(row);
260
+ }
261
+ const evalIds = new Set(ledgers.evals.rows.map(({ row }) => row && row.initiative_id).filter(Boolean));
262
+ const acceptedIds = new Set(ledgers.acceptances.rows
263
+ .filter(({ row }) => row && (row.accepted === true || row.action === 'approve'))
264
+ .map(({ row }) => row.initiative_id).filter(Boolean));
265
+
266
+ // ── delivery, read once ───────────────────────────────────────────────────────────────────────
267
+ const del = delivery.deliveryIndex(root, ids);
268
+ if (!del.ok) {
269
+ // Not silence, and not a wall of false findings either: without the trunk, every delivery-derived
270
+ // class is undecidable, so they are skipped and the skip is REPORTED. A sweep that reported
271
+ // "0 delivered-unlogged" here would be claiming the loop is closed on evidence it never read.
272
+ notes.push(`delivery could not be derived — ${del.reason}. The delivered-* classes were NOT evaluated; this is not a clean result for them.`);
273
+ }
274
+
275
+ // ── merged branches with no record ────────────────────────────────────────────────────────────
276
+ // `mergedBranches`, NOT the attribution index's `byBranch`. This finding RE-CREATES something from
277
+ // its match, and the attribution set is deliberately broad enough to include ids quoted inside a
278
+ // commit body — which on this repository would have written records for `foo`, `save_okr` and
279
+ // `pmos-operating-guide.`, three tokens that appear in prose and name nothing. A repair tool that
280
+ // invents initiatives out of code examples is worse than one that misses a real gap.
281
+ if (!only) {
282
+ const merged = delivery.mergedBranches(root);
283
+ if (!merged.ok) notes.push(`merged branches could not be read — ${merged.reason}. The missing-record class was NOT evaluated.`);
284
+ for (const [b, m] of merged.ids || []) {
285
+ if (idset.has(b)) continue;
286
+ const when = m.at ? new Date(m.at * 1000).toISOString().slice(0, 10) : null;
287
+ findings.push(finding('missing-record', {
288
+ id: b,
289
+ what: `an initiative/${b} branch merged to the trunk and there is no record at ${path.relative(root, path.join(L.initiatives, `${b}.md`))}`,
290
+ evidence: `${(m.sha || '').slice(0, 8)} ${m.subject}`.trim().slice(0, 160),
291
+ // `back` travels with the repair so the record can say which evidence it had. A back-merge
292
+ // subject describes housekeeping, not the work, and quoting it as an initiative's title
293
+ // would put a sentence about merging main into the title slot of the thing that shipped.
294
+ repair: {
295
+ kind: 'reconstruct-record', id: b, merged: when,
296
+ sha: m.sha ? m.sha.slice(0, 8) : null,
297
+ title: m.back ? null : (m.subject || null),
298
+ evidence_kind: m.back ? 'back-merge' : 'landing-merge',
299
+ subject: m.subject || null,
300
+ },
301
+ }));
302
+ }
303
+ }
304
+
305
+ // ── per record ────────────────────────────────────────────────────────────────────────────────
306
+ for (const id of ids) {
307
+ if (only && id !== only) continue;
308
+ const recAbs = path.join(L.initiatives, `${id}.md`);
309
+ const recRel = path.relative(root, recAbs);
310
+ let text;
311
+ try { text = fs.readFileSync(recAbs, 'utf8'); } catch { continue; }
312
+ const fm = engine.parseLeadingFrontmatter(text);
313
+ const lane = (fm.lane || 'feature').trim() || 'feature';
314
+
315
+ // dangling declared paths
316
+ for (const key of ['prd_path', 'rubric_path', 'explainer_path']) {
317
+ const v = String(fm[key] || '').trim();
318
+ if (!v) continue;
319
+ if (fs.existsSync(path.join(root, v))) continue;
320
+ const cands = candidatesFor(root, L, key, id);
321
+ findings.push(finding('dangling-path', {
322
+ id, file: recRel,
323
+ what: `${key} points at '${v}', which is not in the repository`,
324
+ evidence: cands.length ? `candidate(s) at the conventional location: ${cands.join(', ')}` : 'no file at any conventional location for this key',
325
+ // Exactly one candidate is mechanical. Two is a choice, and a choice is not this tool's.
326
+ repair: cands.length === 1 ? { kind: 'repoint', file: recRel, key, from: v, to: cands[0] } : null,
327
+ hand: cands.length > 1
328
+ ? `${cands.length} candidates exist (${cands.join(', ')}) — choosing between them is a judgement, so it is reported rather than guessed`
329
+ : cands.length === 0
330
+ ? 'no candidate exists — the artifact was never written, or lives somewhere no convention names. Write it, or clear the key: optional never means dangling'
331
+ : null,
332
+ }));
333
+ }
334
+
335
+ // damaged frontmatter values
336
+ for (const [key, v] of Object.entries(fm)) {
337
+ const dmg = damagedValue(v);
338
+ if (!dmg) continue;
339
+ // The repair is a RESTORATION or it is nothing. Closing the bracket would balance the YAML and
340
+ // assert an ending the text does not have — `…the meta-watermelon made computab)` reads as a
341
+ // finished sentence that is missing its last four words. Only git can say what the value used
342
+ // to be, so only git may supply it; where it cannot, this is a human's line to finish.
343
+ const restored = recoverFromHistory(root, recRel, key, String(v));
344
+ findings.push(finding('damaged-field', {
345
+ id, file: recRel,
346
+ what: `${key} is truncated — ${dmg.kind === 'paren' ? `${dmg.missing} unclosed parenthes${dmg.missing > 1 ? 'es' : 'is'}` : 'an unclosed quote'}`,
347
+ evidence: String(v).slice(0, 120),
348
+ repair: restored ? { kind: 'restore-from-git', file: recRel, key, from: String(v), to: restored.value, sha: restored.sha } : null,
349
+ hand: restored ? null
350
+ : 'the intact value is not in this file\'s git history, so the lost text cannot be recovered mechanically. Closing the bracket alone would assert an ending the sentence does not have — finish the line yourself',
351
+ }));
352
+ }
353
+
354
+ // the anchor
355
+ {
356
+ const raw = String(fm.parent_kr || '').trim();
357
+ const r = anchors.resolveAnchor(raw, engineOkrIndex(root), budgetsFor(root, L));
358
+ if (r.status === 'unresolvable' || r.status === 'empty') {
359
+ findings.push(finding('unresolvable-anchor', {
360
+ id, file: recRel,
361
+ what: r.status === 'empty' ? 'parent_kr is empty — unanchored work is rejected (D59)' : `parent_kr '${raw.slice(0, 60)}' resolves to nothing`,
362
+ evidence: r.reason || null,
363
+ }));
364
+ }
365
+ }
366
+
367
+ // a declared delivery that does not check out
368
+ {
369
+ const dd = delivery.declaredDelivery(root, fm.delivered_by);
370
+ if (dd.declared && !dd.ok && !dd.unknown) {
371
+ findings.push(finding('unverifiable-delivery', {
372
+ id, file: recRel,
373
+ what: dd.reason,
374
+ evidence: `delivered_by: ${String(fm.delivered_by).slice(0, 100)}`,
375
+ }));
376
+ }
377
+ }
378
+
379
+ // ── owed ────────────────────────────────────────────────────────────────────────────────────
380
+ // Everything below is real work that has not happened. None of it is repairable, by design, and
381
+ // the reason is written on each finding rather than only in this file's header — the person
382
+ // reading the output is the one who needs to know why the tool stopped.
383
+ const signal = del.ok ? delivery.attribution(del, id) : 'unknown';
384
+ const declaredOk = delivery.declaredDelivery(root, fm.delivered_by).ok === true;
385
+ const delivered = del.ok && (!!signal || declaredOk);
386
+ const runs = runsBy.get(id) || [];
387
+
388
+ if (delivered) {
389
+ const how = declaredOk ? 'declared' : signal;
390
+ if (!runs.length) {
391
+ findings.push(finding('delivered-unlogged', {
392
+ id, file: recRel,
393
+ what: 'work is attributable to this initiative on the trunk and no run was ever logged',
394
+ evidence: delivery.attributionEvidence(how, id),
395
+ hand: 'append the run to the ledger yourself (AGENTS.md step 7). The sweep will not write it: a run row carries a pre_run_contract and a human_corrections count, and inventing either fabricates the north-star signal it feeds',
396
+ }));
397
+ }
398
+ if (String(fm.rubric_path || '').trim() && !evalIds.has(id)) {
399
+ findings.push(finding('delivered-unreviewed', {
400
+ id, file: recRel,
401
+ what: 'delivered against a rubric with no Review-Gate verdict recorded',
402
+ evidence: `rubric_path: ${fm.rubric_path}`,
403
+ hand: 'score it (build-skills/evaluator.skill) or record it as SKIPPED — a gate that was skipped is recorded as skipped, and the sweep cannot score anything without being the evaluator it is auditing',
404
+ }));
405
+ }
406
+ if (!acceptedIds.has(id)) {
407
+ findings.push(finding('delivered-unaccepted', {
408
+ id, file: recRel,
409
+ what: 'delivered with no acceptance row — the D12 gate is open',
410
+ evidence: delivery.attributionEvidence(how, id),
411
+ hand: 'the Acceptance Gate is the PM\'s and is non-delegable (D12). This is the one finding the sweep is structurally forbidden from closing',
412
+ }));
413
+ }
414
+ }
415
+
416
+ for (const r of runs) {
417
+ if (!r.run_id) continue;
418
+ const expl = path.join(L.explainers, `${r.run_id}-explainer.md`);
419
+ if (fs.existsSync(expl)) continue;
420
+ findings.push(finding('unexplained-run', {
421
+ id, file: recRel,
422
+ what: `run ${r.run_id} has no explainer at ${path.relative(root, expl)}`,
423
+ evidence: 'O6 — the explainer is what the PM reviews; raw-transcript vigilance degrades within ~30 min',
424
+ }));
425
+ }
426
+ }
427
+
428
+ const defects = findings.filter((f) => f.severity === DEFECT);
429
+ const owed = findings.filter((f) => f.severity === OWED);
430
+ return {
431
+ ok: true, root, lane_layout: L.kit ? 'kit' : 'pmos-self',
432
+ initiatives: ids.length,
433
+ findings, defects, owed, notes,
434
+ repairable: defects.filter((f) => f.repair).length,
435
+ delivery_read: del.ok ? null : del.reason,
436
+ };
437
+ }
438
+
439
+ // Cached the same way the engine caches them — the sweep derives over every record in one pass.
440
+ const _okr = new Map();
441
+ function engineOkrIndex(root) {
442
+ if (!_okr.has(root)) _okr.set(root, anchors.parseOkrIndex(root));
443
+ return _okr.get(root);
444
+ }
445
+ const _bud = new Map();
446
+ function budgetsFor(root, L) {
447
+ if (!_bud.has(root)) {
448
+ try { _bud.set(root, { ok: true, text: fs.readFileSync(L.budgets, 'utf8'), path: path.relative(root, L.budgets) }); }
449
+ catch { _bud.set(root, { ok: false, text: '', path: path.relative(root, L.budgets) }); }
450
+ }
451
+ return _bud.get(root);
452
+ }
453
+
454
+ /*
455
+ * The intact value, if this file's own history holds one — the only mechanical repair for truncated
456
+ * text that is a FACT rather than a guess.
457
+ *
458
+ * The test is strict and one-directional: an earlier version of the same key, in the same file,
459
+ * whose text STARTS WITH the damaged text and is longer, and which is not itself damaged. A value
460
+ * that merely differs is a rewrite, not a restoration, and reverting a deliberate edit under the
461
+ * banner of "repair" would be the tool overwriting the PM.
462
+ *
463
+ * On this repository it finds nothing: the 26 damaged values were truncated by whatever wrote them,
464
+ * so no intact version was ever committed. That is the correct outcome and the sweep says so on each
465
+ * finding, rather than reaching for the delimiter it could balance instead.
466
+ */
467
+ function recoverFromHistory(root, rel, key, damaged) {
468
+ const dmg = damaged.trim();
469
+ if (!dmg) return null;
470
+ const log = git(root, ['log', '--format=%H', '--', rel]);
471
+ if (!log) return null;
472
+ const shas = log.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 40);
473
+ for (const sha of shas) {
474
+ const text = git(root, ['show', `${sha}:${rel}`]);
475
+ if (!text) continue;
476
+ const past = engine.parseLeadingFrontmatter(text)[key];
477
+ if (past == null) continue;
478
+ const p = String(past).trim();
479
+ if (p === dmg || p.length <= dmg.length) continue;
480
+ if (!p.startsWith(dmg)) continue;
481
+ if (damagedValue(p)) continue; // an earlier damaged version is not a restoration
482
+ return { value: p, sha };
483
+ }
484
+ return null;
485
+ }
486
+
487
+ // ── repair ─────────────────────────────────────────────────────────────────────────────────────
488
+ /*
489
+ * The closed repair set. Three kinds, and each is a fact rather than a judgement:
490
+ * repoint — the file is at the conventional location and the pointer is stale
491
+ * close-delimiter — the text was truncated mid-write and the missing character is decidable
492
+ * reconstruct-record — a merge exists with no record; the record states it was reconstructed
493
+ *
494
+ * Nothing here writes a `.jsonl`, and nothing writes a value that expresses an opinion. The
495
+ * reconstructed record carries `lane: chore` with a stated intent rather than a guessed lane and a
496
+ * pointer at a spec that does not exist, and `reconstructed_from` rather than a backdated timestamp,
497
+ * so it can never read as though it followed the loop.
498
+ */
499
+ function applyRepairs(res, opts) {
500
+ const dry = !!(opts && opts.dryRun);
501
+ const root = res.root;
502
+ const L = engine.layout(root);
503
+ const applied = [];
504
+ const skipped = [];
505
+
506
+ // Group by file so multiple field repairs on one record are a single read/write.
507
+ const byFile = new Map();
508
+ for (const f of res.defects) {
509
+ if (!f.repair) continue;
510
+ const key = f.repair.kind === 'reconstruct-record' ? `__new__${f.repair.id}` : f.repair.file;
511
+ if (!byFile.has(key)) byFile.set(key, []);
512
+ byFile.get(key).push(f);
513
+ }
514
+
515
+ for (const [key, group] of byFile) {
516
+ if (key.startsWith('__new__')) {
517
+ for (const f of group) {
518
+ const r = f.repair;
519
+ const abs = path.join(L.initiatives, `${r.id}.md`);
520
+ const rel = path.relative(root, abs);
521
+ if (fs.existsSync(abs)) { skipped.push({ finding: f, why: 'a record now exists at that path' }); continue; }
522
+ const body = reconstructedRecord(r);
523
+ if (!dry) fs.writeFileSync(abs, body, 'utf8');
524
+ applied.push({ class: f.class, file: rel, before: '(no file)', after: `a reconstructed record, lane chore, reconstructed_from: ${r.merged || 'unknown'}` });
525
+ }
526
+ continue;
527
+ }
528
+ const abs = path.join(root, key);
529
+ let text;
530
+ try { text = fs.readFileSync(abs, 'utf8'); }
531
+ catch { for (const f of group) skipped.push({ finding: f, why: `${key} could not be read` }); continue; }
532
+ let next = text;
533
+ for (const f of group) {
534
+ const r = f.repair;
535
+ const before = frontmatterValue(next, r.key);
536
+ if (before === null) { skipped.push({ finding: f, why: `${r.key} is no longer in the frontmatter` }); continue; }
537
+ let after;
538
+ if (r.kind === 'repoint') {
539
+ if (before.trim() !== r.from) { skipped.push({ finding: f, why: `${r.key} changed since the sweep ran` }); continue; }
540
+ after = r.to;
541
+ } else if (r.kind === 'restore-from-git') {
542
+ if (before.trim() !== r.from.trim()) { skipped.push({ finding: f, why: `${r.key} changed since the sweep ran` }); continue; }
543
+ after = r.to;
544
+ } else { skipped.push({ finding: f, why: `unknown repair kind '${r.kind}'` }); continue; }
545
+ next = setFrontmatterValue(next, r.key, after);
546
+ applied.push({ class: f.class, file: key, key: r.key, before: before.slice(0, 90), after: after.slice(0, 90) });
547
+ }
548
+ if (next !== text && !dry) fs.writeFileSync(abs, next, 'utf8');
549
+ }
550
+ return { applied, skipped, dry };
551
+ }
552
+
553
+ /** The LEADING frontmatter only, matching every other reader in this repo. */
554
+ function frontmatterValue(text, key) {
555
+ const lines = String(text).split('\n');
556
+ for (const line of lines) {
557
+ const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
558
+ if (m && m[1] === key) return m[2];
559
+ if (line.trim() && !line.startsWith('---') && !m) break;
560
+ }
561
+ return null;
562
+ }
563
+ function setFrontmatterValue(text, key, value) {
564
+ const lines = String(text).split('\n');
565
+ for (let i = 0; i < lines.length; i++) {
566
+ const m = lines[i].match(/^([a-z_]+):\s*(.*?)\s*$/);
567
+ if (m && m[1] === key) { lines[i] = `${key}: ${value}`; return lines.join('\n'); }
568
+ if (lines[i].trim() && !lines[i].startsWith('---') && !m) break;
569
+ }
570
+ return text;
571
+ }
572
+
573
+ /*
574
+ * A record for work that merged without one. It is deliberately the MINIMUM that makes the work
575
+ * visible, and it states its own provenance in three places — `reconstructed_from`, the `status`
576
+ * intent, and the body — because the one thing a reconstruction must never do is read as though
577
+ * somebody wrote it at intake.
578
+ *
579
+ * `lane: chore` is not a guess about how large the work was. It is the only lane whose spec artifact
580
+ * is a sentence in the record itself, so it is the only lane a reconstruction can satisfy honestly:
581
+ * every other lane would need a `prd_path` pointing at a document nobody wrote.
582
+ */
583
+ function reconstructedRecord(r) {
584
+ const back = r.evidence_kind === 'back-merge';
585
+ // With no landing-merge subject there is no honest title to quote, so the record says what it
586
+ // knows instead of borrowing a sentence about something else.
587
+ const title = (r.title || `${r.id} — reconstructed from git; no landing-merge subject exists`)
588
+ .replace(/"/g, '\'').slice(0, 160);
589
+ const when = r.merged || 'unknown';
590
+ const evidenceLine = back
591
+ ? `- evidence: a **back-merge** (\`${(r.subject || '').replace(/`/g, '').slice(0, 90)}\`) reachable from the trunk. That commit is only
592
+ reachable at all because this branch was later merged in, so it proves the branch landed — but it
593
+ describes merging main INTO the branch, so its subject is not this work's title and its date is
594
+ the housekeeping's, not the delivery's. A squash merge left no landing subject to read.`
595
+ : `- evidence: the landing merge's own subject, quoted verbatim above`;
596
+ return `---
597
+ type: Initiative
598
+ title: "${title}"
599
+ timestamp: ${new Date().toISOString().slice(0, 10)}
600
+ id: ${r.id}
601
+ lane: chore
602
+ parent_kr: health:tech
603
+ reconstructed_from: ${when}
604
+ status: "RECONSTRUCTED by scripts/workflow-sweep.js from the merge, not authored at intake. No spec artifact was ever written for this work; the chore lane records that rather than pointing at a document which does not exist. The anchor is the D59 tech budget because no honest KR can be inferred after the fact — correct it here if you know better."
605
+ ---
606
+
607
+ # Initiative: ${r.id}
608
+
609
+ **This record was reconstructed from git, not written at intake.** An \`initiative/${r.id}\` branch
610
+ merged to the trunk with no record in the repository, so the work was invisible to the Initiative
611
+ Gate, to [\`scripts/workflow-state.js\`](/scripts/workflow-state.js), and to every board.
612
+
613
+ - merged: **${when}**${r.sha ? ` (\`${r.sha}\`)` : ''}${back ? ' — the back-merge\'s date; the landing date is not recoverable' : ''}
614
+ - anchor: \`health:tech\` — reconstructed after the fact, D59 tech budget, no honest KR
615
+ - lane: \`chore\` — the only lane whose spec artifact is a sentence in this record; every other lane
616
+ would need a \`prd_path\` pointing at a document nobody wrote
617
+ ${evidenceLine}
618
+
619
+ *Nothing here has been reconstructed from memory, and no run, eval or acceptance was written on this
620
+ initiative's behalf. Set \`delivered_by:\` to the commit this work actually landed in if you know it —
621
+ the ladder checks that against the trunk.*
622
+ `;
623
+ }
624
+
625
+ // ── rendering ──────────────────────────────────────────────────────────────────────────────────
626
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
627
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
628
+ const brief = (s, n) => {
629
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
630
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
631
+ };
632
+
633
+ function render(res, opts) {
634
+ if (!res.ok) return c(31, `✗ ${res.error}`);
635
+ const out = [''];
636
+ const D = res.defects.length;
637
+ const O = res.owed.length;
638
+ out.push(` ${c(1, 'PMOS workflow sweep')} · ${res.initiatives} initiatives · ${res.lane_layout} layout`);
639
+ out.push(` ${D ? c(31, `${D} defect${D === 1 ? '' : 's'}`) : c(32, 'no defects')} — state that contradicts reality or cannot be read${res.repairable ? c(33, ` · ${res.repairable} repairable with --fix`) : ''}`);
640
+ out.push(` ${O ? c(33, `${O} owed`) : c(32, 'nothing owed')} — work that honestly has not happened. ${c(90, 'Never repaired: see --owed')}`);
641
+ out.push(` ${c(90, '─'.repeat(94))}`);
642
+
643
+ const show = (list, heading, colour) => {
644
+ if (!list.length) return;
645
+ out.push('');
646
+ out.push(` ${c(colour, heading)}`);
647
+ const byClass = new Map();
648
+ for (const f of list) {
649
+ if (!byClass.has(f.class)) byClass.set(f.class, []);
650
+ byClass.get(f.class).push(f);
651
+ }
652
+ for (const [cls, group] of byClass) {
653
+ out.push(` ${c(1, cls)} ${c(90, `× ${group.length}`)} ${c(90, `— ${CLASSES[cls].why}`)}`);
654
+ const cap = opts && opts.all ? group.length : 6;
655
+ for (const f of group.slice(0, cap)) {
656
+ out.push(` ${c(colour, '•')} ${c(1, (f.id || f.file || '').padEnd(34))} ${brief(f.what, 96)}`);
657
+ if (f.evidence) out.push(` ${c(90, brief(f.evidence, 108))}`);
658
+ if (f.repair) out.push(` ${c(32, '→ repairable')} ${c(90, repairSentence(f.repair))}`);
659
+ else if (f.hand) out.push(` ${c(33, '→ by hand')} ${c(90, brief(f.hand, 104))}`);
660
+ }
661
+ // Never a silent cap — a truncated list that does not say so reads as "that is everything".
662
+ if (group.length > cap) out.push(` ${c(33, `+ ${group.length - cap} more ${cls}, not shown`)} ${c(90, '— --all')}`);
663
+ }
664
+ };
665
+
666
+ if (!(opts && opts.owedOnly)) show(res.defects, 'DEFECTS — the board is lying about these', 31);
667
+ if (!(opts && opts.defectsOnly)) show(res.owed, 'OWED — real work, honestly not done. The sweep reports these and will not close them.', 33);
668
+
669
+ for (const n of res.notes) out.push(`\n ${c(33, '!')} ${n}`);
670
+ out.push('');
671
+ if (!D && !O) out.push(` ${c(32, 'nothing to sweep — every record and every ledger row checks out.')}`);
672
+ else if (!D) out.push(` ${c(32, 'no defects.')} ${c(90, `${O} owed item(s) are work, not damage.`)}`);
673
+ out.push('');
674
+ return out.join('\n');
675
+ }
676
+
677
+ function repairSentence(r) {
678
+ if (r.kind === 'repoint') return `${r.key}: ${r.from} → ${r.to}`;
679
+ if (r.kind === 'restore-from-git') return `${r.key}: restore the intact value from ${String(r.sha).slice(0, 8)}`;
680
+ if (r.kind === 'reconstruct-record') return `write a reconstructed record (lane chore, reconstructed_from: ${r.merged || 'unknown'})`;
681
+ return r.kind;
682
+ }
683
+
684
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────────────
685
+ function main(argv) {
686
+ const args = argv.slice(2);
687
+ let root = process.cwd();
688
+ let json = false; let fix = false; let dryRun = false; let gate = false;
689
+ let owedOnly = false; let defectsOnly = false; let all = false;
690
+ const rest = [];
691
+ for (let i = 0; i < args.length; i++) {
692
+ const a = args[i];
693
+ if (a === '--root') { root = args[++i]; continue; }
694
+ if (a === '--json') { json = true; continue; }
695
+ if (a === '--fix') { fix = true; continue; }
696
+ if (a === '--dry-run') { dryRun = true; continue; }
697
+ if (a === '--gate') { gate = true; continue; }
698
+ if (a === '--owed') { owedOnly = true; continue; }
699
+ if (a === '--defects') { defectsOnly = true; continue; }
700
+ if (a === '--all') { all = true; continue; }
701
+ if (a === '--help' || a === '-h') {
702
+ process.stdout.write([
703
+ '', ' node scripts/workflow-sweep.js [<initiative-id>] [--json] [--fix [--dry-run]] [--gate] [--owed|--defects] [--all] [--root <dir>]', '',
704
+ ' (no id) sweep every initiative and every ledger row',
705
+ ' <id> sweep one initiative',
706
+ ' --fix apply the repairable DEFECTS. Never writes a .jsonl, never writes a judgement.',
707
+ ' --dry-run with --fix: print what would be written, write nothing',
708
+ ' --gate CI mode — exit 1 while any defect remains. Owed findings never fail it.',
709
+ ' --owed the PM worklist: real work, honestly not done',
710
+ ' --defects only state that contradicts reality',
711
+ ' --all do not cap each class at 6 rows',
712
+ ' --json machine-readable', '',
713
+ ' defect = the board is lying · owed = the loop is open. They never share a bucket.', '',
714
+ ].join('\n') + '\n');
715
+ return 0;
716
+ }
717
+ rest.push(a);
718
+ }
719
+
720
+ const res = sweep({ root, id: rest[0] });
721
+ if (!res.ok) {
722
+ process.stdout.write((json ? JSON.stringify(res, null, 2) : render(res, {})) + '\n');
723
+ return 1;
724
+ }
725
+
726
+ let repairs = null;
727
+ if (fix) {
728
+ repairs = applyRepairs(res, { dryRun });
729
+ if (!json) {
730
+ const head = dryRun ? ' --fix --dry-run — nothing was written:' : ' --fix applied:';
731
+ process.stdout.write(`\n${c(1, head)}\n`);
732
+ if (!repairs.applied.length) process.stdout.write(` ${c(90, 'no repairable defect found.')}\n`);
733
+ for (const a of repairs.applied) {
734
+ process.stdout.write(` ${c(32, '✓')} ${a.file}${a.key ? ` [${a.key}]` : ''}\n ${c(90, 'before:')} ${brief(a.before, 90)}\n ${c(90, 'after: ')} ${brief(a.after, 90)}\n`);
735
+ }
736
+ for (const s of repairs.skipped) process.stdout.write(` ${c(33, '·')} skipped ${s.finding.class} on ${s.finding.file || s.finding.id} — ${s.why}\n`);
737
+ process.stdout.write(`\n ${c(90, 'repairs land as working-tree edits. Review the diff — the sweep never commits.')}\n`);
738
+ }
739
+ }
740
+
741
+ if (json) {
742
+ process.stdout.write(JSON.stringify({ ...res, repairs }, null, 2) + '\n');
743
+ } else if (!fix || owedOnly || defectsOnly) {
744
+ process.stdout.write(render(res, { owedOnly, defectsOnly, all }));
745
+ } else {
746
+ process.stdout.write(render(res, { owedOnly, defectsOnly, all }));
747
+ }
748
+
749
+ // Owed findings NEVER fail the gate. 53 open loops is a worklist, and a gate that fails on a
750
+ // worklist is a gate everyone learns to bypass — which would cost the 4 real defects their alarm.
751
+ if (gate) return res.defects.length ? 1 : 0;
752
+ return 0;
753
+ }
754
+
755
+ module.exports = { sweep, applyRepairs, render, CLASSES, damagedValue, readLedger, frontmatterValue, setFrontmatterValue, DEFECT, OWED };
756
+
757
+ /*
758
+ * `exitCode`, not `process.exit()`. `process.exit` tears the process down without waiting for stdout
759
+ * to drain, and when stdout is a PIPE — every CI invocation, every `| jq`, every test harness —
760
+ * writes larger than the pipe buffer are asynchronous. `--json` on this repository is ~65 KB, so it
761
+ * arrived TRUNCATED MID-STRING and parsed as invalid JSON, while an interactive run to a TTY looked
762
+ * perfect. Setting the code and letting the process end naturally flushes first.
763
+ */
764
+ if (require.main === module) process.exitCode = main(process.argv);