create-pmos 0.38.4 → 0.38.6

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 (35) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS.md +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/okf/core/templates/requirements-brief-template.md +61 -0
  5. package/payload/planning/evals/eval-calibration.md +1 -1
  6. package/payload/planning/okrs/health-budgets.md +1 -1
  7. package/payload/scripts/lane-defs.js +226 -0
  8. package/payload/scripts/workflow-state.js +108 -24
  9. package/payload/scripts/workflow-sweep.js +155 -3
  10. package/payload/skills/design-brief.skill +5 -6
  11. package/payload/skills/discovery.skill +1 -2
  12. package/payload/skills/eval-rubric.skill +2 -5
  13. package/payload/skills/pmos-router.skill +3 -2
  14. package/payload/skills/prd.skill +7 -13
  15. package/payload/skills/requirements-elicitation.skill +231 -0
  16. package/payload/skills-native/design-brief/SKILL.md +5 -6
  17. package/payload/skills-native/discovery/SKILL.md +1 -2
  18. package/payload/skills-native/eval-rubric/SKILL.md +2 -5
  19. package/payload/skills-native/pmos-router/SKILL.md +3 -2
  20. package/payload/skills-native/prd/SKILL.md +7 -13
  21. package/payload/skills-native/requirements-elicitation/SKILL.md +233 -0
  22. package/payload/state/discoveries.md +1 -1
  23. package/payload/state/log.md +1 -1
  24. package/workspace/lib/audit.js +54 -0
  25. package/workspace/lib/engine/lane-defs.js +226 -0
  26. package/workspace/lib/engine/workflow-state.js +108 -24
  27. package/workspace/lib/engine/workflow-sweep.js +934 -0
  28. package/workspace/package-lock.json +1 -1
  29. package/workspace/package.json +2 -2
  30. package/workspace/public/pm/data.js +67 -24
  31. package/workspace/public/pm/panel-detail.js +12 -2
  32. package/workspace/public/pm/panel-repos.js +36 -5
  33. package/workspace/public/pm/tab-task.js +4 -1
  34. package/workspace/server.js +22 -1
  35. package/workspace/skills/gather-requirements.skill +13 -1
@@ -0,0 +1,934 @@
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
+ 'unpointed-artifact': { severity: DEFECT, repairable: true, why: 'a document names this initiative and no path key in the record points at it, so the ladder reads the step as open over finished work (D77)' },
80
+ 'lane-outside-profile': { severity: DEFECT, repairable: false, why: 'the record names a lane its repository profile does not offer, so the engine applies a different lane than the one written (D68, D77)' },
81
+ 'acceptance-not-counted': { severity: DEFECT, repairable: false, why: 'an acceptances.jsonl row names an initiative and the ladder does not count it, so the ladder shows the gate open over a row that looks like an approval (D12)' },
82
+ 'done-without-basis': { severity: DEFECT, repairable: false, why: 'a step reads done and names no file, ledger or commit history, so the pane has nothing to open (D77)' },
83
+ 'status-claims-ahead': { severity: OWED, repairable: false, why: 'the record\'s status says accepted or complete while the ladder shows the acceptance step open' },
84
+ 'delivered-unlogged': { severity: OWED, repairable: false, why: 'the trunk attributes work to this initiative and no run was ever logged (AGENTS.md step 7)' },
85
+ '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' },
86
+ 'delivered-unaccepted': { severity: OWED, repairable: false, why: 'delivered with no acceptance row — the D12 gate is open' },
87
+ 'unexplained-run': { severity: OWED, repairable: false, why: 'a logged run with no explainer; raw-transcript vigilance degrades within ~30 min (O6)' },
88
+ };
89
+
90
+ function finding(cls, o) {
91
+ const meta = CLASSES[cls];
92
+ if (!meta) throw new Error(`workflow-sweep: unknown finding class '${cls}' — add it to CLASSES with its severity and whether it is repairable`);
93
+ return {
94
+ class: cls,
95
+ severity: meta.severity,
96
+ repairable: meta.repairable,
97
+ id: o.id || null,
98
+ file: o.file || null,
99
+ line: o.line == null ? null : o.line,
100
+ what: o.what,
101
+ evidence: o.evidence || null,
102
+ // Every finding says either how it is repaired or why it cannot be. A finding with neither is a
103
+ // dead end presented as information.
104
+ repair: o.repair || null,
105
+ hand: o.hand || (meta.repairable ? null : meta.why),
106
+ };
107
+ }
108
+
109
+ // ── reading ────────────────────────────────────────────────────────────────────────────────────
110
+
111
+ function git(root, args) {
112
+ try {
113
+ return execFileSync('git', ['-C', root, ...args], {
114
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
115
+ });
116
+ } catch { return null; }
117
+ }
118
+
119
+ /** Every non-blank line of a ledger, parsed or reported. Never both, never neither. */
120
+ function readLedger(abs) {
121
+ const rel = abs;
122
+ let text;
123
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { return { present: false, rows: [], torn: [] }; }
124
+ const rows = [];
125
+ const torn = [];
126
+ const lines = text.split('\n');
127
+ for (let i = 0; i < lines.length; i++) {
128
+ const t = lines[i].trim();
129
+ if (!t) continue;
130
+ try { rows.push({ row: JSON.parse(t), line: i + 1 }); }
131
+ 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) }); }
132
+ }
133
+ return { present: true, rows, torn };
134
+ }
135
+
136
+ /*
137
+ * A frontmatter value is DAMAGED when it was truncated mid-write: an opening bracket or quote that
138
+ * never closes on the same value.
139
+ *
140
+ * The test is deliberately narrow. Prose in these fields is normal and encouraged — the router's
141
+ * answers are reasoning, not enum tokens — so "looks unfinished" is not something to guess at. An
142
+ * unbalanced delimiter is different: it is a fact about the text, decidable without judgement, and
143
+ * it is what the twenty-four damaged records in this repository actually have.
144
+ */
145
+ function damagedValue(v) {
146
+ const s = String(v == null ? '' : v);
147
+ if (!s.trim()) return null;
148
+ const opens = (s.match(/\(/g) || []).length;
149
+ const closes = (s.match(/\)/g) || []).length;
150
+ if (opens > closes) return { kind: 'paren', missing: opens - closes };
151
+ // A value that opens with a quote and does not close it — the YAML would have swallowed the rest.
152
+ if (/^"/.test(s.trim()) && !/"\s*$/.test(s.trim())) return { kind: 'quote', missing: 1 };
153
+ return null;
154
+ }
155
+
156
+ /** The conventional locations a lane's spec artifact is written to, for the dangling-path repair. */
157
+ function candidatesFor(root, L, key, id) {
158
+ const kitish = L.kit;
159
+ const dirs = {
160
+ prd_path: kitish ? ['planning/prd'] : ['planning/prd', 'planning/briefs', 'planning/research'],
161
+ rubric_path: ['planning/evals'],
162
+ explainer_path: kitish ? ['pmos/state/explainers'] : ['planning/explainers', 'planning/evals/explainers'],
163
+ }[key] || [];
164
+ const names = {
165
+ prd_path: [`${id}-prd.md`, `${id}-spec.md`, `${id}-brief.md`, `${id}.md`],
166
+ rubric_path: [`${id}-eval-rubric.md`, `${id}-rubric.md`],
167
+ explainer_path: [`${id}-surface-explainer.md`, `${id}-explainer.md`],
168
+ }[key] || [];
169
+ const out = [];
170
+ for (const d of dirs) for (const n of names) {
171
+ const rel = `${d}/${n}`;
172
+ if (fs.existsSync(path.join(root, rel))) out.push(rel);
173
+ }
174
+ return [...new Set(out)];
175
+ }
176
+
177
+ /*
178
+ * D77 — documents that name an initiative. The engine reads only the paths a record points at and
179
+ * never searches, so a finished PRD that no record points at leaves its step open. This is the search
180
+ * the engine does not do. It reads Markdown under the conventional document folders, skips the
181
+ * folders that hold control records, ledgers, rubrics and explainers, and matches on the file's
182
+ * `initiative_id` (frontmatter or a leading bullet) or on a name that starts with the id.
183
+ */
184
+ const DOC_ROOTS = ['planning', 'docs', 'doc', 'prd', 'prds', 'briefs', 'specs', 'strategy', 'plans', 'reports', 'decisions', 'requirements', 'product'];
185
+ const DOC_SKIP = new Set(['node_modules', '.git', 'initiatives', 'runs', 'state', 'explainers', 'evals', 'okf']);
186
+ function buildDocIndex(root) {
187
+ const out = [];
188
+ const walk = (dirRel, depth) => {
189
+ if (depth > 4 || out.length > 5000) return;
190
+ let ents;
191
+ try { ents = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true }); } catch { return; }
192
+ for (const e of ents) {
193
+ const rel = dirRel ? `${dirRel}/${e.name}` : e.name;
194
+ if (e.isDirectory()) { if (!DOC_SKIP.has(e.name)) walk(rel, depth + 1); continue; }
195
+ if (!/\.md$/i.test(e.name) || /^(index|log)\.md$/i.test(e.name)) continue;
196
+ let head = '';
197
+ try {
198
+ const fd = fs.openSync(path.join(root, rel), 'r');
199
+ const buf = Buffer.alloc(3000); const n = fs.readSync(fd, buf, 0, 3000, 0); fs.closeSync(fd);
200
+ head = buf.slice(0, n).toString('utf8');
201
+ } catch { continue; }
202
+ const m = head.match(/^[-\s]*initiative_id:\s*["']?([\w.-]+)/m);
203
+ out.push({ rel, base: e.name, iid: m ? m[1] : null });
204
+ }
205
+ };
206
+ for (const d of DOC_ROOTS) walk(d, 0);
207
+ return out;
208
+ }
209
+ function documentsNaming(index, id) {
210
+ return index.filter((f) => f.iid === id || f.base === `${id}.md` || f.base.startsWith(`${id}-`)).map((f) => f.rel);
211
+ }
212
+ const isBriefName = (rel) => /brief/i.test(path.basename(rel));
213
+
214
+ // ── the sweep ──────────────────────────────────────────────────────────────────────────────────
215
+
216
+ function sweep(opts) {
217
+ const root = path.resolve((opts && opts.root) || process.cwd());
218
+ const L = engine.layout(root);
219
+ const only = opts && opts.id;
220
+ const findings = [];
221
+ const notes = [];
222
+
223
+ const ids = engine.listInitiatives(root);
224
+ /*
225
+ * ABSENT and EMPTY are different facts and must not share an answer — the same rule the workspace
226
+ * applies to a ladder read. A freshly vendored kit HAS a `state/initiatives/` directory with
227
+ * nothing in it yet, and telling that adopter "is this a PMOS repo?" on their first run is the
228
+ * tool being wrong about a repository that is perfectly correct. A missing directory is the real
229
+ * "you pointed me somewhere else" case, and only it gets the error.
230
+ */
231
+ if (!ids.length) {
232
+ if (!fs.existsSync(L.initiatives)) {
233
+ 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: [] };
234
+ }
235
+ return {
236
+ ok: true, root, lane_layout: L.kit ? 'kit' : 'pmos-self', initiatives: 0, empty: true,
237
+ findings: [], defects: [], owed: [], repairable: 0, delivery_read: null,
238
+ notes: [`no initiative records yet under ${path.relative(root, L.initiatives)} — nothing to sweep, which is a clean result rather than a failed read`],
239
+ };
240
+ }
241
+ const idset = new Set(ids);
242
+ const profileInfo = engine.lanesFor(root);
243
+ const pmProfile = profileInfo.profile === 'pm-artifacts';
244
+ let docIndex = null;
245
+ let wfRange = null;
246
+ try { wfRange = engine.historyRange(root); } catch { wfRange = null; }
247
+
248
+ // ── the ledgers, read once ────────────────────────────────────────────────────────────────────
249
+ const ledgerFiles = {
250
+ runs: path.join(L.runsDir, L.runsFile),
251
+ evals: path.join(L.runsDir, 'evals.jsonl'),
252
+ acceptances: path.join(L.runsDir, 'acceptances.jsonl'),
253
+ };
254
+ const ledgers = {};
255
+ for (const [name, abs] of Object.entries(ledgerFiles)) {
256
+ const led = readLedger(abs);
257
+ ledgers[name] = led;
258
+ const rel = path.relative(root, abs);
259
+ if (!led.present) { notes.push(`${rel} is not present — no rows to sweep`); continue; }
260
+
261
+ for (const t of led.torn) {
262
+ findings.push(finding('torn-ledger-line', {
263
+ file: rel, line: t.line,
264
+ what: `${rel}:${t.line} does not parse and is silently dropped by every reader`,
265
+ evidence: `${t.reason} — ${t.text}`,
266
+ }));
267
+ }
268
+ // A row naming an initiative with no record. Reported, never removed: the ledgers are
269
+ // append-only, and a sweep that deletes rows to make its own output clean is the worst possible
270
+ // version of this tool.
271
+ for (const { row, line } of led.rows) {
272
+ const iid = row && row.initiative_id;
273
+ if (iid && !idset.has(iid)) {
274
+ findings.push(finding('orphan-ledger-row', {
275
+ id: iid, file: rel, line,
276
+ what: `${rel}:${line} records ${name === 'acceptances' ? 'an acceptance' : name === 'evals' ? 'an eval' : 'a run'} for '${iid}', which has no initiative record`,
277
+ evidence: JSON.stringify(row).slice(0, 200),
278
+ hand: name === 'acceptances'
279
+ ? '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'
280
+ : CLASSES['orphan-ledger-row'].why,
281
+ }));
282
+ }
283
+ }
284
+ }
285
+ // Duplicate run ids — checked across the runs ledger only, where run_id is the identity every
286
+ // other rung dereferences.
287
+ {
288
+ const seen = new Map();
289
+ for (const { row, line } of ledgers.runs.rows) {
290
+ const rid = row && row.run_id;
291
+ if (!rid) continue;
292
+ if (seen.has(rid)) {
293
+ findings.push(finding('duplicate-run-id', {
294
+ id: row.initiative_id || null, file: path.relative(root, ledgerFiles.runs), line,
295
+ what: `run_id '${rid}' appears twice (lines ${seen.get(rid)} and ${line})`,
296
+ evidence: 'the contract and priors rungs dereference run_id through a Map — last write wins, so one of these rows is invisible to both',
297
+ }));
298
+ } else seen.set(rid, line);
299
+ }
300
+ }
301
+
302
+ const runsBy = new Map();
303
+ for (const { row } of ledgers.runs.rows) {
304
+ if (!row || !row.initiative_id) continue;
305
+ if (!runsBy.has(row.initiative_id)) runsBy.set(row.initiative_id, []);
306
+ runsBy.get(row.initiative_id).push(row);
307
+ }
308
+ const evalIds = new Set(ledgers.evals.rows.map(({ row }) => row && row.initiative_id).filter(Boolean));
309
+ const acceptedIds = new Set(ledgers.acceptances.rows
310
+ .filter(({ row }) => row && row.accepted === true)
311
+ .map(({ row }) => row.initiative_id).filter(Boolean));
312
+
313
+ // D77 — a row that looks like an approval and that the ladder does not count
314
+ for (const { row, line } of ledgers.acceptances.rows) {
315
+ if (!row || !row.initiative_id || !idset.has(row.initiative_id)) continue;
316
+ if (only && row.initiative_id !== only) continue;
317
+ if (row.accepted === true) continue;
318
+ if (row.action !== 'approve') continue;
319
+ findings.push(finding('acceptance-not-counted', {
320
+ id: row.initiative_id, file: path.relative(root, ledgerFiles.acceptances), line,
321
+ what: `an acceptances.jsonl row has action 'approve' and accepted '${row.accepted}' — the ladder counts only accepted: true`,
322
+ evidence: JSON.stringify(row).slice(0, 140),
323
+ hand: 'the sweep never writes a ledger: if this was an acceptance, the PM records it with scripts/record-acceptance.js; if it was something else, no action',
324
+ }));
325
+ }
326
+
327
+ // ── delivery, read once ───────────────────────────────────────────────────────────────────────
328
+ const del = delivery.deliveryIndex(root, ids);
329
+ if (!del.ok) {
330
+ // Not silence, and not a wall of false findings either: without the trunk, every delivery-derived
331
+ // class is undecidable, so they are skipped and the skip is REPORTED. A sweep that reported
332
+ // "0 delivered-unlogged" here would be claiming the loop is closed on evidence it never read.
333
+ notes.push(`delivery could not be derived — ${del.reason}. The delivered-* classes were NOT evaluated; this is not a clean result for them.`);
334
+ }
335
+
336
+ // ── merged branches with no record ────────────────────────────────────────────────────────────
337
+ // `mergedBranches`, NOT the attribution index's `byBranch`. This finding RE-CREATES something from
338
+ // its match, and the attribution set is deliberately broad enough to include ids quoted inside a
339
+ // commit body — which on this repository would have written records for `foo`, `save_okr` and
340
+ // `pmos-operating-guide.`, three tokens that appear in prose and name nothing. A repair tool that
341
+ // invents initiatives out of code examples is worse than one that misses a real gap.
342
+ if (!only) {
343
+ const merged = delivery.mergedBranches(root);
344
+ if (!merged.ok) notes.push(`merged branches could not be read — ${merged.reason}. The missing-record class was NOT evaluated.`);
345
+ for (const [b, m] of merged.ids || []) {
346
+ if (idset.has(b)) continue;
347
+ const when = m.at ? new Date(m.at * 1000).toISOString().slice(0, 10) : null;
348
+ findings.push(finding('missing-record', {
349
+ id: b,
350
+ what: `an initiative/${b} branch merged to the trunk and there is no record at ${path.relative(root, path.join(L.initiatives, `${b}.md`))}`,
351
+ evidence: `${(m.sha || '').slice(0, 8)} ${m.subject}`.trim().slice(0, 160),
352
+ // `back` travels with the repair so the record can say which evidence it had. A back-merge
353
+ // subject describes housekeeping, not the work, and quoting it as an initiative's title
354
+ // would put a sentence about merging main into the title slot of the thing that shipped.
355
+ repair: {
356
+ kind: 'reconstruct-record', id: b, merged: when,
357
+ sha: m.sha ? m.sha.slice(0, 8) : null,
358
+ title: m.back ? null : (m.subject || null),
359
+ evidence_kind: m.back ? 'back-merge' : 'landing-merge',
360
+ subject: m.subject || null,
361
+ },
362
+ }));
363
+ }
364
+ }
365
+
366
+ // ── per record ────────────────────────────────────────────────────────────────────────────────
367
+ for (const id of ids) {
368
+ if (only && id !== only) continue;
369
+ const recAbs = path.join(L.initiatives, `${id}.md`);
370
+ const recRel = path.relative(root, recAbs);
371
+ let text;
372
+ try { text = fs.readFileSync(recAbs, 'utf8'); } catch { continue; }
373
+ const fm = engine.parseLeadingFrontmatter(text);
374
+ const lane = (fm.lane || 'feature').trim() || 'feature';
375
+
376
+ // dangling declared paths
377
+ for (const key of pmProfile
378
+ ? ['prd_path', 'brief_path', 'doc_path', 'rubric_path', 'explainer_path']
379
+ : ['prd_path', 'rubric_path', 'explainer_path']) {
380
+ const v = String(fm[key] || '').trim();
381
+ if (!v) continue;
382
+ if (fs.existsSync(path.join(root, v))) continue;
383
+ let cands;
384
+ if (key === 'brief_path' || key === 'doc_path') {
385
+ if (!docIndex) docIndex = buildDocIndex(root);
386
+ const named = documentsNaming(docIndex, id);
387
+ cands = named.filter((f) => (key === 'brief_path') === isBriefName(f));
388
+ } else cands = candidatesFor(root, L, key, id);
389
+ findings.push(finding('dangling-path', {
390
+ id, file: recRel,
391
+ what: `${key} points at '${v}', which is not in the repository`,
392
+ evidence: cands.length ? `candidate(s) at the conventional location: ${cands.join(', ')}` : 'no file at any conventional location for this key',
393
+ // Exactly one candidate is mechanical. Two is a choice, and a choice is not this tool's.
394
+ repair: cands.length === 1 ? { kind: 'repoint', file: recRel, key, from: v, to: cands[0] } : null,
395
+ hand: cands.length > 1
396
+ ? `${cands.length} candidates exist (${cands.join(', ')}) — choosing between them is a judgement, so it is reported rather than guessed`
397
+ : cands.length === 0
398
+ ? 'no candidate exists — the artifact was never written, or lives somewhere no convention names. Write it, or clear the key: optional never means dangling'
399
+ : null,
400
+ }));
401
+ }
402
+
403
+ // D77 — a lane the profile does not offer
404
+ {
405
+ const declared = String(fm.lane || '').trim();
406
+ if (declared && !profileInfo.lanes.includes(declared)) {
407
+ const applied = pmProfile ? 'prd' : 'feature';
408
+ findings.push(finding('lane-outside-profile', {
409
+ id, file: recRel,
410
+ what: `lane '${declared}' is not a ${profileInfo.profile} lane`,
411
+ evidence: `the engine applies '${applied}' instead; this repository offers: ${profileInfo.lanes.join(', ')}`,
412
+ hand: `choose one of: ${profileInfo.lanes.join(', ')} — the lane is the PM's choice, so it is reported and never rewritten`,
413
+ }));
414
+ }
415
+ }
416
+
417
+ // D77 — a finished document that no record points at (document profile)
418
+ if (pmProfile) {
419
+ if (!docIndex) docIndex = buildDocIndex(root);
420
+ const named = documentsNaming(docIndex, id);
421
+ const declaredKeys = ['brief_path', 'prd_path', 'doc_path'].map((k) => String(fm[k] || '').trim()).filter(Boolean);
422
+ const unref = named.filter((f) => !declaredKeys.includes(f));
423
+ const briefEmpty = !String(fm.brief_path || fm.prd_path || '').trim();
424
+ const docEmpty = !String(fm.doc_path || '').trim();
425
+ if (unref.length && (briefEmpty || docEmpty)) {
426
+ let repair = null;
427
+ let hand = null;
428
+ if (unref.length === 1) {
429
+ const key = isBriefName(unref[0]) ? (briefEmpty ? 'brief_path' : null) : (docEmpty ? 'doc_path' : null);
430
+ if (key) repair = { kind: 'add-key', file: recRel, key, from: '', to: unref[0] };
431
+ else hand = `${unref[0]} names this initiative, but the key it would fill is already set — decide by hand`;
432
+ } else hand = `${unref.length} documents name this initiative (${unref.join(', ')}) — choosing which is the brief and which the deliverable is a judgement, so it is reported rather than guessed`;
433
+ findings.push(finding('unpointed-artifact', {
434
+ id, file: recRel,
435
+ what: `${unref.length} document${unref.length > 1 ? 's name' : ' names'} this initiative and no path key points at ${unref.length > 1 ? 'them' : 'it'}: ${unref.join(', ')}`,
436
+ evidence: `brief_path/prd_path ${briefEmpty ? 'is empty' : 'is set'}; doc_path ${docEmpty ? 'is empty' : 'is set'}`,
437
+ repair, hand,
438
+ }));
439
+ }
440
+ }
441
+
442
+ // D77 — what the ladder says against the record's own words, and against its own basis rule
443
+ if (wfRange) {
444
+ let wf = null;
445
+ try { wf = engine.computeWorkflow(id, { root, range: wfRange }); } catch { wf = null; }
446
+ if (wf && wf.ok && Array.isArray(wf.steps)) {
447
+ for (const st of wf.steps) {
448
+ if (st.status === 'done' && st.basis && st.basis.source === 'none' && st.key !== 'quality') {
449
+ findings.push(finding('done-without-basis', {
450
+ id, file: recRel,
451
+ what: `step '${st.key}' reads done and names no file, ledger or commit history`,
452
+ evidence: String(st.evidence || '').slice(0, 120),
453
+ hand: `the engine decided '${st.key}' from something the detail pane cannot open — fix the rung to name what it read`,
454
+ }));
455
+ }
456
+ }
457
+ const claim = String(fm.status || '').trim();
458
+ const gate = wf.steps.find((st) => st.key === 'accept' || st.key === 'approved');
459
+ if (/^(accepted|approved|complete|completed|done|delivered|shipped)\b/i.test(claim) && gate && gate.status !== 'done' && gate.status !== 'na') {
460
+ findings.push(finding('status-claims-ahead', {
461
+ id, file: recRel,
462
+ what: `status says '${claim.slice(0, 60)}' while the ${gate.key} step is ${gate.status}`,
463
+ evidence: String(gate.evidence || '').slice(0, 120),
464
+ }));
465
+ }
466
+ }
467
+ }
468
+
469
+ // damaged frontmatter values
470
+ for (const [key, v] of Object.entries(fm)) {
471
+ const dmg = damagedValue(v);
472
+ if (!dmg) continue;
473
+ // The repair is a RESTORATION or it is nothing. Closing the bracket would balance the YAML and
474
+ // assert an ending the text does not have — `…the meta-watermelon made computab)` reads as a
475
+ // finished sentence that is missing its last four words. Only git can say what the value used
476
+ // to be, so only git may supply it; where it cannot, this is a human's line to finish.
477
+ const restored = recoverFromHistory(root, recRel, key, String(v));
478
+ findings.push(finding('damaged-field', {
479
+ id, file: recRel,
480
+ what: `${key} is truncated — ${dmg.kind === 'paren' ? `${dmg.missing} unclosed parenthes${dmg.missing > 1 ? 'es' : 'is'}` : 'an unclosed quote'}`,
481
+ evidence: String(v).slice(0, 120),
482
+ repair: restored ? { kind: 'restore-from-git', file: recRel, key, from: String(v), to: restored.value, sha: restored.sha } : null,
483
+ hand: restored ? null
484
+ : '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',
485
+ }));
486
+ }
487
+
488
+ // the anchor
489
+ {
490
+ const raw = String(fm.parent_kr || '').trim();
491
+ const r = anchors.resolveAnchor(raw, engineOkrIndex(root), budgetsFor(root, L));
492
+ if (r.status === 'unresolvable' || r.status === 'empty') {
493
+ findings.push(finding('unresolvable-anchor', {
494
+ id, file: recRel,
495
+ what: r.status === 'empty' ? 'parent_kr is empty — unanchored work is rejected (D59)' : `parent_kr '${raw.slice(0, 60)}' resolves to nothing`,
496
+ evidence: r.reason || null,
497
+ }));
498
+ }
499
+ }
500
+
501
+ // a declared delivery that does not check out — read ONCE, the owed gate below reuses it: a
502
+ // second declaredDelivery call on the same inputs paid its git subprocesses twice per record
503
+ // for the same answer.
504
+ const dd = delivery.declaredDelivery(root, fm.delivered_by);
505
+ if (dd.declared && !dd.ok && !dd.unknown) {
506
+ findings.push(finding('unverifiable-delivery', {
507
+ id, file: recRel,
508
+ what: dd.reason,
509
+ evidence: `delivered_by: ${String(fm.delivered_by).slice(0, 100)}`,
510
+ }));
511
+ }
512
+
513
+ // ── owed ────────────────────────────────────────────────────────────────────────────────────
514
+ // Everything below is real work that has not happened. None of it is repairable, by design, and
515
+ // the reason is written on each finding rather than only in this file's header — the person
516
+ // reading the output is the one who needs to know why the tool stopped.
517
+ const signal = del.ok ? delivery.attribution(del, id) : 'unknown';
518
+ const declaredOk = dd.ok === true;
519
+ const delivered = del.ok && (!!signal || declaredOk);
520
+ const runs = runsBy.get(id) || [];
521
+
522
+ if (delivered) {
523
+ const how = declaredOk ? 'declared' : signal;
524
+ if (!runs.length) {
525
+ findings.push(finding('delivered-unlogged', {
526
+ id, file: recRel,
527
+ what: 'work is attributable to this initiative on the trunk and no run was ever logged',
528
+ evidence: delivery.attributionEvidence(how, id),
529
+ 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',
530
+ }));
531
+ }
532
+ if (String(fm.rubric_path || '').trim() && !evalIds.has(id)) {
533
+ findings.push(finding('delivered-unreviewed', {
534
+ id, file: recRel,
535
+ what: 'delivered against a rubric with no Review-Gate verdict recorded',
536
+ evidence: `rubric_path: ${fm.rubric_path}`,
537
+ 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',
538
+ }));
539
+ }
540
+ if (!acceptedIds.has(id)) {
541
+ findings.push(finding('delivered-unaccepted', {
542
+ id, file: recRel,
543
+ what: 'delivered with no acceptance row — the D12 gate is open',
544
+ evidence: delivery.attributionEvidence(how, id),
545
+ 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',
546
+ }));
547
+ }
548
+ }
549
+
550
+ for (const r of runs) {
551
+ if (!r.run_id) continue;
552
+ const expl = path.join(L.explainers, `${r.run_id}-explainer.md`);
553
+ if (fs.existsSync(expl)) continue;
554
+ findings.push(finding('unexplained-run', {
555
+ id, file: recRel,
556
+ what: `run ${r.run_id} has no explainer at ${path.relative(root, expl)}`,
557
+ evidence: 'O6 — the explainer is what the PM reviews; raw-transcript vigilance degrades within ~30 min',
558
+ }));
559
+ }
560
+ }
561
+
562
+ const defects = findings.filter((f) => f.severity === DEFECT);
563
+ const owed = findings.filter((f) => f.severity === OWED);
564
+ return {
565
+ ok: true, root, lane_layout: L.kit ? 'kit' : 'pmos-self',
566
+ initiatives: ids.length,
567
+ findings, defects, owed, notes,
568
+ repairable: defects.filter((f) => f.repair).length,
569
+ delivery_read: del.ok ? null : del.reason,
570
+ };
571
+ }
572
+
573
+ // Cached the same way the engine caches them — the sweep derives over every record in one pass —
574
+ // and keyed the same way too: mtime+size over the documents (plus their directories, so a file
575
+ // added or removed re-lists), never the root alone. The sweep is CLI-today, but a copy of the
576
+ // engine's cache with a weaker key is exactly the two-places-one-rule drift this file exists to
577
+ // sweep for.
578
+ const _okr = new Map(); // root -> { stamp, files, index }
579
+ const _bud = new Map(); // root -> { stamp, value }
580
+ function statStamp(p) {
581
+ try { const s = fs.statSync(p); return `${s.mtimeMs}:${s.size}`; } catch { return 'absent'; }
582
+ }
583
+ function okrStamp(files) {
584
+ const dirs = [...new Set(files.map((f) => path.dirname(f)))];
585
+ return files.concat(dirs).map((p) => `${p}=${statStamp(p)}`).join('|');
586
+ }
587
+ function engineOkrIndex(root) {
588
+ const hit = _okr.get(root);
589
+ if (hit && hit.files.length && okrStamp(hit.files) === hit.stamp) return hit.index;
590
+ const files = anchors.listOkrFiles(root);
591
+ const entry = { stamp: okrStamp(files), files, index: anchors.parseOkrIndex(root) };
592
+ _okr.set(root, entry);
593
+ return entry.index;
594
+ }
595
+ function budgetsFor(root, L) {
596
+ const stamp = statStamp(L.budgets);
597
+ const hit = _bud.get(root);
598
+ if (!hit || hit.stamp !== stamp) {
599
+ let value;
600
+ try { value = { ok: true, text: fs.readFileSync(L.budgets, 'utf8'), path: path.relative(root, L.budgets) }; }
601
+ catch { value = { ok: false, text: '', path: path.relative(root, L.budgets) }; }
602
+ _bud.set(root, { stamp, value });
603
+ }
604
+ // D67: the domain registry rides on the budgets object, read fresh (a small, PM-edited file)
605
+ return anchors.withDomains(_bud.get(root).value, path.dirname(L.budgets), root);
606
+ }
607
+
608
+ /*
609
+ * The intact value, if this file's own history holds one — the only mechanical repair for truncated
610
+ * text that is a FACT rather than a guess.
611
+ *
612
+ * The test is strict and one-directional: an earlier version of the same key, in the same file,
613
+ * whose text STARTS WITH the damaged text and is longer, and which is not itself damaged. A value
614
+ * that merely differs is a rewrite, not a restoration, and reverting a deliberate edit under the
615
+ * banner of "repair" would be the tool overwriting the PM.
616
+ *
617
+ * On this repository it finds nothing: the 26 damaged values were truncated by whatever wrote them,
618
+ * so no intact version was ever committed. That is the correct outcome and the sweep says so on each
619
+ * finding, rather than reaching for the delimiter it could balance instead.
620
+ */
621
+ function recoverFromHistory(root, rel, key, damaged) {
622
+ const dmg = damaged.trim();
623
+ if (!dmg) return null;
624
+ const log = git(root, ['log', '--format=%H', '--', rel]);
625
+ if (!log) return null;
626
+ const shas = log.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 40);
627
+ for (const sha of shas) {
628
+ const text = git(root, ['show', `${sha}:${rel}`]);
629
+ if (!text) continue;
630
+ const past = engine.parseLeadingFrontmatter(text)[key];
631
+ if (past == null) continue;
632
+ const p = String(past).trim();
633
+ if (p === dmg || p.length <= dmg.length) continue;
634
+ if (!p.startsWith(dmg)) continue;
635
+ if (damagedValue(p)) continue; // an earlier damaged version is not a restoration
636
+ return { value: p, sha };
637
+ }
638
+ return null;
639
+ }
640
+
641
+ // ── repair ─────────────────────────────────────────────────────────────────────────────────────
642
+ /*
643
+ * The closed repair set. Three kinds, and each is a fact rather than a judgement:
644
+ * repoint — the file is at the conventional location and the pointer is stale
645
+ * close-delimiter — the text was truncated mid-write and the missing character is decidable
646
+ * reconstruct-record — a merge exists with no record; the record states it was reconstructed
647
+ *
648
+ * Nothing here writes a `.jsonl`, and nothing writes a value that expresses an opinion. The
649
+ * reconstructed record carries `lane: chore` with a stated intent rather than a guessed lane and a
650
+ * pointer at a spec that does not exist, and `reconstructed_from` rather than a backdated timestamp,
651
+ * so it can never read as though it followed the loop.
652
+ */
653
+ function applyRepairs(res, opts) {
654
+ const dry = !!(opts && opts.dryRun);
655
+ const root = res.root;
656
+ const L = engine.layout(root);
657
+ const applied = [];
658
+ const skipped = [];
659
+
660
+ // Group by file so multiple field repairs on one record are a single read/write.
661
+ const byFile = new Map();
662
+ for (const f of res.defects) {
663
+ if (!f.repair) continue;
664
+ const key = f.repair.kind === 'reconstruct-record' ? `__new__${f.repair.id}` : f.repair.file;
665
+ if (!byFile.has(key)) byFile.set(key, []);
666
+ byFile.get(key).push(f);
667
+ }
668
+
669
+ for (const [key, group] of byFile) {
670
+ if (key.startsWith('__new__')) {
671
+ for (const f of group) {
672
+ const r = f.repair;
673
+ const abs = path.join(L.initiatives, `${r.id}.md`);
674
+ const rel = path.relative(root, abs);
675
+ if (fs.existsSync(abs)) { skipped.push({ finding: f, why: 'a record now exists at that path' }); continue; }
676
+ const body = reconstructedRecord(r);
677
+ if (!dry) fs.writeFileSync(abs, body, 'utf8');
678
+ applied.push({ class: f.class, file: rel, before: '(no file)', after: `a reconstructed record, lane chore, reconstructed_from: ${r.merged || 'unknown'}` });
679
+ }
680
+ continue;
681
+ }
682
+ const abs = path.join(root, key);
683
+ let text;
684
+ try { text = fs.readFileSync(abs, 'utf8'); }
685
+ catch { for (const f of group) skipped.push({ finding: f, why: `${key} could not be read` }); continue; }
686
+ let next = text;
687
+ for (const f of group) {
688
+ const r = f.repair;
689
+ const before = frontmatterValue(next, r.key);
690
+ if (r.kind === 'add-key') {
691
+ // One key, only when it is still absent or empty. A value the PM wrote since the sweep ran wins.
692
+ if (before !== null && before.trim() !== '') { skipped.push({ finding: f, why: `${r.key} was set since the sweep ran` }); continue; }
693
+ const edited = before === null ? insertFrontmatterKey(next, r.key, r.to) : setFrontmatterValue(next, r.key, r.to);
694
+ if (edited === next) { skipped.push({ finding: f, why: `${key} has no frontmatter to add ${r.key} to` }); continue; }
695
+ next = edited;
696
+ applied.push({ class: f.class, file: key, key: r.key, before: '(not set)', after: r.to.slice(0, 90) });
697
+ continue;
698
+ }
699
+ if (before === null) { skipped.push({ finding: f, why: `${r.key} is no longer in the frontmatter` }); continue; }
700
+ let after;
701
+ if (r.kind === 'repoint') {
702
+ if (before.trim() !== r.from) { skipped.push({ finding: f, why: `${r.key} changed since the sweep ran` }); continue; }
703
+ after = r.to;
704
+ } else if (r.kind === 'restore-from-git') {
705
+ if (before.trim() !== r.from.trim()) { skipped.push({ finding: f, why: `${r.key} changed since the sweep ran` }); continue; }
706
+ after = r.to;
707
+ } else { skipped.push({ finding: f, why: `unknown repair kind '${r.kind}'` }); continue; }
708
+ next = setFrontmatterValue(next, r.key, after);
709
+ applied.push({ class: f.class, file: key, key: r.key, before: before.slice(0, 90), after: after.slice(0, 90) });
710
+ }
711
+ if (next !== text && !dry) fs.writeFileSync(abs, next, 'utf8');
712
+ }
713
+ return { applied, skipped, dry };
714
+ }
715
+
716
+ /** The LEADING frontmatter only, matching every other reader in this repo. */
717
+ function frontmatterValue(text, key) {
718
+ const lines = String(text).split('\n');
719
+ for (const line of lines) {
720
+ const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
721
+ if (m && m[1] === key) return m[2];
722
+ if (line.trim() && !line.startsWith('---') && !m) break;
723
+ }
724
+ return null;
725
+ }
726
+ /** Insert `key: value` as the last line of the leading frontmatter; the text is returned unchanged when there is none. */
727
+ function insertFrontmatterKey(text, key, value) {
728
+ const lines = String(text).split('\n');
729
+ if (lines[0] !== '---') return text;
730
+ for (let i = 1; i < lines.length; i++) {
731
+ if (lines[i] === '---') { lines.splice(i, 0, `${key}: ${value}`); return lines.join('\n'); }
732
+ }
733
+ return text;
734
+ }
735
+ function setFrontmatterValue(text, key, value) {
736
+ const lines = String(text).split('\n');
737
+ for (let i = 0; i < lines.length; i++) {
738
+ const m = lines[i].match(/^([a-z_]+):\s*(.*?)\s*$/);
739
+ if (m && m[1] === key) { lines[i] = `${key}: ${value}`; return lines.join('\n'); }
740
+ if (lines[i].trim() && !lines[i].startsWith('---') && !m) break;
741
+ }
742
+ return text;
743
+ }
744
+
745
+ /*
746
+ * A record for work that merged without one. It is deliberately the MINIMUM that makes the work
747
+ * visible, and it states its own provenance in three places — `reconstructed_from`, the `status`
748
+ * intent, and the body — because the one thing a reconstruction must never do is read as though
749
+ * somebody wrote it at intake.
750
+ *
751
+ * `lane: chore` is not a guess about how large the work was. It is the only lane whose spec artifact
752
+ * is a sentence in the record itself, so it is the only lane a reconstruction can satisfy honestly:
753
+ * every other lane would need a `prd_path` pointing at a document nobody wrote.
754
+ */
755
+ function reconstructedRecord(r) {
756
+ const back = r.evidence_kind === 'back-merge';
757
+ // With no landing-merge subject there is no honest title to quote, so the record says what it
758
+ // knows instead of borrowing a sentence about something else.
759
+ const title = (r.title || `${r.id} — reconstructed from git; no landing-merge subject exists`)
760
+ .replace(/"/g, '\'').slice(0, 160);
761
+ const when = r.merged || 'unknown';
762
+ const evidenceLine = back
763
+ ? `- evidence: a **back-merge** (\`${(r.subject || '').replace(/`/g, '').slice(0, 90)}\`) reachable from the trunk. That commit is only
764
+ reachable at all because this branch was later merged in, so it proves the branch landed — but it
765
+ describes merging main INTO the branch, so its subject is not this work's title and its date is
766
+ the housekeeping's, not the delivery's. A squash merge left no landing subject to read.`
767
+ : `- evidence: the landing merge's own subject, quoted verbatim above`;
768
+ return `---
769
+ type: Initiative
770
+ title: "${title}"
771
+ timestamp: ${new Date().toISOString().slice(0, 10)}
772
+ id: ${r.id}
773
+ lane: chore
774
+ parent_kr: health:tech
775
+ reconstructed_from: ${when}
776
+ 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."
777
+ ---
778
+
779
+ # Initiative: ${r.id}
780
+
781
+ **This record was reconstructed from git, not written at intake.** An \`initiative/${r.id}\` branch
782
+ merged to the trunk with no record in the repository, so the work was invisible to the Initiative
783
+ Gate, to [\`scripts/workflow-state.js\`](/scripts/workflow-state.js), and to every board.
784
+
785
+ - merged: **${when}**${r.sha ? ` (\`${r.sha}\`)` : ''}${back ? ' — the back-merge\'s date; the landing date is not recoverable' : ''}
786
+ - anchor: \`health:tech\` — reconstructed after the fact, D59 tech budget, no honest KR
787
+ - lane: \`chore\` — the only lane whose spec artifact is a sentence in this record; every other lane
788
+ would need a \`prd_path\` pointing at a document nobody wrote
789
+ ${evidenceLine}
790
+
791
+ *Nothing here has been reconstructed from memory, and no run, eval or acceptance was written on this
792
+ initiative's behalf. Set \`delivered_by:\` to the commit this work actually landed in if you know it —
793
+ the ladder checks that against the trunk.*
794
+ `;
795
+ }
796
+
797
+ // ── rendering ──────────────────────────────────────────────────────────────────────────────────
798
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
799
+ const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
800
+ const brief = (s, n) => {
801
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
802
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
803
+ };
804
+
805
+ function render(res, opts) {
806
+ if (!res.ok) return c(31, `✗ ${res.error}`);
807
+ const out = [''];
808
+ const D = res.defects.length;
809
+ const O = res.owed.length;
810
+ out.push(` ${c(1, 'PMOS workflow sweep')} · ${res.initiatives} initiatives · ${res.lane_layout} layout`);
811
+ 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`) : ''}`);
812
+ out.push(` ${O ? c(33, `${O} owed`) : c(32, 'nothing owed')} — work that honestly has not happened. ${c(90, 'Never repaired: see --owed')}`);
813
+ out.push(` ${c(90, '─'.repeat(94))}`);
814
+
815
+ const show = (list, heading, colour) => {
816
+ if (!list.length) return;
817
+ out.push('');
818
+ out.push(` ${c(colour, heading)}`);
819
+ const byClass = new Map();
820
+ for (const f of list) {
821
+ if (!byClass.has(f.class)) byClass.set(f.class, []);
822
+ byClass.get(f.class).push(f);
823
+ }
824
+ for (const [cls, group] of byClass) {
825
+ out.push(` ${c(1, cls)} ${c(90, `× ${group.length}`)} ${c(90, `— ${CLASSES[cls].why}`)}`);
826
+ const cap = opts && opts.all ? group.length : 6;
827
+ for (const f of group.slice(0, cap)) {
828
+ out.push(` ${c(colour, '•')} ${c(1, (f.id || f.file || '').padEnd(34))} ${brief(f.what, 96)}`);
829
+ if (f.evidence) out.push(` ${c(90, brief(f.evidence, 108))}`);
830
+ if (f.repair) out.push(` ${c(32, '→ repairable')} ${c(90, repairSentence(f.repair))}`);
831
+ else if (f.hand) out.push(` ${c(33, '→ by hand')} ${c(90, brief(f.hand, 104))}`);
832
+ }
833
+ // Never a silent cap — a truncated list that does not say so reads as "that is everything".
834
+ if (group.length > cap) out.push(` ${c(33, `+ ${group.length - cap} more ${cls}, not shown`)} ${c(90, '— --all')}`);
835
+ }
836
+ };
837
+
838
+ if (!(opts && opts.owedOnly)) show(res.defects, 'DEFECTS — the board is lying about these', 31);
839
+ if (!(opts && opts.defectsOnly)) show(res.owed, 'OWED — real work, honestly not done. The sweep reports these and will not close them.', 33);
840
+
841
+ for (const n of res.notes) out.push(`\n ${c(33, '!')} ${n}`);
842
+ out.push('');
843
+ if (!D && !O) out.push(` ${c(32, 'nothing to sweep — every record and every ledger row checks out.')}`);
844
+ else if (!D) out.push(` ${c(32, 'no defects.')} ${c(90, `${O} owed item(s) are work, not damage.`)}`);
845
+ out.push('');
846
+ return out.join('\n');
847
+ }
848
+
849
+ function repairSentence(r) {
850
+ if (r.kind === 'repoint') return `${r.key}: ${r.from} → ${r.to}`;
851
+ if (r.kind === 'restore-from-git') return `${r.key}: restore the intact value from ${String(r.sha).slice(0, 8)}`;
852
+ if (r.kind === 'reconstruct-record') return `write a reconstructed record (lane chore, reconstructed_from: ${r.merged || 'unknown'})`;
853
+ return r.kind;
854
+ }
855
+
856
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────────────
857
+ function main(argv) {
858
+ const args = argv.slice(2);
859
+ let root = process.cwd();
860
+ let json = false; let fix = false; let dryRun = false; let gate = false;
861
+ let owedOnly = false; let defectsOnly = false; let all = false;
862
+ const rest = [];
863
+ for (let i = 0; i < args.length; i++) {
864
+ const a = args[i];
865
+ if (a === '--root') { root = args[++i]; continue; }
866
+ if (a === '--json') { json = true; continue; }
867
+ if (a === '--fix') { fix = true; continue; }
868
+ if (a === '--dry-run') { dryRun = true; continue; }
869
+ if (a === '--gate') { gate = true; continue; }
870
+ if (a === '--owed') { owedOnly = true; continue; }
871
+ if (a === '--defects') { defectsOnly = true; continue; }
872
+ if (a === '--all') { all = true; continue; }
873
+ if (a === '--help' || a === '-h') {
874
+ process.stdout.write([
875
+ '', ' node scripts/workflow-sweep.js [<initiative-id>] [--json] [--fix [--dry-run]] [--gate] [--owed|--defects] [--all] [--root <dir>]', '',
876
+ ' (no id) sweep every initiative and every ledger row',
877
+ ' <id> sweep one initiative',
878
+ ' --fix apply the repairable DEFECTS. Never writes a .jsonl, never writes a judgement.',
879
+ ' --dry-run with --fix: print what would be written, write nothing',
880
+ ' --gate CI mode — exit 1 while any defect remains. Owed findings never fail it.',
881
+ ' --owed the PM worklist: real work, honestly not done',
882
+ ' --defects only state that contradicts reality',
883
+ ' --all do not cap each class at 6 rows',
884
+ ' --json machine-readable', '',
885
+ ' defect = the board is lying · owed = the loop is open. They never share a bucket.', '',
886
+ ].join('\n') + '\n');
887
+ return 0;
888
+ }
889
+ rest.push(a);
890
+ }
891
+
892
+ const res = sweep({ root, id: rest[0] });
893
+ if (!res.ok) {
894
+ process.stdout.write((json ? JSON.stringify(res, null, 2) : render(res, {})) + '\n');
895
+ return 1;
896
+ }
897
+
898
+ let repairs = null;
899
+ if (fix) {
900
+ repairs = applyRepairs(res, { dryRun });
901
+ if (!json) {
902
+ const head = dryRun ? ' --fix --dry-run — nothing was written:' : ' --fix applied:';
903
+ process.stdout.write(`\n${c(1, head)}\n`);
904
+ if (!repairs.applied.length) process.stdout.write(` ${c(90, 'no repairable defect found.')}\n`);
905
+ for (const a of repairs.applied) {
906
+ 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`);
907
+ }
908
+ 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`);
909
+ process.stdout.write(`\n ${c(90, 'repairs land as working-tree edits. Review the diff — the sweep never commits.')}\n`);
910
+ }
911
+ }
912
+
913
+ if (json) {
914
+ process.stdout.write(JSON.stringify({ ...res, repairs }, null, 2) + '\n');
915
+ } else {
916
+ process.stdout.write(render(res, { owedOnly, defectsOnly, all }));
917
+ }
918
+
919
+ // Owed findings NEVER fail the gate. 53 open loops is a worklist, and a gate that fails on a
920
+ // worklist is a gate everyone learns to bypass — which would cost the 4 real defects their alarm.
921
+ if (gate) return res.defects.length ? 1 : 0;
922
+ return 0;
923
+ }
924
+
925
+ module.exports = { sweep, applyRepairs, render, CLASSES, damagedValue, readLedger, frontmatterValue, setFrontmatterValue, DEFECT, OWED };
926
+
927
+ /*
928
+ * `exitCode`, not `process.exit()`. `process.exit` tears the process down without waiting for stdout
929
+ * to drain, and when stdout is a PIPE — every CI invocation, every `| jq`, every test harness —
930
+ * writes larger than the pipe buffer are asynchronous. `--json` on this repository is ~65 KB, so it
931
+ * arrived TRUNCATED MID-STRING and parsed as invalid JSON, while an interactive run to a TTY looked
932
+ * perfect. Setting the code and letting the process end naturally flushes first.
933
+ */
934
+ if (require.main === module) process.exitCode = main(process.argv);