@sabaiway/agent-workflow-kit 1.38.0 → 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.
@@ -15,8 +15,9 @@
15
15
  // 4. probe each of the loop's fixable-bug bound testIds N times (Decision 3 / 10 + D4, shell-free,
16
16
  // per-run timeout) for resolvability + an N/N-green baseline, hashing each bound test file
17
17
  // (the D5 custody anchor);
18
- // 5. append ONE machine-only v2 run record, bound to BOTH the tree fingerprint AND the sorted
19
- // fixable-bug testId set (Decision 9), to <git dir>/agent-workflow-fold-completeness.jsonl.
18
+ // 5. append ONE machine-only v3 run record segment-framed (base = git rev-parse HEAD, AD-048 D7)
19
+ // and bound to BOTH the tree fingerprint AND the SEGMENT's sorted fixable-bug testId set
20
+ // (Decision 9) — to <git dir>/agent-workflow-fold-completeness.jsonl.
20
21
  // A SECOND verb, --red "<testId>" (BUGFREE-1 / AD-047), observes a testId RED on the current
21
22
  // (pre-fold) tree and mints a red-probe receipt — the observed-red half of the honest red→green
22
23
  // proof; observed-green / unresolvable / mixed / timed-out are distinguished refusals, nothing written.
@@ -35,7 +36,7 @@ import { spawnSync } from 'node:child_process';
35
36
  import { createHash } from 'node:crypto';
36
37
  import { writeContainedFileAtomic } from './atomic-write.mjs';
37
38
  import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
38
- import { resolveLedgerPath, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
39
+ import { resolveLedgerPath, resolveBase, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
39
40
  import {
40
41
  RESULT_SCHEMA_VERSION,
41
42
  resolveResultsPath,
@@ -43,6 +44,13 @@ import {
43
44
  collectBoundTestIds,
44
45
  probeVerdict,
45
46
  } from './fold-completeness.mjs';
47
+ // The changed-surface computation lives in the NEUTRAL shared module (BUGFREE-2 / AD-048, D4): the
48
+ // review-ledger writer's diff-size cap and this runner's coverage domain consume ONE computation,
49
+ // and the writer never imports this runner (the sole-tree-toucher boundary — import-split pinned).
50
+ // Re-exported below so the runner's tests (and any consumer) keep one entry point per concern.
51
+ import { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface, DIFF_FLAGS, parsePositiveIntKnob } from './changed-surface.mjs';
52
+
53
+ export { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface };
46
54
 
47
55
  const ACTIVITY = 'plan-execution';
48
56
  const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
@@ -67,65 +75,6 @@ const childTestEnv = (env, extra = {}) => {
67
75
  return out;
68
76
  };
69
77
 
70
- // ── Decision 5: the CLOSED changed-path classification rule (no heuristics) ───────────────────────
71
-
72
- const TEST_FILE_RE = /\.(test|spec)\.[^./]+$/; // a.test.mjs, b.spec.js, c.test.cjs, d.spec.ts
73
- const ASSESSABLE_EXT = new Set(['.mjs', '.cjs', '.js']);
74
- const UNSUPPORTED_EXT = new Set(['.ts', '.tsx', '.jsx', '.mts', '.cts']);
75
-
76
- // classifyChangedPath(rel) → 'assessable' | 'unsupported' | 'out-of-domain' | 'excluded-test'.
77
- export const classifyChangedPath = (rel) => {
78
- const base = rel.split('/').pop();
79
- if (TEST_FILE_RE.test(base)) return 'excluded-test';
80
- const dot = base.lastIndexOf('.');
81
- const ext = dot >= 0 ? base.slice(dot) : '';
82
- if (ASSESSABLE_EXT.has(ext)) return 'assessable';
83
- if (UNSUPPORTED_EXT.has(ext)) return 'unsupported';
84
- return 'out-of-domain';
85
- };
86
-
87
- // ── unified-diff → new-side changed line numbers (line numbers only; content lines are ignored) ───
88
-
89
- const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
90
-
91
- // parseUnifiedDiff(diffText) → Map<rel, number[]>. Robust against a content line that happens to look
92
- // like a `+++ ` header: a `+++ ` line is a FILE header only in the header region (right after a
93
- // `diff --git`, before any `@@`); inside a hunk body it is ignored. New-side lines come purely from the
94
- // `@@` headers, so no content-line disambiguation is needed for the line numbers themselves.
95
- export const parseUnifiedDiff = (diffText) => {
96
- const map = new Map();
97
- let current = null;
98
- let inHeader = false;
99
- for (const line of String(diffText).split('\n')) {
100
- if (line.startsWith('diff --git ')) {
101
- current = null;
102
- inHeader = true;
103
- continue;
104
- }
105
- if (inHeader && line.startsWith('--- ')) continue;
106
- if (inHeader && line.startsWith('+++ ')) {
107
- const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes (agy R6)
108
- current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
109
- inHeader = false;
110
- continue;
111
- }
112
- const m = DIFF_HUNK_RE.exec(line);
113
- if (m) {
114
- inHeader = false;
115
- if (current == null) continue;
116
- const start = Number(m[1]);
117
- const count = m[2] === undefined ? 1 : Number(m[2]);
118
- if (count > 0) {
119
- const arr = map.get(current) ?? [];
120
- for (let i = 0; i < count; i += 1) arr.push(start + i);
121
- map.set(current, arr);
122
- }
123
- }
124
- }
125
- for (const [k, v] of map) map.set(k, [...new Set(v)].sort((a, b) => a - b));
126
- return map;
127
- };
128
-
129
78
  // ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
130
79
 
131
80
  // lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
@@ -216,8 +165,12 @@ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
216
165
  return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
217
166
  };
218
167
 
219
- // defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a shell).
220
- export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', '--test-name-pattern', pattern, file];
168
+ // defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a
169
+ // shell). The pattern rides in the `=`-joined form: as a SEPARATE argv token a pattern beginning
170
+ // with "-"/"--" (a test name like "--telemetry refuses …") parses as an OPTION and the probe
171
+ // silently selects no test — the pattern-half sibling of the AD-047 dash-spawn file fix (found
172
+ // live by this plan's own --red loop).
173
+ export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', `--test-name-pattern=${pattern}`, file];
221
174
 
222
175
  // ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
223
176
  // Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
@@ -291,12 +244,7 @@ export const resolveBoundArgv = (env = process.env) => {
291
244
  return (file, pattern) => tmpl.map((a) => a.replace(/\{file\}/g, () => file).replace(/\{pattern\}/g, () => pattern));
292
245
  };
293
246
 
294
- // ── the changed surface (git-driven) ──────────────────────────────────────────────────────────────
295
-
296
- // The one diff-invocation shape both surface passes use. The a/ b/ prefixes are pinned EXPLICITLY
297
- // (agy R6): a user's global diff.noprefix=true would otherwise drop them and the parsers would eat
298
- // a real directory named "a" — user git config must never bend the parse.
299
- const DIFF_FLAGS = ['--unified=0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/'];
247
+ // ── read-only git plumbing (the changed-surface computation itself lives in changed-surface.mjs) ──
300
248
 
301
249
  const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
302
250
  const gitStdout = (args, cwd) => {
@@ -317,101 +265,10 @@ const canon = (path) => {
317
265
  return path;
318
266
  }
319
267
  };
320
- // A changed assessable LEAF is assessed only if it is a REGULAR file. lstat (no-follow): a symlinked
321
- // or non-regular *.mjs must NEVER be read/canonicalized — following it could read outside the work
322
- // tree or HANG on a FIFO/device (codex R2). A non-regular leaf fails closed (routed to `unsupported`).
323
- const isRegularLeaf = (abs) => {
324
- try {
325
- return lstatSync(abs).isFile();
326
- } catch {
327
- return false;
328
- }
329
- };
330
-
331
- // computeChangedSurface(root) → { assessable: Map<rel, number[]>, unsupported: [rel], outOfDomain: [rel] }.
332
- // Domain = the review-payload domain (tracked working-vs-HEAD changes + untracked-not-ignored files),
333
- // classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0`; an untracked file
334
- // is wholly new, so all its lines are "changed".
335
- export const computeChangedSurface = (root) => {
336
- const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
337
- ?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
338
- ?? '';
339
- const trackedLines = parseUnifiedDiff(trackedDiff);
340
- const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
341
- const untracked = untrackedZ.split('\0').filter(Boolean);
342
-
343
- const assessable = new Map();
344
- const unsupported = [];
345
- const outOfDomain = [];
346
- const place = (rel, cls, lines) => {
347
- if (cls === 'excluded-test') return;
348
- if (cls === 'assessable') {
349
- if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
350
- else unsupported.push(rel); // a symlinked / non-regular source → fail closed, never followed
351
- return;
352
- }
353
- if (cls === 'unsupported') unsupported.push(rel);
354
- else outOfDomain.push(rel);
355
- };
356
- for (const [rel, lines] of trackedLines) place(rel, classifyChangedPath(rel), lines);
357
- for (const rel of untracked) {
358
- const cls = classifyChangedPath(rel);
359
- if (cls !== 'assessable') {
360
- place(rel, cls, []);
361
- continue;
362
- }
363
- // Guard the leaf BEFORE reading — never follow a symlink to count an untracked file's lines.
364
- const abs = join(root, rel);
365
- if (!isRegularLeaf(abs)) {
366
- unsupported.push(rel);
367
- continue;
368
- }
369
- const src = readFileSafe(abs);
370
- const count = src == null || src.length === 0 ? 0 : src.split('\n').length;
371
- assessable.set(rel, Array.from({ length: count }, (_, i) => i + 1));
372
- }
373
- return { assessable, unsupported: unsupported.sort(), outOfDomain: outOfDomain.sort() };
374
- };
375
-
376
268
  // ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
377
269
 
378
270
  const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
379
271
 
380
- // Git C-quotes diff-header paths carrying quotes/control/non-ASCII bytes ("a/\321\202….mjs") — an
381
- // unparsed quoted path compares unequal to its classifier/testId form and would silently escape the
382
- // tamper/coverage surface (agy R5). Strip the quotes and decode escapes BYTE-wise (octal escapes
383
- // are UTF-8 bytes). Space-only paths are not quoted (they carry a trailing TAB — trimmed upstream).
384
- const CQUOTE_SIMPLE = { n: 10, t: 9, r: 13, f: 12, v: 11, b: 8, a: 7, '"': 34, '\\': 92 };
385
- export const unquoteDiffPath = (p) => {
386
- if (!(p.length >= 2 && p.startsWith('"') && p.endsWith('"'))) return p;
387
- const inner = p.slice(1, -1);
388
- const bytes = [];
389
- for (let i = 0; i < inner.length; i += 1) {
390
- const c = inner[i];
391
- if (c !== '\\') {
392
- // Consume a full CODE POINT — 16-bit-unit iteration would split a surrogate pair (an
393
- // unescaped non-BMP char, reachable under core.quotepath=false) into replacement bytes (agy R6).
394
- const ch = String.fromCodePoint(inner.codePointAt(i));
395
- for (const b of Buffer.from(ch, 'utf8')) bytes.push(b);
396
- i += ch.length - 1;
397
- continue;
398
- }
399
- const rest = inner.slice(i + 1);
400
- const oct = /^[0-7]{1,3}/.exec(rest);
401
- if (oct) {
402
- bytes.push(Number.parseInt(oct[0], 8) & 0xff);
403
- i += oct[0].length;
404
- } else if (rest[0] in CQUOTE_SIMPLE) {
405
- bytes.push(CQUOTE_SIMPLE[rest[0]]);
406
- i += 1;
407
- } else if (rest[0] !== undefined) {
408
- for (const b of Buffer.from(rest[0], 'utf8')) bytes.push(b);
409
- i += 1;
410
- }
411
- }
412
- return Buffer.from(bytes).toString('utf8');
413
- };
414
-
415
272
  // parseDiffOldSide(diffText) → Map<oldRel, { removals: boolean }> — the OLD-side view of a -U0
416
273
  // tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
417
274
  // old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
@@ -549,26 +406,15 @@ const appendRecord = (resultsPath, record) => {
549
406
 
550
407
  // ── the run ───────────────────────────────────────────────────────────────────────────────────────
551
408
 
552
- // The shared fail-closed integer parser for the D4 probe knobs: zero / negative / fractional /
553
- // non-numeric values are typed refusals by name — the parseInt(...)||default idiom would silently
554
- // accept bad truthy values (codex R2). Unset → the default; set → a positive integer, exactly.
555
- const parsePositiveIntKnob = (env, name, fallback) => {
556
- const raw = env[name];
557
- if (raw === undefined) return fallback;
558
- if (!/^\d+$/.test(String(raw).trim()) || Number.parseInt(raw, 10) < 1) {
559
- throw stop(`${name} must be a positive integer (got "${raw}") — refusing to guess (fail closed)`);
560
- }
561
- return Number.parseInt(raw, 10);
562
- };
563
-
564
409
  export const budgetsFromEnv = (env) => ({
565
410
  mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
566
411
  hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
567
412
  timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
568
413
  // D4: N reruns per probe side; the per-RUN probe timeout (each of the N runs gets its own budget,
569
- // never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status quo.
570
- foldReruns: parsePositiveIntKnob(env, 'AW_FOLD_RERUNS', 3),
571
- probeTimeoutS: parsePositiveIntKnob(env, 'AW_FOLD_PROBE_TIMEOUT_S', 120),
414
+ // never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status
415
+ // quo. The fail-closed parser is the shared one (changed-surface.mjs), thrown as THIS tool's STOP.
416
+ foldReruns: parsePositiveIntKnob(env, 'AW_FOLD_RERUNS', 3, stop),
417
+ probeTimeoutS: parsePositiveIntKnob(env, 'AW_FOLD_PROBE_TIMEOUT_S', 120, stop),
572
418
  });
573
419
 
574
420
  // ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
@@ -656,10 +502,13 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
656
502
  for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
657
503
  }
658
504
 
659
- // Decision 3 / 10 + D4: probe each fixable-bug bound testId N times (shell-free, per-run timeout).
505
+ // Decision 3 / 10 + D4: probe each of the SEGMENT's fixable-bug bound testIds N times
506
+ // (shell-free, per-run timeout). Segment scope (D7): a committed phase's folds are closed
507
+ // obligations — only triages recorded at the current base bind.
660
508
  const ledgerPath = resolveLedgerPath(cwd, env);
661
509
  const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
662
- const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop });
510
+ const base = resolveBase(cwd);
511
+ const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
663
512
  const testIds = boundTestIds.map(
664
513
  (id) => probeBound({ id, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
665
514
  );
@@ -672,6 +521,7 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
672
521
  schema: RESULT_SCHEMA_VERSION,
673
522
  kind: 'run',
674
523
  loop,
524
+ base,
675
525
  fingerprint,
676
526
  boundTestIds,
677
527
  testIds,
@@ -743,6 +593,7 @@ export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } =
743
593
  schema: RESULT_SCHEMA_VERSION,
744
594
  kind: 'red-probe',
745
595
  loop,
596
+ base: resolveBase(cwd), // the SEGMENT frame (D7): a receipt attests red within its segment
746
597
  testId,
747
598
  fileHash: entry.fileHash,
748
599
  runs: entry.runs,
@@ -766,15 +617,17 @@ Usage:
766
617
  node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
767
618
 
768
619
  The default run: runs the in-flight plan-execution loop's suite ONCE under coverage, maps every
769
- changed executable line to covered/uncovered, probes each fixable-bug bound testId N times
770
- (AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test file's
771
- content hash (custody), and appends one v2 run record to
620
+ changed executable line to covered/uncovered, probes each of the SEGMENT's fixable-bug bound testIds
621
+ N times (AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test
622
+ file's content hash (custody), and appends one v3 run record — segment-framed (base = git rev-parse
623
+ HEAD; the bound set is the current segment's, AD-048 D7) — to
772
624
  <git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
773
625
 
774
626
  --red observes a testId RED on the CURRENT (pre-fold) tree — the honest fold-time order is: classify
775
627
  the fixable-bug with its testId → write the test → --red observes it FAIL (N/N) BEFORE the fix is
776
628
  applied → fold the fix → the normal run observes green → the gate checks receipt + order + custody.
777
- An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint); observed-green /
629
+ An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint, and base — the
630
+ current SEGMENT frame: a receipt never crosses a commit boundary); observed-green /
778
631
  unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
779
632
  (mixed/timeout = QUARANTINE — never converts, no override lane).
780
633
 
@@ -14,10 +14,12 @@
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, 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
21
23
  // PRECEDING the latest run in ledger order (anti-post-hoc), and content CUSTODY — the run's
22
24
  // recorded test-file hash equals the latest custody-eligible red-probe hash on that file
23
25
  // (eligible = the receipt's own testId is bound AND it precedes the run, D5); plus 0
@@ -26,7 +28,7 @@
26
28
  // (review ledger v3), and the reserved EMPTY mutation shape (no mutation ships). A
27
29
  // recorded red-proof override waives the receipt + custody proof for exactly the testId
28
30
  // 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
31
+ // exit 1 for any DIRTY in-flight plan-execution segment lacking such a current run record — including
30
32
  // the stale-fingerprint case (a tree edit moves the fingerprint), the same-fingerprint/
31
33
  // new-testId case (a triage recorded after the run moves the bound-testId set, Decision 9),
32
34
  // and a schema-1 (or tamper-less) record as the loop's latest run (an older runner; D2) —
@@ -58,24 +60,32 @@
58
60
  // free, Node >= 18. No side effects on import (the isDirectRun idiom).
59
61
 
60
62
  import { readFileSync } from 'node:fs';
61
- import { join } from 'node:path';
62
63
  import { pathToFileURL } from 'node:url';
63
64
  import { spawnSync } from 'node:child_process';
64
65
  import { detectBackends } from './detect-backends.mjs';
65
66
  import { resolveActivityRecipe, planRecipe, DISPLAY_ALIASES } from './recipes.mjs';
66
67
  import { CONFIG_REL, fail, loadConfig } from './orchestration-config.mjs';
67
68
  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';
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 };
71
77
  // SCHEMA v2 (BUGFREE-1 / AD-047): records gain a kind discriminator — `run` (the fold-completeness
72
78
  // 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]);
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]);
79
89
  const ACTIVITY = 'plan-execution';
80
90
  const SLOT = 'review';
81
91
 
@@ -89,22 +99,17 @@ const gitLine = (args, cwd) => {
89
99
 
90
100
  const gitRoot = (cwd) => gitLine(['rev-parse', '--show-toplevel'], cwd);
91
101
 
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
102
  // ── the bound fixable-bug testId set (the SINGLE source of truth — runner + checker share it) ────
100
103
 
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 } = {}) => {
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 } = {}) => {
106
111
  const ids = new Set();
107
- for (const r of filterLoopRecords(reviewRecords, { activity, loop })) {
112
+ for (const r of filterSegmentRecords(reviewRecords, { activity, loop, base })) {
108
113
  if (r.kind !== 'triage') continue;
109
114
  for (const c of r.classifications) if (c.class === 'fixable-bug' && typeof c.testId === 'string' && c.testId.length > 0) ids.add(c.testId);
110
115
  }
@@ -120,11 +125,18 @@ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'st
120
125
 
121
126
  const HASH_RE = /^[0-9a-f]{64}$/; // sha-256 hex — the content-custody hash shape
122
127
 
123
- // The shared record frame (both versions, both kinds).
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).
124
131
  const validateFrame = (obj) => {
125
132
  if (!isNonEmptyString(obj.loop)) return 'missing loop';
126
133
  if (!(obj.fingerprint === null || isNonEmptyString(obj.fingerprint))) return 'fingerprint must be null or a non-empty string';
127
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
+ }
128
140
  return null;
129
141
  };
130
142
 
@@ -208,9 +220,11 @@ export const validateRunRecord = (obj) => {
208
220
  if (obj.kind === 'run') {
209
221
  const r = validateRunBody(obj, validateV2Entry);
210
222
  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.
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' };
214
228
  if (obj.tamper !== undefined && (!isPlainObject(obj.tamper) || !isStringArray(obj.tamper.tampered))) {
215
229
  return { ok: false, reason: 'tamper.tampered must be an array of strings (when the tamper surface is recorded)' };
216
230
  }
@@ -255,6 +269,12 @@ export const readResults = (path, readFile = readFileSync) => {
255
269
  // (latest is last).
256
270
  export const filterLoopResults = (records, loop) => records.filter((r) => r.loop === loop);
257
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
+
258
278
  // ── kind-aware selectors (shared: the runner and the checker read the ledger through THESE) ──────
259
279
 
260
280
  // A v1 record IS a run (the kindless AD-046 shape); a v2 record is a run only under kind:"run".
@@ -271,17 +291,9 @@ export const latestRunRecord = (loopRecords) => {
271
291
  return null;
272
292
  };
273
293
 
274
- // probeVerdict(entry) 'green' | 'red' | 'quarantine' | 'unresolvable' the D4 verdict algebra,
275
- // the SINGLE home shared by the runner (--red mints only on 'red') and the checker (the gate passes
276
- // only on 'green'). RED/GREEN are strict N/N verdicts; any timeout, mixed outcome, or partial
277
- // resolution is QUARANTINE — it never converts and has no override lane (a flaky pin proves
278
- // nothing — replace the test). Zero resolved runs (or a defensive runs=0) reads unresolvable.
279
- export const probeVerdict = (t) => {
280
- const unresolved = t.runs - t.greens - t.reds - t.timeouts;
281
- if (unresolved >= t.runs) return 'unresolvable';
282
- if (t.timeouts > 0 || unresolved > 0 || (t.greens > 0 && t.reds > 0)) return 'quarantine';
283
- return t.greens === t.runs ? 'green' : 'red';
284
- };
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.
285
297
 
286
298
  // ── the check + report core ─────────────────────────────────────────────────────────────────────
287
299
 
@@ -304,6 +316,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
304
316
  const plans = plansInFlight(root);
305
317
  const fingerprint = computeTreeFingerprint(cwd);
306
318
  const clean = fingerprint == null ? null : isTreeClean(cwd);
319
+ const base = resolveBase(cwd);
307
320
  const resultsPath = resolveResultsPath(cwd, env);
308
321
  const resultRead = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0, malformedReasons: [] };
309
322
  const reviewPath = resolveLedgerPath(cwd, env);
@@ -315,6 +328,7 @@ export const buildFoldState = ({ cwd, env = process.env, detect = detectBackends
315
328
  plans,
316
329
  fingerprint,
317
330
  clean,
331
+ base,
318
332
  resultsPath,
319
333
  resultRecords: resultRead.records,
320
334
  resultMalformed: resultRead.malformed,
@@ -353,20 +367,28 @@ export const decideCheck = (state) => {
353
367
  if (state.reviewMalformed > 0) return { code: 1, reason: `the review ledger has ${state.reviewMalformed} malformed line(s) — failing closed; inspect ${state.reviewPath}` };
354
368
 
355
369
  const loop = state.plans[0].replace(/\.md$/, '');
356
- const loopResults = filterLoopResults(state.resultRecords, loop);
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);
357
374
  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` };
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
+ }
359
382
  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` };
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.
365
387
 
366
388
  // Decision 9 — the double binding. A tree edit moves the fingerprint; a new fixable-bug triage moves
367
389
  // the bound-testId set. Either mismatch is STALE (the run no longer describes the committable tree).
368
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` };
369
- const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop });
391
+ const currentBound = collectBoundTestIds(state.reviewRecords, { activity: ACTIVITY, loop, base: state.base });
370
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` };
371
393
  // Fail CLOSED if the run's probe set does not cover its bound-testId set EXACTLY. A well-formed
372
394
  // runner record always probes every bound testId (one testIds[] entry per boundTestId), but a record
@@ -480,9 +502,9 @@ const formatHuman = (state, check) => {
480
502
  lines.push(` result ledger: ${state.resultsPath ?? '(unresolvable — no git dir)'} (${state.resultRecords.length} record(s)${state.resultMalformed ? `, ${state.resultMalformed} malformed — inspect the file` : ''})`);
481
503
  if (state.plans.length === 1) {
482
504
  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)));
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)));
486
508
  }
487
509
  lines.push(` check: ${check.code === 0 ? 'PASS' : 'FAIL'} — ${check.reason}`);
488
510
  return lines.join('\n');
@@ -495,15 +517,18 @@ Usage:
495
517
 
496
518
  Reads the result ledger the runner writes (<git dir>/${RESULTS_BASENAME}; AW_FOLD_RESULTS overrides),
497
519
  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.
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.
499
523
 
500
524
  --status (default) → the human report: resolved recipe, plan-in-flight, the latest run summary
501
525
  (per-testId D4 verdicts + rerun counts), the loop's red-probe receipts, verdict.
502
526
  --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
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
507
532
  test-surface file covered by a recorded oracle-change override, and the reserved empty mutation
508
533
  shape. A recorded red-proof override (review-ledger-write override) waives receipt + custody for
509
534
  exactly its testId — never green-N/N, never QUARANTINE. exit 1 otherwise (stale/missing/older-runner
@@ -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
  ]