@sabaiway/agent-workflow-kit 1.37.1 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,10 +12,15 @@
12
12
  // TS-JSX / out-of-domain) and derive per-file changed line ranges;
13
13
  // 3. M3a — run the suite ONCE under NODE_V8_COVERAGE (a dir OUTSIDE the work tree, Decision 8) and
14
14
  // map every changed executable line to covered/uncovered via V8 innermost-range-wins (Decision 6);
15
- // 4. probe each of the loop's fixable-bug bound testIds once (Decision 3 / 10, shell-free) for
16
- // resolvability + a green baseline;
17
- // 5. append ONE machine-only result record, bound to BOTH the tree fingerprint AND the sorted
18
- // fixable-bug testId set (Decision 9), to <git dir>/agent-workflow-fold-completeness.jsonl.
15
+ // 4. probe each of the loop's fixable-bug bound testIds N times (Decision 3 / 10 + D4, shell-free,
16
+ // per-run timeout) for resolvability + an N/N-green baseline, hashing each bound test file
17
+ // (the D5 custody anchor);
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.
21
+ // A SECOND verb, --red "<testId>" (BUGFREE-1 / AD-047), observes a testId RED on the current
22
+ // (pre-fold) tree and mints a red-probe receipt — the observed-red half of the honest red→green
23
+ // proof; observed-green / unresolvable / mixed / timed-out are distinguished refusals, nothing written.
19
24
  // The researched mutation half (M3b) was SHELVED — bounded local-boundary mutation adds too little
20
25
  // over coverage and is not language-independent — so the `mutation` field stays the reserved empty shape.
21
26
  //
@@ -24,19 +29,28 @@
24
29
  // TS/JSX source is out of scope v1. Dependency-free, Node >= 18. No side effects on import.
25
30
 
26
31
  import { readFileSync, readdirSync, mkdtempSync, rmSync, realpathSync, lstatSync } from 'node:fs';
27
- import { join, dirname, basename } from 'node:path';
32
+ import { join, dirname, basename, isAbsolute, normalize, posix, sep } from 'node:path';
28
33
  import { tmpdir } from 'node:os';
29
34
  import { pathToFileURL, fileURLToPath } from 'node:url';
30
35
  import { spawnSync } from 'node:child_process';
36
+ import { createHash } from 'node:crypto';
31
37
  import { writeContainedFileAtomic } from './atomic-write.mjs';
32
38
  import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
33
- import { resolveLedgerPath, readLedger } from './review-ledger.mjs';
39
+ import { resolveLedgerPath, resolveBase, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
34
40
  import {
35
41
  RESULT_SCHEMA_VERSION,
36
42
  resolveResultsPath,
37
43
  validateRunRecord,
38
44
  collectBoundTestIds,
45
+ probeVerdict,
39
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 };
40
54
 
41
55
  const ACTIVITY = 'plan-execution';
42
56
  const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
@@ -61,65 +75,6 @@ const childTestEnv = (env, extra = {}) => {
61
75
  return out;
62
76
  };
63
77
 
64
- // ── Decision 5: the CLOSED changed-path classification rule (no heuristics) ───────────────────────
65
-
66
- const TEST_FILE_RE = /\.(test|spec)\.[^./]+$/; // a.test.mjs, b.spec.js, c.test.cjs, d.spec.ts
67
- const ASSESSABLE_EXT = new Set(['.mjs', '.cjs', '.js']);
68
- const UNSUPPORTED_EXT = new Set(['.ts', '.tsx', '.jsx', '.mts', '.cts']);
69
-
70
- // classifyChangedPath(rel) → 'assessable' | 'unsupported' | 'out-of-domain' | 'excluded-test'.
71
- export const classifyChangedPath = (rel) => {
72
- const base = rel.split('/').pop();
73
- if (TEST_FILE_RE.test(base)) return 'excluded-test';
74
- const dot = base.lastIndexOf('.');
75
- const ext = dot >= 0 ? base.slice(dot) : '';
76
- if (ASSESSABLE_EXT.has(ext)) return 'assessable';
77
- if (UNSUPPORTED_EXT.has(ext)) return 'unsupported';
78
- return 'out-of-domain';
79
- };
80
-
81
- // ── unified-diff → new-side changed line numbers (line numbers only; content lines are ignored) ───
82
-
83
- const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
84
-
85
- // parseUnifiedDiff(diffText) → Map<rel, number[]>. Robust against a content line that happens to look
86
- // like a `+++ ` header: a `+++ ` line is a FILE header only in the header region (right after a
87
- // `diff --git`, before any `@@`); inside a hunk body it is ignored. New-side lines come purely from the
88
- // `@@` headers, so no content-line disambiguation is needed for the line numbers themselves.
89
- export const parseUnifiedDiff = (diffText) => {
90
- const map = new Map();
91
- let current = null;
92
- let inHeader = false;
93
- for (const line of String(diffText).split('\n')) {
94
- if (line.startsWith('diff --git ')) {
95
- current = null;
96
- inHeader = true;
97
- continue;
98
- }
99
- if (inHeader && line.startsWith('--- ')) continue;
100
- if (inHeader && line.startsWith('+++ ')) {
101
- const p = line.slice(4).trim();
102
- current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
103
- inHeader = false;
104
- continue;
105
- }
106
- const m = DIFF_HUNK_RE.exec(line);
107
- if (m) {
108
- inHeader = false;
109
- if (current == null) continue;
110
- const start = Number(m[1]);
111
- const count = m[2] === undefined ? 1 : Number(m[2]);
112
- if (count > 0) {
113
- const arr = map.get(current) ?? [];
114
- for (let i = 0; i < count; i += 1) arr.push(start + i);
115
- map.set(current, arr);
116
- }
117
- }
118
- }
119
- for (const [k, v] of map) map.set(k, [...new Set(v)].sort((a, b) => a - b));
120
- return map;
121
- };
122
-
123
78
  // ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
124
79
 
125
80
  // lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
@@ -210,8 +165,65 @@ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
210
165
  return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
211
166
  };
212
167
 
213
- // defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a shell).
214
- 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];
174
+
175
+ // ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
176
+ // Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
177
+ // deliberately suffix-free and format-only (review-ledger.mjs), so path safety lives here: the file
178
+ // half must be repo-relative (absolute + parent-escaping refused), a REGULAR file under the no-follow
179
+ // lstat discipline, and its RESOLVED real path must be contained under the REAL repo root — a leaf
180
+ // check alone would let a symlinked PARENT directory escape the work tree.
181
+
182
+ // resolveTestFile(rootTop, rel, deps?) → { ok: true, abs } | { ok: false, reason } (never throws).
183
+ // deps.{lstat,realpath} are injectable so the defensive fs-race catch is unit-testable (the family
184
+ // deps idiom — review-ledger-write.mjs).
185
+ export const resolveTestFile = (rootTop, rel, deps = {}) => {
186
+ const lstat = deps.lstat ?? lstatSync;
187
+ const realpath = deps.realpath ?? realpathSync;
188
+ if (typeof rel !== 'string' || rel.length === 0) return { ok: false, reason: 'empty file path' };
189
+ if (isAbsolute(rel)) return { ok: false, reason: `absolute path "${rel}" — the testId file half must be repo-relative` };
190
+ const norm = normalize(rel);
191
+ if (norm === '..' || norm.startsWith(`..${sep}`)) return { ok: false, reason: `path "${rel}" escapes the repo root` };
192
+ const abs = join(rootTop, norm);
193
+ let st;
194
+ try {
195
+ st = lstat(abs);
196
+ } catch {
197
+ return { ok: false, reason: `file "${rel}" does not exist` };
198
+ }
199
+ if (!st.isFile()) return { ok: false, reason: `"${rel}" is not a regular file (a symlink/directory/device is never followed — fail closed)` };
200
+ let realAbs;
201
+ let realRoot;
202
+ try {
203
+ realAbs = realpath(abs);
204
+ realRoot = realpath(rootTop);
205
+ } catch {
206
+ return { ok: false, reason: `cannot resolve the real path of "${rel}"` };
207
+ }
208
+ if (!containsPath(realRoot, realAbs)) return { ok: false, reason: `"${rel}" resolves outside the repo root (a symlinked parent directory) — fail closed` };
209
+ return { ok: true, abs: realAbs };
210
+ };
211
+
212
+ // containsPath(realRoot, realAbs) → realAbs is strictly INSIDE realRoot. Segment-safe ('/a' never
213
+ // contains '/ab') and correct for a repo at the filesystem root, where realRoot already ends with
214
+ // the separator ('/'+sep would be '//' and reject every valid path — agy R1).
215
+ export const containsPath = (realRoot, realAbs) => realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
216
+
217
+ // The D5 custody hash: sha-256 over the file's BYTES (no encoding normalization). null on a read
218
+ // failure — the caller reads that as an unresolvable file (exported for the unit test of exactly
219
+ // that fail-closed edge).
220
+ export const hashFileBytes = (abs) => {
221
+ try {
222
+ return createHash('sha256').update(readFileSync(abs)).digest('hex');
223
+ } catch {
224
+ return null;
225
+ }
226
+ };
215
227
 
216
228
  // resolveBoundArgv(env) → (file, pattern) => argv[]. Default = the node:test shape; AW_FOLD_BOUND_CMD
217
229
  // overrides with a JSON array of argv strings using {file}/{pattern} placeholders (the universality
@@ -232,7 +244,7 @@ export const resolveBoundArgv = (env = process.env) => {
232
244
  return (file, pattern) => tmpl.map((a) => a.replace(/\{file\}/g, () => file).replace(/\{pattern\}/g, () => pattern));
233
245
  };
234
246
 
235
- // ── the changed surface (git-driven) ──────────────────────────────────────────────────────────────
247
+ // ── read-only git plumbing (the changed-surface computation itself lives in changed-surface.mjs) ──
236
248
 
237
249
  const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
238
250
  const gitStdout = (args, cwd) => {
@@ -253,60 +265,72 @@ const canon = (path) => {
253
265
  return path;
254
266
  }
255
267
  };
256
- // A changed assessable LEAF is assessed only if it is a REGULAR file. lstat (no-follow): a symlinked
257
- // or non-regular *.mjs must NEVER be read/canonicalized — following it could read outside the work
258
- // tree or HANG on a FIFO/device (codex R2). A non-regular leaf fails closed (routed to `unsupported`).
259
- const isRegularLeaf = (abs) => {
260
- try {
261
- return lstatSync(abs).isFile();
262
- } catch {
263
- return false;
264
- }
265
- };
268
+ // ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
266
269
 
267
- // computeChangedSurface(root) { assessable: Map<rel, number[]>, unsupported: [rel], outOfDomain: [rel] }.
268
- // Domain = the review-payload domain (tracked working-vs-HEAD changes + untracked-not-ignored files),
269
- // classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0`; an untracked file
270
- // is wholly new, so all its lines are "changed".
271
- export const computeChangedSurface = (root) => {
272
- const trackedDiff = gitStdout(['diff', 'HEAD', '--unified=0', '--no-color', '--no-ext-diff', '--no-renames'], root)
273
- ?? gitStdout(['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-renames'], root) // no HEAD yet (unborn branch)
274
- ?? '';
275
- const trackedLines = parseUnifiedDiff(trackedDiff);
276
- const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
277
- const untracked = untrackedZ.split('\0').filter(Boolean);
278
-
279
- const assessable = new Map();
280
- const unsupported = [];
281
- const outOfDomain = [];
282
- const place = (rel, cls, lines) => {
283
- if (cls === 'excluded-test') return;
284
- if (cls === 'assessable') {
285
- if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
286
- else unsupported.push(rel); // a symlinked / non-regular source → fail closed, never followed
287
- return;
270
+ const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
271
+
272
+ // parseDiffOldSide(diffText) Map<oldRel, { removals: boolean }> the OLD-side view of a -U0
273
+ // tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
274
+ // old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
275
+ // DELETED file's hunks remove every line, so it reads removals: true. Renames under --no-renames
276
+ // read as delete+add the delete side lands here (stated).
277
+ export const parseDiffOldSide = (diffText) => {
278
+ const map = new Map();
279
+ let current = null;
280
+ let inHeader = false;
281
+ for (const line of String(diffText).split('\n')) {
282
+ if (line.startsWith('diff --git ')) {
283
+ current = null;
284
+ inHeader = true;
285
+ continue;
288
286
  }
289
- if (cls === 'unsupported') unsupported.push(rel);
290
- else outOfDomain.push(rel);
291
- };
292
- for (const [rel, lines] of trackedLines) place(rel, classifyChangedPath(rel), lines);
293
- for (const rel of untracked) {
294
- const cls = classifyChangedPath(rel);
295
- if (cls !== 'assessable') {
296
- place(rel, cls, []);
287
+ if (inHeader && line.startsWith('--- ')) {
288
+ // Strip ONLY the git-appended trailing TAB (space-carrying paths) and a CRLF \r — never a
289
+ // legitimate trailing character of the filename itself (agy R6; a raw TAB in a name is
290
+ // C-quoted anyway, so [\t\r]+$ can only match git artifacts).
291
+ const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, ''));
292
+ current = p === '/dev/null' ? null : p.startsWith('a/') ? p.slice(2) : p;
293
+ if (current != null && !map.has(current)) map.set(current, { removals: false });
297
294
  continue;
298
295
  }
299
- // Guard the leaf BEFORE reading — never follow a symlink to count an untracked file's lines.
300
- const abs = join(root, rel);
301
- if (!isRegularLeaf(abs)) {
302
- unsupported.push(rel);
296
+ if (inHeader && line.startsWith('+++ ')) {
297
+ inHeader = false;
303
298
  continue;
304
299
  }
305
- const src = readFileSafe(abs);
306
- const count = src == null || src.length === 0 ? 0 : src.split('\n').length;
307
- assessable.set(rel, Array.from({ length: count }, (_, i) => i + 1));
300
+ const m = DIFF_HUNK_OLD_RE.exec(line);
301
+ if (m) {
302
+ inHeader = false;
303
+ if (current == null) continue;
304
+ const oldCount = m[1] === undefined ? 1 : Number(m[1]);
305
+ if (oldCount > 0) map.get(current).removals = true;
306
+ }
307
+ }
308
+ return map;
309
+ };
310
+
311
+ // computeTamperedTests(root, boundFiles) → { tampered: [rel...] } — the tamper surface is the union
312
+ // of test-classified paths (TEST_FILE_RE) and the loop's bound-testId file halves (the testId format
313
+ // carries no suffix rule, so a bound test at a nonstandard path must not escape the guard),
314
+ // restricted to files that exist at HEAD (having an old side in the tracked diff IS existing at
315
+ // HEAD). Tampered = any removed/modified line; pure additions and new/untracked files never trip it
316
+ // (widening to any-change-is-tamper would flag the standard append-a-new-test flow — the FP churn
317
+ // this series exists to kill).
318
+ export const computeTamperedTests = (root, boundFiles = new Set()) => {
319
+ const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
320
+ ?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
321
+ ?? '';
322
+ // The file halves are user-authored — './checks/x.mjs' probes and hashes as 'checks/x.mjs', so
323
+ // the surface compares NORMALIZED halves or a modified bound file escapes the guard (codex R5).
324
+ // Normalization happens in GIT/POSIX path space (codex+agy R6): node's OS-local normalize emits
325
+ // backslashes on Windows while git diff paths stay slash-separated — the compare must never
326
+ // depend on the host separator.
327
+ const boundSet = new Set([...boundFiles].map((f) => posix.normalize(f)));
328
+ const tampered = [];
329
+ for (const [rel, info] of parseDiffOldSide(trackedDiff)) {
330
+ if (!info.removals) continue;
331
+ if (classifyChangedPath(rel) === 'excluded-test' || boundSet.has(rel)) tampered.push(rel);
308
332
  }
309
- return { assessable, unsupported: unsupported.sort(), outOfDomain: outOfDomain.sort() };
333
+ return { tampered: tampered.sort() };
310
334
  };
311
335
 
312
336
  // ── coverage (run the suite once under NODE_V8_COVERAGE, outside the tree) ─────────────────────────
@@ -382,12 +406,60 @@ const appendRecord = (resultsPath, record) => {
382
406
 
383
407
  // ── the run ───────────────────────────────────────────────────────────────────────────────────────
384
408
 
385
- const budgetsFromEnv = (env) => ({
409
+ export const budgetsFromEnv = (env) => ({
386
410
  mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
387
411
  hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
388
412
  timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
413
+ // D4: N reruns per probe side; the per-RUN probe timeout (each of the N runs gets its own budget,
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),
389
418
  });
390
419
 
420
+ // ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
421
+
422
+ // probeBound({ id, rootTop, env, boundArgv, reruns, timeoutS }) → { entry, resolveReason }. The entry
423
+ // is the v2 per-testId record shape: rerun counts (evidence) + the custody content hash + the derived
424
+ // booleans. The custody hash is taken BEFORE the runs (the content the observation attests). A
425
+ // timed-out or signal-killed run is neither red nor green — it lands in `timeouts` (quarantine fuel).
426
+ const probeBound = ({ id, rootTop, env, boundArgv, reruns, timeoutS }) => {
427
+ const { file, pattern } = splitTestId(id);
428
+ const resolved = resolveTestFile(rootTop, file);
429
+ const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
430
+ let executed = 0;
431
+ let greens = 0;
432
+ let reds = 0;
433
+ let timeouts = 0;
434
+ if (resolved.ok && fileHash != null) {
435
+ // ALWAYS spawn the resolver's canonical absolute path — the executed file must be the hashed
436
+ // file independent of runner path semantics (codex R1+R2, BUGFREE-1 live loop): a raw
437
+ // leading-dash filename parses as an OPTION (and a ./-prefix does not survive node's runner
438
+ // normalization), and a raw traversal path like linkdir/../x lets an OS-resolving runner
439
+ // execute a different filesystem target than the lexically-normalized file the hash covers.
440
+ const argv = boundArgv(resolved.abs, pattern);
441
+ for (let i = 0; i < reruns; i += 1) {
442
+ const res = spawnSync(argv[0], argv.slice(1), {
443
+ cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
444
+ });
445
+ if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
446
+ timeouts += 1;
447
+ continue;
448
+ }
449
+ const p = parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file });
450
+ executed = Math.max(executed, p.executed);
451
+ if (!p.resolvable) continue; // an unresolved run (the pattern selected nothing)
452
+ if (p.baselineGreen) greens += 1;
453
+ else reds += 1;
454
+ }
455
+ }
456
+ const entry = {
457
+ id, executed, runs: reruns, greens, reds, timeouts, fileHash,
458
+ resolvable: greens + reds === reruns, baselineGreen: greens === reruns,
459
+ };
460
+ return { entry, resolveReason: resolved.ok ? (fileHash == null ? `cannot read "${file}"` : null) : resolved.reason };
461
+ };
462
+
391
463
  // runFoldCompleteness({ cwd, env, suiteCmd }) → { writtenPath, record }. THROWS a typed STOP (loop
392
464
  // derivation / suite discovery / a malformed record / an fs error) or a native fs error.
393
465
  export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, suiteCmd } = {}) => {
@@ -430,28 +502,33 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
430
502
  for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
431
503
  }
432
504
 
433
- // Decision 3 / 10: probe each fixable-bug bound testId once (shell-free).
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.
434
508
  const ledgerPath = resolveLedgerPath(cwd, env);
435
509
  const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
436
- const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop });
437
- const testIds = boundTestIds.map((id) => {
438
- const at = id.indexOf('#');
439
- const file = id.slice(0, at);
440
- const pattern = id.slice(at + 1);
441
- const argv = boundArgv(file, pattern);
442
- const res = spawnSync(argv[0], argv.slice(1), { cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
443
- return { id, ...parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file }) };
444
- });
510
+ const base = resolveBase(cwd);
511
+ const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
512
+ const testIds = boundTestIds.map(
513
+ (id) => probeBound({ id, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
514
+ );
515
+
516
+ // Approach-3: the oracle-tamper pass over the tracked working-vs-HEAD diff, restricted to the
517
+ // test surface the bound-testId file halves. Recorded; the checker enforces overrides.
518
+ const tamper = computeTamperedTests(rootTop, new Set(boundTestIds.map((id) => splitTestId(id).file)));
445
519
 
446
520
  const record = {
447
521
  schema: RESULT_SCHEMA_VERSION,
522
+ kind: 'run',
448
523
  loop,
524
+ base,
449
525
  fingerprint,
450
526
  boundTestIds,
451
527
  testIds,
452
528
  unsupported,
453
529
  outOfDomain,
454
530
  coverage: { uncoveredChanged },
531
+ tamper,
455
532
  mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
456
533
  budgets,
457
534
  timestamp: isoNow(),
@@ -464,28 +541,109 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
464
541
  return appendRecord(resultsPath, record);
465
542
  };
466
543
 
544
+ // ── the --red verb (D6): observe RED when it actually happens, mint the custody receipt ──────────
545
+
546
+ // runRedProbe({ cwd, env, testId }) → { writtenPath, record }. Observes `testId` on the CURRENT
547
+ // (pre-fold) tree: resolvable + failing on N/N runs → appends a red-probe receipt (testId, counts,
548
+ // the test file's content hash, fingerprint, timestamp) to the fold results ledger. Observed green,
549
+ // unresolvable, mixed, or timed out → a typed refusal DISTINGUISHED by name, and NOTHING is written
550
+ // (D4: mixed/timeout is QUARANTINE — it never converts and has no override lane). No triage-order
551
+ // requirement: the checker joins receipts to the bound set at gate time (D6).
552
+ export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
553
+ if (!isWellFormedTestId(testId)) {
554
+ throw usageFail(`--red needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
555
+ }
556
+ const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
557
+ if (root == null) throw stop('not a git work tree — nothing to observe');
558
+ const rootTop = root.replace(/\r?\n$/, '');
559
+ const plans = plansInFlight(rootTop);
560
+ if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to observe');
561
+ if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
562
+ const loop = plans[0].replace(/\.md$/, '');
563
+
564
+ const boundArgv = resolveBoundArgv(env);
565
+ const budgets = budgetsFromEnv(env);
566
+ const { entry, resolveReason } = probeBound({ id: testId, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS });
567
+ const verdict = probeVerdict(entry);
568
+ 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)`;
569
+ if (verdict === 'unresolvable') {
570
+ throw stop(
571
+ `--red refused for "${testId}": unresolvable — ${resolveReason ?? 'the pattern selects no test'} (${counts}). ` +
572
+ `If the test cannot even LOAD pre-fold (it imports an export the fix introduces), author it with a dynamic import() so it loads and FAILS pre-fold; ` +
573
+ `if the red is genuinely unestablishable, the loud escape is a recorded red-proof override (review-ledger-write override). Nothing was recorded.`,
574
+ );
575
+ }
576
+ if (verdict === 'green') {
577
+ throw stop(
578
+ `--red refused for "${testId}": observed GREEN on ${entry.greens}/${entry.runs} runs — the test does not fail on the current (pre-fold) tree, so it proves nothing about the fix. ` +
579
+ `Write a test that FAILS before the fix is applied, then re-run --red BEFORE folding the fix. Nothing was recorded.`,
580
+ );
581
+ }
582
+ if (verdict === 'quarantine') {
583
+ const flavor = entry.timeouts > 0
584
+ ? `${entry.timeouts} of ${entry.runs} probe run(s) timed out (AW_FOLD_PROBE_TIMEOUT_S=${budgets.probeTimeoutS}) — a timed-out run is neither red nor green`
585
+ : `mixed outcomes (${counts}) — a flaky test can launder a fake red`;
586
+ throw stop(
587
+ `--red refused for "${testId}": QUARANTINE — ${flavor}. QUARANTINE never converts and has no override lane: ` +
588
+ `${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
589
+ );
590
+ }
591
+
592
+ const record = {
593
+ schema: RESULT_SCHEMA_VERSION,
594
+ kind: 'red-probe',
595
+ loop,
596
+ base: resolveBase(cwd), // the SEGMENT frame (D7): a receipt attests red within its segment
597
+ testId,
598
+ fileHash: entry.fileHash,
599
+ runs: entry.runs,
600
+ reds: entry.reds,
601
+ fingerprint: computeTreeFingerprint(cwd),
602
+ timestamp: isoNow(),
603
+ };
604
+ const v = validateRunRecord(record);
605
+ if (!v.ok) throw stop(`refusing to write a malformed red-probe record: ${v.reason}`);
606
+ const resultsPath = resolveResultsPath(cwd, env);
607
+ if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
608
+ return appendRecord(resultsPath, record);
609
+ };
610
+
467
611
  // ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
468
612
 
469
- const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046).
613
+ const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046 + AD-047).
470
614
 
471
615
  Usage:
472
616
  node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
473
-
474
- Runs the in-flight plan-execution loop's suite ONCE under coverage, maps every changed executable line
475
- to covered/uncovered, probes each fixable-bug bound testId for resolvability + a green baseline, and
476
- appends one result record to <git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS
477
- overrides). The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
617
+ node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
618
+
619
+ The default run: runs the in-flight plan-execution loop's suite ONCE under coverage, maps every
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
624
+ <git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
625
+
626
+ --red observes a testId RED on the CURRENT (pre-fold) tree — the honest fold-time order is: classify
627
+ the fixable-bug with its testId → write the test → --red observes it FAIL (N/N) BEFORE the fix is
628
+ applied → fold the fix → the normal run observes green → the gate checks receipt + order + custody.
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 /
631
+ unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
632
+ (mixed/timeout = QUARANTINE — never converts, no override lane).
478
633
 
479
634
  Suite command: --suite "<cmd>" or AW_FOLD_SUITE_CMD, else the unit-tests gate cmd in docs/ai/gates.json.
480
- Bound-test runs default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
481
- with a JSON argv array using {file}/{pattern}. Budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX /
482
- AW_FOLD_TIME_BUDGET_S (recorded but inert the mutation half is not shipped).
635
+ Bound-test probes default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
636
+ with a JSON argv array using {file}/{pattern}. Probe knobs (fail-closed positive integers):
637
+ AW_FOLD_RERUNS (default 3) · AW_FOLD_PROBE_TIMEOUT_S (default 120, per probe RUN, probes only).
638
+ Inert budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX / AW_FOLD_TIME_BUDGET_S (mutation shelved).
483
639
 
484
- Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / malformed record / fs error);
485
- 2 usage.`;
640
+ The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
641
+
642
+ Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / a --red refusal / malformed
643
+ record / fs error); 2 usage.`;
486
644
 
487
645
  const parseArgs = (argv) => {
488
- const opts = { cwd: undefined, suite: undefined };
646
+ const opts = { cwd: undefined, suite: undefined, red: undefined };
489
647
  for (let i = 0; i < argv.length; i += 1) {
490
648
  const a = argv[i];
491
649
  if (a === '--cwd') {
@@ -496,6 +654,10 @@ const parseArgs = (argv) => {
496
654
  opts.suite = argv[i + 1];
497
655
  if (opts.suite === undefined) throw usageFail('--suite needs a command');
498
656
  i += 1;
657
+ } else if (a === '--red') {
658
+ opts.red = argv[i + 1];
659
+ if (opts.red === undefined) throw usageFail('--red needs a testId ("<test-file>#<test-name-pattern>")');
660
+ i += 1;
499
661
  } else {
500
662
  throw usageFail(`unknown argument: ${a}`);
501
663
  }
@@ -509,7 +671,16 @@ export const main = (argv, ctx = {}) => {
509
671
  try {
510
672
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
511
673
  const opts = parseArgs(argv);
512
- const { writtenPath, record } = runFoldCompleteness({ cwd: opts.cwd ?? cwd0, env, suiteCmd: opts.suite });
674
+ const cwd = opts.cwd ?? cwd0;
675
+ if (opts.red !== undefined) {
676
+ const { writtenPath, record } = runRedProbe({ cwd, env, testId: opts.red });
677
+ return {
678
+ code: 0,
679
+ stdout: `fold-completeness-run: minted a red-probe receipt for "${record.testId}" (loop "${record.loop}", ${record.reds}/${record.runs} observed red, hash ${record.fileHash.slice(0, 12)}…) → ${writtenPath}`,
680
+ stderr: '',
681
+ };
682
+ }
683
+ const { writtenPath, record } = runFoldCompleteness({ cwd, env, suiteCmd: opts.suite });
513
684
  const uncovered = record.coverage.uncoveredChanged.length;
514
685
  const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;
515
686
  return {