create-pmos 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/payload/AGENTS.md +7 -0
- package/payload/KIT-VERSION +1 -1
- package/payload/README.md +16 -1
- package/payload/scripts/anchor-resolve.js +277 -0
- package/payload/scripts/delivery-index.js +278 -0
- package/payload/scripts/workflow-state.js +1139 -0
- package/payload/scripts/workflow-sweep.js +764 -0
- package/workspace/lib/console-state.js +39 -18
- package/workspace/lib/engine/delivery-index.js +278 -0
- package/workspace/lib/engine/workflow-state.js +165 -21
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/server.js +38 -51
|
@@ -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);
|