@sabaiway/agent-workflow-kit 1.39.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.
@@ -15,14 +15,16 @@
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
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
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
20
21
  // sorted fixable-bug testId set recorded in the run — with, per bound testId: an
21
22
  // N/N-green probe (D4), an observed-red receipt in this SEGMENT (a committed phase's
22
23
  // obligations closed with its commit; a receipt never crosses a commit boundary), that receipt
23
24
  // 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
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
26
28
  // uncovered changed lines, 0 changed unsupported-source files, a recorded tamper surface
27
29
  // with every tampered test-surface file covered by a recorded oracle-change override
28
30
  // (review ledger v3), and the reserved EMPTY mutation shape (no mutation ships). A
@@ -84,8 +86,17 @@ export { probeVerdict, RESULTS_BASENAME, resolveResultsPath };
84
86
  // v1→v2 precedent), so v1/v2 ledgers never retroactively become malformed — but only a v3 record
85
87
  // can enter a segment, so a dirty segment whose loop holds only pre-v3 records fails with a reason
86
88
  // 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]);
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';
89
100
  const ACTIVITY = 'plan-execution';
90
101
  const SLOT = 'review';
91
102
 
@@ -202,6 +213,30 @@ const validateRedProbe = (obj) => {
202
213
  return null;
203
214
  };
204
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
+
205
240
  // validateRunRecord(obj) → { ok, reason }. Per-version, per-kind (D2): v1 records (no kind) keep the
206
241
  // AD-046 single-run rules; v2 records carry the kind discriminator. The `reason` names the exact
207
242
  // failed check so the malformed-line surface and the per-check named tests can assert it. Mutation
@@ -228,13 +263,27 @@ export const validateRunRecord = (obj) => {
228
263
  if (obj.tamper !== undefined && (!isPlainObject(obj.tamper) || !isStringArray(obj.tamper.tampered))) {
229
264
  return { ok: false, reason: 'tamper.tampered must be an array of strings (when the tamper surface is recorded)' };
230
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
+ }
231
274
  return { ok: true };
232
275
  }
233
276
  if (obj.kind === 'red-probe') {
234
277
  const r = validateRedProbe(obj);
235
278
  return r ? { ok: false, reason: r } : { ok: true };
236
279
  }
237
- 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)})` };
238
287
  };
239
288
 
240
289
  // readResults(path) → { records, malformed, malformedReasons, readError }. Absent file → empty (no run
@@ -280,6 +329,9 @@ export const filterSegmentResults = (records, loop, base) =>
280
329
  // A v1 record IS a run (the kindless AD-046 shape); a v2 record is a run only under kind:"run".
281
330
  export const isRunRecord = (r) => r.schema === 1 || r.kind === 'run';
282
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;
283
335
 
284
336
  // latestRunRecord(loopRecords) → { record, index } | null over ONE loop's ordered records. Kind-aware
285
337
  // (codex R2): a red-probe appended after a run must never be read as the loop's "latest run" — the
@@ -295,6 +347,40 @@ export const latestRunRecord = (loopRecords) => {
295
347
  // above): the runner (--red mints only on 'red'), this checker (the gate passes only on 'green'),
296
348
  // and the review-ledger telemetry (quarantine counts) all read the SAME algebra.
297
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 };
382
+ };
383
+
298
384
  // ── the check + report core ─────────────────────────────────────────────────────────────────────
299
385
 
300
386
  // buildFoldState({ cwd, env, detect }) → everything both renders need. Pure I/O at the edges; every
@@ -424,6 +510,10 @@ export const decideCheck = (state) => {
424
510
  const probes = new Map(latest.testIds.map((t) => [t.id, t]));
425
511
  const boundSet = new Set(latest.boundTestIds);
426
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));
427
517
  for (const id of latest.boundTestIds) {
428
518
  const t = probes.get(id);
429
519
  const verdict = probeVerdict(t);
@@ -439,13 +529,14 @@ export const decideCheck = (state) => {
439
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}"` };
440
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)` };
441
531
  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);
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);
446
537
  const anchor = eligible[eligible.length - 1];
447
538
  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` };
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` };
449
540
  }
450
541
  }
451
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(', ')}` };
@@ -518,8 +609,9 @@ Usage:
518
609
  Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOLD_RESULTS overrides),
519
610
  resolves the effective ${ACTIVITY}.${SLOT} recipe, recomputes the canonical uncommitted-state
520
611
  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.
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.
523
615
 
524
616
  --status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
525
617
  (per-testId D4 verdicts + rerun counts), the loop's red-probe receipts, verdict.
@@ -527,7 +619,8 @@ and tamper all filter to the current segment; a committed phase's obligations cl
527
619
  exit 0 for solo / no plan in flight / a clean tree / not-a-git-tree / a CURRENT segment run whose
528
620
  fingerprint AND segment bound-testId set both match, with — per bound testId — an N/N-green probe,
529
621
  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
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
531
624
  unsupported source, every tampered
532
625
  test-surface file covered by a recorded oracle-change override, and the reserved empty mutation
533
626
  shape. A recorded red-proof override (review-ledger-write override) waives receipt + custody for
@@ -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.`
package/tools/lcov.mjs ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // lcov.mjs — a dependency-free LCOV parser for the fold-completeness runner's coverage.kind:"lcov"
3
+ // branch (BUGFREE-3, AD-049 — the language-independence contract). When a verification profile
4
+ // declares coverage.kind:"lcov", the consumer's OWN suite leaves an LCOV file at the declared path
5
+ // (the diff-cover / c8 --reporter=lcov pattern) and this module reads it into a per-file
6
+ // line→hits map, keyed by the SAME canonical absolute path the V8 coverage map uses — so the
7
+ // runner's ONE uncovered-changed loop consumes either source unchanged (computeUncoveredLines /
8
+ // effectiveCount stay the V8-only path, D10).
9
+ //
10
+ // FAIL-CLOSED posture (the whole reason the fold gate exists): a malformed record can never mark a
11
+ // line COVERED — a DA with a valid line but a non-integer HIT count reads the line UNCOVERED (hits 0,
12
+ // the gate fails), and a DA with no valid line number is skipped (unattributable — there is no line to
13
+ // flag), never a false green. An SF path that resolves OUTSIDE the repo — or does not resolve at all —
14
+ // is never trusted as coverage (a wrong file could otherwise read covered). (STATED residual, inherent
15
+ // to the LCOV data model: a CONFORMANT producer emits DA:N,0 for every uncovered executable line, so a
16
+ // changed executable line with NO DA entry reads non-executable — LCOV carries no separate
17
+ // executability signal; the V8 path, which reads an absent line as uncovered, differs by format.)
18
+ //
19
+ // Dependency-free, Node >= 18. No side effects on import.
20
+
21
+ import { join, resolve, isAbsolute, sep } from 'node:path';
22
+ import { realpathSync } from 'node:fs';
23
+
24
+ // Only these three LCOV records are consulted; every other record (FN/FNDA/BRDA/LF/LH/BRF/…) is
25
+ // ignored. LCOV groups per-file sections between an `SF:<path>` line and `end_of_record`.
26
+ const SF_RE = /^SF:(.*)$/;
27
+ const DA_RE = /^DA:(\d+),(.*)$/; // DA:<line>,<hits-field>… — the LINE must be an int; the hits field is
28
+ // parsed leniently below so a truncated/malformed hit count reads UNCOVERED (fail-closed), not omitted.
29
+ const END_RE = /^end_of_record\s*$/;
30
+
31
+ // parseLcov(text) → Map<sfString, Map<lineNumber, hits>>. Raw SF strings (NOT yet canonicalized).
32
+ // A line's hits is the MAX across duplicate DA entries within a section (some tracers emit per-test
33
+ // sections that also repeat lines). A DA whose line or hit count is not a base-10 integer is skipped
34
+ // (fail-closed: it can never mark a line covered). Lines with no DA entry never appear (a
35
+ // non-executable line — blank/comment — is simply absent, mirroring the V8 blank-line skip).
36
+ export const parseLcov = (text) => {
37
+ const out = new Map();
38
+ let current = null; // the Map<line,hits> for the SF section in progress
39
+ for (const rawLine of String(text).split('\n')) {
40
+ const line = rawLine.replace(/\r$/, '');
41
+ const sf = SF_RE.exec(line);
42
+ if (sf) {
43
+ const path = sf[1].trim();
44
+ if (path.length === 0) {
45
+ current = null;
46
+ continue;
47
+ }
48
+ current = out.get(path);
49
+ if (!current) {
50
+ current = new Map();
51
+ out.set(path, current);
52
+ }
53
+ continue;
54
+ }
55
+ if (END_RE.test(line)) {
56
+ current = null;
57
+ continue;
58
+ }
59
+ const da = DA_RE.exec(line);
60
+ if (da && current) {
61
+ const n = Number.parseInt(da[1], 10);
62
+ if (!Number.isInteger(n) || n < 1) continue; // no valid line number → unattributable, skip
63
+ const hitsField = da[2].split(',')[0]; // the hit count (a trailing ,checksum is ignored)
64
+ // FAIL-CLOSED, STRICT: the hit count must be ALL digits — a truncated `DA:42,`, a
65
+ // `DA:2,xyz`, a partial `DA:5,1abc`, or a fractional `DA:6,2.5` all read UNCOVERED (hits 0), never
66
+ // parseInt-coerced to a positive "covered" value. A covered sibling still wins (max).
67
+ const effHits = /^\d+$/.test(hitsField) ? Number.parseInt(hitsField, 10) : 0;
68
+ current.set(n, Math.max(current.get(n) ?? 0, effHits));
69
+ }
70
+ }
71
+ return out;
72
+ };
73
+
74
+ const defaultCanon = (p) => {
75
+ try {
76
+ return realpathSync(p);
77
+ } catch {
78
+ return p;
79
+ }
80
+ };
81
+
82
+ // Segment-safe containment ('/a' never contains '/ab'); correct at the filesystem root (mirrors
83
+ // fold-completeness-run.mjs containsPath). Defined locally so lcov.mjs never imports the runner (the
84
+ // runner imports THIS module).
85
+ const containsPath = (realRoot, realAbs) =>
86
+ realAbs === realRoot || realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
87
+
88
+ // lcovCoveredMap(text, rootTop, { canon }?) → Map<canonAbsKey, Map<line, hits>>. Each SF is resolved
89
+ // against rootTop (a relative / ./-prefixed / absolute-in-repo SF all normalize to the same key the
90
+ // V8 map uses), canon()-normalized, and KEPT only when it resolves strictly INSIDE the repo root; an
91
+ // outside-tree or unresolvable SF is DROPPED (never trusted as coverage). Duplicate SF sections are
92
+ // merged taking the max hits per line. canon is injectable for hermetic tests (default realpathSync).
93
+ export const lcovCoveredMap = (text, rootTop, deps = {}) => {
94
+ const canon = deps.canon ?? defaultCanon;
95
+ const raw = parseLcov(text);
96
+ const rootCanon = canon(rootTop);
97
+ const out = new Map();
98
+ for (const [sf, lineHits] of raw) {
99
+ const abs = isAbsolute(sf) ? sf : resolve(rootTop, sf);
100
+ const key = canon(abs);
101
+ if (!containsPath(rootCanon, key)) continue; // outside the repo (or unresolved elsewhere) → drop
102
+ let m = out.get(key);
103
+ if (!m) {
104
+ m = new Map();
105
+ out.set(key, m);
106
+ }
107
+ for (const [line, hits] of lineHits) m.set(line, Math.max(m.get(line) ?? 0, hits));
108
+ }
109
+ return out;
110
+ };
111
+
112
+ // uncoveredChangedFromLcov(coveredMap, key, changedLines) → the sorted changed lines that the LCOV
113
+ // records as EXECUTABLE-but-unexecuted (a DA entry with 0 hits). A file ABSENT from the map (no SF)
114
+ // is signalled by a null return → the caller records a file-level RED (line:null), exactly like the
115
+ // V8 "absent from coverage" case. A changed line with NO DA entry in a PRESENT file is non-executable
116
+ // (blank/comment) and never flagged — mirroring the V8 blank-line skip.
117
+ export const uncoveredChangedFromLcov = (coveredMap, key, changedLines) => {
118
+ const lineHits = coveredMap.get(key);
119
+ if (!lineHits) return null; // file absent from coverage → file-level RED (caller decides)
120
+ const uncovered = [];
121
+ for (const n of changedLines) {
122
+ const hits = lineHits.get(n);
123
+ if (hits === 0) uncovered.push(n); // executable, 0 hits → uncovered
124
+ // hits > 0 → covered; hits === undefined → non-executable line, skip
125
+ }
126
+ return [...new Set(uncovered)].sort((a, b) => a - b);
127
+ };
@@ -37,7 +37,7 @@ import { dirname } from 'node:path';
37
37
  import { pathToFileURL } from 'node:url';
38
38
  import { spawnSync } from 'node:child_process';
39
39
  import { writeContainedFileAtomic } from './atomic-write.mjs';
40
- import { computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
40
+ import { buildState, computeTreeFingerprint, plansInFlight, readReceipts, resolveReceiptsPath } from './review-state.mjs';
41
41
  import {
42
42
  REVIEW_CAP,
43
43
  SCHEMA_VERSION,
@@ -101,6 +101,52 @@ const appendRecord = (ledgerPath, record, deps = {}) => {
101
101
  return { writtenPath: ledgerPath, record };
102
102
  };
103
103
 
104
+ // ── (g) record --from-receipts: draft the backends[] from the current-fingerprint receipts ────────
105
+ // (BUGFREE-3 / AD-049). The orchestrator hand-maintained each backend's { backend, verdict } beside
106
+ // the counts every round — drift-prone busywork. --from-receipts DRAFTS the backends[] instead: for
107
+ // each recipe-named backend it reads the review-state receipt status (verdict from the fresh grounded
108
+ // code receipt) and computes the counts from the orchestrator's OWN supplied findings; `origins` and
109
+ // `findings` stay explicit input. It NEVER invents a backend — a recipe-named backend with no fresh
110
+ // grounded receipt is a LOUD STOP (run its review, or supply it explicitly as a degraded backend).
111
+ // The assembled backends[] then rides the normal recordRound teeth (validateRound re-checks that
112
+ // findings-by-severity equals the drafted counts — true by construction).
113
+ export const draftBackendsFromReceipts = ({ state, findings = [], explicitBackends = [] }, deps = {}) => {
114
+ const stopFn = deps.stop ?? stop;
115
+ const explicitByName = new Map((Array.isArray(explicitBackends) ? explicitBackends : []).filter((b) => b && typeof b.backend === 'string').map((b) => [b.backend, b]));
116
+ const findingsArr = Array.isArray(findings) ? findings : [];
117
+ const countFor = (name) => {
118
+ const own = findingsArr.filter((f) => f && f.backend === name);
119
+ return {
120
+ blockers: own.filter((f) => f.severity === 'blocker').length,
121
+ majors: own.filter((f) => f.severity === 'major').length,
122
+ minors: own.filter((f) => f.severity === 'minor').length,
123
+ };
124
+ };
125
+ return state.requiredBackends.map((name) => {
126
+ const explicit = explicitByName.get(name);
127
+ if (explicit) {
128
+ // An explicit row is honored verbatim ONLY for a DEGRADED backend (a bridge the operator knows
129
+ // is down, which minted no receipt). A NON-degraded explicit row would BYPASS the
130
+ // receipt-derived verdict --from-receipts exists to compute (a stale hand-composed row silently
131
+ // winning) — a loud STOP, fail-closed (codex council R1). Drop --from-receipts to compose a
132
+ // non-degraded row by hand, or mark the backend degraded.
133
+ if (explicit.degraded !== true) {
134
+ throw stopFn(
135
+ `refusing --from-receipts: an explicit non-degraded backends[] row for ${name} would bypass the receipt-derived verdict this flag computes — mark it degraded (a bridge that is down), or drop --from-receipts and compose the round by hand`,
136
+ );
137
+ }
138
+ return explicit;
139
+ }
140
+ const status = state.backends.find((b) => b.backend === name);
141
+ if (!status || status.state !== 'current') {
142
+ throw stopFn(
143
+ `refusing --from-receipts: no fresh grounded code receipt for ${name} (state: ${status?.state ?? 'missing'}) — run its review wrapper (codex-review code / agy-review code --facts @f) first, or supply it explicitly as a degraded backend in the payload's backends[]`,
144
+ );
145
+ }
146
+ return { backend: name, degraded: false, ...countFor(name), verdict: status.verdict };
147
+ });
148
+ };
149
+
104
150
  // ── recordRound (the teeth + the integrity binding) ─────────────────────────────────────────────
105
151
 
106
152
  // recordRound({ cwd, env, loop, activity, round, origins, backends, findings, timestamp }, deps) →
@@ -365,7 +411,7 @@ export const recordTriage = (params, deps = {}) => {
365
411
  const HELP = `review-ledger-write — the review-round ledger WRITER (agent-workflow family, AD-045 + AD-047 + AD-048).
366
412
 
367
413
  Usage:
368
- node review-ledger-write.mjs record --json '<round-payload>' [--cwd <dir>]
414
+ node review-ledger-write.mjs record --json '<round-payload>' [--from-receipts] [--cwd <dir>]
369
415
  node review-ledger-write.mjs classify --json '<triage-payload>' [--cwd <dir>]
370
416
  node review-ledger-write.mjs override --json '<override-payload>' [--cwd <dir>]
371
417
 
@@ -373,7 +419,11 @@ Every verb operates on the current SEGMENT — (loop, base = git rev-parse HEAD)
373
419
  caps, and teeth reset only when a gated commit moves base (schema 4; records carry base).
374
420
 
375
421
  record appends one review round. The JSON payload carries { loop, round, origins, backends,
376
- findings } (activity defaults to plan-execution; timestamp defaults to now). REFUSES while
422
+ findings } (activity defaults to plan-execution; timestamp defaults to now). With
423
+ --from-receipts the backends[] is DRAFTED from the current-fingerprint grounded code
424
+ receipts (verdict per backend) with counts computed from the supplied findings — origins /
425
+ findings stay explicit; a recipe-named backend with no receipt is a LOUD stop (run its
426
+ review, or supply it explicitly as a degraded backend). REFUSES while
377
427
  triage is required; beyond the hard-max ceiling of ${HARD_MAX} rounds within one segment;
378
428
  while a blocking finding of the segment's previous round VANISHED unclassified (D6 —
379
429
  classify it first, "refuted" is the honest phantom lane); while the changed source surface
@@ -401,7 +451,7 @@ The read-only checker is a SEPARATE tool: node review-ledger.mjs --check / --sta
401
451
  Exit codes: 0 written; 1 a typed STOP (teeth / malformed / missing receipt / fs error); 2 usage.`;
402
452
 
403
453
  const parseArgs = (argv) => {
404
- const opts = { cwd: undefined, json: undefined };
454
+ const opts = { cwd: undefined, json: undefined, fromReceipts: false };
405
455
  for (let i = 0; i < argv.length; i += 1) {
406
456
  const a = argv[i];
407
457
  if (a === '--cwd') {
@@ -412,6 +462,8 @@ const parseArgs = (argv) => {
412
462
  opts.json = argv[i + 1];
413
463
  if (opts.json === undefined) throw usageFail('--json needs a JSON payload');
414
464
  i += 1;
465
+ } else if (a === '--from-receipts') {
466
+ opts.fromReceipts = true;
415
467
  } else {
416
468
  throw usageFail(`unknown argument: ${a}`);
417
469
  }
@@ -436,8 +488,15 @@ export const main = (argv, ctx = {}) => {
436
488
  const sub = argv[0];
437
489
  if (sub !== 'record' && sub !== 'classify' && sub !== 'override') throw usageFail(`unknown subcommand "${sub}" (expected: record | classify | override)`);
438
490
  const opts = parseArgs(argv.slice(1));
491
+ if (opts.fromReceipts && sub !== 'record') throw usageFail('--from-receipts applies only to `record`');
439
492
  const payload = parsePayload(opts.json);
440
493
  const cwd = opts.cwd ?? cwd0;
494
+ if (opts.fromReceipts) {
495
+ // Draft backends[] from the current-fingerprint receipts + the supplied findings; origins /
496
+ // findings stay explicit. The drafted array then rides the normal recordRound teeth.
497
+ const state = buildState({ cwd, env });
498
+ payload.backends = draftBackendsFromReceipts({ state, findings: payload.findings, explicitBackends: payload.backends ?? [] });
499
+ }
441
500
  const result =
442
501
  sub === 'record'
443
502
  ? recordRound({ cwd, env, ...payload })
@@ -104,13 +104,15 @@ const SEVERITIES = new Set(['blocker', 'major', 'minor']);
104
104
  export const ORIGINS = ['first-draft', 'fold-induced', 'mechanics'];
105
105
  const CLASSES = new Set(['fixable-bug', 'inherent-layer-residual', 'escalate']);
106
106
  // v4 (BUGFREE-2 / D6): `refuted` — the honest lane for a phantom finding, refuted against code with
107
- // a MANDATORY non-empty note citing the grounds; never silently dropped, never folded.
108
- const V4_CLASSES = new Set([...CLASSES, 'refuted']);
107
+ // a MANDATORY non-empty note citing the grounds; never silently dropped, never folded. Exported as
108
+ // the code-side vocabulary source the doc-parity lint (BUGFREE-3 / AD-049) checks the contract docs
109
+ // against — so the mode files can never drift from the schema's own class/scope lexicon.
110
+ export const V4_CLASSES = new Set([...CLASSES, 'refuted']);
109
111
  const OVERRIDE_SCOPES = new Set(['oracle-change', 'red-proof']);
110
112
  // v4 (BUGFREE-2 / D4): `size-cap` — the recorded waiver for a changed surface beyond the diff cap;
111
113
  // exact payload carries the sanctioned magnitude, and it is SEGMENT-scoped (loop + base), unlike
112
114
  // the two loop-scoped v3 scopes.
113
- const V4_OVERRIDE_SCOPES = new Set([...OVERRIDE_SCOPES, 'size-cap']);
115
+ export const V4_OVERRIDE_SCOPES = new Set([...OVERRIDE_SCOPES, 'size-cap']);
114
116
 
115
117
  // ── git-dir resolution (read-only queries; the ledger lives in the git dir, uncommittable) ──────
116
118