@sabaiway/agent-workflow-kit 1.37.0 → 1.38.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/CHANGELOG.md +60 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/fold-completeness.md +7 -3
- package/references/modes/review-ledger.md +3 -2
- package/tools/commands.mjs +2 -2
- package/tools/fold-completeness-run.mjs +360 -37
- package/tools/fold-completeness.mjs +247 -57
- package/tools/review-ledger-write.mjs +60 -6
- package/tools/review-ledger.mjs +69 -14
- package/tools/seed-gates.mjs +2 -2
|
@@ -14,20 +14,35 @@
|
|
|
14
14
|
// here). The gate is plan-EXECUTION-scoped, filtered to the in-flight plan's filename stem:
|
|
15
15
|
// exit 0 when the resolved plan-execution.review recipe is solo (configured, or degraded there);
|
|
16
16
|
// when no plan is in flight; when the tree is clean; when the cwd is not a git work tree;
|
|
17
|
-
// and when the in-flight plan-execution loop has a CURRENT run record
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// and when the in-flight plan-execution loop has a CURRENT run record (kind-aware: the
|
|
18
|
+
// latest RUN, never a later red-probe) whose BOTH bindings match — the tree fingerprint AND
|
|
19
|
+
// the sorted fixable-bug testId set recorded in the run — with, per bound testId: an
|
|
20
|
+
// N/N-green probe (D4), an observed-red receipt in this loop (Approach-1.i), that receipt
|
|
21
|
+
// PRECEDING the latest run in ledger order (anti-post-hoc), and content CUSTODY — the run's
|
|
22
|
+
// recorded test-file hash equals the latest custody-eligible red-probe hash on that file
|
|
23
|
+
// (eligible = the receipt's own testId is bound AND it precedes the run, D5); plus 0
|
|
24
|
+
// uncovered changed lines, 0 changed unsupported-source files, a recorded tamper surface
|
|
25
|
+
// with every tampered test-surface file covered by a recorded oracle-change override
|
|
26
|
+
// (review ledger v3), and the reserved EMPTY mutation shape (no mutation ships). A
|
|
27
|
+
// recorded red-proof override waives the receipt + custody proof for exactly the testId
|
|
28
|
+
// it names (D7) — never the N/N-green requirement, never QUARANTINE.
|
|
21
29
|
// exit 1 for any DIRTY in-flight plan-execution loop lacking such a current run record — including
|
|
22
|
-
// the stale-fingerprint case (a tree edit moves the fingerprint)
|
|
23
|
-
// new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9)
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
30
|
+
// the stale-fingerprint case (a tree edit moves the fingerprint), the same-fingerprint/
|
|
31
|
+
// new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9),
|
|
32
|
+
// and a schema-1 (or tamper-less) record as the loop's latest run (an older runner; D2) —
|
|
33
|
+
// or a tampered test-surface file (a removed/modified line in a pre-existing test file,
|
|
34
|
+
// or a deleted one — the union of test-classified paths and bound-testId file halves)
|
|
35
|
+
// not covered by a recorded oracle-change override, a run naming an unresolvable bound
|
|
36
|
+
// test, a QUARANTINED bound
|
|
37
|
+
// test (mixed or timed-out probe runs — never an N/N verdict, never converted, no override
|
|
38
|
+
// lane; D4), a red-baseline bound test, a green bound test with NO observed-red receipt /
|
|
39
|
+
// a post-hoc receipt / broken custody (D5), an uncovered changed line, a changed
|
|
40
|
+
// unsupported-source file, or ANY mutation data (no mutation ships — such a record was not
|
|
41
|
+
// produced by this runner; fail closed); when MORE THAN ONE plan is in flight (ambiguous
|
|
42
|
+
// loop id). Fail-CLOSED (unknown state, never a fail-open pass) on a detector failure, an
|
|
43
|
+
// unreadable/malformed result or review ledger, or a corrupt run set — the only
|
|
44
|
+
// detector-independent green is an EXPLICIT configured solo. Changed OUT-OF-DOMAIN files
|
|
45
|
+
// (docs/config the suite does not execute) are LOUDLY listed but never gate-blocking
|
|
31
46
|
// (Decision 5): guarding what the tool cannot assess with a red gate is a pretend-mechanism.
|
|
32
47
|
//
|
|
33
48
|
// HONEST residuals (accepted, documented — exactly like review-state's / review-ledger's): coverage
|
|
@@ -50,10 +65,17 @@ import { detectBackends } from './detect-backends.mjs';
|
|
|
50
65
|
import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
|
|
51
66
|
import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
|
|
52
67
|
import { computeTreeFingerprint, isTreeClean, plansInFlight } from './review-state.mjs';
|
|
53
|
-
import { resolveLedgerPath, readLedger, filterLoopRecords } from './review-ledger.mjs';
|
|
68
|
+
import { resolveLedgerPath, readLedger, filterLoopRecords, collectOverrides, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
|
|
54
69
|
|
|
55
70
|
export const RESULTS_BASENAME = 'agent-workflow-fold-completeness.jsonl';
|
|
56
|
-
|
|
71
|
+
// SCHEMA v2 (BUGFREE-1 / AD-047): records gain a kind discriminator — `run` (the fold-completeness
|
|
72
|
+
// run, now with per-testId rerun counts + the test file's content hash) | `red-probe` (the
|
|
73
|
+
// observed-red receipt --red mints). RESULT_SCHEMA_VERSION is what the WRITER emits; the reader
|
|
74
|
+
// tolerates every SUPPORTED version under its own per-version rules (the review-ledger v1→v2
|
|
75
|
+
// precedent), so v1 ledgers never retroactively become malformed — but a v1 record as the loop's
|
|
76
|
+
// LATEST run fails the gate with a named re-run reason (D2).
|
|
77
|
+
export const RESULT_SCHEMA_VERSION = 2;
|
|
78
|
+
export const SUPPORTED_RESULT_SCHEMAS = new Set([1, 2]);
|
|
57
79
|
const ACTIVITY = 'plan-execution';
|
|
58
80
|
const SLOT = 'review';
|
|
59
81
|
|
|
@@ -96,37 +118,109 @@ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
|
96
118
|
const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
|
|
97
119
|
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (!
|
|
105
|
-
if (obj.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
const HASH_RE = /^[0-9a-f]{64}$/; // sha-256 hex — the content-custody hash shape
|
|
122
|
+
|
|
123
|
+
// The shared record frame (both versions, both kinds).
|
|
124
|
+
const validateFrame = (obj) => {
|
|
125
|
+
if (!isNonEmptyString(obj.loop)) return 'missing loop';
|
|
126
|
+
if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return 'fingerprint must be null or a non-empty string';
|
|
127
|
+
if (!isNonEmptyString(obj.timestamp)) return 'missing timestamp';
|
|
128
|
+
return null;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// A v1 per-testId probe entry (single-run booleans — the AD-046 shape, tolerated read-only).
|
|
132
|
+
const validateV1Entry = (t) => {
|
|
133
|
+
if (!isPlainObject(t) || !isNonEmptyString(t.id)) return 'each testId entry needs an id';
|
|
134
|
+
if (typeof t.resolvable !== 'boolean' || typeof t.baselineGreen !== 'boolean') return `testId ${t.id} needs boolean resolvable + baselineGreen`;
|
|
135
|
+
if (!isNonNegInt(t.executed)) return `testId ${t.id} executed must be a non-negative integer`;
|
|
136
|
+
return null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// A v2 per-testId probe entry: rerun counts (the D4 evidence) + the test file's content hash (the
|
|
140
|
+
// D5 custody anchor) + the derived booleans, VALIDATED consistent with the counts so a forged
|
|
141
|
+
// verdict cannot ride beside honest-looking evidence.
|
|
142
|
+
const validateV2Entry = (t) => {
|
|
143
|
+
if (!isPlainObject(t) || !isNonEmptyString(t.id)) return 'each testId entry needs an id';
|
|
144
|
+
if (!isNonNegInt(t.executed)) return `testId ${t.id} executed must be a non-negative integer`;
|
|
145
|
+
if (!(Number.isInteger(t.runs) && t.runs >= 1)) return `testId ${t.id} runs must be a positive integer`;
|
|
146
|
+
if (!isNonNegInt(t.greens) || !isNonNegInt(t.reds) || !isNonNegInt(t.timeouts)) return `testId ${t.id} rerun counts (greens/reds/timeouts) must be non-negative integers`;
|
|
147
|
+
if (t.greens + t.reds + t.timeouts > t.runs) return `testId ${t.id} rerun counts exceed runs`;
|
|
148
|
+
// A resolved run means at least one matched (executed) test result — greens/reds with executed=0
|
|
149
|
+
// is a forged N/N verdict carrying zero-match evidence (codex R1, BUGFREE-1 live loop).
|
|
150
|
+
if (t.greens + t.reds > 0 && t.executed < 1) return `testId ${t.id} executed must be positive when a run resolved (greens+reds > 0)`;
|
|
151
|
+
if (!(t.fileHash === null || (typeof t.fileHash === 'string' && HASH_RE.test(t.fileHash)))) return `testId ${t.id} fileHash must be null or a 64-hex content hash`;
|
|
152
|
+
if (t.resolvable !== (t.greens + t.reds === t.runs)) return `testId ${t.id} resolvable must equal (greens + reds === runs)`;
|
|
153
|
+
if (t.baselineGreen !== (t.greens === t.runs)) return `testId ${t.id} baselineGreen must equal (greens === runs)`;
|
|
154
|
+
return null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// The run-record body shared by v1 and v2 (surface classes, coverage, reserved mutation, budgets);
|
|
158
|
+
// the per-testId entry rule is the per-version part.
|
|
159
|
+
const validateRunBody = (obj, validateEntry) => {
|
|
160
|
+
if (!isStringArray(obj.boundTestIds)) return 'boundTestIds must be an array of strings';
|
|
161
|
+
if (!Array.isArray(obj.testIds)) return 'testIds must be an array';
|
|
110
162
|
for (const t of obj.testIds) {
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
if (!isNonNegInt(t.executed)) return { ok: false, reason: `testId ${t.id} executed must be a non-negative integer` };
|
|
163
|
+
const r = validateEntry(t);
|
|
164
|
+
if (r) return r;
|
|
114
165
|
}
|
|
115
|
-
if (!isStringArray(obj.unsupported)) return
|
|
116
|
-
if (!isStringArray(obj.outOfDomain)) return
|
|
117
|
-
if (!isPlainObject(obj.coverage) || !Array.isArray(obj.coverage.uncoveredChanged)) return
|
|
166
|
+
if (!isStringArray(obj.unsupported)) return 'unsupported must be an array of strings';
|
|
167
|
+
if (!isStringArray(obj.outOfDomain)) return 'outOfDomain must be an array of strings';
|
|
168
|
+
if (!isPlainObject(obj.coverage) || !Array.isArray(obj.coverage.uncoveredChanged)) return 'coverage.uncoveredChanged must be an array';
|
|
118
169
|
for (const u of obj.coverage.uncoveredChanged) {
|
|
119
|
-
if (!isPlainObject(u) || !isNonEmptyString(u.file)) return
|
|
120
|
-
if (!(u.line === null || (Number.isInteger(u.line) && u.line >= 1))) return
|
|
170
|
+
if (!isPlainObject(u) || !isNonEmptyString(u.file)) return 'each uncoveredChanged entry needs a file';
|
|
171
|
+
if (!(u.line === null || (Number.isInteger(u.line) && u.line >= 1))) return `uncoveredChanged ${u.file} line must be null or an integer >= 1`;
|
|
121
172
|
}
|
|
122
173
|
const m = obj.mutation;
|
|
123
174
|
if (!isPlainObject(m) || !isNonNegInt(m.total) || !isNonNegInt(m.killed) || !Array.isArray(m.survived) || !isNonNegInt(m.skipped)) {
|
|
124
|
-
return
|
|
175
|
+
return 'mutation must be { total, killed, survived[], skipped, killSetBasis }';
|
|
125
176
|
}
|
|
126
|
-
if (!(m.killSetBasis === null || isNonEmptyString(m.killSetBasis))) return
|
|
127
|
-
if (!isPlainObject(obj.budgets)) return
|
|
128
|
-
|
|
129
|
-
|
|
177
|
+
if (!(m.killSetBasis === null || isNonEmptyString(m.killSetBasis))) return 'mutation.killSetBasis must be null or a non-empty string';
|
|
178
|
+
if (!isPlainObject(obj.budgets)) return 'budgets must be an object';
|
|
179
|
+
return null;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// The red-probe receipt (v2 only): the machine attestation that testId FAILED on N/N runs at the
|
|
183
|
+
// recorded content hash. Minted only by the runner's --red verb; reds must equal runs — a receipt
|
|
184
|
+
// never records anything but an honest N/N red (refusals write nothing).
|
|
185
|
+
const validateRedProbe = (obj) => {
|
|
186
|
+
if (!isWellFormedTestId(obj.testId)) return 'red-probe testId must be "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)';
|
|
187
|
+
if (!(typeof obj.fileHash === 'string' && HASH_RE.test(obj.fileHash))) return 'red-probe fileHash must be a 64-hex content hash';
|
|
188
|
+
if (!(Number.isInteger(obj.runs) && obj.runs >= 1)) return 'red-probe runs must be a positive integer';
|
|
189
|
+
if (obj.reds !== obj.runs) return 'red-probe reds must equal runs (a receipt attests N/N observed red)';
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// validateRunRecord(obj) → { ok, reason }. Per-version, per-kind (D2): v1 records (no kind) keep the
|
|
194
|
+
// AD-046 single-run rules; v2 records carry the kind discriminator. The `reason` names the exact
|
|
195
|
+
// failed check so the malformed-line surface and the per-check named tests can assert it. Mutation
|
|
196
|
+
// stays the reserved empty shape (the mutation half is shelved); the schema carries the fields so a
|
|
197
|
+
// record validates uniformly and decideCheck enforces the exact reserved shape.
|
|
198
|
+
export const validateRunRecord = (obj) => {
|
|
199
|
+
if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
|
|
200
|
+
if (!SUPPORTED_RESULT_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_RESULT_SCHEMAS].join(', ')}` };
|
|
201
|
+
const frame = validateFrame(obj);
|
|
202
|
+
if (frame) return { ok: false, reason: frame };
|
|
203
|
+
if (obj.schema === 1) {
|
|
204
|
+
if (obj.kind !== undefined) return { ok: false, reason: 'a v1 record must not carry kind (kind is a v2 discriminator)' };
|
|
205
|
+
const r = validateRunBody(obj, validateV1Entry);
|
|
206
|
+
return r ? { ok: false, reason: r } : { ok: true };
|
|
207
|
+
}
|
|
208
|
+
if (obj.kind === 'run') {
|
|
209
|
+
const r = validateRunBody(obj, validateV2Entry);
|
|
210
|
+
if (r) return { ok: false, reason: r };
|
|
211
|
+
// The tamper surface (Phase 2.2) is OPTIONAL on read — a record written by the pre-tamper v2
|
|
212
|
+
// runner stays readable (never retroactively malformed); the GATE handles the staleness. When
|
|
213
|
+
// present, the shape is exact.
|
|
214
|
+
if (obj.tamper !== undefined && (!isPlainObject(obj.tamper) || !isStringArray(obj.tamper.tampered))) {
|
|
215
|
+
return { ok: false, reason: 'tamper.tampered must be an array of strings (when the tamper surface is recorded)' };
|
|
216
|
+
}
|
|
217
|
+
return { ok: true };
|
|
218
|
+
}
|
|
219
|
+
if (obj.kind === 'red-probe') {
|
|
220
|
+
const r = validateRedProbe(obj);
|
|
221
|
+
return r ? { ok: false, reason: r } : { ok: true };
|
|
222
|
+
}
|
|
223
|
+
return { ok: false, reason: `kind must be "run" or "red-probe" (got ${JSON.stringify(obj.kind)})` };
|
|
130
224
|
};
|
|
131
225
|
|
|
132
226
|
// readResults(path) → { records, malformed, malformedReasons, readError }. Absent file → empty (no run
|
|
@@ -157,9 +251,38 @@ export const readResults = (path, readFile = readFileSync) => {
|
|
|
157
251
|
return { records, malformed: malformedReasons.length, malformedReasons };
|
|
158
252
|
};
|
|
159
253
|
|
|
160
|
-
// filterLoopResults(records, loop) → the
|
|
254
|
+
// filterLoopResults(records, loop) → the result records of ONE loop (both kinds), order preserved
|
|
255
|
+
// (latest is last).
|
|
161
256
|
export const filterLoopResults = (records, loop) => records.filter((r) => r.loop === loop);
|
|
162
257
|
|
|
258
|
+
// ── kind-aware selectors (shared: the runner and the checker read the ledger through THESE) ──────
|
|
259
|
+
|
|
260
|
+
// A v1 record IS a run (the kindless AD-046 shape); a v2 record is a run only under kind:"run".
|
|
261
|
+
export const isRunRecord = (r) => r.schema === 1 || r.kind === 'run';
|
|
262
|
+
export const isRedProbeRecord = (r) => r.schema >= 2 && r.kind === 'red-probe';
|
|
263
|
+
|
|
264
|
+
// latestRunRecord(loopRecords) → { record, index } | null over ONE loop's ordered records. Kind-aware
|
|
265
|
+
// (codex R2): a red-probe appended after a run must never be read as the loop's "latest run" — the
|
|
266
|
+
// index anchors the D5/order checks (a custody-eligible receipt PRECEDES the latest run).
|
|
267
|
+
export const latestRunRecord = (loopRecords) => {
|
|
268
|
+
for (let i = loopRecords.length - 1; i >= 0; i -= 1) {
|
|
269
|
+
if (isRunRecord(loopRecords[i])) return { record: loopRecords[i], index: i };
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// probeVerdict(entry) → 'green' | 'red' | 'quarantine' | 'unresolvable' — the D4 verdict algebra,
|
|
275
|
+
// the SINGLE home shared by the runner (--red mints only on 'red') and the checker (the gate passes
|
|
276
|
+
// only on 'green'). RED/GREEN are strict N/N verdicts; any timeout, mixed outcome, or partial
|
|
277
|
+
// resolution is QUARANTINE — it never converts and has no override lane (a flaky pin proves
|
|
278
|
+
// nothing — replace the test). Zero resolved runs (or a defensive runs=0) reads unresolvable.
|
|
279
|
+
export const probeVerdict = (t) => {
|
|
280
|
+
const unresolved = t.runs - t.greens - t.reds - t.timeouts;
|
|
281
|
+
if (unresolved >= t.runs) return 'unresolvable';
|
|
282
|
+
if (t.timeouts > 0 || unresolved > 0 || (t.greens > 0 && t.reds > 0)) return 'quarantine';
|
|
283
|
+
return t.greens === t.runs ? 'green' : 'red';
|
|
284
|
+
};
|
|
285
|
+
|
|
163
286
|
// ── the check + report core ─────────────────────────────────────────────────────────────────────
|
|
164
287
|
|
|
165
288
|
// buildFoldState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges; every
|
|
@@ -230,9 +353,15 @@ export const decideCheck = (state) => {
|
|
|
230
353
|
if (state.reviewMalformed > 0) return { code: 1, reason: `the review ledger has ${state.reviewMalformed} malformed line(s) — failing closed; inspect ${state.reviewPath}` };
|
|
231
354
|
|
|
232
355
|
const loop = state.plans[0].replace(/\.md$/, '');
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
356
|
+
const loopResults = filterLoopResults(state.resultRecords, loop);
|
|
357
|
+
const sel = latestRunRecord(loopResults);
|
|
358
|
+
if (sel == null) return { code: 1, reason: `dirty plan-execution loop "${loop}" but no fold-completeness run recorded — run fold-completeness-run.mjs` };
|
|
359
|
+
const latest = sel.record;
|
|
360
|
+
// D2 — a v1 record as the loop's latest run: the gate now needs the v2 evidence (rerun counts +
|
|
361
|
+
// custody hashes), which an older runner never recorded. Fail with a named re-run reason. The same
|
|
362
|
+
// rule covers a v2 record with NO recorded tamper surface (the pre-2.2 runner).
|
|
363
|
+
if (latest.schema === 1) return { code: 1, reason: `the loop's latest run is a schema-1 record (an older runner — no rerun counts, no custody hashes) — re-run fold-completeness-run.mjs` };
|
|
364
|
+
if (latest.tamper === undefined) return { code: 1, reason: `the loop's latest run has no recorded tamper surface (an older runner) — re-run fold-completeness-run.mjs` };
|
|
236
365
|
|
|
237
366
|
// Decision 9 — the double binding. A tree edit moves the fingerprint; a new fixable-bug triage moves
|
|
238
367
|
// the bound-testId set. Either mismatch is STALE (the run no longer describes the committable tree).
|
|
@@ -249,10 +378,54 @@ export const decideCheck = (state) => {
|
|
|
249
378
|
// A changed unsupported-source file → fail closed: the signal never vouches for JS-family source it
|
|
250
379
|
// cannot assess (TS/JSX out of scope v1, Decision 5).
|
|
251
380
|
if (latest.unsupported.length > 0) return { code: 1, reason: `changed unsupported-source file(s) the signal cannot assess (TS/JSX out of scope v1): ${latest.unsupported.join(', ')}` };
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
381
|
+
|
|
382
|
+
// The recorded overrides of this loop (review ledger, schema v3): oracle-change lifts named
|
|
383
|
+
// tampered files; red-proof waives the receipt + custody proof for exactly the testId it names.
|
|
384
|
+
const overrides = collectOverrides(state.reviewRecords, { activity: ACTIVITY, loop });
|
|
385
|
+
|
|
386
|
+
// Approach-3 — the oracle-tamper guard: any tampered test-surface file (a removed/modified line in
|
|
387
|
+
// a pre-existing test file, or a deleted one) must be covered by a recorded oracle-change
|
|
388
|
+
// override. Never silent: the override is a durable, auditable ledger entry.
|
|
389
|
+
const tamperedUncovered = latest.tamper.tampered.filter((f) => !overrides.oracleChangeFiles.has(f));
|
|
390
|
+
if (tamperedUncovered.length > 0) {
|
|
391
|
+
return {
|
|
392
|
+
code: 1,
|
|
393
|
+
reason: `tampered test-surface file(s) — a fix diff modified/deleted pre-existing test expectations — without a recorded oracle-change override: ${tamperedUncovered.join(', ')}. If the oracle change is deliberate, record it: node review-ledger-write.mjs override --json '{"loop":"${loop}","round":<n>,"scope":"oracle-change","files":${JSON.stringify(tamperedUncovered)},"reason":"<why the expectation legitimately changed>"}'`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Per-testId enforcement, in bound (sorted) order: the D4 verdict algebra first — QUARANTINE and
|
|
398
|
+
// red/unresolvable are probe-outcome failures regardless of receipts — then, for a green test, the
|
|
399
|
+
// red-proof chain: receipt exists (Approach-1.i) → the receipt PRECEDES the loop's latest run
|
|
400
|
+
// (anti-post-hoc, codex R2) → content custody (D5). The checker stays PURE: the current file hash
|
|
401
|
+
// IS the latest run's recorded hash (the run is fingerprint-bound to the current tree).
|
|
402
|
+
const probes = new Map(latest.testIds.map((t) => [t.id, t]));
|
|
403
|
+
const boundSet = new Set(latest.boundTestIds);
|
|
404
|
+
const receipts = loopResults.map((r, i) => ({ r, i })).filter(({ r }) => isRedProbeRecord(r));
|
|
405
|
+
for (const id of latest.boundTestIds) {
|
|
406
|
+
const t = probes.get(id);
|
|
407
|
+
const verdict = probeVerdict(t);
|
|
408
|
+
if (verdict === 'unresolvable') return { code: 1, reason: `unresolvable bound testId(s) — the pattern selects no test: ${id}` };
|
|
409
|
+
if (verdict === 'quarantine') return { code: 1, reason: `bound test in QUARANTINE — not an N/N verdict (${t.greens} green / ${t.reds} red / ${t.timeouts} timed out of ${t.runs} runs): ${id}. A flaky/timed-out probe proves nothing and has no override lane — replace or speed up the test, then re-run` };
|
|
410
|
+
if (verdict === 'red') return { code: 1, reason: `bound test(s) with a red baseline (the fold is not complete): ${id}` };
|
|
411
|
+
// D7 — a recorded red-proof override replaces the receipt + custody proof for EXACTLY this
|
|
412
|
+
// testId (green N/N was already required above; QUARANTINE was already refused — no override
|
|
413
|
+
// lane converts a flaky probe).
|
|
414
|
+
if (overrides.redProofTestIds.has(id)) continue;
|
|
415
|
+
// verdict green — the red-proof chain.
|
|
416
|
+
const own = receipts.filter(({ r }) => r.testId === id);
|
|
417
|
+
if (own.length === 0) return { code: 1, reason: `no observed-red receipt for ${id} — a test never seen failing proves nothing about the fix. BEFORE folding a fix, run: node fold-completeness-run.mjs --red "${id}"` };
|
|
418
|
+
if (!own.some(({ i }) => i < sel.index)) return { code: 1, reason: `the observed-red receipt for ${id} was minted AFTER the loop's latest run — a post-hoc red proves nothing; run fold-completeness-run.mjs once more (a fresh run after the receipt)` };
|
|
419
|
+
const { file } = splitTestId(id);
|
|
420
|
+
// Custody eligibility (D5, codex R3): the anchor is the LATEST receipt on that FILE whose own
|
|
421
|
+
// testId is in the bound set AND which precedes the latest run — an unbound throwaway receipt,
|
|
422
|
+
// or one minted post-run, never re-attests a file.
|
|
423
|
+
const eligible = receipts.filter(({ r, i }) => i < sel.index && boundSet.has(r.testId) && splitTestId(r.testId).file === file);
|
|
424
|
+
const anchor = eligible[eligible.length - 1];
|
|
425
|
+
if (!anchor || t.fileHash == null || anchor.r.fileHash !== t.fileHash) {
|
|
426
|
+
return { code: 1, reason: `custody broken for ${id}: the test file ${file} no longer matches its last observed-red content — re-observe red after the edit (node fold-completeness-run.mjs --red "<the file's newest testId>"), or record a red-proof override if the red is genuinely unestablishable` };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
256
429
|
if (latest.coverage.uncoveredChanged.length > 0) return { code: 1, reason: `uncovered changed line(s) — changed code no test executed: ${latest.coverage.uncoveredChanged.map(renderUncovered).join(', ')}` };
|
|
257
430
|
// v1 ships NO mutation (the mutation half was shelved): the shipped runner only ever writes the
|
|
258
431
|
// reserved empty shape, so a record carrying ANY mutation data was not produced by this runner
|
|
@@ -275,12 +448,19 @@ export const decideCheck = (state) => {
|
|
|
275
448
|
|
|
276
449
|
// ── rendering ─────────────────────────────────────────────────────────────────────────────────
|
|
277
450
|
|
|
278
|
-
const
|
|
279
|
-
|
|
451
|
+
const testLine = (r, t) => {
|
|
452
|
+
if (r.schema === 1) return `${t.id}${t.resolvable ? (t.baselineGreen ? '✓' : ' red-baseline') : ' unresolvable'}`;
|
|
453
|
+
const v = probeVerdict(t);
|
|
454
|
+
return v === 'green' ? `${t.id} ✓ ${t.greens}/${t.runs}` : `${t.id} ${v} (${t.greens}g/${t.reds}r/${t.timeouts}t of ${t.runs})`;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const runLine = (r, receipts) => {
|
|
458
|
+
const tests = r.testIds.length ? r.testIds.map((t) => testLine(r, t)).join(', ') : '(no bound tests)';
|
|
280
459
|
const uncov = r.coverage.uncoveredChanged.length ? r.coverage.uncoveredChanged.map(renderUncovered).join(', ') : 'none';
|
|
281
460
|
return [
|
|
282
461
|
` latest run — fingerprint ${r.fingerprint}`,
|
|
283
462
|
` bound testIds: ${tests}`,
|
|
463
|
+
` red-probe receipts: ${receipts.length ? receipts.map((p) => `${p.testId} (${p.reds}/${p.runs} red @${p.fileHash.slice(0, 8)})`).join(', ') : 'none'}`,
|
|
284
464
|
` uncovered changed: ${uncov}`,
|
|
285
465
|
` unsupported: ${r.unsupported.length ? r.unsupported.join(', ') : 'none'} · out-of-domain: ${r.outOfDomain.length ? r.outOfDomain.join(', ') : 'none'}`,
|
|
286
466
|
` mutation: ${r.mutation.total} total / ${r.mutation.killed} killed / ${r.mutation.survived.length} survived / ${r.mutation.skipped} skipped`,
|
|
@@ -300,8 +480,9 @@ const formatHuman = (state, check) => {
|
|
|
300
480
|
lines.push(` result ledger: ${state.resultsPath ?? '(unresolvable — no git dir)'} (${state.resultRecords.length} record(s)${state.resultMalformed ? `, ${state.resultMalformed} malformed — inspect the file` : ''})`);
|
|
301
481
|
if (state.plans.length === 1) {
|
|
302
482
|
const loop = state.plans[0].replace(/\.md$/, '');
|
|
303
|
-
const
|
|
304
|
-
|
|
483
|
+
const loopResults = filterLoopResults(state.resultRecords, loop);
|
|
484
|
+
const sel = latestRunRecord(loopResults); // kind-aware: never render a red-probe as "the run"
|
|
485
|
+
if (sel) lines.push(runLine(sel.record, loopResults.filter(isRedProbeRecord)));
|
|
305
486
|
}
|
|
306
487
|
lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
|
|
307
488
|
return lines.join('\n');
|
|
@@ -316,14 +497,23 @@ Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOL
|
|
|
316
497
|
resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the canonical uncommitted-state
|
|
317
498
|
fingerprint, and decides whether the in-flight plan-execution loop's changed code is pinned by tests.
|
|
318
499
|
|
|
319
|
-
--status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
|
|
500
|
+
--status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
|
|
501
|
+
(per-testId D4 verdicts + rerun counts), the loop's red-probe receipts, verdict.
|
|
320
502
|
--check → the gate exit code. The normative exit contract lives in the tool header (the single home):
|
|
321
503
|
exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT run whose fingerprint
|
|
322
|
-
AND bound-testId set both match, with
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
504
|
+
AND bound-testId set both match, with — per bound testId — an N/N-green probe, an observed-red
|
|
505
|
+
receipt that PRECEDES the run, and content custody (run hash == the latest custody-eligible receipt
|
|
506
|
+
hash on that file); plus 0 uncovered changed lines, 0 changed unsupported source, every tampered
|
|
507
|
+
test-surface file covered by a recorded oracle-change override, and the reserved empty mutation
|
|
508
|
+
shape. A recorded red-proof override (review-ledger-write override) waives receipt + custody for
|
|
509
|
+
exactly its testId — never green-N/N, never QUARANTINE. exit 1 otherwise (stale/missing/older-runner
|
|
510
|
+
run, an unresolvable bound test, a QUARANTINED bound test — mixed/timeout, never converted, no
|
|
511
|
+
override lane — a red baseline, a missing/post-hoc receipt, broken custody, an uncovered tampered
|
|
512
|
+
file, an uncovered line, changed TS/JSX, any mutation data, >1 plan, an unreadable/malformed
|
|
513
|
+
ledger, or a detector failure). Out-of-domain changes are listed, never blocking. The honest
|
|
514
|
+
fold-time order: classify the fixable-bug with its testId → write the test →
|
|
515
|
+
fold-completeness-run.mjs --red "<testId>" observes red BEFORE the fix → fold the fix → the
|
|
516
|
+
normal run observes green → --check.
|
|
327
517
|
--json → the structured state + decision.
|
|
328
518
|
|
|
329
519
|
The runner is a SEPARATE tool (fold-completeness-run.mjs) — this read-only checker never imports it.
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
import { readFileSync } from 'node:fs';
|
|
30
30
|
import { dirname } from 'node:path';
|
|
31
31
|
import { pathToFileURL } from 'node:url';
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
32
33
|
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
33
|
-
import { computeTreeFingerprint, readReceipts, resolveReceiptsPath } from './review-state.mjs';
|
|
34
|
+
import { computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
|
|
34
35
|
import {
|
|
35
36
|
REVIEW_CAP,
|
|
36
37
|
SCHEMA_VERSION,
|
|
@@ -145,6 +146,50 @@ export const recordRound = (params, deps = {}) => {
|
|
|
145
146
|
return appendRecord(ledgerPath, record, deps);
|
|
146
147
|
};
|
|
147
148
|
|
|
149
|
+
// ── recordOverride (BUGFREE-1 / AD-047, D3/D7 — the loud, recorded waiver) ──────────────────────
|
|
150
|
+
|
|
151
|
+
// A read-only git-root probe for the in-flight tooth (the writer's only other git query is inside
|
|
152
|
+
// computeTreeFingerprint).
|
|
153
|
+
const gitRoot = (cwd) => {
|
|
154
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, windowsHide: true });
|
|
155
|
+
if (r.error || r.status !== 0) return null;
|
|
156
|
+
return r.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// recordOverride({ cwd, env, loop, activity, round, scope, files, testId, reason, timestamp }, deps)
|
|
160
|
+
// → { writtenPath, record }. Standard teeth: field validation via the schema (exact per-scope
|
|
161
|
+
// payloads), fail-closed ledger read, and the IN-FLIGHT tooth — an override is a waiver, minted only
|
|
162
|
+
// inside its live loop, never retro-recorded for a finished or foreign one. The fold-completeness
|
|
163
|
+
// gate matches on loop + payload, never on the (audit-only) fingerprint.
|
|
164
|
+
export const recordOverride = (params, deps = {}) => {
|
|
165
|
+
const { cwd = process.cwd(), env = process.env, loop, activity = DEFAULT_ACTIVITY, round, scope, files, testId, reason, timestamp } = params;
|
|
166
|
+
const ledgerPath = deps.ledgerPath ?? resolveLedgerPath(cwd, env);
|
|
167
|
+
if (ledgerPath == null) throw stop('cannot resolve the ledger path — not a git work tree and AW_REVIEW_LEDGER is unset');
|
|
168
|
+
if (!(Number.isInteger(round) && round >= 1)) throw stop(`round must be an integer >= 1 (got ${round})`);
|
|
169
|
+
// Exactly ONE in-flight plan, and it must be the named loop (codex+agy R5): a waiver minted while
|
|
170
|
+
// multiple plans are active could later cover a now-single loop — the ambiguity the rest of the
|
|
171
|
+
// family refuses everywhere (the single-plan rule).
|
|
172
|
+
const plans = deps.plansInFlight ? deps.plansInFlight() : plansInFlight(gitRoot(cwd) ?? cwd);
|
|
173
|
+
if (plans.length !== 1 || plans[0] !== `${loop}.md`) {
|
|
174
|
+
throw stop(`refusing to record an override for loop "${loop}": an override is minted only inside its SINGLE in-flight loop (in flight: ${plans.length ? plans.join(', ') : 'none'})`);
|
|
175
|
+
}
|
|
176
|
+
const fingerprint = deps.computeFingerprint ? deps.computeFingerprint(cwd) : computeTreeFingerprint(cwd);
|
|
177
|
+
const record = {
|
|
178
|
+
schema: SCHEMA_VERSION, loop, activity, kind: 'override', round, fingerprint, scope,
|
|
179
|
+
...(files !== undefined ? { files } : {}), ...(testId !== undefined ? { testId } : {}),
|
|
180
|
+
reason, timestamp: timestamp ?? isoNow(),
|
|
181
|
+
};
|
|
182
|
+
const v = validateRecord(record);
|
|
183
|
+
if (!v.ok) throw stop(`refusing to record a malformed override: ${v.reason}`);
|
|
184
|
+
const { records, malformed, malformedReasons, readError } = readLedger(ledgerPath, deps.readFile);
|
|
185
|
+
if (readError) throw stop(`cannot read the existing ledger (${readError}) — refusing to append (fail closed)`);
|
|
186
|
+
if (malformed > 0) throw stop(`the existing ledger has ${malformed} malformed line(s) — refusing to append until they are fixed (fail closed): ${malformedReasons.join('; ')}`);
|
|
187
|
+
if (!roundSequenceIntact(filterLoopRecords(records, { activity, loop }))) {
|
|
188
|
+
throw stop(`refusing to record an override for loop "${loop}": its recorded round sequence is corrupt (not 1..n) — fix the ledger by hand first`);
|
|
189
|
+
}
|
|
190
|
+
return appendRecord(ledgerPath, record, deps);
|
|
191
|
+
};
|
|
192
|
+
|
|
148
193
|
// ── recordTriage (the deadlock-breaker — no teeth, no receipt binding) ──────────────────────────
|
|
149
194
|
|
|
150
195
|
// recordTriage({ cwd, env, loop, activity, round, classifications, timestamp }, deps) →
|
|
@@ -186,11 +231,12 @@ export const recordTriage = (params, deps = {}) => {
|
|
|
186
231
|
|
|
187
232
|
// ── CLI (record / classify) ──────────────────────────────────────────────────────────────────────
|
|
188
233
|
|
|
189
|
-
const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045).
|
|
234
|
+
const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047).
|
|
190
235
|
|
|
191
236
|
Usage:
|
|
192
|
-
node review-ledger-write.mjs record --json '<round-payload>'
|
|
193
|
-
node review-ledger-write.mjs classify --json '<triage-payload>'
|
|
237
|
+
node review-ledger-write.mjs record --json '<round-payload>' [--cwd <dir>]
|
|
238
|
+
node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
|
|
239
|
+
node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
|
|
194
240
|
|
|
195
241
|
record appends one review round. The JSON payload carries { loop, round, origins, backends,
|
|
196
242
|
findings } (activity defaults to plan-execution; timestamp defaults to now). REFUSES while
|
|
@@ -200,6 +246,12 @@ classify appends one triage record. The JSON payload carries { loop, round, clas
|
|
|
200
246
|
{ findingKey, class, accepted, testId, note }). A fixable-bug REQUIRES a testId — the
|
|
201
247
|
red→green test that pins the fold, formatted "<test-file>#<test-name-pattern>" (write it
|
|
202
248
|
first); inherent-layer-residual / escalate may omit it. This is what permits the next round.
|
|
249
|
+
override appends one override record (schema 3) — the LOUD, durable waiver the fold-completeness
|
|
250
|
+
gate consumes. The JSON payload carries { loop, round, scope, reason } plus, per scope:
|
|
251
|
+
scope "oracle-change" → files[] (non-empty repo-relative paths whose tamper flag it lifts);
|
|
252
|
+
scope "red-proof" → testId (the exact bound testId whose observed-red receipt + custody it
|
|
253
|
+
waives — for a red that is GENUINELY unestablishable pre-fold, D7). REFUSES for a loop that
|
|
254
|
+
is not the in-flight plan. QUARANTINE (a flaky/timed-out probe) has NO override lane.
|
|
203
255
|
|
|
204
256
|
The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --status / --json.
|
|
205
257
|
Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
|
|
@@ -238,14 +290,16 @@ export const main = (argv, ctx = {}) => {
|
|
|
238
290
|
try {
|
|
239
291
|
if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) return { code: argv.length === 0 ? 2 : 0, stdout: HELP, stderr: '' };
|
|
240
292
|
const sub = argv[0];
|
|
241
|
-
if (sub !== 'record' && sub !== 'classify') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify)`);
|
|
293
|
+
if (sub !== 'record' && sub !== 'classify' && sub !== 'override') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify | override)`);
|
|
242
294
|
const opts = parseArgs(argv.slice(1));
|
|
243
295
|
const payload = parsePayload(opts.json);
|
|
244
296
|
const cwd = opts.cwd ?? cwd0;
|
|
245
297
|
const result =
|
|
246
298
|
sub === 'record'
|
|
247
299
|
? recordRound({ cwd, env, ...payload })
|
|
248
|
-
:
|
|
300
|
+
: sub === 'classify'
|
|
301
|
+
? recordTriage({ cwd, env, ...payload })
|
|
302
|
+
: recordOverride({ cwd, env, ...payload });
|
|
249
303
|
return { code: 0, stdout: `review-ledger-write: recorded a ${result.record.kind} for loop "${result.record.loop}" round ${result.record.round} → ${result.writtenPath}`, stderr: '' };
|
|
250
304
|
} catch (err) {
|
|
251
305
|
return { code: err.exitCode ?? 1, stdout: '', stderr: `review-ledger-write: ${err.message}` };
|