@sabaiway/agent-workflow-kit 1.37.1 → 1.38.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,14 @@
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
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 v2 run record, bound to BOTH the tree fingerprint AND the sorted
18
19
  // fixable-bug testId set (Decision 9), to <git dir>/agent-workflow-fold-completeness.jsonl.
20
+ // A SECOND verb, --red "<testId>" (BUGFREE-1 / AD-047), observes a testId RED on the current
21
+ // (pre-fold) tree and mints a red-probe receipt — the observed-red half of the honest red→green
22
+ // proof; observed-green / unresolvable / mixed / timed-out are distinguished refusals, nothing written.
19
23
  // The researched mutation half (M3b) was SHELVED — bounded local-boundary mutation adds too little
20
24
  // over coverage and is not language-independent — so the `mutation` field stays the reserved empty shape.
21
25
  //
@@ -24,18 +28,20 @@
24
28
  // TS/JSX source is out of scope v1. Dependency-free, Node >= 18. No side effects on import.
25
29
 
26
30
  import { readFileSync, readdirSync, mkdtempSync, rmSync, realpathSync, lstatSync } from 'node:fs';
27
- import { join, dirname, basename } from 'node:path';
31
+ import { join, dirname, basename, isAbsolute, normalize, posix, sep } from 'node:path';
28
32
  import { tmpdir } from 'node:os';
29
33
  import { pathToFileURL, fileURLToPath } from 'node:url';
30
34
  import { spawnSync } from 'node:child_process';
35
+ import { createHash } from 'node:crypto';
31
36
  import { writeContainedFileAtomic } from './atomic-write.mjs';
32
37
  import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
33
- import { resolveLedgerPath, readLedger } from './review-ledger.mjs';
38
+ import { resolveLedgerPath, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
34
39
  import {
35
40
  RESULT_SCHEMA_VERSION,
36
41
  resolveResultsPath,
37
42
  validateRunRecord,
38
43
  collectBoundTestIds,
44
+ probeVerdict,
39
45
  } from './fold-completeness.mjs';
40
46
 
41
47
  const ACTIVITY = 'plan-execution';
@@ -98,7 +104,7 @@ export const parseUnifiedDiff = (diffText) => {
98
104
  }
99
105
  if (inHeader && line.startsWith('--- ')) continue;
100
106
  if (inHeader && line.startsWith('+++ ')) {
101
- const p = line.slice(4).trim();
107
+ const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes (agy R6)
102
108
  current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
103
109
  inHeader = false;
104
110
  continue;
@@ -213,6 +219,59 @@ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
213
219
  // defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a shell).
214
220
  export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', '--test-name-pattern', pattern, file];
215
221
 
222
+ // ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
223
+ // Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
224
+ // deliberately suffix-free and format-only (review-ledger.mjs), so path safety lives here: the file
225
+ // half must be repo-relative (absolute + parent-escaping refused), a REGULAR file under the no-follow
226
+ // lstat discipline, and its RESOLVED real path must be contained under the REAL repo root — a leaf
227
+ // check alone would let a symlinked PARENT directory escape the work tree.
228
+
229
+ // resolveTestFile(rootTop, rel, deps?) → { ok: true, abs } | { ok: false, reason } (never throws).
230
+ // deps.{lstat,realpath} are injectable so the defensive fs-race catch is unit-testable (the family
231
+ // deps idiom — review-ledger-write.mjs).
232
+ export const resolveTestFile = (rootTop, rel, deps = {}) => {
233
+ const lstat = deps.lstat ?? lstatSync;
234
+ const realpath = deps.realpath ?? realpathSync;
235
+ if (typeof rel !== 'string' || rel.length === 0) return { ok: false, reason: 'empty file path' };
236
+ if (isAbsolute(rel)) return { ok: false, reason: `absolute path "${rel}" — the testId file half must be repo-relative` };
237
+ const norm = normalize(rel);
238
+ if (norm === '..' || norm.startsWith(`..${sep}`)) return { ok: false, reason: `path "${rel}" escapes the repo root` };
239
+ const abs = join(rootTop, norm);
240
+ let st;
241
+ try {
242
+ st = lstat(abs);
243
+ } catch {
244
+ return { ok: false, reason: `file "${rel}" does not exist` };
245
+ }
246
+ if (!st.isFile()) return { ok: false, reason: `"${rel}" is not a regular file (a symlink/directory/device is never followed — fail closed)` };
247
+ let realAbs;
248
+ let realRoot;
249
+ try {
250
+ realAbs = realpath(abs);
251
+ realRoot = realpath(rootTop);
252
+ } catch {
253
+ return { ok: false, reason: `cannot resolve the real path of "${rel}"` };
254
+ }
255
+ if (!containsPath(realRoot, realAbs)) return { ok: false, reason: `"${rel}" resolves outside the repo root (a symlinked parent directory) — fail closed` };
256
+ return { ok: true, abs: realAbs };
257
+ };
258
+
259
+ // containsPath(realRoot, realAbs) → realAbs is strictly INSIDE realRoot. Segment-safe ('/a' never
260
+ // contains '/ab') and correct for a repo at the filesystem root, where realRoot already ends with
261
+ // the separator ('/'+sep would be '//' and reject every valid path — agy R1).
262
+ export const containsPath = (realRoot, realAbs) => realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
263
+
264
+ // The D5 custody hash: sha-256 over the file's BYTES (no encoding normalization). null on a read
265
+ // failure — the caller reads that as an unresolvable file (exported for the unit test of exactly
266
+ // that fail-closed edge).
267
+ export const hashFileBytes = (abs) => {
268
+ try {
269
+ return createHash('sha256').update(readFileSync(abs)).digest('hex');
270
+ } catch {
271
+ return null;
272
+ }
273
+ };
274
+
216
275
  // resolveBoundArgv(env) → (file, pattern) => argv[]. Default = the node:test shape; AW_FOLD_BOUND_CMD
217
276
  // overrides with a JSON array of argv strings using {file}/{pattern} placeholders (the universality
218
277
  // escape hatch — a consumer on another runner). A malformed override is a typed refusal, never a
@@ -234,6 +293,11 @@ export const resolveBoundArgv = (env = process.env) => {
234
293
 
235
294
  // ── the changed surface (git-driven) ──────────────────────────────────────────────────────────────
236
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/'];
300
+
237
301
  const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
238
302
  const gitStdout = (args, cwd) => {
239
303
  const r = runGit(args, cwd);
@@ -269,8 +333,8 @@ const isRegularLeaf = (abs) => {
269
333
  // classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0`; an untracked file
270
334
  // is wholly new, so all its lines are "changed".
271
335
  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)
336
+ const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
337
+ ?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
274
338
  ?? '';
275
339
  const trackedLines = parseUnifiedDiff(trackedDiff);
276
340
  const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
@@ -309,6 +373,109 @@ export const computeChangedSurface = (root) => {
309
373
  return { assessable, unsupported: unsupported.sort(), outOfDomain: outOfDomain.sort() };
310
374
  };
311
375
 
376
+ // ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
377
+
378
+ const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
379
+
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
+ // parseDiffOldSide(diffText) → Map<oldRel, { removals: boolean }> — the OLD-side view of a -U0
416
+ // tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
417
+ // old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
418
+ // DELETED file's hunks remove every line, so it reads removals: true. Renames under --no-renames
419
+ // read as delete+add — the delete side lands here (stated).
420
+ export const parseDiffOldSide = (diffText) => {
421
+ const map = new Map();
422
+ let current = null;
423
+ let inHeader = false;
424
+ for (const line of String(diffText).split('\n')) {
425
+ if (line.startsWith('diff --git ')) {
426
+ current = null;
427
+ inHeader = true;
428
+ continue;
429
+ }
430
+ if (inHeader && line.startsWith('--- ')) {
431
+ // Strip ONLY the git-appended trailing TAB (space-carrying paths) and a CRLF \r — never a
432
+ // legitimate trailing character of the filename itself (agy R6; a raw TAB in a name is
433
+ // C-quoted anyway, so [\t\r]+$ can only match git artifacts).
434
+ const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, ''));
435
+ current = p === '/dev/null' ? null : p.startsWith('a/') ? p.slice(2) : p;
436
+ if (current != null && !map.has(current)) map.set(current, { removals: false });
437
+ continue;
438
+ }
439
+ if (inHeader && line.startsWith('+++ ')) {
440
+ inHeader = false;
441
+ continue;
442
+ }
443
+ const m = DIFF_HUNK_OLD_RE.exec(line);
444
+ if (m) {
445
+ inHeader = false;
446
+ if (current == null) continue;
447
+ const oldCount = m[1] === undefined ? 1 : Number(m[1]);
448
+ if (oldCount > 0) map.get(current).removals = true;
449
+ }
450
+ }
451
+ return map;
452
+ };
453
+
454
+ // computeTamperedTests(root, boundFiles) → { tampered: [rel...] } — the tamper surface is the union
455
+ // of test-classified paths (TEST_FILE_RE) and the loop's bound-testId file halves (the testId format
456
+ // carries no suffix rule, so a bound test at a nonstandard path must not escape the guard),
457
+ // restricted to files that exist at HEAD (having an old side in the tracked diff IS existing at
458
+ // HEAD). Tampered = any removed/modified line; pure additions and new/untracked files never trip it
459
+ // (widening to any-change-is-tamper would flag the standard append-a-new-test flow — the FP churn
460
+ // this series exists to kill).
461
+ export const computeTamperedTests = (root, boundFiles = new Set()) => {
462
+ const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
463
+ ?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
464
+ ?? '';
465
+ // The file halves are user-authored — './checks/x.mjs' probes and hashes as 'checks/x.mjs', so
466
+ // the surface compares NORMALIZED halves or a modified bound file escapes the guard (codex R5).
467
+ // Normalization happens in GIT/POSIX path space (codex+agy R6): node's OS-local normalize emits
468
+ // backslashes on Windows while git diff paths stay slash-separated — the compare must never
469
+ // depend on the host separator.
470
+ const boundSet = new Set([...boundFiles].map((f) => posix.normalize(f)));
471
+ const tampered = [];
472
+ for (const [rel, info] of parseDiffOldSide(trackedDiff)) {
473
+ if (!info.removals) continue;
474
+ if (classifyChangedPath(rel) === 'excluded-test' || boundSet.has(rel)) tampered.push(rel);
475
+ }
476
+ return { tampered: tampered.sort() };
477
+ };
478
+
312
479
  // ── coverage (run the suite once under NODE_V8_COVERAGE, outside the tree) ─────────────────────────
313
480
 
314
481
  // readCoverage(covDir) → Map<canonicalAbsPath, Array<Array<range>>> — one flat range-list per process.
@@ -382,12 +549,71 @@ const appendRecord = (resultsPath, record) => {
382
549
 
383
550
  // ── the run ───────────────────────────────────────────────────────────────────────────────────────
384
551
 
385
- const budgetsFromEnv = (env) => ({
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
+ export const budgetsFromEnv = (env) => ({
386
565
  mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
387
566
  hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
388
567
  timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
568
+ // 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),
389
572
  });
390
573
 
574
+ // ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
575
+
576
+ // probeBound({ id, rootTop, env, boundArgv, reruns, timeoutS }) → { entry, resolveReason }. The entry
577
+ // is the v2 per-testId record shape: rerun counts (evidence) + the custody content hash + the derived
578
+ // booleans. The custody hash is taken BEFORE the runs (the content the observation attests). A
579
+ // 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 }) => {
581
+ const { file, pattern } = splitTestId(id);
582
+ const resolved = resolveTestFile(rootTop, file);
583
+ const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
584
+ let executed = 0;
585
+ let greens = 0;
586
+ let reds = 0;
587
+ let timeouts = 0;
588
+ 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
+ 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;
602
+ }
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
+ }
609
+ }
610
+ const entry = {
611
+ id, executed, runs: reruns, greens, reds, timeouts, fileHash,
612
+ resolvable: greens + reds === reruns, baselineGreen: greens === reruns,
613
+ };
614
+ return { entry, resolveReason: resolved.ok ? (fileHash == null ? `cannot read "${file}"` : null) : resolved.reason };
615
+ };
616
+
391
617
  // runFoldCompleteness({ cwd, env, suiteCmd }) → { writtenPath, record }. THROWS a typed STOP (loop
392
618
  // derivation / suite discovery / a malformed record / an fs error) or a native fs error.
393
619
  export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, suiteCmd } = {}) => {
@@ -430,21 +656,21 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
430
656
  for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
431
657
  }
432
658
 
433
- // Decision 3 / 10: probe each fixable-bug bound testId once (shell-free).
659
+ // Decision 3 / 10 + D4: probe each fixable-bug bound testId N times (shell-free, per-run timeout).
434
660
  const ledgerPath = resolveLedgerPath(cwd, env);
435
661
  const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
436
662
  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
- });
663
+ const testIds = boundTestIds.map(
664
+ (id) => probeBound({ id, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
665
+ );
666
+
667
+ // Approach-3: the oracle-tamper pass over the tracked working-vs-HEAD diff, restricted to the
668
+ // test surface the bound-testId file halves. Recorded; the checker enforces overrides.
669
+ const tamper = computeTamperedTests(rootTop, new Set(boundTestIds.map((id) => splitTestId(id).file)));
445
670
 
446
671
  const record = {
447
672
  schema: RESULT_SCHEMA_VERSION,
673
+ kind: 'run',
448
674
  loop,
449
675
  fingerprint,
450
676
  boundTestIds,
@@ -452,6 +678,7 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
452
678
  unsupported,
453
679
  outOfDomain,
454
680
  coverage: { uncoveredChanged },
681
+ tamper,
455
682
  mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
456
683
  budgets,
457
684
  timestamp: isoNow(),
@@ -464,28 +691,106 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
464
691
  return appendRecord(resultsPath, record);
465
692
  };
466
693
 
694
+ // ── the --red verb (D6): observe RED when it actually happens, mint the custody receipt ──────────
695
+
696
+ // runRedProbe({ cwd, env, testId }) → { writtenPath, record }. Observes `testId` on the CURRENT
697
+ // (pre-fold) tree: resolvable + failing on N/N runs → appends a red-probe receipt (testId, counts,
698
+ // the test file's content hash, fingerprint, timestamp) to the fold results ledger. Observed green,
699
+ // unresolvable, mixed, or timed out → a typed refusal DISTINGUISHED by name, and NOTHING is written
700
+ // (D4: mixed/timeout is QUARANTINE — it never converts and has no override lane). No triage-order
701
+ // requirement: the checker joins receipts to the bound set at gate time (D6).
702
+ export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
703
+ if (!isWellFormedTestId(testId)) {
704
+ throw usageFail(`--red needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
705
+ }
706
+ const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
707
+ if (root == null) throw stop('not a git work tree — nothing to observe');
708
+ const rootTop = root.replace(/\r?\n$/, '');
709
+ const plans = plansInFlight(rootTop);
710
+ if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to observe');
711
+ if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
712
+ const loop = plans[0].replace(/\.md$/, '');
713
+
714
+ const boundArgv = resolveBoundArgv(env);
715
+ const budgets = budgetsFromEnv(env);
716
+ const { entry, resolveReason } = probeBound({ id: testId, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS });
717
+ const verdict = probeVerdict(entry);
718
+ 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
+ if (verdict === 'unresolvable') {
720
+ throw stop(
721
+ `--red refused for "${testId}": unresolvable — ${resolveReason ?? 'the pattern selects no test'} (${counts}). ` +
722
+ `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; ` +
723
+ `if the red is genuinely unestablishable, the loud escape is a recorded red-proof override (review-ledger-write override). Nothing was recorded.`,
724
+ );
725
+ }
726
+ if (verdict === 'green') {
727
+ throw stop(
728
+ `--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. ` +
729
+ `Write a test that FAILS before the fix is applied, then re-run --red BEFORE folding the fix. Nothing was recorded.`,
730
+ );
731
+ }
732
+ if (verdict === 'quarantine') {
733
+ const flavor = entry.timeouts > 0
734
+ ? `${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`
735
+ : `mixed outcomes (${counts}) — a flaky test can launder a fake red`;
736
+ throw stop(
737
+ `--red refused for "${testId}": QUARANTINE — ${flavor}. QUARANTINE never converts and has no override lane: ` +
738
+ `${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
739
+ );
740
+ }
741
+
742
+ const record = {
743
+ schema: RESULT_SCHEMA_VERSION,
744
+ kind: 'red-probe',
745
+ loop,
746
+ testId,
747
+ fileHash: entry.fileHash,
748
+ runs: entry.runs,
749
+ reds: entry.reds,
750
+ fingerprint: computeTreeFingerprint(cwd),
751
+ timestamp: isoNow(),
752
+ };
753
+ const v = validateRunRecord(record);
754
+ if (!v.ok) throw stop(`refusing to write a malformed red-probe record: ${v.reason}`);
755
+ const resultsPath = resolveResultsPath(cwd, env);
756
+ if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
757
+ return appendRecord(resultsPath, record);
758
+ };
759
+
467
760
  // ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
468
761
 
469
- const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046).
762
+ const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046 + AD-047).
470
763
 
471
764
  Usage:
472
765
  node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
766
+ node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
767
+
768
+ 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
772
+ <git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
473
773
 
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.
774
+ --red observes a testId RED on the CURRENT (pre-fold) tree the honest fold-time order is: classify
775
+ the fixable-bug with its testId write the test --red observes it FAIL (N/N) BEFORE the fix is
776
+ 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 /
778
+ unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
779
+ (mixed/timeout = QUARANTINE — never converts, no override lane).
478
780
 
479
781
  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).
782
+ Bound-test probes default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
783
+ with a JSON argv array using {file}/{pattern}. Probe knobs (fail-closed positive integers):
784
+ AW_FOLD_RERUNS (default 3) · AW_FOLD_PROBE_TIMEOUT_S (default 120, per probe RUN, probes only).
785
+ Inert budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX / AW_FOLD_TIME_BUDGET_S (mutation shelved).
786
+
787
+ The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
483
788
 
484
- Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / malformed record / fs error);
485
- 2 usage.`;
789
+ Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / a --red refusal / malformed
790
+ record / fs error); 2 usage.`;
486
791
 
487
792
  const parseArgs = (argv) => {
488
- const opts = { cwd: undefined, suite: undefined };
793
+ const opts = { cwd: undefined, suite: undefined, red: undefined };
489
794
  for (let i = 0; i < argv.length; i += 1) {
490
795
  const a = argv[i];
491
796
  if (a === '--cwd') {
@@ -496,6 +801,10 @@ const parseArgs = (argv) => {
496
801
  opts.suite = argv[i + 1];
497
802
  if (opts.suite === undefined) throw usageFail('--suite needs a command');
498
803
  i += 1;
804
+ } else if (a === '--red') {
805
+ opts.red = argv[i + 1];
806
+ if (opts.red === undefined) throw usageFail('--red needs a testId ("<test-file>#<test-name-pattern>")');
807
+ i += 1;
499
808
  } else {
500
809
  throw usageFail(`unknown argument: ${a}`);
501
810
  }
@@ -509,7 +818,16 @@ export const main = (argv, ctx = {}) => {
509
818
  try {
510
819
  if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
511
820
  const opts = parseArgs(argv);
512
- const { writtenPath, record } = runFoldCompleteness({ cwd: opts.cwd ?? cwd0, env, suiteCmd: opts.suite });
821
+ const cwd = opts.cwd ?? cwd0;
822
+ if (opts.red !== undefined) {
823
+ const { writtenPath, record } = runRedProbe({ cwd, env, testId: opts.red });
824
+ return {
825
+ code: 0,
826
+ 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}`,
827
+ stderr: '',
828
+ };
829
+ }
830
+ const { writtenPath, record } = runFoldCompleteness({ cwd, env, suiteCmd: opts.suite });
513
831
  const uncovered = record.coverage.uncoveredChanged.length;
514
832
  const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;
515
833
  return {