@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.
@@ -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,14 +36,32 @@ 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, collectOverrides } from './review-ledger.mjs';
39
40
  import {
40
41
  RESULT_SCHEMA_VERSION,
42
+ REATTEST_KIND,
41
43
  resolveResultsPath,
42
44
  validateRunRecord,
43
45
  collectBoundTestIds,
44
46
  probeVerdict,
47
+ readResults,
48
+ filterSegmentResults,
49
+ isRedProbeRecord,
50
+ isReattestRecord,
45
51
  } from './fold-completeness.mjs';
52
+ // The changed-surface computation lives in the NEUTRAL shared module (BUGFREE-2 / AD-048, D4): the
53
+ // review-ledger writer's diff-size cap and this runner's coverage domain consume ONE computation,
54
+ // and the writer never imports this runner (the sole-tree-toucher boundary — import-split pinned).
55
+ // Re-exported below so the runner's tests (and any consumer) keep one entry point per concern.
56
+ import { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface, DIFF_FLAGS, parsePositiveIntKnob } from './changed-surface.mjs';
57
+ // The verification PROFILE (BUGFREE-3 / AD-049): the read-core generalizes the coverage SOURCE and
58
+ // the single-test RESULT FORMAT so this runner drives the fold gate on another language/runner. An
59
+ // absent profile reproduces today's exact behaviour (V8 + node:test TAP on stdout).
60
+ import { loadProfile, resolveCoverage, resolveSingleTest, resolveSarifPath, FILE_BASED_FORMATS } from './verification-profile.mjs';
61
+ import { lcovCoveredMap, uncoveredChangedFromLcov } from './lcov.mjs';
62
+ import { parseSarif, renderSarifFindings } from './sarif.mjs';
63
+
64
+ export { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface };
46
65
 
47
66
  const ACTIVITY = 'plan-execution';
48
67
  const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
@@ -61,71 +80,12 @@ const isoNow = () => new Date().toISOString();
61
80
  // runner is itself invoked from within a test context (e.g. this kit's own fold-completeness-run
62
81
  // tests, or a consumer's), the child runs nothing and every file reads as uncovered. Unset in normal
63
82
  // (non-test) invocation, so stripping is a no-op there.
64
- const childTestEnv = (env, extra = {}) => {
83
+ export const childTestEnv = (env, extra = {}) => {
65
84
  const out = { ...env, ...extra };
66
85
  delete out.NODE_TEST_CONTEXT;
67
86
  return out;
68
87
  };
69
88
 
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
89
  // ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
130
90
 
131
91
  // lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
@@ -184,40 +144,99 @@ export const computeUncoveredLines = ({ perProcessRanges, sourceText, changedLin
184
144
 
185
145
  // ── Decision 3 / 10: the bound-test probe ─────────────────────────────────────────────────────────
186
146
 
187
- const PROBE_RESULT_RE = /^(?:ok|not ok) \d+ - (.*)$/; // a column-0 TAP result line
147
+ const PROBE_RESULT_RE = /^(ok|not ok) \d+ - (.*)$/; // a column-0 TAP result line (verb, description)
188
148
  const PROBE_FAIL_RE = /^# fail (\d+)$/;
189
149
  const PROBE_DIRECTIVE_RE = /#\s*(?:skip|todo)\b/i; // a TAP SKIP/TODO directive — the test did NOT run
190
150
 
191
- // parseProbeOutput({ stdout, code, fileArg }) → { resolvable, executed, baselineGreen }. A node:test
192
- // run with a pattern that matches NOTHING emits only a file-wrapper result whose description is the
193
- // file path itself (`ok N - <file>`) on newer node but node 18/20 ALSO emit every pattern-FILTERED
194
- // test as `ok N - <name> # SKIP test name does not match pattern`, so a result line carrying a TAP
195
- // SKIP/TODO directive must never count: the test was not executed, and counting it green-vouches a
196
- // nonexistent testId on exactly the node versions the kit supports (caught by CI's 18/20 matrix).
197
- // So `resolvable` = at least one column-0, directive-free result whose description is not the file we
198
- // passed; `baselineGreen` = resolvable AND the run was green (exit 0 and `# fail 0`). The wrapper is
199
- // matched by BASENAME, not literally: node normalizes the echoed path ('./x' 'x', or an absolute
200
- // path), so a literal desc===fileArg compare would count the wrapper as a real match and falsely
201
- // report resolvable/green (codex R1). A basename compare is invariant to ./ / abs / rel; a real test
202
- // name colliding with the file's basename or containing a literal "# skip" is absurd and would
203
- // only fail CLOSED (mark unresolvable), never open.
151
+ // parseProbeOutput({ stdout, code, fileArg }) → { resolvable, executed, baselineGreen }. The TAP
152
+ // strategy (both tap-stdout and tap-file a tap-file is this SAME parser applied to the file's text).
153
+ // A node:test run with a pattern that matches NOTHING emits only a file-wrapper result whose
154
+ // description is the file path itself (`ok N - <file>`) on newer node but node 18/20 ALSO emit every
155
+ // pattern-FILTERED test as `ok N - <name> # SKIP test name does not match pattern`, so a result line
156
+ // carrying a TAP SKIP/TODO directive must never count: the test was not executed, and counting it
157
+ // green-vouches a nonexistent testId on exactly the node versions the kit supports (caught by CI's
158
+ // 18/20 matrix). So `resolvable` = at least one column-0, directive-free result whose description is
159
+ // not the file we passed; `baselineGreen` = resolvable AND the run was green exit 0, NO directive-free
160
+ // `not ok` result, and `# fail 0` (a `not ok` is counted directly, so a generic TAP producer that
161
+ // omits the `# fail N` summary yet exits 0 still reads RED the fail-closed posture, BUGFREE-3). The
162
+ // wrapper is matched by BASENAME, not literally: node normalizes the echoed path ('./x' 'x', or an
163
+ // absolute path), so a literal desc===fileArg compare would count the wrapper as a real match and
164
+ // falsely report resolvable/green (codex R1). A basename compare is invariant to ./ / abs / rel.
204
165
  export const parseProbeOutput = ({ stdout, code, fileArg }) => {
205
166
  let matched = 0;
167
+ let notOk = 0;
206
168
  let failCount = null;
207
169
  const wanted = basename(String(fileArg).trim());
208
170
  for (const line of String(stdout).split('\n')) {
209
171
  const m = PROBE_RESULT_RE.exec(line);
210
- if (m && !PROBE_DIRECTIVE_RE.test(m[1]) && basename(m[1].trim()) !== wanted) matched += 1;
172
+ if (m && !PROBE_DIRECTIVE_RE.test(m[2]) && basename(m[2].trim()) !== wanted) {
173
+ matched += 1;
174
+ if (m[1] === 'not ok') notOk += 1;
175
+ }
211
176
  const f = PROBE_FAIL_RE.exec(line.trim());
212
177
  if (f) failCount = Number(f[1]);
213
178
  }
214
179
  const resolvable = matched > 0;
215
- const fails = failCount ?? (code === 0 ? 0 : 1);
180
+ const fails = (failCount ?? 0) + notOk; // either signal marks a fail (only the ===0 green check matters)
216
181
  return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
217
182
  };
218
183
 
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];
184
+ // parseJunitXml({ resultText }) → { resolvable, executed, baselineGreen }. A dependency-free JUnit-XML
185
+ // reader (regex over well-formed testcase elements no XML lib): a <testcase> carrying <skipped> did
186
+ // NOT run (excluded, the TAP SKIP/TODO analogue); a <testcase> carrying <failure> or <error> is red.
187
+ // resolvable = at least one NON-skipped testcase (so an empty report / tests="0" reads UNRESOLVABLE,
188
+ // never green — the "0 tests never green" invariant); baselineGreen = resolvable AND no non-skipped
189
+ // failure/error. The XML report is authoritative (fail-closed: a failure element is red regardless of
190
+ // the process exit code — a reporter that exits 0 while recording failures still reads RED).
191
+ // CDATA + comment CONTENT is arbitrary text (a test's captured stdout may legally contain '<skipped',
192
+ // '<failure>', or even a literal '</testcase>'). Stripped BEFORE the regex scan so it can never
193
+ // fabricate a skip/failure match nor desync the lazy body capture (the fail-closed posture: a real
194
+ // <failure> is never dropped, a real element boundary is never truncated).
195
+ const stripXmlNoise = (xml) => String(xml).replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '').replace(/<!--[\s\S]*?-->/g, '');
196
+ export const parseJunitXml = ({ resultText }) => {
197
+ let executed = 0;
198
+ let failed = 0;
199
+ const text = stripXmlNoise(resultText ?? '');
200
+ // The /g regex is LOCAL per call: a fresh matcher owns its own lastIndex — no shared
201
+ // module-level state to reset, no reentrancy risk under any future refactor.
202
+ const caseRe = /<testcase\b[^>]*?(\/>|>([\s\S]*?)<\/testcase>)/g;
203
+ let m;
204
+ while ((m = caseRe.exec(text)) !== null) {
205
+ const body = m[2] ?? '';
206
+ if (/<skipped\b/.test(body)) continue; // skipped → did not run
207
+ executed += 1;
208
+ if (/<(?:failure|error)\b/.test(body)) failed += 1;
209
+ }
210
+ const resolvable = executed > 0;
211
+ return { resolvable, executed, baselineGreen: resolvable && failed === 0 };
212
+ };
213
+
214
+ // parseProbeResult({ format, stdout, code, fileArg, resultText }) → the SAME
215
+ // { resolvable, executed, baselineGreen } shape, dispatched by the profile's singleTest.resultFormat.
216
+ // A file-based format whose result file was NOT written (resultText == null — the probe crashed or the
217
+ // pattern selected nothing) reads UNRESOLVABLE (never green): the freshness invariant (a fresh
218
+ // out-of-tree path per probe run — see probeBound) means a stale file can never be re-read as green.
219
+ export const parseProbeResult = ({ format = 'tap-stdout', stdout, code, fileArg, resultText }) => {
220
+ if (format === 'tap-file') {
221
+ if (resultText == null) return { resolvable: false, executed: 0, baselineGreen: false };
222
+ return parseProbeOutput({ stdout: resultText, code, fileArg });
223
+ }
224
+ if (format === 'junit-xml') {
225
+ if (resultText == null) return { resolvable: false, executed: 0, baselineGreen: false };
226
+ const r = parseJunitXml({ resultText });
227
+ // Fail-closed + symmetric with tap-file: an all-pass report with a NONZERO process exit reads RED
228
+ // (a report may be written before a post-test hook/crash fails the process). Internal sweep fold.
229
+ return { ...r, baselineGreen: r.baselineGreen && code === 0 };
230
+ }
231
+ return parseProbeOutput({ stdout, code, fileArg }); // tap-stdout (default) — unchanged
232
+ };
233
+
234
+ // defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a
235
+ // shell). The pattern rides in the `=`-joined form: as a SEPARATE argv token a pattern beginning
236
+ // with "-"/"--" (a test name like "--telemetry refuses …") parses as an OPTION and the probe
237
+ // silently selects no test — the pattern-half sibling of the AD-047 dash-spawn file fix (found
238
+ // live by this plan's own --red loop).
239
+ export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', `--test-name-pattern=${pattern}`, file];
221
240
 
222
241
  // ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
223
242
  // Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
@@ -272,31 +291,39 @@ export const hashFileBytes = (abs) => {
272
291
  }
273
292
  };
274
293
 
275
- // resolveBoundArgv(env) → (file, pattern) => argv[]. Default = the node:test shape; AW_FOLD_BOUND_CMD
276
- // overrides with a JSON array of argv strings using {file}/{pattern} placeholders (the universality
277
- // escape hatch — a consumer on another runner). A malformed override is a typed refusal, never a
278
- // silent fallback to a shell. Substitution uses function replacers so a `$` in a testId is literal.
279
- export const resolveBoundArgv = (env = process.env) => {
294
+ // resolveBoundArgv(env, profile?) → (file, pattern, resultPath?) => argv[]. PRECEDENCE (env WINS,
295
+ // Decision 3): AW_FOLD_BOUND_CMD (a JSON argv array the universality escape hatch) beats the
296
+ // profile's singleTest.argv template, which beats the built-in node:test shape. Placeholders
297
+ // {file}/{pattern} are always substituted; {resultPath} is the FILE-BASED-format placeholder (the
298
+ // runner substitutes a fresh out-of-tree path per probe — see probeBound; validateProfile requires it
299
+ // for a file-based profile argv). A malformed override is a typed refusal, never a silent fall to a
300
+ // shell. Substitution uses function replacers so a `$` in a testId/path is literal.
301
+ export const resolveBoundArgv = (env = process.env, profile = null) => {
280
302
  const raw = env.AW_FOLD_BOUND_CMD;
281
- if (!raw) return (file, pattern) => defaultBoundArgv(file, pattern);
282
- let tmpl;
283
- try {
284
- tmpl = JSON.parse(raw);
285
- } catch (err) {
286
- throw stop(`AW_FOLD_BOUND_CMD is not valid JSON (${err.message}) — expected a JSON array of argv strings`);
287
- }
288
- if (!Array.isArray(tmpl) || tmpl.length === 0 || !tmpl.every((a) => typeof a === 'string')) {
289
- throw stop('AW_FOLD_BOUND_CMD must be a non-empty JSON array of argv strings with {file}/{pattern} placeholders');
303
+ let tmpl = null;
304
+ if (raw) {
305
+ try {
306
+ tmpl = JSON.parse(raw);
307
+ } catch (err) {
308
+ throw stop(`AW_FOLD_BOUND_CMD is not valid JSON (${err.message}) — expected a JSON array of argv strings`);
309
+ }
310
+ if (!Array.isArray(tmpl) || tmpl.length === 0 || !tmpl.every((a) => typeof a === 'string')) {
311
+ throw stop('AW_FOLD_BOUND_CMD must be a non-empty JSON array of argv strings with {file}/{pattern} placeholders');
312
+ }
313
+ } else if (Array.isArray(profile?.singleTest?.argv) && profile.singleTest.argv.length > 0) {
314
+ tmpl = profile.singleTest.argv;
290
315
  }
291
- return (file, pattern) => tmpl.map((a) => a.replace(/\{file\}/g, () => file).replace(/\{pattern\}/g, () => pattern));
316
+ if (!tmpl) return (file, pattern) => defaultBoundArgv(file, pattern);
317
+ return (file, pattern, resultPath) =>
318
+ tmpl.map((a) =>
319
+ a
320
+ .replace(/\{file\}/g, () => file)
321
+ .replace(/\{pattern\}/g, () => pattern)
322
+ .replace(/\{resultPath\}/g, () => resultPath ?? ''),
323
+ );
292
324
  };
293
325
 
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/'];
326
+ // ── read-only git plumbing (the changed-surface computation itself lives in changed-surface.mjs) ──
300
327
 
301
328
  const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
302
329
  const gitStdout = (args, cwd) => {
@@ -317,101 +344,10 @@ const canon = (path) => {
317
344
  return path;
318
345
  }
319
346
  };
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
347
  // ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
377
348
 
378
349
  const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
379
350
 
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
351
  // parseDiffOldSide(diffText) → Map<oldRel, { removals: boolean }> — the OLD-side view of a -U0
416
352
  // tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
417
353
  // old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
@@ -549,26 +485,15 @@ const appendRecord = (resultsPath, record) => {
549
485
 
550
486
  // ── the run ───────────────────────────────────────────────────────────────────────────────────────
551
487
 
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
488
  export const budgetsFromEnv = (env) => ({
565
489
  mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
566
490
  hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
567
491
  timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
568
492
  // 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),
493
+ // never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status
494
+ // quo. The fail-closed parser is the shared one (changed-surface.mjs), thrown as THIS tool's STOP.
495
+ foldReruns: parsePositiveIntKnob(env, 'AW_FOLD_RERUNS', 3, stop),
496
+ probeTimeoutS: parsePositiveIntKnob(env, 'AW_FOLD_PROBE_TIMEOUT_S', 120, stop),
572
497
  });
573
498
 
574
499
  // ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
@@ -577,34 +502,49 @@ export const budgetsFromEnv = (env) => ({
577
502
  // is the v2 per-testId record shape: rerun counts (evidence) + the custody content hash + the derived
578
503
  // booleans. The custody hash is taken BEFORE the runs (the content the observation attests). A
579
504
  // timed-out or signal-killed run is neither red nor green — it lands in `timeouts` (quarantine fuel).
580
- const probeBound = ({ id, rootTop, env, boundArgv, reruns, timeoutS }) => {
505
+ const probeBound = ({ id, rootTop, env, boundArgv, resultFormat = 'tap-stdout', reruns, timeoutS }) => {
581
506
  const { file, pattern } = splitTestId(id);
582
507
  const resolved = resolveTestFile(rootTop, file);
583
508
  const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
509
+ const fileBased = FILE_BASED_FORMATS.has(resultFormat);
584
510
  let executed = 0;
585
511
  let greens = 0;
586
512
  let reds = 0;
587
513
  let timeouts = 0;
588
514
  if (resolved.ok && fileHash != null) {
589
- // ALWAYS spawn the resolver's canonical absolute path — the executed file must be the hashed
590
- // file independent of runner path semantics (codex R1+R2, BUGFREE-1 live loop): a raw
591
- // leading-dash filename parses as an OPTION (and a ./-prefix does not survive node's runner
592
- // normalization), and a raw traversal path like linkdir/../x lets an OS-resolving runner
593
- // execute a different filesystem target than the lexically-normalized file the hash covers.
594
- const argv = boundArgv(resolved.abs, pattern);
595
515
  for (let i = 0; i < reruns; i += 1) {
596
- const res = spawnSync(argv[0], argv.slice(1), {
597
- cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
598
- });
599
- if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
600
- timeouts += 1;
601
- continue;
516
+ // FILE-BASED formats (tap-file / junit-xml): a FRESH out-of-tree result path PER probe run (the
517
+ // mkdtempSync-outside-tree precedent) this realizes the freshness invariant: a stale green
518
+ // file from a previous run / a crashed or zero-match probe can never be re-read as green.
519
+ let resultDir = null;
520
+ let resultPath = null;
521
+ if (fileBased) {
522
+ resultDir = mkdtempSync(join(tmpdir(), 'agent-workflow-fold-probe-'));
523
+ resultPath = join(resultDir, resultFormat === 'junit-xml' ? 'result.xml' : 'result.tap');
524
+ }
525
+ try {
526
+ // ALWAYS spawn the resolver's canonical absolute path — the executed file must be the hashed
527
+ // file independent of runner path semantics (codex R1+R2, BUGFREE-1 live loop): a raw
528
+ // leading-dash filename parses as an OPTION (and a ./-prefix does not survive node's runner
529
+ // normalization), and a raw traversal path like linkdir/../x lets an OS-resolving runner
530
+ // execute a different filesystem target than the lexically-normalized file the hash covers.
531
+ const argv = boundArgv(resolved.abs, pattern, resultPath);
532
+ const res = spawnSync(argv[0], argv.slice(1), {
533
+ cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
534
+ });
535
+ if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
536
+ timeouts += 1;
537
+ continue;
538
+ }
539
+ const resultText = fileBased ? readFileSafe(resultPath) : null;
540
+ const p = parseProbeResult({ format: resultFormat, stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file, resultText });
541
+ executed = Math.max(executed, p.executed);
542
+ if (!p.resolvable) continue; // an unresolved run (the pattern selected nothing / no result file)
543
+ if (p.baselineGreen) greens += 1;
544
+ else reds += 1;
545
+ } finally {
546
+ if (resultDir) rmSync(resultDir, { recursive: true, force: true });
602
547
  }
603
- const p = parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file });
604
- executed = Math.max(executed, p.executed);
605
- if (!p.resolvable) continue; // an unresolved run (the pattern selected nothing)
606
- if (p.baselineGreen) greens += 1;
607
- else reds += 1;
608
548
  }
609
549
  }
610
550
  const entry = {
@@ -629,39 +569,89 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
629
569
 
630
570
  const fingerprint = computeTreeFingerprint(cwd);
631
571
  const cmd = resolveSuiteCmd(rootTop, env, suiteCmd);
632
- const boundArgv = resolveBoundArgv(env); // resolves BEFORE any spawn (a malformed override refuses loudly)
572
+ // The verification profile (BUGFREE-3 / AD-049) decides the coverage SOURCE and the single-test
573
+ // RESULT FORMAT + argv template. Absent → today's V8 + node:test-TAP-on-stdout. Loaded BEFORE any
574
+ // spawn so a malformed profile / unsafe declared path (Decision 4) — and a malformed
575
+ // AW_FOLD_BOUND_CMD — both refuse loudly before the suite runs.
576
+ const { profile } = loadProfile(rootTop);
577
+ const boundArgv = resolveBoundArgv(env, profile);
578
+ const { resultFormat } = resolveSingleTest(profile);
633
579
  const budgets = budgetsFromEnv(env);
634
580
 
635
581
  const { assessable, unsupported, outOfDomain } = computeChangedSurface(rootTop);
636
582
 
637
- // M3a: run the suite once under coverage in a dir OUTSIDE the work tree (Decision 8), then map.
638
- const covDir = mkdtempSync(join(tmpdir(), 'agent-workflow-fold-cov-'));
639
- let coverage;
583
+ // Coverage SOURCE: absent / kind "v8" today's V8 path; kind "lcov" the consumer's suite leaves
584
+ // an LCOV file at the declared path (validated gitignored/out-of-tree by loadProfile — Decision 4).
585
+ const { kind: coverageKind, lcovPath } = resolveCoverage(profile);
586
+ const lcovAbs = coverageKind === 'lcov' ? (isAbsolute(lcovPath) ? lcovPath : join(rootTop, lcovPath)) : null;
587
+ // FRESHNESS: remove any STALE LCOV before the suite runs — symmetric with
588
+ // the V8 fresh mkdtemp covDir. A suite that fails/is misconfigured and does NOT re-emit LCOV then reads
589
+ // ABSENT (a loud STOP below), never a leftover file that could mask an uncovered changed line as green.
590
+ if (coverageKind === 'lcov') rmSync(lcovAbs, { force: true });
591
+
592
+ // M3a: run the suite ONCE, then map every changed executable line to covered/uncovered. V8 injects
593
+ // NODE_V8_COVERAGE into a dir OUTSIDE the work tree (Decision 8); LCOV runs the suite clean and
594
+ // reads the file the suite itself wrote (the env stays untouched). Either source resolves to the
595
+ // SAME canonical-abs key space, so the ONE per-file loop below consumes both.
596
+ const covDir = coverageKind === 'v8' ? mkdtempSync(join(tmpdir(), 'agent-workflow-fold-cov-')) : null;
597
+ let coverage; // v8 → Map<absKey, Array<Array<range>>>; lcov → Map<absKey, Map<line, hits>>
598
+ let suiteExit = null; // (a) v4: the suite exit code — the credit fires only on exit 0
640
599
  try {
641
- const suite = spawnSync('bash', ['-c', cmd], { cwd: rootTop, env: childTestEnv(env, { NODE_V8_COVERAGE: covDir }), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
600
+ const suiteEnv = coverageKind === 'v8' ? childTestEnv(env, { NODE_V8_COVERAGE: covDir }) : childTestEnv(env);
601
+ const suite = spawnSync('bash', ['-c', cmd], { cwd: rootTop, env: suiteEnv, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
642
602
  if (suite.error && suite.error.code === 'ENOENT') throw stop('bash is unavailable — the suite command is a bash command line');
643
- coverage = readCoverage(covDir);
603
+ suiteExit = suite.status; // number, or null when signal-killed (a null exit never credits — like nonzero)
604
+ if (coverageKind === 'lcov') {
605
+ const lcovText = readFileSafe(lcovAbs);
606
+ if (lcovText == null) {
607
+ throw stop(`coverage.kind is "lcov" but no LCOV file was found at "${lcovPath}" after the suite ran — ensure the suite writes LCOV there (see docs/ai/verification-profile.json)`);
608
+ }
609
+ coverage = lcovCoveredMap(lcovText, rootTop, { canon });
610
+ } else {
611
+ coverage = readCoverage(covDir);
612
+ }
644
613
  } finally {
645
- rmSync(covDir, { recursive: true, force: true });
614
+ if (covDir) rmSync(covDir, { recursive: true, force: true });
646
615
  }
616
+ // (a) v4 suite-execution evidence: the ONE suite spawn per fingerprint, recorded so run-gates
617
+ // --record can CREDIT the unit-tests gate from it (fingerprint-bound + tree-unchanged + cmd-identity
618
+ // + exit-0 — the ledger writer enforces that). The POST fingerprint proves the suite left the tree
619
+ // unchanged (coverage went out-of-tree / to a gitignored LCOV path).
620
+ const fingerprintAfter = computeTreeFingerprint(cwd);
621
+ const suite = { cmd, exit: suiteExit ?? null, fingerprintBefore: fingerprint, fingerprintAfter };
647
622
  const uncoveredChanged = [];
648
623
  for (const [rel, lines] of assessable) {
649
- const perProc = coverage.get(canon(join(rootTop, rel)));
650
- if (!perProc || perProc.length === 0) {
651
- uncoveredChanged.push({ file: rel, line: null }); // absent from coverage file-level RED (Decision 6)
652
- continue;
624
+ const key = canon(join(rootTop, rel));
625
+ if (coverageKind === 'lcov') {
626
+ // LCOV supplies the per-file uncovered set directly (computeUncoveredLines stays the V8-only
627
+ // path — D10); a file absent from the LCOV → a file-level RED, exactly like the V8 case.
628
+ const uncov = uncoveredChangedFromLcov(coverage, key, lines);
629
+ if (uncov === null) {
630
+ uncoveredChanged.push({ file: rel, line: null });
631
+ continue;
632
+ }
633
+ for (const n of uncov) uncoveredChanged.push({ file: rel, line: n });
634
+ } else {
635
+ const perProc = coverage.get(key);
636
+ if (!perProc || perProc.length === 0) {
637
+ uncoveredChanged.push({ file: rel, line: null }); // absent from coverage → file-level RED (Decision 6)
638
+ continue;
639
+ }
640
+ const src = readFileSafe(join(rootTop, rel));
641
+ if (src == null) continue;
642
+ for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
653
643
  }
654
- const src = readFileSafe(join(rootTop, rel));
655
- if (src == null) continue;
656
- for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
657
644
  }
658
645
 
659
- // Decision 3 / 10 + D4: probe each fixable-bug bound testId N times (shell-free, per-run timeout).
646
+ // Decision 3 / 10 + D4: probe each of the SEGMENT's fixable-bug bound testIds N times
647
+ // (shell-free, per-run timeout). Segment scope (D7): a committed phase's folds are closed
648
+ // obligations — only triages recorded at the current base bind.
660
649
  const ledgerPath = resolveLedgerPath(cwd, env);
661
650
  const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
662
- const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop });
651
+ const base = resolveBase(cwd);
652
+ const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
663
653
  const testIds = boundTestIds.map(
664
- (id) => probeBound({ id, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
654
+ (id) => probeBound({ id, rootTop, env, boundArgv, resultFormat, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
665
655
  );
666
656
 
667
657
  // Approach-3: the oracle-tamper pass over the tracked working-vs-HEAD diff, restricted to the
@@ -672,6 +662,7 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
672
662
  schema: RESULT_SCHEMA_VERSION,
673
663
  kind: 'run',
674
664
  loop,
665
+ base,
675
666
  fingerprint,
676
667
  boundTestIds,
677
668
  testIds,
@@ -679,6 +670,7 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
679
670
  outOfDomain,
680
671
  coverage: { uncoveredChanged },
681
672
  tamper,
673
+ suite, // (a) v4 suite-execution evidence
682
674
  mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
683
675
  budgets,
684
676
  timestamp: isoNow(),
@@ -711,9 +703,11 @@ export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } =
711
703
  if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
712
704
  const loop = plans[0].replace(/\.md$/, '');
713
705
 
714
- const boundArgv = resolveBoundArgv(env);
706
+ const { profile } = loadProfile(rootTop);
707
+ const boundArgv = resolveBoundArgv(env, profile);
708
+ const { resultFormat } = resolveSingleTest(profile);
715
709
  const budgets = budgetsFromEnv(env);
716
- const { entry, resolveReason } = probeBound({ id: testId, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS });
710
+ const { entry, resolveReason } = probeBound({ id: testId, rootTop, env, boundArgv, resultFormat, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS });
717
711
  const verdict = probeVerdict(entry);
718
712
  const counts = `${entry.greens} green / ${entry.reds} red / ${entry.timeouts} timed out / ${entry.runs - entry.greens - entry.reds - entry.timeouts} unresolved of ${entry.runs} run(s)`;
719
713
  if (verdict === 'unresolvable') {
@@ -743,6 +737,7 @@ export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } =
743
737
  schema: RESULT_SCHEMA_VERSION,
744
738
  kind: 'red-probe',
745
739
  loop,
740
+ base: resolveBase(cwd), // the SEGMENT frame (D7): a receipt attests red within its segment
746
741
  testId,
747
742
  fileHash: entry.fileHash,
748
743
  runs: entry.runs,
@@ -757,6 +752,223 @@ export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } =
757
752
  return appendRecord(resultsPath, record);
758
753
  };
759
754
 
755
+ // ── the --reattest verb (c): re-anchor custody at a bound test file's CURRENT bytes ────────────────
756
+
757
+ // runReattest({ cwd, env, testId }) → { writtenPath, record }. Records the test file's CURRENT hash as
758
+ // a custody anchor after a green-only append — the honest replacement for mis-using a red-proof waiver
759
+ // (a green append has no red to observe). Operator-ASSERTED, never auto-detected: "additions-only" is
760
+ // unsafe to auto-relax (an in-body `return;` is additions-only yet weakening). Re-attest ONLY re-anchors
761
+ // custody — the N/N-green probe and observed-red receipt requirements are unchanged, so a red test
762
+ // still fails the gate, and the custody guard still fails closed on any un-reattested change.
763
+ export const runReattest = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
764
+ if (!isWellFormedTestId(testId)) {
765
+ throw usageFail(`--reattest needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
766
+ }
767
+ const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
768
+ if (root == null) throw stop('not a git work tree — nothing to re-attest');
769
+ const rootTop = root.replace(/\r?\n$/, '');
770
+ const plans = plansInFlight(rootTop);
771
+ if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to re-attest');
772
+ if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
773
+ const loop = plans[0].replace(/\.md$/, '');
774
+
775
+ const { file } = splitTestId(testId);
776
+ const resolved = resolveTestFile(rootTop, file);
777
+ if (!resolved.ok) throw stop(`--reattest refused for "${testId}": ${resolved.reason} — cannot anchor custody to a file that does not resolve safely`);
778
+ const fileHash = hashFileBytes(resolved.abs);
779
+ if (fileHash == null) throw stop(`--reattest refused for "${testId}": cannot read "${file}" — nothing to anchor`);
780
+
781
+ const record = {
782
+ schema: RESULT_SCHEMA_VERSION,
783
+ kind: REATTEST_KIND,
784
+ loop,
785
+ base: resolveBase(cwd), // the SEGMENT frame (D7): a re-attest never crosses a commit boundary
786
+ testId,
787
+ fileHash,
788
+ fingerprint: computeTreeFingerprint(cwd),
789
+ timestamp: isoNow(),
790
+ };
791
+ const v = validateRunRecord(record);
792
+ if (!v.ok) throw stop(`refusing to write a malformed re-attest record: ${v.reason}`);
793
+ const resultsPath = resolveResultsPath(cwd, env);
794
+ if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
795
+ return appendRecord(resultsPath, record);
796
+ };
797
+
798
+ // ── the --preflight verb (f): the CHEAP half — the overrides/re-attests to record BEFORE coverage ──
799
+
800
+ // runPreflight({ cwd, env }) → { loop, base, fingerprint, boundTestIds, tamper, actions }. Read-only:
801
+ // runs only the cheap set (ledger reads + tamper + per-bound-file custody hashing) and returns the
802
+ // actions to RECORD before the expensive coverage/probe pass, routed by kind — `oracle-change` for a
803
+ // tampered test file, `reattest` for a green-only custody delta, `red` for a bound testId with no
804
+ // observed-red receipt. Coverage is never predicted, the suite is never spawned, nothing is written.
805
+ export const runPreflight = ({ cwd = process.cwd(), env = process.env } = {}) => {
806
+ const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
807
+ if (root == null) throw stop('not a git work tree — nothing to preflight');
808
+ const rootTop = root.replace(/\r?\n$/, '');
809
+ const plans = plansInFlight(rootTop);
810
+ if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to preflight');
811
+ if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
812
+ const loop = plans[0].replace(/\.md$/, '');
813
+ const base = resolveBase(cwd);
814
+ const fingerprint = computeTreeFingerprint(cwd);
815
+
816
+ // Cheap reads only — the ledgers + the git diff. No suite, no probes, no coverage (the reorder note:
817
+ // these live AFTER the coverage block in runFoldCompleteness; the preflight pulls them forward).
818
+ const ledgerPath = resolveLedgerPath(cwd, env);
819
+ // Fail CLOSED on an unreadable/malformed review ledger — the SAME posture decideCheck takes (a
820
+ // dropped line could hide a bound testId / an override); never a false all-clear.
821
+ const reviewRead = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
822
+ if (reviewRead.readError) throw stop(`cannot read the review ledger (${reviewRead.readError}) — failing closed; inspect ${ledgerPath}`);
823
+ if (reviewRead.malformed > 0) throw stop(`the review ledger has ${reviewRead.malformed} malformed line(s) — failing closed; inspect ${ledgerPath}`);
824
+ const reviewRecords = reviewRead.records;
825
+ const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
826
+ const boundSet = new Set(boundTestIds);
827
+ const boundFiles = new Set(boundTestIds.map((id) => splitTestId(id).file));
828
+ const tamper = computeTamperedTests(rootTop, boundFiles); // the SAME tamper surface the run records
829
+ const tamperedSet = new Set(tamper.tampered);
830
+ const overrides = collectOverrides(reviewRecords, { activity: ACTIVITY, loop });
831
+
832
+ const resultsPath = resolveResultsPath(cwd, env);
833
+ const resultRead = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0 };
834
+ if (resultRead.readError) throw stop(`cannot read the result ledger (${resultRead.readError}) — failing closed; inspect ${resultsPath}`);
835
+ if (resultRead.malformed > 0) throw stop(`the result ledger has ${resultRead.malformed} malformed line(s) — failing closed; inspect ${resultsPath}`);
836
+ const segRecords = filterSegmentResults(resultRead.records, loop, base);
837
+ const anchors = segRecords.filter((r) => isRedProbeRecord(r) || isReattestRecord(r)); // custody anchors
838
+ const receipts = segRecords.filter((r) => isRedProbeRecord(r)); // observed-red receipts only
839
+
840
+ const actions = [];
841
+ // 1. tampered test-surface files → oracle-change (unless already covered). ORTHOGONAL to the
842
+ // per-testId chain below: decideCheck's tamper guard and its per-testId observed-red + custody
843
+ // chain are independent guards — a tampered bound file needs BOTH an oracle-change AND a
844
+ // current-bytes custody anchor for each of its bound testIds.
845
+ for (const f of tamper.tampered) {
846
+ if (overrides.oracleChangeFiles.has(f)) continue;
847
+ actions.push({
848
+ kind: 'oracle-change',
849
+ file: f,
850
+ command: `node review-ledger-write.mjs override --json '{"loop":"${loop}","round":<n>,"scope":"oracle-change","files":${JSON.stringify([f])},"reason":"<why the expectation legitimately changed>"}'`,
851
+ });
852
+ }
853
+ // 2. per bound testId — mirror decideCheck's per-testId requirements. There is NO tamper skip: a
854
+ // tampered file's bound testIds STILL face the receipt + custody chain, so skipping them read as a
855
+ // false all-clear. Ordered as decideCheck evaluates: unresolvable (a hard fail before any override
856
+ // lane) → missing receipt → custody delta.
857
+ const reattestedFiles = new Set(); // one re-attest re-anchors the whole file (decideCheck keys custody by file) — dedup
858
+ const unresolvableFiles = new Set(); // recovery is file-level (restore / re-triage) — dedup
859
+ for (const id of boundTestIds) {
860
+ const { file } = splitTestId(id);
861
+ const resolved = resolveTestFile(rootTop, file);
862
+ const currentHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
863
+ if (currentHash == null) {
864
+ // the bound file does not resolve → decideCheck fails `unresolvable` UNCONDITIONALLY, BEFORE the
865
+ // red-proof / oracle-change lanes (probeVerdict `unresolvable` precedes the red-proof `continue`) —
866
+ // no override lifts it. This check MUST precede the red-proof skip below, else a red-proof'd deleted
867
+ // bound file reads clear here yet fails decideCheck. A deleted file is also tampered (the
868
+ // oracle-change above fires), but that does NOT rescue a deletion; surface the blocker.
869
+ if (!unresolvableFiles.has(file)) {
870
+ unresolvableFiles.add(file);
871
+ actions.push({
872
+ kind: 'unresolvable',
873
+ testId: id,
874
+ file,
875
+ note: `the bound test file ${file} does not resolve — the probe reads unresolvable and no override (oracle-change / red-proof / re-attest) lifts it; restore the file or re-triage the fixable-bug binding`,
876
+ });
877
+ }
878
+ continue;
879
+ }
880
+ if (overrides.redProofTestIds.has(id)) continue; // red-proof waives the receipt + custody proof — but ONLY for a resolvable file (the unresolvable guard above runs first, per decideCheck)
881
+ const own = receipts.filter((r) => r.testId === id);
882
+ if (own.length === 0) {
883
+ // no observed-red receipt — strictly per-testId (never deduped by file).
884
+ actions.push({
885
+ kind: 'red',
886
+ testId: id,
887
+ command: `node fold-completeness-run.mjs --red ${JSON.stringify(id)}`,
888
+ note: tamperedSet.has(file)
889
+ ? 'the test file was modified (tampered) — observe red at the modified expectations before folding; if the red is genuinely unestablishable, record a red-proof override instead'
890
+ : 'observe red BEFORE folding the fix; if the red is genuinely unestablishable, record a red-proof override instead',
891
+ });
892
+ continue;
893
+ }
894
+ const fileAnchors = anchors.filter((r) => boundSet.has(r.testId) && splitTestId(r.testId).file === file);
895
+ const latestAnchor = fileAnchors[fileAnchors.length - 1];
896
+ if (latestAnchor && latestAnchor.fileHash === currentHash) continue; // custody intact → no action
897
+ if (tamperedSet.has(file)) {
898
+ // a tampered (modified/removed old-side) file → re-observe red: the prior red-probe proved the OLD
899
+ // oracle and is now stale; --reattest is scoped to a green-only append and cannot honestly anchor a
900
+ // real edit (decideCheck's own recovery for a real edit is to re-observe red).
901
+ actions.push({
902
+ kind: 'red',
903
+ testId: id,
904
+ command: `node fold-completeness-run.mjs --red ${JSON.stringify(id)}`,
905
+ note: 're-observe red at the modified (tampered) expectations — --reattest is scoped to a green-only append and cannot anchor a real edit',
906
+ });
907
+ } else if (!reattestedFiles.has(file)) {
908
+ // an additions-only custody delta (NOT tampered — no old-side removal) → CANDIDATE for re-attest.
909
+ // The tamper flag catches removed/modified old-side lines, but an additions-only edit can still
910
+ // WEAKEN a bound test (an inserted early `return;` before the assertions) — undetectable without
911
+ // AST (the AD-047 residual). So preflight only SUGGESTS re-attest, with a caveat: re-attest is
912
+ // honest for a genuine green-only APPEND (a new sibling test); for an in-body insertion, re-observe
913
+ // red (--red) instead. The custody guard stays fail-closed until the operator records one or other.
914
+ reattestedFiles.add(file);
915
+ actions.push({
916
+ kind: 'reattest',
917
+ testId: id,
918
+ file,
919
+ command: `node fold-completeness-run.mjs --reattest ${JSON.stringify(id)}`,
920
+ note: 'valid only for a genuine green-only APPEND (a new sibling test); if the change INSERTS into an existing bound test body (an additions-only edit can still weaken it), re-observe red instead: node fold-completeness-run.mjs --red ' + JSON.stringify(id),
921
+ });
922
+ }
923
+ }
924
+ return { loop, base, fingerprint, boundTestIds, tamper, actions };
925
+ };
926
+
927
+ // renderPreflight(state) → a human block: the loop/base + the routed actions (or an all-clear note).
928
+ export const renderPreflight = ({ loop, boundTestIds, tamper, actions }) => {
929
+ const lines = [
930
+ `fold-completeness preflight — loop "${loop}" (cheap half; the suite was NOT run, nothing was written)`,
931
+ ` bound testIds: ${boundTestIds.length ? boundTestIds.join(', ') : '(none)'}`,
932
+ ` tampered test-surface files: ${tamper.tampered.length ? tamper.tampered.join(', ') : 'none'}`,
933
+ ];
934
+ if (actions.length === 0) {
935
+ lines.push(' ✓ no overrides / re-attests needed before the coverage pass — run: node fold-completeness-run.mjs');
936
+ return lines.join('\n');
937
+ }
938
+ lines.push(` ${actions.length} action(s) to resolve BEFORE the coverage pass:`);
939
+ const head = (a) => {
940
+ if (a.kind === 'oracle-change') return `oracle-change for ${a.file}`;
941
+ if (a.kind === 'reattest') return `re-attest ${a.testId} (green-only custody delta)`;
942
+ if (a.kind === 'unresolvable') return `unresolvable bound file ${a.file} — restore or re-triage`;
943
+ return `observe red for ${a.testId}`;
944
+ };
945
+ for (const a of actions) {
946
+ lines.push(` [${a.kind}] ${head(a)}`);
947
+ if (a.command) lines.push(` ${a.command}`);
948
+ if (a.note) lines.push(` (${a.note})`);
949
+ }
950
+ return lines.join('\n');
951
+ };
952
+
953
+ // ── the --findings verb (1.4): OPTIONAL SARIF advisory intake — print-only, NEVER recorded ────────
954
+
955
+ // runFindings({ cwd, env }) → { findings, note }. Reads the profile's findings.sarifPath, ADVISORY
956
+ // ONLY: nothing is written and the fold gate never reads SARIF, so it can never block a fold. Absent
957
+ // path / missing file → a no-op note; a malformed SARIF throws (a loud advisory failure), --check
958
+ // unaffected.
959
+ export const runFindings = ({ cwd = process.cwd(), env = process.env } = {}) => {
960
+ const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
961
+ const rootTop = root == null ? cwd : root.replace(/\r?\n$/, '');
962
+ const { profile } = loadProfile(rootTop);
963
+ const sarifPath = resolveSarifPath(profile);
964
+ if (!sarifPath) return { findings: [], note: 'no findings.sarifPath declared in the verification profile — nothing to read (SARIF advisory is opt-in)' };
965
+ const abs = isAbsolute(sarifPath) ? sarifPath : join(rootTop, sarifPath);
966
+ const text = readFileSafe(abs);
967
+ if (text == null) return { findings: [], note: `no SARIF file at "${sarifPath}" — the suite may not have written it yet (advisory, non-blocking)` };
968
+ const { findings } = parseSarif(text); // throws on malformed → the CLI exits nonzero (advisory-loud)
969
+ return { findings, note: null };
970
+ };
971
+
760
972
  // ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
761
973
 
762
974
  const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046 + AD-047).
@@ -764,33 +976,64 @@ const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-w
764
976
  Usage:
765
977
  node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
766
978
  node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
979
+ node fold-completeness-run.mjs --reattest "<test-file>#<test-name-pattern>" [--cwd <dir>]
980
+ node fold-completeness-run.mjs --preflight [--cwd <dir>]
981
+ node fold-completeness-run.mjs --findings [--cwd <dir>]
767
982
 
768
983
  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
984
+ changed executable line to covered/uncovered, probes each of the SEGMENT's fixable-bug bound testIds
985
+ N times (AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test
986
+ file's content hash (custody), the suite-execution evidence (cmd + exit + pre/post fingerprints), and
987
+ appends one v4 run record — segment-framed (base = git rev-parse
988
+ HEAD; the bound set is the current segment's, AD-048 D7) — to
772
989
  <git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
773
990
 
774
991
  --red observes a testId RED on the CURRENT (pre-fold) tree — the honest fold-time order is: classify
775
992
  the fixable-bug with its testId → write the test → --red observes it FAIL (N/N) BEFORE the fix is
776
993
  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 /
994
+ An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint, and base — the
995
+ current SEGMENT frame: a receipt never crosses a commit boundary); observed-green /
778
996
  unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
779
997
  (mixed/timeout = QUARANTINE — never converts, no override lane).
780
998
 
999
+ --reattest re-anchors a bound test FILE's custody at its CURRENT bytes WITHOUT observing red — the
1000
+ honest replacement for a red-proof waiver after a GREEN-ONLY test-file append (there is no red to
1001
+ observe). It mints a fold-v4 re-attest receipt (testId + the current file hash). It is
1002
+ operator-asserted (self-discipline, the same trust model as the recorded overrides), NOT auto-detected:
1003
+ the custody guard still fails CLOSED on any un-reattested change, and re-attest never converts a red
1004
+ baseline or waives the observed-red proof (a weakened/red test still fails the gate).
1005
+
781
1006
  Suite command: --suite "<cmd>" or AW_FOLD_SUITE_CMD, else the unit-tests gate cmd in docs/ai/gates.json.
782
1007
  Bound-test probes default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
783
1008
  with a JSON argv array using {file}/{pattern}. Probe knobs (fail-closed positive integers):
784
1009
  AW_FOLD_RERUNS (default 3) · AW_FOLD_PROBE_TIMEOUT_S (default 120, per probe RUN, probes only).
785
1010
  Inert budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX / AW_FOLD_TIME_BUDGET_S (mutation shelved).
786
1011
 
1012
+ The VERIFICATION PROFILE (docs/ai/verification-profile.json, BUGFREE-3) generalizes the coverage
1013
+ SOURCE (coverage.kind v8|lcov + lcovPath) and the single-test RESULT FORMAT (singleTest.argv +
1014
+ resultFormat tap-stdout|tap-file|junit-xml; a file-based format's argv carries {resultPath}). Absent →
1015
+ today's exact behaviour (V8 + node:test TAP on stdout). Env knobs still override (AW_FOLD_SUITE_CMD /
1016
+ AW_FOLD_BOUND_CMD win over the profile).
1017
+
1018
+ --preflight runs ONLY the cheap half (the tamper surface + custody deltas from the git diff + the
1019
+ ledgers, seconds) WITHOUT the coverage suite run: it prints the overrides / re-attests to RECORD
1020
+ BEFORE the expensive pass — routed by kind: oracle-change for a tampered test file, --reattest for a
1021
+ green-only custody delta, --red (or a red-proof override if the red is unestablishable) for a bound
1022
+ testId with no observed-red receipt yet. It spawns no suite, runs no probe, predicts no coverage, and
1023
+ writes nothing.
1024
+
1025
+ --findings reads the profile's OPTIONAL findings.sarifPath and PRINTS the SARIF findings — ADVISORY
1026
+ ONLY: nothing is recorded, and the fold gate (fold-completeness --check) never reads SARIF, so it can
1027
+ never block a fold. Absent path / missing file → a stated no-op; a malformed SARIF exits nonzero (a
1028
+ loud advisory failure) but leaves --check unaffected.
1029
+
787
1030
  The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
788
1031
 
789
- Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / a --red refusal / malformed
790
- record / fs error); 2 usage.`;
1032
+ Exit codes: 0 written / advisory printed; 1 a typed STOP (loop derivation / suite discovery / a --red
1033
+ or --reattest refusal / a malformed SARIF on --findings / malformed record / fs error); 2 usage.`;
791
1034
 
792
1035
  const parseArgs = (argv) => {
793
- const opts = { cwd: undefined, suite: undefined, red: undefined };
1036
+ const opts = { cwd: undefined, suite: undefined, red: undefined, reattest: undefined, findings: false, preflight: false };
794
1037
  for (let i = 0; i < argv.length; i += 1) {
795
1038
  const a = argv[i];
796
1039
  if (a === '--cwd') {
@@ -805,6 +1048,14 @@ const parseArgs = (argv) => {
805
1048
  opts.red = argv[i + 1];
806
1049
  if (opts.red === undefined) throw usageFail('--red needs a testId ("<test-file>#<test-name-pattern>")');
807
1050
  i += 1;
1051
+ } else if (a === '--reattest') {
1052
+ opts.reattest = argv[i + 1];
1053
+ if (opts.reattest === undefined) throw usageFail('--reattest needs a testId ("<test-file>#<test-name-pattern>")');
1054
+ i += 1;
1055
+ } else if (a === '--findings') {
1056
+ opts.findings = true;
1057
+ } else if (a === '--preflight') {
1058
+ opts.preflight = true;
808
1059
  } else {
809
1060
  throw usageFail(`unknown argument: ${a}`);
810
1061
  }
@@ -819,6 +1070,13 @@ export const main = (argv, ctx = {}) => {
819
1070
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
820
1071
  const opts = parseArgs(argv);
821
1072
  const cwd = opts.cwd ?? cwd0;
1073
+ if (opts.preflight) {
1074
+ return { code: 0, stdout: renderPreflight(runPreflight({ cwd, env })), stderr: '' };
1075
+ }
1076
+ if (opts.findings) {
1077
+ const { findings, note } = runFindings({ cwd, env });
1078
+ return { code: 0, stdout: note ?? renderSarifFindings(findings), stderr: '' };
1079
+ }
822
1080
  if (opts.red !== undefined) {
823
1081
  const { writtenPath, record } = runRedProbe({ cwd, env, testId: opts.red });
824
1082
  return {
@@ -827,6 +1085,14 @@ export const main = (argv, ctx = {}) => {
827
1085
  stderr: '',
828
1086
  };
829
1087
  }
1088
+ if (opts.reattest !== undefined) {
1089
+ const { writtenPath, record } = runReattest({ cwd, env, testId: opts.reattest });
1090
+ return {
1091
+ code: 0,
1092
+ stdout: `fold-completeness-run: minted a custody re-attest for "${record.testId}" (loop "${record.loop}", hash ${record.fileHash.slice(0, 12)}…) → ${writtenPath}`,
1093
+ stderr: '',
1094
+ };
1095
+ }
830
1096
  const { writtenPath, record } = runFoldCompleteness({ cwd, env, suiteCmd: opts.suite });
831
1097
  const uncovered = record.coverage.uncoveredChanged.length;
832
1098
  const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;