@sabaiway/agent-workflow-kit 1.37.1 → 1.39.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.
@@ -14,20 +14,37 @@
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 whose BOTH bindings
18
- // match the tree fingerprint AND the sorted fixable-bug testId set recorded in the run
19
- // with every bound testId resolvable + baseline-green, 0 uncovered changed lines, 0 changed
20
- // unsupported-source files, and the reserved EMPTY mutation shape (v1 ships no mutation).
21
- // 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) and the same-fingerprint/
23
- // new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9) —
24
- // or a run naming an unresolvable/red-baseline bound test, an uncovered changed line, a
25
- // changed unsupported-source file, or ANY mutation data (v1 ships no mutation such a record
26
- // was not produced by this runner version; fail closed); when MORE THAN ONE plan is in
27
- // flight (ambiguous loop id). Fail-CLOSED (unknown state, never a fail-open pass) on a
28
- // detector failure, an unreadable/malformed result or review ledger, or a corrupt run set —
29
- // the only detector-independent green is an EXPLICIT configured solo. Changed OUT-OF-DOMAIN
30
- // files (docs/config the suite does not execute) are LOUDLY listed but never gate-blocking
17
+ // and when the in-flight plan-execution SEGMENT (loop, base = git rev-parse HEAD),
18
+ // schema v3, AD-048 D7 has a CURRENT run record (kind-aware: the latest RUN, never a
19
+ // later red-probe) whose BOTH bindings match the tree fingerprint AND the SEGMENT's
20
+ // sorted fixable-bug testId set recorded in the run with, per bound testId: an
21
+ // N/N-green probe (D4), an observed-red receipt in this SEGMENT (a committed phase's
22
+ // obligations closed with its commit; a receipt never crosses a commit boundary), that receipt
23
+ // PRECEDING the latest run in ledger order (anti-post-hoc), and content CUSTODY the run's
24
+ // recorded test-file hash equals the latest custody-eligible red-probe hash on that file
25
+ // (eligible = the receipt's own testId is bound AND it precedes the run, D5); plus 0
26
+ // uncovered changed lines, 0 changed unsupported-source files, a recorded tamper surface
27
+ // with every tampered test-surface file covered by a recorded oracle-change override
28
+ // (review ledger v3), and the reserved EMPTY mutation shape (no mutation ships). A
29
+ // recorded red-proof override waives the receipt + custody proof for exactly the testId
30
+ // it names (D7) never the N/N-green requirement, never QUARANTINE.
31
+ // exit 1 for any DIRTY in-flight plan-execution segment lacking such a current run record — including
32
+ // the stale-fingerprint case (a tree edit moves the fingerprint), the same-fingerprint/
33
+ // new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9),
34
+ // and a schema-1 (or tamper-less) record as the loop's latest run (an older runner; D2) —
35
+ // or a tampered test-surface file (a removed/modified line in a pre-existing test file,
36
+ // or a deleted one — the union of test-classified paths and bound-testId file halves)
37
+ // not covered by a recorded oracle-change override, a run naming an unresolvable bound
38
+ // test, a QUARANTINED bound
39
+ // test (mixed or timed-out probe runs — never an N/N verdict, never converted, no override
40
+ // lane; D4), a red-baseline bound test, a green bound test with NO observed-red receipt /
41
+ // a post-hoc receipt / broken custody (D5), an uncovered changed line, a changed
42
+ // unsupported-source file, or ANY mutation data (no mutation ships — such a record was not
43
+ // produced by this runner; fail closed); when MORE THAN ONE plan is in flight (ambiguous
44
+ // loop id). Fail-CLOSED (unknown state, never a fail-open pass) on a detector failure, an
45
+ // unreadable/malformed result or review ledger, or a corrupt run set — the only
46
+ // detector-independent green is an EXPLICIT configured solo. Changed OUT-OF-DOMAIN files
47
+ // (docs/config the suite does not execute) are LOUDLY listed but never gate-blocking
31
48
  // (Decision 5): guarding what the tool cannot assess with a red gate is a pretend-mechanism.
32
49
  //
33
50
  // HONEST residuals (accepted, documented — exactly like review-state's / review-ledger's): coverage
@@ -43,17 +60,32 @@
43
60
  // free, Node >= 18. No side effects on import (the isDirectRun idiom).
44
61
 
45
62
  import { readFileSync } from 'node:fs';
46
- import { join } from 'node:path';
47
63
  import { pathToFileURL } from 'node:url';
48
64
  import { spawnSync } from 'node:child_process';
49
65
  import { detectBackends } from './detect-backends.mjs';
50
66
  import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
51
67
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
52
68
  import { computeTreeFingerprint, isTreeClean, plansInFlight } from './review-state.mjs';
53
- import { resolveLedgerPath, readLedger, filterLoopRecords } from './review-ledger.mjs';
54
-
55
- export const RESULTS_BASENAME = 'agent-workflow-fold-completeness.jsonl';
56
- export const RESULT_SCHEMA_VERSION = 1;
69
+ import { resolveLedgerPath, resolveBase, readLedger, filterSegmentRecords, collectOverrides, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
70
+ // The fold-ledger locator and the D4 probe-verdict algebra live in the NEUTRAL shared module
71
+ // (BUGFREE-2 / AD-048, D8): review-ledger.mjs telemetry reads the fold ledger through THAT module —
72
+ // importing this checker from there would close an import cycle (this file imports review-ledger).
73
+ // Re-exported here so the runner and every existing consumer keep their one entry point.
74
+ import { probeVerdict, RESULTS_BASENAME, resolveResultsPath } from './changed-surface.mjs';
75
+
76
+ export { probeVerdict, RESULTS_BASENAME, resolveResultsPath };
77
+ // SCHEMA v2 (BUGFREE-1 / AD-047): records gain a kind discriminator — `run` (the fold-completeness
78
+ // run, now with per-testId rerun counts + the test file's content hash) | `red-probe` (the
79
+ // observed-red receipt --red mints). SCHEMA v3 (BUGFREE-2 / AD-048, D7): records carry `base` — the
80
+ // SEGMENT frame — and the gate scopes bound testIds, receipts, custody, and tamper to
81
+ // (loop, base = current HEAD): a committed phase's custody obligations close with its commit (the
82
+ // cross-phase-churn override class dies). RESULT_SCHEMA_VERSION is what the WRITER emits; the
83
+ // reader tolerates every SUPPORTED version under its own per-version rules (the review-ledger
84
+ // v1→v2 precedent), so v1/v2 ledgers never retroactively become malformed — but only a v3 record
85
+ // can enter a segment, so a dirty segment whose loop holds only pre-v3 records fails with a reason
86
+ // naming the schema upgrade (D7 legacy rule).
87
+ export const RESULT_SCHEMA_VERSION = 3;
88
+ export const SUPPORTED_RESULT_SCHEMAS = new Set([1, 2, 3]);
57
89
  const ACTIVITY = 'plan-execution';
58
90
  const SLOT = 'review';
59
91
 
@@ -67,22 +99,17 @@ const gitLine = (args, cwd) => {
67
99
 
68
100
  const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
69
101
 
70
- // The result-ledger path: AW_FOLD_RESULTS overrides (mirrors AW_REVIEW_LEDGER); else <git dir>/basename.
71
- export const resolveResultsPath = (cwd, env = process.env) => {
72
- if (env.AW_FOLD_RESULTS) return env.AW_FOLD_RESULTS;
73
- const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
74
- return gitDir == null ? null : join(gitDir, RESULTS_BASENAME);
75
- };
76
-
77
102
  // ── the bound fixable-bug testId set (the SINGLE source of truth — runner + checker share it) ────
78
103
 
79
- // collectBoundTestIds(reviewRecords, { activity, loop }) → the sorted, de-duplicated testIds of the
80
- // loop's fixable-bug classifications. Pure over review-ledger records. Both the runner (which records
81
- // the set as a binding) and the checker (which recomputes it for the staleness check) call THIS, so
82
- // the two can never drift (Decision 9: same-fingerprint/new-testId is stale).
83
- export const collectBoundTestIds = (reviewRecords, { activity = ACTIVITY, loop } = {}) => {
104
+ // collectBoundTestIds(reviewRecords, { activity, loop, base }) → the sorted, de-duplicated testIds
105
+ // of the SEGMENT's fixable-bug classifications (D7: pass base a committed segment's folds are
106
+ // closed obligations; `base: undefined` would match nothing, never legacy records). Pure over
107
+ // review-ledger records. Both the runner (which records the set as a binding) and the checker
108
+ // (which recomputes it for the staleness check) call THIS, so the two can never drift (Decision 9:
109
+ // same-fingerprint/new-testId is stale).
110
+ export const collectBoundTestIds = (reviewRecords, { activity = ACTIVITY, loop, base } = {}) => {
84
111
  const ids = new Set();
85
- for (const r of filterLoopRecords(reviewRecords, { activity, loop })) {
112
+ for (const r of filterSegmentRecords(reviewRecords, { activity, loop, base })) {
86
113
  if (r.kind !== 'triage') continue;
87
114
  for (const c of r.classifications) if (c.class === 'fixable-bug' && typeof c.testId === 'string' && c.testId.length > 0) ids.add(c.testId);
88
115
  }
@@ -96,37 +123,118 @@ const isNonEmptyString = (v) => typeof v === 'string' && v.length > 0;
96
123
  const isNonNegInt = (v) => Number.isInteger(v) && v >= 0;
97
124
  const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
98
125
 
99
- // validateRunRecord(obj) { ok, reason }. The `reason` names the exact failed check so the
100
- // malformed-line surface and the per-check named tests can assert it. Mutation is reserved and must
101
- // stay the empty shape in v1 (the mutation half is shelved); the schema carries the fields so a
102
- // record validates uniformly and decideCheck enforces the exact reserved shape.
103
- export const validateRunRecord = (obj) => {
104
- if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
105
- if (obj.schema !== RESULT_SCHEMA_VERSION) return { ok: false, reason: `schema must be ${RESULT_SCHEMA_VERSION}` };
106
- if (!isNonEmptyString(obj.loop)) return { ok: false, reason: 'missing loop' };
107
- if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return { ok: false, reason: 'fingerprint must be null or a non-empty string' };
108
- if (!isStringArray(obj.boundTestIds)) return { ok: false, reason: 'boundTestIds must be an array of strings' };
109
- if (!Array.isArray(obj.testIds)) return { ok: false, reason: 'testIds must be an array' };
126
+ const HASH_RE = /^[0-9a-f]{64}$/; // sha-256 hex the content-custody hash shape
127
+
128
+ // The shared record frame (every version, both kinds). v3 adds the SEGMENT frame: base is REQUIRED
129
+ // (null on an unborn branch); a v1/v2 record never carries it an old record never grows new
130
+ // surface (the review-ledger D2 discipline).
131
+ const validateFrame = (obj) => {
132
+ if (!isNonEmptyString(obj.loop)) return 'missing loop';
133
+ if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return 'fingerprint must be null or a non-empty string';
134
+ if (!isNonEmptyString(obj.timestamp)) return 'missing timestamp';
135
+ if (obj.schema >= 3) {
136
+ if (!(obj.base === null || isNonEmptyString(obj.base))) return 'a v3 record requires base null (unborn branch) or the HEAD commit the dirty tree sits on';
137
+ } else if (obj.base !== undefined) {
138
+ return `base is a v3 frame field — a schema-${obj.schema} record never carries it`;
139
+ }
140
+ return null;
141
+ };
142
+
143
+ // A v1 per-testId probe entry (single-run booleans — the AD-046 shape, tolerated read-only).
144
+ const validateV1Entry = (t) => {
145
+ if (!isPlainObject(t) || !isNonEmptyString(t.id)) return 'each testId entry needs an id';
146
+ if (typeof t.resolvable !== 'boolean' || typeof t.baselineGreen !== 'boolean') return `testId ${t.id} needs boolean resolvable + baselineGreen`;
147
+ if (!isNonNegInt(t.executed)) return `testId ${t.id} executed must be a non-negative integer`;
148
+ return null;
149
+ };
150
+
151
+ // A v2 per-testId probe entry: rerun counts (the D4 evidence) + the test file's content hash (the
152
+ // D5 custody anchor) + the derived booleans, VALIDATED consistent with the counts so a forged
153
+ // verdict cannot ride beside honest-looking evidence.
154
+ const validateV2Entry = (t) => {
155
+ if (!isPlainObject(t) || !isNonEmptyString(t.id)) return 'each testId entry needs an id';
156
+ if (!isNonNegInt(t.executed)) return `testId ${t.id} executed must be a non-negative integer`;
157
+ if (!(Number.isInteger(t.runs) && t.runs >= 1)) return `testId ${t.id} runs must be a positive integer`;
158
+ if (!isNonNegInt(t.greens) || !isNonNegInt(t.reds) || !isNonNegInt(t.timeouts)) return `testId ${t.id} rerun counts (greens/reds/timeouts) must be non-negative integers`;
159
+ if (t.greens + t.reds + t.timeouts > t.runs) return `testId ${t.id} rerun counts exceed runs`;
160
+ // A resolved run means at least one matched (executed) test result — greens/reds with executed=0
161
+ // is a forged N/N verdict carrying zero-match evidence (codex R1, BUGFREE-1 live loop).
162
+ if (t.greens + t.reds > 0 && t.executed < 1) return `testId ${t.id} executed must be positive when a run resolved (greens+reds > 0)`;
163
+ 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`;
164
+ if (t.resolvable !== (t.greens + t.reds === t.runs)) return `testId ${t.id} resolvable must equal (greens + reds === runs)`;
165
+ if (t.baselineGreen !== (t.greens === t.runs)) return `testId ${t.id} baselineGreen must equal (greens === runs)`;
166
+ return null;
167
+ };
168
+
169
+ // The run-record body shared by v1 and v2 (surface classes, coverage, reserved mutation, budgets);
170
+ // the per-testId entry rule is the per-version part.
171
+ const validateRunBody = (obj, validateEntry) => {
172
+ if (!isStringArray(obj.boundTestIds)) return 'boundTestIds must be an array of strings';
173
+ if (!Array.isArray(obj.testIds)) return 'testIds must be an array';
110
174
  for (const t of obj.testIds) {
111
- if (!isPlainObject(t) || !isNonEmptyString(t.id)) return { ok: false, reason: 'each testId entry needs an id' };
112
- if (typeof t.resolvable !== 'boolean' || typeof t.baselineGreen !== 'boolean') return { ok: false, reason: `testId ${t.id} needs boolean resolvable + baselineGreen` };
113
- if (!isNonNegInt(t.executed)) return { ok: false, reason: `testId ${t.id} executed must be a non-negative integer` };
175
+ const r = validateEntry(t);
176
+ if (r) return r;
114
177
  }
115
- if (!isStringArray(obj.unsupported)) return { ok: false, reason: 'unsupported must be an array of strings' };
116
- if (!isStringArray(obj.outOfDomain)) return { ok: false, reason: 'outOfDomain must be an array of strings' };
117
- if (!isPlainObject(obj.coverage) || !Array.isArray(obj.coverage.uncoveredChanged)) return { ok: false, reason: 'coverage.uncoveredChanged must be an array' };
178
+ if (!isStringArray(obj.unsupported)) return 'unsupported must be an array of strings';
179
+ if (!isStringArray(obj.outOfDomain)) return 'outOfDomain must be an array of strings';
180
+ if (!isPlainObject(obj.coverage) || !Array.isArray(obj.coverage.uncoveredChanged)) return 'coverage.uncoveredChanged must be an array';
118
181
  for (const u of obj.coverage.uncoveredChanged) {
119
- if (!isPlainObject(u) || !isNonEmptyString(u.file)) return { ok: false, reason: 'each uncoveredChanged entry needs a file' };
120
- if (!(u.line === null || (Number.isInteger(u.line) && u.line >= 1))) return { ok: false, reason: `uncoveredChanged ${u.file} line must be null or an integer >= 1` };
182
+ if (!isPlainObject(u) || !isNonEmptyString(u.file)) return 'each uncoveredChanged entry needs a file';
183
+ if (!(u.line === null || (Number.isInteger(u.line) && u.line >= 1))) return `uncoveredChanged ${u.file} line must be null or an integer >= 1`;
121
184
  }
122
185
  const m = obj.mutation;
123
186
  if (!isPlainObject(m) || !isNonNegInt(m.total) || !isNonNegInt(m.killed) || !Array.isArray(m.survived) || !isNonNegInt(m.skipped)) {
124
- return { ok: false, reason: 'mutation must be { total, killed, survived[], skipped, killSetBasis }' };
187
+ return 'mutation must be { total, killed, survived[], skipped, killSetBasis }';
125
188
  }
126
- if (!(m.killSetBasis === null || isNonEmptyString(m.killSetBasis))) return { ok: false, reason: 'mutation.killSetBasis must be null or a non-empty string' };
127
- if (!isPlainObject(obj.budgets)) return { ok: false, reason: 'budgets must be an object' };
128
- if (!isNonEmptyString(obj.timestamp)) return { ok: false, reason: 'missing timestamp' };
129
- return { ok: true };
189
+ if (!(m.killSetBasis === null || isNonEmptyString(m.killSetBasis))) return 'mutation.killSetBasis must be null or a non-empty string';
190
+ if (!isPlainObject(obj.budgets)) return 'budgets must be an object';
191
+ return null;
192
+ };
193
+
194
+ // The red-probe receipt (v2 only): the machine attestation that testId FAILED on N/N runs at the
195
+ // recorded content hash. Minted only by the runner's --red verb; reds must equal runs — a receipt
196
+ // never records anything but an honest N/N red (refusals write nothing).
197
+ const validateRedProbe = (obj) => {
198
+ if (!isWellFormedTestId(obj.testId)) return 'red-probe testId must be "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)';
199
+ if (!(typeof obj.fileHash === 'string' && HASH_RE.test(obj.fileHash))) return 'red-probe fileHash must be a 64-hex content hash';
200
+ if (!(Number.isInteger(obj.runs) && obj.runs >= 1)) return 'red-probe runs must be a positive integer';
201
+ if (obj.reds !== obj.runs) return 'red-probe reds must equal runs (a receipt attests N/N observed red)';
202
+ return null;
203
+ };
204
+
205
+ // validateRunRecord(obj) → { ok, reason }. Per-version, per-kind (D2): v1 records (no kind) keep the
206
+ // AD-046 single-run rules; v2 records carry the kind discriminator. The `reason` names the exact
207
+ // failed check so the malformed-line surface and the per-check named tests can assert it. Mutation
208
+ // stays the reserved empty shape (the mutation half is shelved); the schema carries the fields so a
209
+ // record validates uniformly and decideCheck enforces the exact reserved shape.
210
+ export const validateRunRecord = (obj) => {
211
+ if (!isPlainObject(obj)) return { ok: false, reason: 'not an object' };
212
+ if (!SUPPORTED_RESULT_SCHEMAS.has(obj.schema)) return { ok: false, reason: `schema must be one of ${[...SUPPORTED_RESULT_SCHEMAS].join(', ')}` };
213
+ const frame = validateFrame(obj);
214
+ if (frame) return { ok: false, reason: frame };
215
+ if (obj.schema === 1) {
216
+ if (obj.kind !== undefined) return { ok: false, reason: 'a v1 record must not carry kind (kind is a v2 discriminator)' };
217
+ const r = validateRunBody(obj, validateV1Entry);
218
+ return r ? { ok: false, reason: r } : { ok: true };
219
+ }
220
+ if (obj.kind === 'run') {
221
+ const r = validateRunBody(obj, validateV2Entry);
222
+ if (r) return { ok: false, reason: r };
223
+ // The tamper surface is OPTIONAL on a v2 read — a record written by the pre-tamper v2 runner
224
+ // stays readable (never retroactively malformed); the GATE handles the staleness. A v3 runner
225
+ // ALWAYS records it, so a v3 run without one is not this runner's output. When present, the
226
+ // shape is exact.
227
+ if (obj.schema >= 3 && obj.tamper === undefined) return { ok: false, reason: 'a v3 run records its tamper surface' };
228
+ if (obj.tamper !== undefined && (!isPlainObject(obj.tamper) || !isStringArray(obj.tamper.tampered))) {
229
+ return { ok: false, reason: 'tamper.tampered must be an array of strings (when the tamper surface is recorded)' };
230
+ }
231
+ return { ok: true };
232
+ }
233
+ if (obj.kind === 'red-probe') {
234
+ const r = validateRedProbe(obj);
235
+ return r ? { ok: false, reason: r } : { ok: true };
236
+ }
237
+ return { ok: false, reason: `kind must be "run" or "red-probe" (got ${JSON.stringify(obj.kind)})` };
130
238
  };
131
239
 
132
240
  // readResults(path) → { records, malformed, malformedReasons, readError }. Absent file → empty (no run
@@ -157,9 +265,36 @@ export const readResults = (path, readFile = readFileSync) => {
157
265
  return { records, malformed: malformedReasons.length, malformedReasons };
158
266
  };
159
267
 
160
- // filterLoopResults(records, loop) → the run records of ONE loop, order preserved (latest is last).
268
+ // filterLoopResults(records, loop) → the result records of ONE loop (both kinds), order preserved
269
+ // (latest is last).
161
270
  export const filterLoopResults = (records, loop) => records.filter((r) => r.loop === loop);
162
271
 
272
+ // filterSegmentResults(records, loop, base) → the result records of ONE segment (D7), order
273
+ // preserved. STRICT: only a v3+ record carries base, so pre-v3 history can never enter a segment
274
+ // (readable, never re-scoped — the review-ledger filterSegmentRecords discipline).
275
+ export const filterSegmentResults = (records, loop, base) =>
276
+ filterLoopResults(records, loop).filter((r) => r.schema >= 3 && r.base === base);
277
+
278
+ // ── kind-aware selectors (shared: the runner and the checker read the ledger through THESE) ──────
279
+
280
+ // A v1 record IS a run (the kindless AD-046 shape); a v2 record is a run only under kind:"run".
281
+ export const isRunRecord = (r) => r.schema === 1 || r.kind === 'run';
282
+ export const isRedProbeRecord = (r) => r.schema >= 2 && r.kind === 'red-probe';
283
+
284
+ // latestRunRecord(loopRecords) → { record, index } | null over ONE loop's ordered records. Kind-aware
285
+ // (codex R2): a red-probe appended after a run must never be read as the loop's "latest run" — the
286
+ // index anchors the D5/order checks (a custody-eligible receipt PRECEDES the latest run).
287
+ export const latestRunRecord = (loopRecords) => {
288
+ for (let i = loopRecords.length - 1; i >= 0; i -= 1) {
289
+ if (isRunRecord(loopRecords[i])) return { record: loopRecords[i], index: i };
290
+ }
291
+ return null;
292
+ };
293
+
294
+ // probeVerdict — the D4 verdict algebra, ONE home (now the neutral changed-surface.mjs; re-exported
295
+ // above): the runner (--red mints only on 'red'), this checker (the gate passes only on 'green'),
296
+ // and the review-ledger telemetry (quarantine counts) all read the SAME algebra.
297
+
163
298
  // ── the check + report core ─────────────────────────────────────────────────────────────────────
164
299
 
165
300
  // buildFoldState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges; every
@@ -181,6 +316,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
181
316
  const plans = plansInFlight(root);
182
317
  const fingerprint = computeTreeFingerprint(cwd);
183
318
  const clean = fingerprint == null ? null : isTreeClean(cwd);
319
+ const base = resolveBase(cwd);
184
320
  const resultsPath = resolveResultsPath(cwd, env);
185
321
  const resultRead = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0, malformedReasons: [] };
186
322
  const reviewPath = resolveLedgerPath(cwd, env);
@@ -192,6 +328,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
192
328
  plans,
193
329
  fingerprint,
194
330
  clean,
331
+ base,
195
332
  resultsPath,
196
333
  resultRecords: resultRead.records,
197
334
  resultMalformed: resultRead.malformed,
@@ -230,14 +367,28 @@ export const decideCheck = (state) => {
230
367
  if (state.reviewMalformed > 0) return { code: 1, reason: `the review ledger has ${state.reviewMalformed} malformed line(s) — failing closed; inspect ${state.reviewPath}` };
231
368
 
232
369
  const loop = state.plans[0].replace(/\.md$/, '');
233
- const runs = filterLoopResults(state.resultRecords, loop);
234
- if (runs.length === 0) return { code: 1, reason: `dirty plan-execution loop "${loop}" but no fold-completeness run recorded — run fold-completeness-run.mjs` };
235
- const latest = runs[runs.length - 1];
370
+ // SEGMENT scope (D7): the gate judges the current segment — (loop, base = the HEAD the dirty
371
+ // tree sits on). A committed phase's runs, receipts, custody chains, and tamper obligations are
372
+ // closed history; only v3 records at the current base enter.
373
+ const loopResults = filterSegmentResults(state.resultRecords, loop, state.base);
374
+ const sel = latestRunRecord(loopResults);
375
+ if (sel == null) {
376
+ const allLoop = filterLoopResults(state.resultRecords, loop);
377
+ const legacy = allLoop.length > 0 && allLoop.every((r) => !(r.schema >= 3))
378
+ ? ' (the loop has only pre-v3 records, which never enter a segment — the schema upgrade requires a fresh run)'
379
+ : '';
380
+ return { code: 1, reason: `dirty plan-execution loop "${loop}" but no fold-completeness run recorded for the current segment — run fold-completeness-run.mjs${legacy}` };
381
+ }
382
+ const latest = sel.record;
383
+ // No per-record schema re-checks here (agy Phase-2 nit): only a VALID v3 record can be a segment
384
+ // member — the reader binned anything older or tamper-less before this point (a v1 never carries
385
+ // base; a v3 run without a tamper surface is malformed), and a malformed line already failed the
386
+ // gate closed above.
236
387
 
237
388
  // Decision 9 — the double binding. A tree edit moves the fingerprint; a new fixable-bug triage moves
238
389
  // the bound-testId set. Either mismatch is STALE (the run no longer describes the committable tree).
239
390
  if (latest.fingerprint !== state.fingerprint) return { code: 1, reason: `no fold-completeness run for the current tree (the tree was edited after the run) — re-run fold-completeness-run.mjs after the last edit` };
240
- const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop });
391
+ const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop, base: state.base });
241
392
  if (!sameSet(latest.boundTestIds, currentBound)) return { code: 1, reason: `a fixable-bug testId was triaged after the run (the bound-testId set changed) — re-run fold-completeness-run.mjs` };
242
393
  // Fail CLOSED if the run's probe set does not cover its bound-testId set EXACTLY. A well-formed
243
394
  // runner record always probes every bound testId (one testIds[] entry per boundTestId), but a record
@@ -249,10 +400,54 @@ export const decideCheck = (state) => {
249
400
  // A changed unsupported-source file → fail closed: the signal never vouches for JS-family source it
250
401
  // cannot assess (TS/JSX out of scope v1, Decision 5).
251
402
  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
- const unresolvable = latest.testIds.filter((t) => !t.resolvable).map((t) => t.id);
253
- if (unresolvable.length > 0) return { code: 1, reason: `unresolvable bound testId(s) — the pattern selects no test: ${unresolvable.join(', ')}` };
254
- const redBaseline = latest.testIds.filter((t) => t.resolvable && !t.baselineGreen).map((t) => t.id);
255
- if (redBaseline.length > 0) return { code: 1, reason: `bound test(s) with a red baseline (the fold is not complete): ${redBaseline.join(', ')}` };
403
+
404
+ // The recorded overrides of this loop (review ledger, schema v3): oracle-change lifts named
405
+ // tampered files; red-proof waives the receipt + custody proof for exactly the testId it names.
406
+ const overrides = collectOverrides(state.reviewRecords, { activity: ACTIVITY, loop });
407
+
408
+ // Approach-3 — the oracle-tamper guard: any tampered test-surface file (a removed/modified line in
409
+ // a pre-existing test file, or a deleted one) must be covered by a recorded oracle-change
410
+ // override. Never silent: the override is a durable, auditable ledger entry.
411
+ const tamperedUncovered = latest.tamper.tampered.filter((f) => !overrides.oracleChangeFiles.has(f));
412
+ if (tamperedUncovered.length > 0) {
413
+ return {
414
+ code: 1,
415
+ 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>"}'`,
416
+ };
417
+ }
418
+
419
+ // Per-testId enforcement, in bound (sorted) order: the D4 verdict algebra first — QUARANTINE and
420
+ // red/unresolvable are probe-outcome failures regardless of receipts — then, for a green test, the
421
+ // red-proof chain: receipt exists (Approach-1.i) → the receipt PRECEDES the loop's latest run
422
+ // (anti-post-hoc, codex R2) → content custody (D5). The checker stays PURE: the current file hash
423
+ // IS the latest run's recorded hash (the run is fingerprint-bound to the current tree).
424
+ const probes = new Map(latest.testIds.map((t) => [t.id, t]));
425
+ const boundSet = new Set(latest.boundTestIds);
426
+ const receipts = loopResults.map((r, i) => ({ r, i })).filter(({ r }) => isRedProbeRecord(r));
427
+ for (const id of latest.boundTestIds) {
428
+ const t = probes.get(id);
429
+ const verdict = probeVerdict(t);
430
+ if (verdict === 'unresolvable') return { code: 1, reason: `unresolvable bound testId(s) — the pattern selects no test: ${id}` };
431
+ 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` };
432
+ if (verdict === 'red') return { code: 1, reason: `bound test(s) with a red baseline (the fold is not complete): ${id}` };
433
+ // D7 — a recorded red-proof override replaces the receipt + custody proof for EXACTLY this
434
+ // testId (green N/N was already required above; QUARANTINE was already refused — no override
435
+ // lane converts a flaky probe).
436
+ if (overrides.redProofTestIds.has(id)) continue;
437
+ // verdict green — the red-proof chain.
438
+ const own = receipts.filter(({ r }) => r.testId === id);
439
+ 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}"` };
440
+ 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)` };
441
+ const { file } = splitTestId(id);
442
+ // Custody eligibility (D5, codex R3): the anchor is the LATEST receipt on that FILE whose own
443
+ // testId is in the bound set AND which precedes the latest run — an unbound throwaway receipt,
444
+ // or one minted post-run, never re-attests a file.
445
+ const eligible = receipts.filter(({ r, i }) => i < sel.index && boundSet.has(r.testId) && splitTestId(r.testId).file === file);
446
+ const anchor = eligible[eligible.length - 1];
447
+ if (!anchor || t.fileHash == null || anchor.r.fileHash !== t.fileHash) {
448
+ 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` };
449
+ }
450
+ }
256
451
  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
452
  // v1 ships NO mutation (the mutation half was shelved): the shipped runner only ever writes the
258
453
  // reserved empty shape, so a record carrying ANY mutation data was not produced by this runner
@@ -275,12 +470,19 @@ export const decideCheck = (state) => {
275
470
 
276
471
  // ── rendering ─────────────────────────────────────────────────────────────────────────────────
277
472
 
278
- const runLine = (r) => {
279
- const tests = r.testIds.length ? r.testIds.map((t) => `${t.id}${t.resolvable ? (t.baselineGreen ? '✓' : ' red-baseline') : ' unresolvable'}`).join(', ') : '(no bound tests)';
473
+ const testLine = (r, t) => {
474
+ if (r.schema === 1) return `${t.id}${t.resolvable ? (t.baselineGreen ? '✓' : ' red-baseline') : ' unresolvable'}`;
475
+ const v = probeVerdict(t);
476
+ return v === 'green' ? `${t.id} ✓ ${t.greens}/${t.runs}` : `${t.id} ${v} (${t.greens}g/${t.reds}r/${t.timeouts}t of ${t.runs})`;
477
+ };
478
+
479
+ const runLine = (r, receipts) => {
480
+ const tests = r.testIds.length ? r.testIds.map((t) => testLine(r, t)).join(', ') : '(no bound tests)';
280
481
  const uncov = r.coverage.uncoveredChanged.length ? r.coverage.uncoveredChanged.map(renderUncovered).join(', ') : 'none';
281
482
  return [
282
483
  ` latest run — fingerprint ${r.fingerprint}`,
283
484
  ` bound testIds: ${tests}`,
485
+ ` red-probe receipts: ${receipts.length ? receipts.map((p) => `${p.testId} (${p.reds}/${p.runs} red @${p.fileHash.slice(0, 8)})`).join(', ') : 'none'}`,
284
486
  ` uncovered changed: ${uncov}`,
285
487
  ` unsupported: ${r.unsupported.length ? r.unsupported.join(', ') : 'none'} · out-of-domain: ${r.outOfDomain.length ? r.outOfDomain.join(', ') : 'none'}`,
286
488
  ` mutation: ${r.mutation.total} total / ${r.mutation.killed} killed / ${r.mutation.survived.length} survived / ${r.mutation.skipped} skipped`,
@@ -300,8 +502,9 @@ const formatHuman = (state, check) => {
300
502
  lines.push(` result ledger: ${state.resultsPath ?? '(unresolvable — no git dir)'} (${state.resultRecords.length} record(s)${state.resultMalformed ? `, ${state.resultMalformed} malformed — inspect the file` : ''})`);
301
503
  if (state.plans.length === 1) {
302
504
  const loop = state.plans[0].replace(/\.md$/, '');
303
- const runs = filterLoopResults(state.resultRecords, loop);
304
- if (runs.length > 0) lines.push(runLine(runs[runs.length - 1]));
505
+ const segResults = filterSegmentResults(state.resultRecords, loop, state.base); // the current segment (D7)
506
+ const sel = latestRunRecord(segResults); // kind-aware: never render a red-probe as "the run"
507
+ if (sel) lines.push(runLine(sel.record, segResults.filter(isRedProbeRecord)));
305
508
  }
306
509
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
307
510
  return lines.join('\n');
@@ -314,16 +517,28 @@ Usage:
314
517
 
315
518
  Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOLD_RESULTS overrides),
316
519
  resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the canonical uncommitted-state
317
- fingerprint, and decides whether the in-flight plan-execution loop's changed code is pinned by tests.
520
+ fingerprint, and decides whether the in-flight plan-execution SEGMENT's changed code is pinned by
521
+ tests — (loop, base = git rev-parse HEAD), schema v3 (AD-048 D7): bound testIds, receipts, custody,
522
+ and tamper all filter to the current segment; a committed phase's obligations close with its commit.
318
523
 
319
- --status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary, verdict.
524
+ --status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
525
+ (per-testId D4 verdicts + rerun counts), the loop's red-probe receipts, verdict.
320
526
  --check → the gate exit code. The normative exit contract lives in the tool header (the single home):
321
- 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 resolvable+green bound tests, 0 uncovered changed lines, 0
323
- changed unsupported source, and the reserved empty mutation shape; exit 1 otherwise (stale/missing
324
- run, an unresolvable/red bound test, an uncovered line, changed TS/JSX, any mutation data — v1 ships
325
- no mutation, >1 plan, an unreadable/malformed ledger, or a detector failure). Out-of-domain changes
326
- are listed, never blocking.
527
+ exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT segment run whose
528
+ fingerprint AND segment bound-testId set both match, with per bound testId an N/N-green probe,
529
+ an observed-red receipt in this segment that PRECEDES the run, and content custody (run hash == the
530
+ latest custody-eligible receipt hash on that file); plus 0 uncovered changed lines, 0 changed
531
+ unsupported source, every tampered
532
+ test-surface file covered by a recorded oracle-change override, and the reserved empty mutation
533
+ shape. A recorded red-proof override (review-ledger-write override) waives receipt + custody for
534
+ exactly its testId — never green-N/N, never QUARANTINE. exit 1 otherwise (stale/missing/older-runner
535
+ run, an unresolvable bound test, a QUARANTINED bound test — mixed/timeout, never converted, no
536
+ override lane — a red baseline, a missing/post-hoc receipt, broken custody, an uncovered tampered
537
+ file, an uncovered line, changed TS/JSX, any mutation data, >1 plan, an unreadable/malformed
538
+ ledger, or a detector failure). Out-of-domain changes are listed, never blocking. The honest
539
+ fold-time order: classify the fixable-bug with its testId → write the test →
540
+ fold-completeness-run.mjs --red "<testId>" observes red BEFORE the fix → fold the fix → the
541
+ normal run observes green → --check.
327
542
  --json → the structured state + decision.
328
543
 
329
544
  The runner is a SEPARATE tool (fold-completeness-run.mjs) — this read-only checker never imports it.
@@ -212,7 +212,7 @@ const reviewLoopAdvice = (slots, activity) =>
212
212
  ' • At the cap, classify every surviving blocking finding: fixable-bug (fold ONCE as a red→green test, re-review) / inherent-layer-residual (document + raise to an acceptance criterion) / escalate (the maintainer decides); a minor never forces triage.',
213
213
  ...(activity === 'plan-execution'
214
214
  ? [
215
- ' • The computed instrument for THIS loop: record each round + triage via review-ledger (record / classify); read the stop with review-ledger --status (its render replaces the hand-composed tally); gate the commit with review-ledger --check.',
215
+ ' • The computed instrument for THIS loop: run the FULL gate matrix with run-gates --record BEFORE recording a round (the green-baseline receipt the writer demands), then record each round + triage via review-ledger (record / classify); rounds, caps, and teeth are per SEGMENT (base = HEAD — the counter resets only at a gated commit); read the stop with review-ledger --status (its render replaces the hand-composed tally); gate the commit with review-ledger --check.',
216
216
  ]
217
217
  : []),
218
218
  ]