@sabaiway/agent-workflow-kit 1.38.0 → 1.40.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,19 +14,23 @@
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 (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
17
+ // and when the in-flight plan-execution SEGMENT (loop, base = git rev-parse HEAD),
18
+ // schema v3+ (v4 adds the (a) suite-execution evidence + the (c) reattest custody anchor,
19
+ // BUGFREE-3/AD-049), AD-048 D7 has a CURRENT run record (kind-aware: the latest RUN, never a
20
+ // later red-probe or reattest) whose BOTH bindings match the tree fingerprint AND the SEGMENT's
21
+ // sorted fixable-bug testId set recorded in the run — with, per bound testId: an
22
+ // N/N-green probe (D4), an observed-red receipt in this SEGMENT (a committed phase's
23
+ // obligations closed with its commit; a receipt never crosses a commit boundary), that receipt
21
24
  // 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
25
+ // recorded test-file hash equals the latest custody-eligible red-probe OR reattest hash on that
26
+ // file (eligible = its own testId is bound AND it precedes the run, D5; a green-only append is
27
+ // re-anchored by a recorded --reattest, not a red-proof waiver); plus 0
24
28
  // uncovered changed lines, 0 changed unsupported-source files, a recorded tamper surface
25
29
  // with every tampered test-surface file covered by a recorded oracle-change override
26
30
  // (review ledger v3), and the reserved EMPTY mutation shape (no mutation ships). A
27
31
  // recorded red-proof override waives the receipt + custody proof for exactly the testId
28
32
  // it names (D7) — never the N/N-green requirement, never QUARANTINE.
29
- // exit 1 for any DIRTY in-flight plan-execution loop lacking such a current run record — including
33
+ // exit 1 for any DIRTY in-flight plan-execution segment lacking such a current run record — including
30
34
  // the stale-fingerprint case (a tree edit moves the fingerprint), the same-fingerprint/
31
35
  // new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9),
32
36
  // and a schema-1 (or tamper-less) record as the loop's latest run (an older runner; D2) —
@@ -58,24 +62,41 @@
58
62
  // free, Node >= 18. No side effects on import (the isDirectRun idiom).
59
63
 
60
64
  import { readFileSync } from 'node:fs';
61
- import { join } from 'node:path';
62
65
  import { pathToFileURL } from 'node:url';
63
66
  import { spawnSync } from 'node:child_process';
64
67
  import { detectBackends } from './detect-backends.mjs';
65
68
  import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
66
69
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
67
70
  import { computeTreeFingerprint, isTreeClean, plansInFlight } from './review-state.mjs';
68
- import { resolveLedgerPath, readLedger, filterLoopRecords, collectOverrides, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
69
-
70
- export const RESULTS_BASENAME = 'agent-workflow-fold-completeness.jsonl';
71
+ import { resolveLedgerPath, resolveBase, readLedger, filterSegmentRecords, collectOverrides, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
72
+ // The fold-ledger locator and the D4 probe-verdict algebra live in the NEUTRAL shared module
73
+ // (BUGFREE-2 / AD-048, D8): review-ledger.mjs telemetry reads the fold ledger through THAT module —
74
+ // importing this checker from there would close an import cycle (this file imports review-ledger).
75
+ // Re-exported here so the runner and every existing consumer keep their one entry point.
76
+ import { probeVerdict, RESULTS_BASENAME, resolveResultsPath } from './changed-surface.mjs';
77
+
78
+ export { probeVerdict, RESULTS_BASENAME, resolveResultsPath };
71
79
  // SCHEMA v2 (BUGFREE-1 / AD-047): records gain a kind discriminator — `run` (the fold-completeness
72
80
  // 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]);
81
+ // observed-red receipt --red mints). SCHEMA v3 (BUGFREE-2 / AD-048, D7): records carry `base` — the
82
+ // SEGMENT frame and the gate scopes bound testIds, receipts, custody, and tamper to
83
+ // (loop, base = current HEAD): a committed phase's custody obligations close with its commit (the
84
+ // cross-phase-churn override class dies). RESULT_SCHEMA_VERSION is what the WRITER emits; the
85
+ // reader tolerates every SUPPORTED version under its own per-version rules (the review-ledger
86
+ // v1→v2 precedent), so v1/v2 ledgers never retroactively become malformed — but only a v3 record
87
+ // can enter a segment, so a dirty segment whose loop holds only pre-v3 records fails with a reason
88
+ // naming the schema upgrade (D7 legacy rule).
89
+ // SCHEMA v4 (BUGFREE-3 / AD-049): a v4 run record carries a `suite` execution-evidence block (the (a)
90
+ // one-suite-run credit — cmd + exit + pre/post fingerprints) AND a new record kind `reattest` joins
91
+ // `run`/`red-probe`: an operator-minted, RECORDED custody re-attestation that anchors a bound test
92
+ // file's custody at its CURRENT bytes WITHOUT fabricating a red-observe (the honest replacement for
93
+ // mis-using red-proof on a green-only test-file append, item (c)). A placed pre-v4 checker (SUPPORTED
94
+ // = {1,2,3}) rejects a v4 record outright — the version-skew guard; v1/v2/v3 rules are untouched.
95
+ export const RESULT_SCHEMA_VERSION = 4;
96
+ export const SUPPORTED_RESULT_SCHEMAS = new Set([1, 2, 3, 4]);
97
+ // The v4 record kind (c): a recorded re-attestation. Named as a const so the runner, the checker, and
98
+ // the doc-parity lint share one token (no drift).
99
+ export const REATTEST_KIND = 'reattest';
79
100
  const ACTIVITY = 'plan-execution';
80
101
  const SLOT = 'review';
81
102
 
@@ -89,22 +110,17 @@ const gitLine = (args, cwd) => {
89
110
 
90
111
  const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
91
112
 
92
- // The result-ledger path: AW_FOLD_RESULTS overrides (mirrors AW_REVIEW_LEDGER); else <git dir>/basename.
93
- export const resolveResultsPath = (cwd, env = process.env) => {
94
- if (env.AW_FOLD_RESULTS) return env.AW_FOLD_RESULTS;
95
- const gitDir = gitLine(['rev-parse', '--absolute-git-dir'], cwd);
96
- return gitDir == null ? null : join(gitDir, RESULTS_BASENAME);
97
- };
98
-
99
113
  // ── the bound fixable-bug testId set (the SINGLE source of truth — runner + checker share it) ────
100
114
 
101
- // collectBoundTestIds(reviewRecords, { activity, loop }) → the sorted, de-duplicated testIds of the
102
- // loop's fixable-bug classifications. Pure over review-ledger records. Both the runner (which records
103
- // the set as a binding) and the checker (which recomputes it for the staleness check) call THIS, so
104
- // the two can never drift (Decision 9: same-fingerprint/new-testId is stale).
105
- export const collectBoundTestIds = (reviewRecords, { activity = ACTIVITY, loop } = {}) => {
115
+ // collectBoundTestIds(reviewRecords, { activity, loop, base }) → the sorted, de-duplicated testIds
116
+ // of the SEGMENT's fixable-bug classifications (D7: pass base a committed segment's folds are
117
+ // closed obligations; `base: undefined` would match nothing, never legacy records). Pure over
118
+ // review-ledger records. Both the runner (which records the set as a binding) and the checker
119
+ // (which recomputes it for the staleness check) call THIS, so the two can never drift (Decision 9:
120
+ // same-fingerprint/new-testId is stale).
121
+ export const collectBoundTestIds = (reviewRecords, { activity = ACTIVITY, loop, base } = {}) => {
106
122
  const ids = new Set();
107
- for (const r of filterLoopRecords(reviewRecords, { activity, loop })) {
123
+ for (const r of filterSegmentRecords(reviewRecords, { activity, loop, base })) {
108
124
  if (r.kind !== 'triage') continue;
109
125
  for (const c of r.classifications) if (c.class === 'fixable-bug' && typeof c.testId === 'string' && c.testId.length > 0) ids.add(c.testId);
110
126
  }
@@ -120,11 +136,18 @@ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'st
120
136
 
121
137
  const HASH_RE = /^[0-9a-f]{64}$/; // sha-256 hex — the content-custody hash shape
122
138
 
123
- // The shared record frame (both versions, both kinds).
139
+ // The shared record frame (every version, both kinds). v3 adds the SEGMENT frame: base is REQUIRED
140
+ // (null on an unborn branch); a v1/v2 record never carries it — an old record never grows new
141
+ // surface (the review-ledger D2 discipline).
124
142
  const validateFrame = (obj) => {
125
143
  if (!isNonEmptyString(obj.loop)) return 'missing loop';
126
144
  if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return 'fingerprint must be null or a non-empty string';
127
145
  if (!isNonEmptyString(obj.timestamp)) return 'missing timestamp';
146
+ if (obj.schema >= 3) {
147
+ 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';
148
+ } else if (obj.base !== undefined) {
149
+ return `base is a v3 frame field — a schema-${obj.schema} record never carries it`;
150
+ }
128
151
  return null;
129
152
  };
130
153
 
@@ -190,6 +213,30 @@ const validateRedProbe = (obj) => {
190
213
  return null;
191
214
  };
192
215
 
216
+ // The (c) re-attest receipt (v4 only): the operator asserts a bound test file's CURRENT bytes are a
217
+ // legitimate custody anchor (a green-only append after a --red receipt — there is no red to observe).
218
+ // It mirrors the red-probe receipt MINUS the observation counts: testId + the file's current hash.
219
+ // Minted only by the runner's --reattest verb (self-discipline, the same trust model as the overrides).
220
+ const validateReattest = (obj) => {
221
+ if (!isWellFormedTestId(obj.testId)) return 'reattest testId must be "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty)';
222
+ if (!(typeof obj.fileHash === 'string' && HASH_RE.test(obj.fileHash))) return 'reattest fileHash must be a 64-hex content hash';
223
+ return null;
224
+ };
225
+
226
+ // The (a) suite-execution evidence (v4 run records): the ONE suite spawn per fingerprint, recorded so
227
+ // run-gates --record can CREDIT the unit-tests gate from it instead of re-spawning. Here only the SHAPE
228
+ // is checked (a non-empty cmd, an integer-or-null exit, null-or-string before/after fingerprints); the
229
+ // credit's fingerprint/tree/cmd-identity/exit-0 conditions live in the ledger writer (review-ledger.mjs).
230
+ const validateSuiteEvidence = (suite) => {
231
+ if (!isPlainObject(suite)) return 'a v4 run records its suite-execution evidence ({ cmd, exit, fingerprintBefore, fingerprintAfter })';
232
+ if (!isNonEmptyString(suite.cmd)) return 'suite.cmd must be a non-empty string';
233
+ if (!(suite.exit === null || Number.isInteger(suite.exit))) return 'suite.exit must be an integer or null';
234
+ for (const k of ['fingerprintBefore', 'fingerprintAfter']) {
235
+ if (!(suite[k] === null || isNonEmptyString(suite[k]))) return `suite.${k} must be null or a non-empty fingerprint string`;
236
+ }
237
+ return null;
238
+ };
239
+
193
240
  // validateRunRecord(obj) → { ok, reason }. Per-version, per-kind (D2): v1 records (no kind) keep the
194
241
  // AD-046 single-run rules; v2 records carry the kind discriminator. The `reason` names the exact
195
242
  // failed check so the malformed-line surface and the per-check named tests can assert it. Mutation
@@ -208,19 +255,35 @@ export const validateRunRecord = (obj) => {
208
255
  if (obj.kind === 'run') {
209
256
  const r = validateRunBody(obj, validateV2Entry);
210
257
  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.
258
+ // The tamper surface is OPTIONAL on a v2 read — a record written by the pre-tamper v2 runner
259
+ // stays readable (never retroactively malformed); the GATE handles the staleness. A v3 runner
260
+ // ALWAYS records it, so a v3 run without one is not this runner's output. When present, the
261
+ // shape is exact.
262
+ if (obj.schema >= 3 && obj.tamper === undefined) return { ok: false, reason: 'a v3 run records its tamper surface' };
214
263
  if (obj.tamper !== undefined && (!isPlainObject(obj.tamper) || !isStringArray(obj.tamper.tampered))) {
215
264
  return { ok: false, reason: 'tamper.tampered must be an array of strings (when the tamper surface is recorded)' };
216
265
  }
266
+ // v4 (a): a v4 runner ALWAYS records the suite-execution evidence, so a v4 run without one is not
267
+ // this runner's output (the tamper-on-v3 precedent). v1/v2/v3 runs never carry it.
268
+ if (obj.schema >= 4) {
269
+ const sr = validateSuiteEvidence(obj.suite);
270
+ if (sr) return { ok: false, reason: sr };
271
+ } else if (obj.suite !== undefined) {
272
+ return { ok: false, reason: `suite is a v4 field — a schema-${obj.schema} run never carries it` };
273
+ }
217
274
  return { ok: true };
218
275
  }
219
276
  if (obj.kind === 'red-probe') {
220
277
  const r = validateRedProbe(obj);
221
278
  return r ? { ok: false, reason: r } : { ok: true };
222
279
  }
223
- return { ok: false, reason: `kind must be "run" or "red-probe" (got ${JSON.stringify(obj.kind)})` };
280
+ if (obj.kind === REATTEST_KIND) {
281
+ // (c): a v4-only record kind — a v1/v2/v3 record never carries it (the version-skew guard).
282
+ if (!(obj.schema >= 4)) return { ok: false, reason: `${REATTEST_KIND} is a v4 record kind — a schema-${obj.schema} record never carries it` };
283
+ const r = validateReattest(obj);
284
+ return r ? { ok: false, reason: r } : { ok: true };
285
+ }
286
+ return { ok: false, reason: `kind must be "run", "red-probe", or "${REATTEST_KIND}" (got ${JSON.stringify(obj.kind)})` };
224
287
  };
225
288
 
226
289
  // readResults(path) → { records, malformed, malformedReasons, readError }. Absent file → empty (no run
@@ -255,11 +318,20 @@ export const readResults = (path, readFile = readFileSync) => {
255
318
  // (latest is last).
256
319
  export const filterLoopResults = (records, loop) => records.filter((r) => r.loop === loop);
257
320
 
321
+ // filterSegmentResults(records, loop, base) → the result records of ONE segment (D7), order
322
+ // preserved. STRICT: only a v3+ record carries base, so pre-v3 history can never enter a segment
323
+ // (readable, never re-scoped — the review-ledger filterSegmentRecords discipline).
324
+ export const filterSegmentResults = (records, loop, base) =>
325
+ filterLoopResults(records, loop).filter((r) => r.schema >= 3 && r.base === base);
326
+
258
327
  // ── kind-aware selectors (shared: the runner and the checker read the ledger through THESE) ──────
259
328
 
260
329
  // A v1 record IS a run (the kindless AD-046 shape); a v2 record is a run only under kind:"run".
261
330
  export const isRunRecord = (r) => r.schema === 1 || r.kind === 'run';
262
331
  export const isRedProbeRecord = (r) => r.schema >= 2 && r.kind === 'red-probe';
332
+ // (c) v4: a recorded custody re-attestation. Never read as "the latest run" (latestRunRecord uses
333
+ // isRunRecord); consumed by the custody chain as a valid anchor for exactly the file it names.
334
+ export const isReattestRecord = (r) => r.schema >= 4 && r.kind === REATTEST_KIND;
263
335
 
264
336
  // latestRunRecord(loopRecords) → { record, index } | null over ONE loop's ordered records. Kind-aware
265
337
  // (codex R2): a red-probe appended after a run must never be read as the loop's "latest run" — the
@@ -271,16 +343,42 @@ export const latestRunRecord = (loopRecords) => {
271
343
  return null;
272
344
  };
273
345
 
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';
346
+ // probeVerdict the D4 verdict algebra, ONE home (now the neutral changed-surface.mjs; re-exported
347
+ // above): the runner (--red mints only on 'red'), this checker (the gate passes only on 'green'),
348
+ // and the review-ledger telemetry (quarantine counts) all read the SAME algebra.
349
+
350
+ // foldSuiteCredit({ cwd, env, fingerprint }) { credited, evidence, reason }. Read half of the (a)
351
+ // credit: may run-gates --record credit the unit-tests gate from the latest segment fold run's suite
352
+ // evidence instead of re-spawning? STRICT the evidence must be fingerprint-bound (before === after
353
+ // === the current fingerprint, which also proves the tree unchanged) and exit 0. The cmd-identity check
354
+ // (suite.cmd === the gate cmd the --only-subset defense) is the caller's. Any mismatch credited:false.
355
+ // ENV residual (bounded, documented maintainer-signed against Decision 7): the fold V8 suite runs under
356
+ // NODE_V8_COVERAGE, a plain gate spawn does not. Both now strip NODE_TEST_CONTEXT (run-gates.mjs
357
+ // spawnGateViaBash mirrors the fold suite's childTestEnv), so that divergence — the real vacuous-skip
358
+ // false-green — is closed. The ONE remaining delta is NODE_V8_COVERAGE: a test that FAILS under coverage
359
+ // exits nonzero and never credits (the exit-0 gate above), so only a test that PASSES *only* under
360
+ // coverage instrumentation (e.g. asserting process.env.NODE_V8_COVERAGE is set) could credit a green the
361
+ // plain gate would fail — an AD-047-class residual, unclosable without the double spawn (a) removes.
362
+ export const foldSuiteCredit = ({ cwd = process.cwd(), env = process.env, fingerprint } = {}) => {
363
+ const plans = plansInFlight(gitRoot(cwd) ?? cwd);
364
+ if (plans.length !== 1) return { credited: false, reason: 'no single in-flight plan (cannot resolve the loop)' };
365
+ const loop = plans[0].replace(/\.md$/, '');
366
+ const base = resolveBase(cwd);
367
+ const resultsPath = resolveResultsPath(cwd, env);
368
+ // Fail CLOSED on an unreadable / malformed fold ledger — the SAME posture decideCheck takes (a dropped
369
+ // line could hide a defect); a partially-trusted ledger never credits.
370
+ const read = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0 };
371
+ if (read.readError) return { credited: false, reason: `the fold ledger is unreadable (${read.readError}) — fail closed, re-spawn` };
372
+ if (read.malformed > 0) return { credited: false, reason: `the fold ledger has ${read.malformed} malformed line(s) — a partially-read ledger never credits (fail closed), re-spawn` };
373
+ const sel = latestRunRecord(filterSegmentResults(read.records, loop, base));
374
+ if (sel == null) return { credited: false, reason: 'no fold run recorded for the current segment' };
375
+ const { suite } = sel.record;
376
+ if (!suite) return { credited: false, reason: 'the fold run carries no suite evidence (a pre-v4 record)' };
377
+ if (fingerprint == null || suite.fingerprintBefore !== fingerprint || suite.fingerprintAfter !== fingerprint) {
378
+ return { credited: false, reason: 'the fold suite evidence is not bound to the current tree (the tree moved) — re-spawn' };
379
+ }
380
+ if (suite.exit !== 0) return { credited: false, reason: `the fold suite exited ${suite.exit} (a nonzero/red suite never credits a green gate-run) — re-spawn` };
381
+ return { credited: true, evidence: suite };
284
382
  };
285
383
 
286
384
  // ── the check + report core ─────────────────────────────────────────────────────────────────────
@@ -304,6 +402,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
304
402
  const plans = plansInFlight(root);
305
403
  const fingerprint = computeTreeFingerprint(cwd);
306
404
  const clean = fingerprint == null ? null : isTreeClean(cwd);
405
+ const base = resolveBase(cwd);
307
406
  const resultsPath = resolveResultsPath(cwd, env);
308
407
  const resultRead = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0, malformedReasons: [] };
309
408
  const reviewPath = resolveLedgerPath(cwd, env);
@@ -315,6 +414,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
315
414
  plans,
316
415
  fingerprint,
317
416
  clean,
417
+ base,
318
418
  resultsPath,
319
419
  resultRecords: resultRead.records,
320
420
  resultMalformed: resultRead.malformed,
@@ -353,20 +453,28 @@ export const decideCheck = (state) => {
353
453
  if (state.reviewMalformed > 0) return { code: 1, reason: `the review ledger has ${state.reviewMalformed} malformed line(s) — failing closed; inspect ${state.reviewPath}` };
354
454
 
355
455
  const loop = state.plans[0].replace(/\.md$/, '');
356
- const loopResults = filterLoopResults(state.resultRecords, loop);
456
+ // SEGMENT scope (D7): the gate judges the current segment — (loop, base = the HEAD the dirty
457
+ // tree sits on). A committed phase's runs, receipts, custody chains, and tamper obligations are
458
+ // closed history; only v3 records at the current base enter.
459
+ const loopResults = filterSegmentResults(state.resultRecords, loop, state.base);
357
460
  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` };
461
+ if (sel == null) {
462
+ const allLoop = filterLoopResults(state.resultRecords, loop);
463
+ const legacy = allLoop.length > 0 && allLoop.every((r) => !(r.schema >= 3))
464
+ ? ' (the loop has only pre-v3 records, which never enter a segment — the schema upgrade requires a fresh run)'
465
+ : '';
466
+ 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}` };
467
+ }
359
468
  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` };
469
+ // No per-record schema re-checks here (agy Phase-2 nit): only a VALID v3 record can be a segment
470
+ // member the reader binned anything older or tamper-less before this point (a v1 never carries
471
+ // base; a v3 run without a tamper surface is malformed), and a malformed line already failed the
472
+ // gate closed above.
365
473
 
366
474
  // Decision 9 — the double binding. A tree edit moves the fingerprint; a new fixable-bug triage moves
367
475
  // the bound-testId set. Either mismatch is STALE (the run no longer describes the committable tree).
368
476
  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` };
369
- const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop });
477
+ const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop, base: state.base });
370
478
  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` };
371
479
  // Fail CLOSED if the run's probe set does not cover its bound-testId set EXACTLY. A well-formed
372
480
  // runner record always probes every bound testId (one testIds[] entry per boundTestId), but a record
@@ -402,6 +510,10 @@ export const decideCheck = (state) => {
402
510
  const probes = new Map(latest.testIds.map((t) => [t.id, t]));
403
511
  const boundSet = new Set(latest.boundTestIds);
404
512
  const receipts = loopResults.map((r, i) => ({ r, i })).filter(({ r }) => isRedProbeRecord(r));
513
+ // Custody ANCHORS (c): a red-probe OR a recorded re-attestation on the file re-anchors custody at
514
+ // its hash — the honest replacement for a red-proof waiver on a green-only append. The OBSERVED-RED
515
+ // requirement below stays red-probe-only (a re-attest is not a red observation).
516
+ const custodyAnchors = loopResults.map((r, i) => ({ r, i })).filter(({ r }) => isRedProbeRecord(r) || isReattestRecord(r));
405
517
  for (const id of latest.boundTestIds) {
406
518
  const t = probes.get(id);
407
519
  const verdict = probeVerdict(t);
@@ -417,13 +529,14 @@ export const decideCheck = (state) => {
417
529
  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
530
  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
531
  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);
532
+ // Custody eligibility (D5, codex R3): the anchor is the LATEST red-probe OR re-attest on that FILE
533
+ // whose own testId is in the bound set AND which precedes the latest run — an unbound throwaway
534
+ // receipt, or one minted post-run, never re-attests a file. A green-only append after a red-probe
535
+ // re-anchors here via a recorded --reattest (item (c)), not a red-proof waiver.
536
+ const eligible = custodyAnchors.filter(({ r, i }) => i < sel.index && boundSet.has(r.testId) && splitTestId(r.testId).file === file);
424
537
  const anchor = eligible[eligible.length - 1];
425
538
  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` };
539
+ return { code: 1, reason: `custody broken for ${id}: the test file ${file} no longer matches its last observed-red/re-attested content — after a green-only append, re-attest the file (node fold-completeness-run.mjs --reattest "<a bound testId in ${file}>"); after a real edit, re-observe red (--red "<the file's newest testId>"), or record a red-proof override if the red is genuinely unestablishable` };
427
540
  }
428
541
  }
429
542
  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(', ')}` };
@@ -480,9 +593,9 @@ const formatHuman = (state, check) => {
480
593
  lines.push(` result ledger: ${state.resultsPath ?? '(unresolvable — no git dir)'} (${state.resultRecords.length} record(s)${state.resultMalformed ? `, ${state.resultMalformed} malformed — inspect the file` : ''})`);
481
594
  if (state.plans.length === 1) {
482
595
  const loop = state.plans[0].replace(/\.md$/, '');
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)));
596
+ const segResults = filterSegmentResults(state.resultRecords, loop, state.base); // the current segment (D7)
597
+ const sel = latestRunRecord(segResults); // kind-aware: never render a red-probe as "the run"
598
+ if (sel) lines.push(runLine(sel.record, segResults.filter(isRedProbeRecord)));
486
599
  }
487
600
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
488
601
  return lines.join('\n');
@@ -495,15 +608,20 @@ Usage:
495
608
 
496
609
  Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOLD_RESULTS overrides),
497
610
  resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the canonical uncommitted-state
498
- fingerprint, and decides whether the in-flight plan-execution loop's changed code is pinned by tests.
611
+ fingerprint, and decides whether the in-flight plan-execution SEGMENT's changed code is pinned by
612
+ tests — (loop, base = git rev-parse HEAD), schema v3+ (v4 adds the suite-execution evidence + the
613
+ reattest custody anchor, BUGFREE-3/AD-049; AD-048 D7): bound testIds, receipts, custody, and tamper
614
+ all filter to the current segment; a committed phase's obligations close with its commit.
499
615
 
500
616
  --status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
501
617
  (per-testId D4 verdicts + rerun counts), the loop's red-probe receipts, verdict.
502
618
  --check → the gate exit code. The normative exit contract lives in the tool header (the single home):
503
- exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT run whose fingerprint
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
619
+ exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT segment run whose
620
+ fingerprint AND segment bound-testId set both match, with — per bound testId — an N/N-green probe,
621
+ an observed-red receipt in this segment that PRECEDES the run, and content custody (run hash == the
622
+ latest custody-eligible red-probe OR reattest hash on that file a green-only append is re-anchored
623
+ by a recorded --reattest, not a red-proof waiver); plus 0 uncovered changed lines, 0 changed
624
+ unsupported source, every tampered
507
625
  test-surface file covered by a recorded oracle-change override, and the reserved empty mutation
508
626
  shape. A recorded red-proof override (review-ledger-write override) waives receipt + custody for
509
627
  exactly its testId — never green-N/N, never QUARANTINE. exit 1 otherwise (stale/missing/older-runner
@@ -30,6 +30,13 @@ import { resolve, join, relative, isAbsolute, dirname, basename } from 'node:pat
30
30
  import { pathToFileURL } from 'node:url';
31
31
  import { spawnSync } from 'node:child_process';
32
32
  import { fail } from './orchestration-config.mjs';
33
+ // (e) --ledger-summary (BUGFREE-3 / AD-049): a computed, loop/base-SCOPED review-ledger digest for
34
+ // the facts payload. The review ledger is read-only here — grounding never writes it. ORIGINS +
35
+ // computeTelemetry give the counts; the segment filter gives the scope.
36
+ import { ORIGINS, computeTelemetry, filterSegmentRecords, readLedger, resolveLedgerPath, resolveBase } from './review-ledger.mjs';
37
+ import { plansInFlight } from './review-state.mjs';
38
+
39
+ const PLAN_EXECUTION = 'plan-execution';
33
40
 
34
41
  // The agy single-argv byte contract (mirrors agy-review.sh — the wrapper is the enforcement home).
35
42
  export const DEFAULT_MAX_PROMPT_BYTES = 120000;
@@ -104,6 +111,73 @@ export const trimToBudget = (payload, budget) => {
104
111
  return { text, trimmedBytes: bytes - keep };
105
112
  };
106
113
 
114
+ // ── (e) --ledger-summary: a loop/base-SCOPED review-ledger digest (read-only, computed) ─────────
115
+ // `computeTelemetry` aggregates ALL loops and is not per-round-facts-shaped, so this renders the
116
+ // in-flight SEGMENT only (plan-execution, loop, base) — the telemetry COUNTS filtered to that
117
+ // segment, plus a terse per-round/triage/override render. Deliberately compact + distinct from the
118
+ // review-ledger `--status` human view (that one is indented + segment-grouped): this is a byte-
119
+ // budgeted FACTS block that agy/codex review against, so unrelated loops are excluded by construction.
120
+
121
+ const baseTag = (base) => (base === null ? '(unborn branch)' : String(base).slice(0, 12));
122
+ const countsOf = (obj) => {
123
+ const keys = Object.keys(obj).sort();
124
+ return keys.length === 0 ? '(none)' : keys.map((k) => `${k}:${obj[k]}`).join(' ');
125
+ };
126
+ const summaryRoundLine = (r) =>
127
+ `round ${r.round} — ${r.backends.map((b) => `${b.backend} ${b.degraded ? `degraded(${b.reason})` : `${b.blockers}/${b.majors}/${b.minors} ${b.verdict}`}`).join(', ')}` +
128
+ (r.findings.length ? ` [${r.findings.map((f) => `${f.findingKey}(${f.severity})`).join(', ')}]` : '');
129
+ const summaryTriageLine = (t) => `triage @round ${t.round} — ${t.classifications.map((c) => `${c.findingKey}=${c.class}`).join(', ')}`;
130
+ const summaryOverrideLine = (o) =>
131
+ `override @round ${o.round} [${o.scope}] — ${o.scope === 'oracle-change' ? o.files.join(', ') : o.scope === 'size-cap' ? `sanctioned ${o.sanctionedLines} lines` : o.testId}: ${o.reason}`;
132
+ const summaryGateRunLine = (g) => `gate-run — status=${g.summary.status} ${g.summary.passed}/${g.summary.gates} green`;
133
+ const summaryRecordLine = (r) =>
134
+ r.kind === 'round' ? summaryRoundLine(r) : r.kind === 'triage' ? summaryTriageLine(r) : r.kind === 'override' ? summaryOverrideLine(r) : summaryGateRunLine(r);
135
+
136
+ // renderLedgerSummary(records, { loop, base }) → the scoped facts section, or '' when the segment
137
+ // holds no records (empty/absent → nothing to ground). Pure: the caller does the I/O.
138
+ export const renderLedgerSummary = (records, { loop, base }) => {
139
+ const segment = filterSegmentRecords(records, { activity: PLAN_EXECUTION, loop, base });
140
+ if (segment.length === 0) return '';
141
+ const t = computeTelemetry(segment, []).loops[0];
142
+ const lines = [
143
+ `## Review-ledger summary — loop ${loop} @ base ${baseTag(base)}`,
144
+ '',
145
+ `rounds ${t.rounds} · divergence-rounds ${t.divergenceRounds}`,
146
+ `finding origins — ${ORIGINS.map((k) => `${k}:${t.origins[k]}`).join(' ')}`,
147
+ `classifications — ${countsOf(t.classifications)}`,
148
+ `backend verdicts — ${Object.keys(t.backendVerdicts).sort().map((b) => `${b}{${countsOf(t.backendVerdicts[b])}}`).join(' · ') || '(none)'}`,
149
+ `overrides — ${countsOf(t.overrides)}`,
150
+ '',
151
+ ...segment.map(summaryRecordLine),
152
+ ];
153
+ return `${lines.join('\n')}\n`;
154
+ };
155
+
156
+ // resolveLedgerSummary({ cwd, env }) → the scoped section text for the SINGLE in-flight plan-execution
157
+ // segment. A loud STOP unless exactly one plan is in flight (the family's single-plan discipline —
158
+ // never guess which loop to ground). Reads the ledger read-only.
159
+ const gitTop = (cwd) => {
160
+ const r = gitLine(['rev-parse', '--show-toplevel'], cwd);
161
+ return r && r.status === 0 ? r.stdout.replace(/\r?\n$/, '') : cwd;
162
+ };
163
+ export const resolveLedgerSummary = ({ cwd, env }) => {
164
+ const root = gitTop(cwd);
165
+ const plans = plansInFlight(root);
166
+ if (plans.length !== 1) {
167
+ throw fail(1, `--ledger-summary needs exactly one in-flight plan (in flight: ${plans.length ? plans.join(', ') : 'none'}) — resolve to one active plan`);
168
+ }
169
+ const loop = plans[0].replace(/\.md$/, '');
170
+ const base = resolveBase(cwd);
171
+ const ledgerPath = resolveLedgerPath(cwd, env);
172
+ const { records, readError, malformed, malformedReasons } = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
173
+ // Fail CLOSED like every sibling ledger reader (No-silent-failures): an unreadable OR malformed
174
+ // ledger must never silently render an empty/partial digest the reviewer then grounds against — an
175
+ // empty section would be indistinguishable from a legitimately-empty segment.
176
+ if (readError) throw fail(1, `--ledger-summary cannot read the ledger (${readError}) — failing closed; inspect ${ledgerPath}`);
177
+ if (malformed > 0) throw fail(1, `--ledger-summary: the ledger has ${malformed} malformed line(s) — failing closed (a dropped line could hide a round/finding): ${malformedReasons.join('; ')}`);
178
+ return renderLedgerSummary(records, { loop, base });
179
+ };
180
+
107
181
  // ── the --out destination guard (gitignored / out-of-repo scratch ONLY) ────────────────
108
182
 
109
183
  const gitLine = (args, cwd) => {
@@ -157,13 +231,17 @@ export const assertScratchDestination = (outPath, cwd) => {
157
231
  const HELP = `grounding — grounded-review facts assembler for the agent-workflow family (AD-038).
158
232
 
159
233
  Usage:
160
- node grounding.mjs [--constraints] [--plan <path>] [--reserve-bytes <n>] [--out <path>]
234
+ node grounding.mjs [--constraints] [--plan <path>] [--ledger-summary] [--reserve-bytes <n>] [--out <path>]
161
235
 
162
236
  --constraints slice the root AGENTS.md "Hard Constraints" section verbatim
163
237
  (exactly one matching heading, else a loud STOP)
164
238
  --plan <path> extract the plan's decision-bearing sections verbatim + whole:
165
239
  "## Approach" + "## Verification" (REQUIRED — STOP if missing),
166
240
  "## Decisions (locked)" when present; a duplicate heading is a STOP
241
+ --ledger-summary append a COMPUTED review-ledger digest for the SINGLE in-flight
242
+ plan-execution segment (rounds · origins · classifications · verdicts ·
243
+ overrides + a per-round render) — "computed, not remembered" facts; a
244
+ loud STOP unless exactly one plan is in flight
167
245
  --reserve-bytes <n> the artifact share agy-review will add around these facts — the output
168
246
  budget becomes AGY_MAX_PROMPT_BYTES − n (loud tail-trim on overflow)
169
247
  --out <path> write instead of stdout — gitignored / out-of-repo scratch ONLY
@@ -181,9 +259,11 @@ const parseArgs = (argv) => {
181
259
  let plan = null;
182
260
  let out = null;
183
261
  let reserve = 0;
262
+ let ledgerSummary = false;
184
263
  for (let i = 0; i < argv.length; i += 1) {
185
264
  const a = argv[i];
186
265
  if (a === '--constraints') constraints = true;
266
+ else if (a === '--ledger-summary') ledgerSummary = true;
187
267
  else if (a === '--plan') {
188
268
  plan = argv[i + 1];
189
269
  if (!plan || plan.startsWith('--')) throw fail(2, '--plan requires a <path>');
@@ -199,8 +279,8 @@ const parseArgs = (argv) => {
199
279
  i += 1;
200
280
  } else throw fail(2, `unknown argument: ${a}`);
201
281
  }
202
- if (!constraints && plan == null) throw fail(2, 'nothing to assemble — pass --constraints and/or --plan <path>');
203
- return { constraints, plan, out, reserve };
282
+ if (!constraints && plan == null && !ledgerSummary) throw fail(2, 'nothing to assemble — pass --constraints, --plan <path>, and/or --ledger-summary');
283
+ return { constraints, plan, out, reserve, ledgerSummary };
204
284
  };
205
285
 
206
286
  const resolveBudget = (env, reserve) => {
@@ -220,7 +300,7 @@ export const main = (argv, ctx = {}) => {
220
300
  const env = ctx.env ?? process.env;
221
301
  try {
222
302
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
223
- const { constraints, plan, out, reserve } = parseArgs(argv);
303
+ const { constraints, plan, out, reserve, ledgerSummary } = parseArgs(argv);
224
304
  const budget = resolveBudget(env, reserve);
225
305
 
226
306
  const readOrStop = (path, label) => {
@@ -233,7 +313,14 @@ export const main = (argv, ctx = {}) => {
233
313
  const constraintsText = constraints ? readOrStop('AGENTS.md', 'root AGENTS.md') : null;
234
314
  const planText = plan != null ? readOrStop(plan, 'plan file') : null;
235
315
 
236
- const payload = assembleGrounding({ constraintsText, planText, planLabel: plan ?? 'plan' });
316
+ const parts = [];
317
+ const assembled = assembleGrounding({ constraintsText, planText, planLabel: plan ?? 'plan' });
318
+ if (assembled) parts.push(assembled);
319
+ if (ledgerSummary) {
320
+ const summary = resolveLedgerSummary({ cwd, env });
321
+ if (summary) parts.push(summary);
322
+ }
323
+ const payload = parts.join('\n');
237
324
  const { text, trimmedBytes } = trimToBudget(payload, budget);
238
325
  const stderr = trimmedBytes > 0
239
326
  ? `[grounding] TRIM: assembled ${Buffer.byteLength(payload, 'utf8')} bytes > budget ${budget} (AGY_MAX_PROMPT_BYTES minus --reserve-bytes ${reserve}) — dropped ${trimmedBytes} tail bytes; the cut is marked in-band.`