@mjasnikovs/pi-task 0.18.30 → 0.18.31

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.
@@ -45,6 +45,14 @@ export interface AutoDeps extends GateDeps {
45
45
  * Leave-failed / Accept, exactly the pre-autofix behavior.
46
46
  */
47
47
  finalGateFix?: FinalGateFixFn;
48
+ /**
49
+ * Paths currently uncommitted in the working tree (`git status` shape), used to
50
+ * detect SUB-FIXES a non-converging final-gate autofix left behind (mx5 run 13
51
+ * PROMPT 4 item 3). Every task is committed by the time the final gate runs, so
52
+ * anything dirty here is the fix pass's own work. Absent → the stranded-fix
53
+ * handling is skipped entirely (prior behavior).
54
+ */
55
+ pendingChanges?: (cwd: string) => Promise<string[]>;
48
56
  }
49
57
  /**
50
58
  * Expand any @file references in the feature text by appending each referenced
@@ -24,12 +24,12 @@ import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../rem
24
24
  import { pushNotify } from '../remote/push.js';
25
25
  import { startAutoLoader } from './widget.js';
26
26
  import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
27
- import { buildGateDeps } from './gate-deps.js';
27
+ import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
28
28
  import { runGatesForTask } from './task-gates.js';
29
29
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
30
30
  import { runFinalIntegrationGate } from './final-gate.js';
31
31
  import { describeDebt } from './accept-debt.js';
32
- import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
32
+ import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE, STRANDED_FIX_COMMIT, strandedFixNote } from './final-gate-fix.js';
33
33
  import { getConfig } from '../config/config.js';
34
34
  import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
35
35
  import { configureResearchRun } from '../workers/research-cache.js';
@@ -904,7 +904,14 @@ function defaultDeps(ctx, cwd, signal, title) {
904
904
  // run-level half of the same verification story.
905
905
  finalGate: (cwd2, planText) => getConfig().verifyWork ?
906
906
  runFinalIntegrationGate(cwd2, undefined, undefined, undefined, planText)
907
- : Promise.resolve({ ok: true, reason: 'disabled' })
907
+ : Promise.resolve({ ok: true, reason: 'disabled' }),
908
+ // Uncommitted paths, for the stranded-sub-fix handling around the final-gate
909
+ // picker (mx5 run 13 PROMPT 4 item 3). Every task is committed by the time
910
+ // the gate runs, so whatever is dirty here belongs to the fix pass.
911
+ pendingChanges: async (cwd2) => {
912
+ const changes = await collectTreeChanges(cwd2, signal);
913
+ return [...changes.modified, ...changes.added, ...changes.deleted].sort();
914
+ }
908
915
  };
909
916
  }
910
917
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -1009,6 +1016,21 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1009
1016
  // after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
1010
1017
  // autofix card is withdrawn so the loop cannot run unbounded.
1011
1018
  let fixAttempts = 0;
1019
+ // Sub-fixes a non-converging autofix attempt left uncommitted.
1020
+ // Refreshed after every attempt; drives the picker note and the
1021
+ // accept-time commit (mx5 run 13 PROMPT 4 item 3).
1022
+ let stranded = [];
1023
+ const refreshStranded = async () => {
1024
+ if (!deps.pendingChanges)
1025
+ return;
1026
+ try {
1027
+ stranded = await deps.pendingChanges(cwd);
1028
+ }
1029
+ catch {
1030
+ // Inconclusive: say nothing rather than claim a clean tree.
1031
+ stranded = [];
1032
+ }
1033
+ };
1012
1034
  while (!fin.ok) {
1013
1035
  const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
1014
1036
  // The picker question shows the debts (the HUMAN weighs them);
@@ -1019,7 +1041,11 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1019
1041
  + '(the project’s own test/build/static commands, run unaided).'
1020
1042
  + (fixAttempts > 0 ?
1021
1043
  `\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
1022
- : '');
1044
+ : '')
1045
+ // Never let a partial repair be invisible at the moment
1046
+ // the human decides (run 13: a bunfig fix that made
1047
+ // `bun run test` pass 116/116 was stranded by an ACCEPT).
1048
+ + strandedFixNote(stranded);
1023
1049
  // YOLO: keep autofixing WHILE the card is still offered — the
1024
1050
  // loop withdraws it after MAX_FINAL_GATE_AUTOFIX, so the cap
1025
1051
  // that bounds a non-converging fix pass still bounds this —
@@ -1058,7 +1084,30 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1058
1084
  const choice = classifyFinalGateAnswer(answer);
1059
1085
  if (choice.action === 'accept') {
1060
1086
  await recGate('final-gate: FAIL accepted by user');
1061
- active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`, 'warning');
1087
+ // STRANDED SUB-FIXES: the run completes here, so anything
1088
+ // the fix pass repaired but never committed would be lost
1089
+ // to the next `git checkout` while HEAD keeps the defect
1090
+ // it fixed. Commit it as its own, named commit — the
1091
+ // ACCEPT is a decision about the FAILING gate, never an
1092
+ // instruction to throw away work (mx5 run 13 item 3).
1093
+ if (stranded.length > 0) {
1094
+ try {
1095
+ const sha = await deps.commit(cwd, STRANDED_FIX_COMMIT(id));
1096
+ await recGate(`final-gate: committed ${stranded.length} stranded fix-pass change(s)`
1097
+ + `${sha ? ` as ${sha}` : ''} — ${stranded.slice(0, 8).join(', ')}`);
1098
+ }
1099
+ catch (err) {
1100
+ // Never break the completion path over this — but
1101
+ // say so, so the changes are not silently lost.
1102
+ await recGate(`final-gate: could NOT commit ${stranded.length} stranded fix-pass `
1103
+ + `change(s) (${err instanceof Error ? err.message : String(err)}) — `
1104
+ + `they remain UNCOMMITTED in the working tree: ${stranded.slice(0, 8).join(', ')}`);
1105
+ }
1106
+ }
1107
+ active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`
1108
+ + (stranded.length > 0 ?
1109
+ ` ${stranded.length} uncommitted fix-pass change(s) committed separately.`
1110
+ : ''), 'warning');
1062
1111
  break;
1063
1112
  }
1064
1113
  if (choice.action === 'autofix' && canAutofix) {
@@ -1077,6 +1126,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1077
1126
  break;
1078
1127
  }
1079
1128
  await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
1129
+ // The attempt's edits survive a non-convergence (only a
1130
+ // guard trip discards). Find out what they are NOW, so
1131
+ // the next picker shows them and an ACCEPT can commit them.
1132
+ await refreshStranded();
1133
+ if (stranded.length > 0) {
1134
+ await recGate(`final-gate: autofix attempt ${fixAttempts} left ${stranded.length} `
1135
+ + `uncommitted change(s) — ${stranded.slice(0, 8).join(', ')}`);
1136
+ }
1080
1137
  active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
1081
1138
  // Work from the FRESH gate failure when the fix pass got
1082
1139
  // as far as re-running the gate; otherwise keep the last.
@@ -1102,8 +1159,19 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1102
1159
  await recGate(yoloFinal !== null ?
1103
1160
  `final-gate: left failed — autofix budget spent, nobody to ask ${YOLO_STAMP}`
1104
1161
  : 'final-gate: left failed (user)');
1162
+ // Leaving the run failed hands the working tree back to the
1163
+ // user, so uncommitted fix-pass edits are theirs to keep or
1164
+ // drop — but they must be VISIBLE, not discovered later by a
1165
+ // stray `git status` (mx5 run 13 item 3).
1166
+ if (stranded.length > 0) {
1167
+ await recGate(`final-gate: ${stranded.length} uncommitted fix-pass change(s) left in the `
1168
+ + `working tree — ${stranded.slice(0, 8).join(', ')}`);
1169
+ }
1105
1170
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1106
- announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`, 'error');
1171
+ announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`
1172
+ + (stranded.length > 0 ?
1173
+ ` NOTE: ${stranded.length} uncommitted fix-pass change(s) are in your working tree (${stranded.slice(0, 4).join(', ')}).`
1174
+ : ''), 'error');
1107
1175
  return;
1108
1176
  }
1109
1177
  }
@@ -58,6 +58,31 @@ export declare function parseFinalFixMarker(text: string): {
58
58
  blocked: boolean;
59
59
  note?: string;
60
60
  };
61
+ /**
62
+ * STRANDED SUB-FIXES (mx5 run 13, PROMPT 4 item 3).
63
+ *
64
+ * A fix attempt that does not converge keeps its edits: they are NOT discarded
65
+ * (only a guard trip discards), and `deps.commit` runs only on `fix.ok`. So a
66
+ * partial fix that genuinely repaired something sits in the working tree, uncommitted,
67
+ * and if the user then ACCEPTs the FAIL the run completes around it — leaving HEAD
68
+ * broken while the repair is invisible unless someone runs `git status`.
69
+ *
70
+ * That is exactly what run 13 shipped: the fix child's bunfig.toml change made
71
+ * `bun run test` pass 116/116, attempt 1 did not converge overall, the user accepted
72
+ * the FAIL, and the tree still shows the file modified while HEAD's `bun run test` is
73
+ * broken. The repair and the breakage were BOTH real; only the repair was discarded
74
+ * by default.
75
+ *
76
+ * The rule: a partial fix is either committed (its own commit, named in the trail) or
77
+ * explicitly surfaced — never silently stranded.
78
+ */
79
+ /** Commit subject for partial fixes committed alongside an accepted gate FAIL. */
80
+ export declare const STRANDED_FIX_COMMIT: (runId: string) => string;
81
+ /**
82
+ * The picker/trail line describing what a non-converging fix pass left behind.
83
+ * Empty string when the tree is clean — the caller then says nothing at all.
84
+ */
85
+ export declare function strandedFixNote(paths: string[]): string;
61
86
  export interface FinalFixResult {
62
87
  /** true → the fix child ran AND the re-run gate passed. */
63
88
  ok: boolean;
@@ -161,6 +161,39 @@ export function parseFinalFixMarker(text) {
161
161
  }
162
162
  return { blocked: false, note: last[2].trim() || undefined };
163
163
  }
164
+ /**
165
+ * STRANDED SUB-FIXES (mx5 run 13, PROMPT 4 item 3).
166
+ *
167
+ * A fix attempt that does not converge keeps its edits: they are NOT discarded
168
+ * (only a guard trip discards), and `deps.commit` runs only on `fix.ok`. So a
169
+ * partial fix that genuinely repaired something sits in the working tree, uncommitted,
170
+ * and if the user then ACCEPTs the FAIL the run completes around it — leaving HEAD
171
+ * broken while the repair is invisible unless someone runs `git status`.
172
+ *
173
+ * That is exactly what run 13 shipped: the fix child's bunfig.toml change made
174
+ * `bun run test` pass 116/116, attempt 1 did not converge overall, the user accepted
175
+ * the FAIL, and the tree still shows the file modified while HEAD's `bun run test` is
176
+ * broken. The repair and the breakage were BOTH real; only the repair was discarded
177
+ * by default.
178
+ *
179
+ * The rule: a partial fix is either committed (its own commit, named in the trail) or
180
+ * explicitly surfaced — never silently stranded.
181
+ */
182
+ /** Commit subject for partial fixes committed alongside an accepted gate FAIL. */
183
+ export const STRANDED_FIX_COMMIT = (runId) => `FINAL GATE PARTIAL FIX (${runId}) — accepted with gate still failing`;
184
+ /**
185
+ * The picker/trail line describing what a non-converging fix pass left behind.
186
+ * Empty string when the tree is clean — the caller then says nothing at all.
187
+ */
188
+ export function strandedFixNote(paths) {
189
+ if (paths.length === 0)
190
+ return '';
191
+ const shown = paths.slice(0, 8).join(', ');
192
+ return (`\n\nUNCOMMITTED: the fix pass left ${paths.length} change(s) in the working tree `
193
+ + `(${shown}${paths.length > 8 ? ', …' : ''}). These are NOT in HEAD. Accepting will `
194
+ + `commit them as their own commit so they are not lost; leaving the run failed keeps `
195
+ + `them in your working tree.`);
196
+ }
164
197
  /**
165
198
  * Run one bounded final-gate fix attempt: snapshot discovery → child → write-guard
166
199
  * stack (diff capture → frozen-path revert → deletion guard → shrink guard → probe
@@ -0,0 +1,96 @@
1
+ import type { AddedLine } from './probe-gaming.js';
2
+ /** One leaked absolute path found in a committed file. */
3
+ export interface ForeignPathFinding {
4
+ /** Repo-relative file the leaked path was committed into. */
5
+ file: string;
6
+ /** The absolute path exactly as written in the file. */
7
+ absolute: string;
8
+ /** The repo-relative path its tail resolves to (the repair target). */
9
+ repoPath: string;
10
+ /** The leading segments that are foreign to this host (e.g. `/workspace`). */
11
+ foreignPrefix: string;
12
+ /** The verbatim line carrying the leak, trimmed. */
13
+ line: string;
14
+ }
15
+ /**
16
+ * Resolve a repo-relative path to the real thing it names, honouring the
17
+ * extensionless module specifiers config files use (`src/client/api` →
18
+ * `src/client/api.ts`). Returns the resolved repo-relative path, or null.
19
+ *
20
+ * This mirrors how the tools that CONSUME these paths (vite/tsconfig aliases,
21
+ * bundler resolvers) look them up — without it the mx5 true positive
22
+ * `/workspace/src/client/api` would be missed, since only `src/client/api.ts` exists.
23
+ */
24
+ export declare function resolveRepoPath(rel: string, exists: (rel: string) => boolean): string | null;
25
+ /**
26
+ * Scan a task's added lines for sandbox-leaked absolute paths.
27
+ *
28
+ * `existsOnHost` answers for an ABSOLUTE path; `existsInRepo` for a REPO-RELATIVE
29
+ * one. Both are injected so the detector is pure and unit-testable against a
30
+ * synthetic tree.
31
+ *
32
+ * The tail search strips the FEWEST leading segments that still resolve, so the
33
+ * finding names the smallest foreign prefix (`/workspace`, not `/workspace/src`)
34
+ * and the largest repo-relative target — the reading that matches how a mount
35
+ * actually shadows a tree.
36
+ */
37
+ export declare function findForeignPaths(lines: AddedLine[], existsOnHost: (abs: string) => boolean, existsInRepo: (rel: string) => boolean): ForeignPathFinding[];
38
+ /**
39
+ * The repair for one finding: the leaked absolute path rewritten relative to the
40
+ * FILE that carries it, in the `./`-prefixed form config resolvers expect. A target
41
+ * in a parent directory keeps its `../` form (already relative, no prefix needed).
42
+ */
43
+ export declare function relativeRepairFor(finding: ForeignPathFinding): string;
44
+ /**
45
+ * Apply every finding for ONE file to that file's text, as literal substitutions.
46
+ * Returns the new text and the substitutions made; `text` is returned untouched
47
+ * when nothing applied (the caller then writes nothing).
48
+ *
49
+ * Deliberately a plain string replacement of a path literal for a path literal: it
50
+ * cannot reflow, reformat, or restructure the file, so a rewrite that turns out to
51
+ * be semantically wrong is still trivially reviewable in the diff. A finding whose
52
+ * absolute string is no longer present (the file moved on) is skipped.
53
+ */
54
+ export declare function applyForeignPathRepairs(text: string, findings: ForeignPathFinding[]): {
55
+ text: string;
56
+ applied: Array<{
57
+ from: string;
58
+ to: string;
59
+ }>;
60
+ };
61
+ /** IO seam for the repair pass; injected so the orchestration is unit-testable. */
62
+ export interface ForeignPathRepairIO {
63
+ readFile: (rel: string) => Promise<string>;
64
+ writeFile: (rel: string, text: string) => Promise<void>;
65
+ /** Does this repo-relative path exist? Used to RE-VALIDATE each repair. */
66
+ existsInRepo: (rel: string) => boolean;
67
+ }
68
+ export interface ForeignPathRepairResult {
69
+ /** Human-readable repairs actually written, e.g. `cfg.ts: /workspace/src → ./src`. */
70
+ repaired: string[];
71
+ /** Findings left alone — these become the verify finding. */
72
+ remaining: ForeignPathFinding[];
73
+ }
74
+ /**
75
+ * Deterministically repair what can be repaired, and hand back the rest.
76
+ *
77
+ * Every repair is RE-VALIDATED before it is written: the file-relative form is
78
+ * resolved back to a repo path and must land on the same real file the finding
79
+ * named. A repair that does not re-resolve is dropped and its finding stays in
80
+ * `remaining`, where the verify block will raise it for a human or an AUTOFIX
81
+ * round. An unreadable or unwritable file does the same. Nothing here can turn a
82
+ * working path into a broken one — the only edit it makes is swapping a path that
83
+ * provably does not resolve for one that provably does.
84
+ */
85
+ export declare function repairForeignPaths(findings: ForeignPathFinding[], io: ForeignPathRepairIO): Promise<ForeignPathRepairResult>;
86
+ /**
87
+ * Verify-child prompt lines: one per leak, naming the file, the leaked path, and
88
+ * the real repo path it shadows. Empty findings → empty array (caller emits no
89
+ * block), matching every other probe's contract.
90
+ */
91
+ export declare function foreignPathVerifyFindings(findings: ForeignPathFinding[]): string[];
92
+ /**
93
+ * Render findings as a critique/enforce defect block — the same shape as
94
+ * skipEscapeDefectText: a numbered list the rewrite must resolve.
95
+ */
96
+ export declare function foreignPathDefectText(findings: ForeignPathFinding[]): string;
Binary file
@@ -13,6 +13,7 @@
13
13
  * path-revisit disabled because re-running the same check IS the job), each with a
14
14
  * status widget and a per-gate debug log under .pi-tasks/.
15
15
  */
16
+ import { existsSync } from 'node:fs';
16
17
  import * as fsp from 'node:fs/promises';
17
18
  import * as path from 'node:path';
18
19
  import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
@@ -34,6 +35,9 @@ import { parseTreeChanges, parseNameStatusChanges, formatTreeChanges } from './w
34
35
  import { taskThatIntroduced, findCrossTaskDeletions } from './task-provenance.js';
35
36
  import { findTestRebuiltAssemblies, testAssemblyVerifyFindings } from './test-assembly.js';
36
37
  import { runBoundedLintFix } from './lint-fix.js';
38
+ import { findForeignPaths, foreignPathVerifyFindings, repairForeignPaths } from './foreign-path.js';
39
+ import { findScriptEscapesInManifest, scriptEscapeVerifyFindings } from './script-escape.js';
40
+ import { assessRunnerGlobs, runnerGlobVerifyFindings } from './runner-globs.js';
37
41
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
38
42
  import { runWorker } from '../workers/pi-worker-core.js';
39
43
  import { formatLoopHint } from './child-runner.js';
@@ -123,6 +127,121 @@ export async function collectAddedLines(cwd, signal) {
123
127
  }
124
128
  return lines;
125
129
  }
130
+ /**
131
+ * Deterministic sandbox-path-leak pass (see foreign-path.ts, mx5 run 13 PROMPT 4
132
+ * item 1): find absolute paths the task committed that resolve nowhere on this
133
+ * machine while the real file sits in the repo, REPAIR the ones whose relative
134
+ * form provably resolves, and return verify findings for whatever is left.
135
+ *
136
+ * The repair runs here, before the verify child, for the same reason lint-fix
137
+ * does: the defect is mechanical and the correct target is already known, so
138
+ * spending an AUTOFIX round (or a human) on a path substitution is waste. What it
139
+ * cannot repair still reaches the child under rule 4e. Failures degrade to no
140
+ * findings — a sharpener, never a blocker.
141
+ */
142
+ async function collectForeignPathFindings(cwd, signal, logDebug) {
143
+ const lines = await collectAddedLines(cwd, signal);
144
+ if (lines.length === 0)
145
+ return [];
146
+ const findings = findForeignPaths(lines, abs => existsSync(abs), rel => existsSync(path.join(cwd, rel)));
147
+ if (findings.length === 0)
148
+ return [];
149
+ const { repaired, remaining } = await repairForeignPaths(findings, {
150
+ readFile: rel => fsp.readFile(path.join(cwd, rel), 'utf8'),
151
+ writeFile: (rel, text) => fsp.writeFile(path.join(cwd, rel), text, 'utf8'),
152
+ existsInRepo: rel => existsSync(path.join(cwd, rel))
153
+ });
154
+ for (const r of repaired)
155
+ logDebug?.(`sandbox path leak repaired — ${r}`);
156
+ for (const f of remaining) {
157
+ logDebug?.(`sandbox path leak NOT repaired — ${f.file}: ${f.absolute}`);
158
+ }
159
+ return foreignPathVerifyFindings(remaining);
160
+ }
161
+ /** Manifests whose `scripts` the check-script scanner understands. */
162
+ const MANIFEST_RE = /(^|\/)package\.json$/;
163
+ /**
164
+ * Deterministic neutered-check-script pass (see script-escape.ts, mx5 run 13 PROMPT
165
+ * 4 item 4): check-class scripts that cannot report failure, in a manifest THIS
166
+ * task changed.
167
+ *
168
+ * Scoped to manifests the task touched, so the finding lands on the task that
169
+ * authored the script rather than being re-served to every later task. A script
170
+ * neutered by an earlier task is the whole-repo final gate's business, which
171
+ * re-checks the shipped manifest at run end regardless of who wrote it.
172
+ *
173
+ * Failures degrade to no findings — a sharpener, never a blocker.
174
+ */
175
+ async function collectScriptEscapeFindings(cwd, signal) {
176
+ const changed = await collectChangedFiles(cwd, signal);
177
+ const manifests = changed.map(f => f.path).filter(p => MANIFEST_RE.test(p));
178
+ const findings = [];
179
+ for (const rel of manifests) {
180
+ try {
181
+ findings.push(...findScriptEscapesInManifest(await fsp.readFile(path.join(cwd, rel), 'utf8')));
182
+ }
183
+ catch {
184
+ // unreadable/absent manifest — nothing to report
185
+ }
186
+ }
187
+ return scriptEscapeVerifyFindings(findings);
188
+ }
189
+ /** Playwright config filenames, in the order playwright itself resolves them. */
190
+ const PLAYWRIGHT_CONFIGS = [
191
+ 'playwright.config.ts',
192
+ 'playwright.config.js',
193
+ 'playwright.config.mts',
194
+ 'playwright-ct.config.ts',
195
+ 'playwright-ct.config.js'
196
+ ];
197
+ /** Read a repo file as text, or null when absent/unreadable. */
198
+ async function readOrNull(cwd, rel) {
199
+ try {
200
+ return await fsp.readFile(path.join(cwd, rel), 'utf8');
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ /**
207
+ * Deterministic test-runner glob-collision pass (see runner-globs.ts, mx5 runs 7 AND
208
+ * 13, PROMPT 4 item 2): the manifest declares both `bun test` and `playwright test`
209
+ * without a provably disjoint file set, so `bun test` imports the playwright specs
210
+ * and dies during collection.
211
+ *
212
+ * Whole-repo rather than diff-scoped, unlike the neutered-script probe: a collision
213
+ * is a property of the PAIR of declarations, and the task that completes the pair is
214
+ * rarely the one that will be blamed by a diff. It is cheap (two small file reads)
215
+ * and silent unless both runners are actually declared. Failures degrade to no
216
+ * findings — a sharpener, never a blocker.
217
+ */
218
+ async function collectRunnerGlobFindings(cwd) {
219
+ const manifestText = await readOrNull(cwd, 'package.json');
220
+ if (manifestText === null)
221
+ return [];
222
+ let scripts;
223
+ try {
224
+ const parsed = JSON.parse(manifestText);
225
+ const raw = parsed?.scripts;
226
+ if (typeof raw !== 'object' || raw === null)
227
+ return [];
228
+ scripts = raw;
229
+ }
230
+ catch {
231
+ return [];
232
+ }
233
+ let playwrightConfig = null;
234
+ for (const name of PLAYWRIGHT_CONFIGS) {
235
+ playwrightConfig = await readOrNull(cwd, name);
236
+ if (playwrightConfig !== null)
237
+ break;
238
+ }
239
+ return runnerGlobVerifyFindings(assessRunnerGlobs({
240
+ scripts,
241
+ bunfig: await readOrNull(cwd, 'bunfig.toml'),
242
+ playwrightConfig
243
+ }));
244
+ }
126
245
  /**
127
246
  * The working tree's current changes as a summary (write-guard shape): what a
128
247
  * write-capable gate child changed, given the tree was clean when it started.
@@ -497,6 +616,28 @@ export function buildGateDeps(params) {
497
616
  // destroyed (typically to green a check). Injected under rule 4d and
498
617
  // carried on a FAIL so an ACCEPT records durable debts.
499
618
  crossTaskDeletionProbe: () => collectTaskTreeChanges(cwd2, signal).then(changes => findCrossTaskDeletions(changes, taskId, rel => taskThatIntroduced(cwd2, rel))),
619
+ // Deterministic sandbox-path-leak probe (mx5 run 13 PROMPT 4 item
620
+ // 1): absolute paths committed from the authoring child's own
621
+ // environment (`/workspace/src/shared`) that resolve nowhere here.
622
+ // Repaired deterministically where the relative form provably
623
+ // resolves; the remainder is injected under rule 4e, whose point is
624
+ // that such a path breaks the BUILD — so the checks that would have
625
+ // caught it report nothing rather than failing.
626
+ foreignPathProbe: () => collectForeignPathFindings(cwd2, signal, msg => void fsp
627
+ .appendFile(path.join(tasksDir(cwd2), 'verify-debug.log'), `${new Date().toISOString()} ${msg}\n`)
628
+ .catch(() => { })),
629
+ // Deterministic neutered-check-script probe (mx5 run 13 PROMPT 4
630
+ // item 4): a check script this task authored that cannot fail
631
+ // (`… || true`, an inverted-grep launder). Injected under rule 4f,
632
+ // because the child provably cannot find this by running the
633
+ // script — it passes, which IS the defect.
634
+ scriptEscapeProbe: () => collectScriptEscapeFindings(cwd2, signal),
635
+ // Deterministic runner glob-collision probe (mx5 runs 7 AND 13,
636
+ // PROMPT 4 item 2): both `bun test` and `playwright test` declared
637
+ // with no proof their file sets are disjoint. Injected under rule
638
+ // 4g — the collision kills the suite during COLLECTION, which does
639
+ // not look like a test failure.
640
+ runnerGlobProbe: () => collectRunnerGlobFindings(cwd2),
500
641
  // Deterministic prohibition probe: paths the spec forbids modifying
501
642
  // that the task's diff modified anyway become prompt-level findings
502
643
  // under the no-waiver rule — the child otherwise rarely runs `git
@@ -27,6 +27,7 @@ import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyTool
27
27
  import { compressTitle } from './title-label.js';
28
28
  import { parseVerifyBlock, validateSpecShape, stripSpecPreamble, isCritiqueClean } from './spec-validation.js';
29
29
  import { findSkipEscapes, skipEscapeDefectText } from './skip-escape.js';
30
+ import { findScriptEscapesInText, scriptEscapeDefectText } from './script-escape.js';
30
31
  import { findSynthesizedWiring, wiringProbeText, readReferencedDocs } from './wiring-claims.js';
31
32
  import { findAbsenceConflicts, absenceProbeText, siblingTitlesFromPlanContext } from './verify-reconcile.js';
32
33
  import { findFrozenPathConflicts, frozenConflictProbeText } from './frozen-conflict.js';
@@ -939,6 +940,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
939
940
  if (grepOnlyProbe) {
940
941
  deps.logDebug?.('grep-theater VERIFY flagged in spec: ' + grepOnly.map(f => f.target).join(' | '));
941
942
  }
943
+ // DETERMINISTIC neutered-check-script probe (mx5 run 13, PROMPT 4 item 4): a
944
+ // spec that DICTATES a check script which cannot fail — `"lint": "… || true"`,
945
+ // or a checker laundered through an inverted grep. Whatever task implements
946
+ // that spec writes the disarmed script into package.json, and from then on
947
+ // every gate that runs it (repo-health verify, the final integration gate)
948
+ // reads a constant. Cheapest to kill here, in the spec, before it is authored.
949
+ const scriptEscapes = findScriptEscapesInText(spec);
950
+ const scriptProbe = scriptEscapes.length > 0 ? scriptEscapeDefectText(scriptEscapes) : null;
951
+ if (scriptProbe) {
952
+ deps.logDebug?.('neutered check script dictated by spec: ' + scriptEscapes.map(f => f.name).join(' | '));
953
+ }
942
954
  let triageDefects = null;
943
955
  if (parseVerifyBlock(spec) !== null) {
944
956
  const tTriage = Date.now();
@@ -956,15 +968,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
956
968
  deps.recordSubStep?.('triage', Date.now() - tTriage);
957
969
  if (verdict !== null) {
958
970
  // A deterministic skip-escape, synthesized-wiring, plan-contradiction,
959
- // unsatisfiable-pair, or grep-theater finding overrides a CLEAN triage: the draft must
960
- // be rewritten to resolve it even if the model judged the rest clean
961
- // (the model does not self-discover any of them reliably).
971
+ // unsatisfiable-pair, grep-theater, or neutered-check-script finding
972
+ // overrides a CLEAN triage: the draft must be rewritten to resolve it
973
+ // even if the model judged the rest clean (the model does not
974
+ // self-discover any of them reliably).
962
975
  if (isCritiqueClean(verdict)) {
963
976
  if (skipDefects === null
964
977
  && wiringProbe === null
965
978
  && absenceProbe === null
966
979
  && frozenProbe === null
967
- && grepOnlyProbe === null) {
980
+ && grepOnlyProbe === null
981
+ && scriptProbe === null) {
968
982
  return spec;
969
983
  }
970
984
  }
@@ -974,9 +988,17 @@ export async function phaseCritique(deps, spec, refined, qa, planContext, resear
974
988
  }
975
989
  }
976
990
  // Merge the deterministic skip-escape + synthesized-wiring + plan-contradiction
977
- // + unsatisfiable-pair + grep-theater defects with any triage defects for the
978
- // rewrite (all are forced FOCUS items).
979
- const rewriteDefects = [skipDefects, wiringProbe, absenceProbe, frozenProbe, grepOnlyProbe, triageDefects]
991
+ // + unsatisfiable-pair + grep-theater + neutered-script defects with any triage
992
+ // defects for the rewrite (all are forced FOCUS items).
993
+ const rewriteDefects = [
994
+ skipDefects,
995
+ wiringProbe,
996
+ absenceProbe,
997
+ frozenProbe,
998
+ grepOnlyProbe,
999
+ scriptProbe,
1000
+ triageDefects
1001
+ ]
980
1002
  .filter(Boolean)
981
1003
  .join('\n\n') || null;
982
1004
  const tRewrite = Date.now();
@@ -0,0 +1,74 @@
1
+ /**
2
+ * runner-globs — deterministic detection of TWO TEST RUNNERS FIGHTING OVER THE SAME
3
+ * FILES, checked as soon as a project declares both rather than discovered at run end.
4
+ *
5
+ * The failure this closes — SECOND occurrence, runs 7 AND 13: a project declares both
6
+ * `bun test` and `playwright test`. Bun's runner scans the whole project for
7
+ * `*.test.*` / `*.spec.*`; Playwright's component/e2e specs ARE `*.spec.tsx`. So
8
+ * `bun test` imports Playwright spec files, which import `@playwright/test` outside a
9
+ * Playwright runner, and the whole suite dies on a module it was never meant to load.
10
+ *
11
+ * Run 7 found it in the final gate. Run 13 found it in the final gate AGAIN — and the
12
+ * fix (a `pathIgnorePatterns` line in bunfig.toml) was still sitting UNCOMMITTED in
13
+ * the working tree when the run ended, so HEAD shipped with `bun run test` broken. A
14
+ * defect that recurs across runs and survives its own fix is not a discovery problem;
15
+ * it is a missing invariant. This module states the invariant so it can be checked the
16
+ * moment both runners are declared:
17
+ *
18
+ * if two runners are declared, their file sets must be provably DISJOINT
19
+ *
20
+ * Disjointness has exactly two mechanical forms, and this module accepts either:
21
+ * - EXCLUSION: the scanning runner is configured to ignore the other's files
22
+ * (bunfig `[test] pathIgnorePatterns`), or
23
+ * - NAMING: the other runner's files are named so the scanner never claims them
24
+ * (Playwright `testMatch` on a suffix outside `*.test.*` / `*.spec.*`, e.g. `.e2e.ts`).
25
+ *
26
+ * Guard direction: UNKNOWN steps aside. A missing/unparseable manifest, one runner
27
+ * only, or a Playwright config whose testMatch cannot be read all return `unknown` —
28
+ * the check may cost time, never work.
29
+ */
30
+ export type GlobCollisionStatus = 'collision' | 'disjoint' | 'unknown';
31
+ export interface GlobCollisionAssessment {
32
+ status: GlobCollisionStatus;
33
+ /** Human- and prompt-readable explanation; '' when there is nothing to say. */
34
+ detail: string;
35
+ /** The script names that invoke each runner (for naming the finding). */
36
+ scanningScripts: string[];
37
+ otherScripts: string[];
38
+ }
39
+ export interface RunnerGlobInputs {
40
+ /** The manifest's `scripts` map. */
41
+ scripts: Record<string, string>;
42
+ /** bunfig.toml text, or null when absent. */
43
+ bunfig: string | null;
44
+ /** playwright config text (any of the playwright*.config.* files), or null. */
45
+ playwrightConfig: string | null;
46
+ }
47
+ /**
48
+ * Bun's declared ignore patterns (`[test] pathIgnorePatterns = [...]`). Returns null
49
+ * when bunfig is absent or the key is not present — absent is not "empty", it is
50
+ * unknown-shaped, and the caller distinguishes them.
51
+ */
52
+ export declare function parsePathIgnorePatterns(bunfig: string | null): string[] | null;
53
+ /**
54
+ * Playwright's `testMatch`, when the config states one. Null → the default, which
55
+ * matches `*.spec.*` and `*.test.*` — precisely Bun's claimed set.
56
+ */
57
+ export declare function parseTestMatch(playwrightConfig: string | null): string[] | null;
58
+ /** Playwright's `testDir`, when stated (used to explain the collision concretely). */
59
+ export declare function parseTestDir(playwrightConfig: string | null): string | null;
60
+ /**
61
+ * Assess whether the declared runners can collide. See the module doc for the
62
+ * invariant and the two accepted forms of disjointness.
63
+ */
64
+ export declare function assessRunnerGlobs(input: RunnerGlobInputs): GlobCollisionAssessment;
65
+ /**
66
+ * Verify-child prompt lines for a collision. Empty for any non-collision status —
67
+ * the caller emits no block.
68
+ */
69
+ export declare function runnerGlobVerifyFindings(a: GlobCollisionAssessment): string[];
70
+ /**
71
+ * A plan-time contract line: the invariant, recorded so slices that add a runner
72
+ * inherit it instead of rediscovering the collision. Empty when not applicable.
73
+ */
74
+ export declare function runnerGlobContractLine(a: GlobCollisionAssessment): string;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * runner-globs — deterministic detection of TWO TEST RUNNERS FIGHTING OVER THE SAME
3
+ * FILES, checked as soon as a project declares both rather than discovered at run end.
4
+ *
5
+ * The failure this closes — SECOND occurrence, runs 7 AND 13: a project declares both
6
+ * `bun test` and `playwright test`. Bun's runner scans the whole project for
7
+ * `*.test.*` / `*.spec.*`; Playwright's component/e2e specs ARE `*.spec.tsx`. So
8
+ * `bun test` imports Playwright spec files, which import `@playwright/test` outside a
9
+ * Playwright runner, and the whole suite dies on a module it was never meant to load.
10
+ *
11
+ * Run 7 found it in the final gate. Run 13 found it in the final gate AGAIN — and the
12
+ * fix (a `pathIgnorePatterns` line in bunfig.toml) was still sitting UNCOMMITTED in
13
+ * the working tree when the run ended, so HEAD shipped with `bun run test` broken. A
14
+ * defect that recurs across runs and survives its own fix is not a discovery problem;
15
+ * it is a missing invariant. This module states the invariant so it can be checked the
16
+ * moment both runners are declared:
17
+ *
18
+ * if two runners are declared, their file sets must be provably DISJOINT
19
+ *
20
+ * Disjointness has exactly two mechanical forms, and this module accepts either:
21
+ * - EXCLUSION: the scanning runner is configured to ignore the other's files
22
+ * (bunfig `[test] pathIgnorePatterns`), or
23
+ * - NAMING: the other runner's files are named so the scanner never claims them
24
+ * (Playwright `testMatch` on a suffix outside `*.test.*` / `*.spec.*`, e.g. `.e2e.ts`).
25
+ *
26
+ * Guard direction: UNKNOWN steps aside. A missing/unparseable manifest, one runner
27
+ * only, or a Playwright config whose testMatch cannot be read all return `unknown` —
28
+ * the check may cost time, never work.
29
+ */
30
+ /** `bun test` — the scanning runner: it claims every `*.test.*` / `*.spec.*` it finds. */
31
+ const BUN_TEST_RE = /\bbun\s+(?:--\S+\s+)*test\b/;
32
+ /** `playwright test` (incl. `bunx`/`npx`/`pnpm exec` prefixes). */
33
+ const PLAYWRIGHT_RE = /\bplaywright\s+test\b/;
34
+ /** The suffixes Bun's test runner claims by default. */
35
+ const BUN_CLAIMED_SUFFIX_RE = /\.(?:test|spec)\./;
36
+ /** Scripts that invoke a given runner, by name. */
37
+ function scriptsMatching(scripts, re) {
38
+ return Object.entries(scripts)
39
+ .filter(([, body]) => typeof body === 'string' && re.test(body))
40
+ .map(([name]) => name);
41
+ }
42
+ /**
43
+ * Bun's declared ignore patterns (`[test] pathIgnorePatterns = [...]`). Returns null
44
+ * when bunfig is absent or the key is not present — absent is not "empty", it is
45
+ * unknown-shaped, and the caller distinguishes them.
46
+ */
47
+ export function parsePathIgnorePatterns(bunfig) {
48
+ if (bunfig === null)
49
+ return null;
50
+ const m = /^[ \t]*pathIgnorePatterns[ \t]*=[ \t]*\[([\s\S]*?)\]/m.exec(bunfig);
51
+ if (!m)
52
+ return null;
53
+ return [...m[1].matchAll(/["']([^"']+)["']/g)].map(x => x[1]);
54
+ }
55
+ /**
56
+ * Playwright's `testMatch`, when the config states one. Null → the default, which
57
+ * matches `*.spec.*` and `*.test.*` — precisely Bun's claimed set.
58
+ */
59
+ export function parseTestMatch(playwrightConfig) {
60
+ if (playwrightConfig === null)
61
+ return null;
62
+ const m = /\btestMatch\s*:\s*(\[[\s\S]*?\]|['"][^'"]+['"]|\/[^/\n]+\/[gimsuy]*)/.exec(playwrightConfig);
63
+ if (!m)
64
+ return null;
65
+ const found = [...m[1].matchAll(/["']([^"']+)["']/g)].map(x => x[1]);
66
+ return found.length > 0 ? found : [m[1]];
67
+ }
68
+ /** Playwright's `testDir`, when stated (used to explain the collision concretely). */
69
+ export function parseTestDir(playwrightConfig) {
70
+ if (playwrightConfig === null)
71
+ return null;
72
+ const m = /\btestDir\s*:\s*['"]([^'"]+)['"]/.exec(playwrightConfig);
73
+ return m ? m[1].replace(/^\.\//, '').replace(/\/+$/, '') : null;
74
+ }
75
+ /**
76
+ * Does an ignore pattern plausibly cover Playwright's spec files? Deliberately
77
+ * generous — this decides whether to STAY SILENT, and a guard that may only cost
78
+ * time should resolve ambiguity toward silence. A pattern naming a `spec`/`test`
79
+ * suffix, or the Playwright testDir, counts.
80
+ */
81
+ function ignoreCoversSpecs(patterns, testDir) {
82
+ return patterns.some(p => {
83
+ if (BUN_CLAIMED_SUFFIX_RE.test(p) || /\bspec\b|\btest\b/.test(p))
84
+ return true;
85
+ return testDir !== null && testDir.length > 0 && p.includes(testDir);
86
+ });
87
+ }
88
+ /**
89
+ * Assess whether the declared runners can collide. See the module doc for the
90
+ * invariant and the two accepted forms of disjointness.
91
+ */
92
+ export function assessRunnerGlobs(input) {
93
+ const scripts = input.scripts ?? {};
94
+ const scanningScripts = scriptsMatching(scripts, BUN_TEST_RE);
95
+ const otherScripts = scriptsMatching(scripts, PLAYWRIGHT_RE);
96
+ const base = { scanningScripts, otherScripts };
97
+ // Only one runner (or none) — nothing to collide.
98
+ if (scanningScripts.length === 0 || otherScripts.length === 0) {
99
+ return { status: 'unknown', detail: '', ...base };
100
+ }
101
+ // NAMING form: Playwright's own testMatch keeps its files outside Bun's claim.
102
+ const testMatch = parseTestMatch(input.playwrightConfig);
103
+ if (testMatch !== null && !testMatch.some(p => BUN_CLAIMED_SUFFIX_RE.test(p))) {
104
+ return {
105
+ status: 'disjoint',
106
+ detail: `playwright testMatch (${testMatch.join(', ')}) names files outside bun test's `
107
+ + `\`*.test.*\` / \`*.spec.*\` claim — the two runners cannot pick up each other's files`,
108
+ ...base
109
+ };
110
+ }
111
+ // EXCLUSION form: bun is told to ignore them.
112
+ const ignore = parsePathIgnorePatterns(input.bunfig);
113
+ const testDir = parseTestDir(input.playwrightConfig);
114
+ if (ignore !== null && ignoreCoversSpecs(ignore, testDir)) {
115
+ return {
116
+ status: 'disjoint',
117
+ detail: `bunfig [test] pathIgnorePatterns (${ignore.join(', ')}) excludes the playwright `
118
+ + `spec files from bun test's scan`,
119
+ ...base
120
+ };
121
+ }
122
+ // Both declared, neither form of disjointness present.
123
+ return {
124
+ status: 'collision',
125
+ detail: `\`${scanningScripts.join('`, `')}\` runs bun test, which scans the whole project for `
126
+ + '`*.test.*` / `*.spec.*`, and `'
127
+ + otherScripts.join('`, `')
128
+ + '` runs playwright, whose specs use those same suffixes'
129
+ + (testDir ? ` (testDir: ${testDir})` : '')
130
+ + '. bun test will import the playwright specs and die on `@playwright/test` outside '
131
+ + 'its runner. Declare disjoint file sets: add a bunfig.toml `[test] '
132
+ + 'pathIgnorePatterns` entry excluding the playwright specs, or give them a suffix '
133
+ + 'bun does not claim (e.g. `*.e2e.ts` via playwright `testMatch`)',
134
+ ...base
135
+ };
136
+ }
137
+ /**
138
+ * Verify-child prompt lines for a collision. Empty for any non-collision status —
139
+ * the caller emits no block.
140
+ */
141
+ export function runnerGlobVerifyFindings(a) {
142
+ return a.status === 'collision' ? [a.detail] : [];
143
+ }
144
+ /**
145
+ * A plan-time contract line: the invariant, recorded so slices that add a runner
146
+ * inherit it instead of rediscovering the collision. Empty when not applicable.
147
+ */
148
+ export function runnerGlobContractLine(a) {
149
+ if (a.scanningScripts.length === 0 || a.otherScripts.length === 0)
150
+ return '';
151
+ return (`Test-runner file sets MUST be disjoint: \`${a.scanningScripts.join('`, `')}\` (bun test, `
152
+ + `scans \`*.test.*\`/\`*.spec.*\` project-wide) and \`${a.otherScripts.join('`, `')}\` `
153
+ + `(playwright) must not claim the same files — enforce via bunfig \`[test] `
154
+ + `pathIgnorePatterns\` or a playwright \`testMatch\` suffix bun does not scan.`);
155
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * script-escape — deterministic detection of CHECK SCRIPTS THAT CANNOT FAIL.
3
+ *
4
+ * The failure this closes (mx5 run 13, PROMPT 4 item 4): the shipped package.json
5
+ * declared, verbatim,
6
+ * "lint": "prettier … && eslint --fix … && (tsc --noEmit 2>&1 | grep -qv 'TS18003' || true)"
7
+ * The typecheck is neutered twice over — its output is piped into an INVERTED grep
8
+ * (so the status becomes "some line did not match", never tsc's verdict), and the
9
+ * whole group is closed with `|| true` (so the script exits 0 unconditionally). Every
10
+ * consumer of that script — the repo-health verify gate, the final integration gate,
11
+ * a human reading a green CI line — is reading a constant, not a measurement.
12
+ *
13
+ * It was harmless in run 13 only by luck: tsc happened to be clean (validated). The
14
+ * class is not harmless — this is the same defect as run-8's F2 skip-escape, moved
15
+ * one level out. findSkipEscapes (skip-escape.ts) scans a spec's own VERIFY block;
16
+ * nothing scanned the SCRIPT DEFINITIONS those VERIFY blocks then invoke by name, so
17
+ * `bun run lint` could be authored into a no-op and every gate would salute it.
18
+ *
19
+ * TWO SHAPES, both crisp, both scoped to CHECK-CLASS script names:
20
+ *
21
+ * A. ALWAYS-ZERO TAIL — the script's last command cannot fail (`… || true`,
22
+ * `… || :`, `… || exit 0`, `…; exit 0`, a trailing `|| echo` fallback). A
23
+ * shell script's status is its last command's status, so this is a proof, not
24
+ * a heuristic: the script exits 0 no matter what the checker found.
25
+ *
26
+ * B. INVERTED-GREP LAUNDERING — a checker piped into `grep -qv` / `grep -vq`.
27
+ * "Some line of the output does NOT match X" is never a checker's verdict; it
28
+ * is true of virtually any non-empty output, including a wall of errors.
29
+ *
30
+ * Deliberately NOT flagged, because each is legitimate and FP-measured against the
31
+ * real corpus (pi-task, aiz-server, aiz-client, gofer, mx5):
32
+ * - `|| exit 1` — a HARDENING, the opposite of an escape (aiz-server).
33
+ * - any `||`/pipe in a NON-check script (`clean`, `dev`, `start`, `copy-fonts`) —
34
+ * a teardown `rm -rf dist || true` is correct and common.
35
+ * - pipes into formatters/reporters (`| tap-spec`, `| tee`) — those propagate
36
+ * status or are presentational; only inverted grep is unambiguous.
37
+ * - a plain `| grep -q "expected"` — that is a real assertion on output.
38
+ */
39
+ /** One neutered check script. */
40
+ export interface ScriptEscapeFinding {
41
+ /** The script's name as declared (e.g. `lint`). */
42
+ name: string;
43
+ /** The script's body, verbatim. */
44
+ body: string;
45
+ /** Why it cannot fail (human- and prompt-readable). */
46
+ reason: string;
47
+ }
48
+ /**
49
+ * Judge one script definition. Returns null when the script is not check-class, or
50
+ * is check-class but can genuinely fail.
51
+ */
52
+ export declare function judgeScript(name: string, body: string): ScriptEscapeFinding | null;
53
+ /**
54
+ * Scan a parsed `scripts` map (package.json shape) for neutered check scripts.
55
+ * A non-object, or a map with no check-class scripts, yields no findings.
56
+ */
57
+ export declare function findScriptEscapes(scripts: unknown): ScriptEscapeFinding[];
58
+ /**
59
+ * Scan a package.json's TEXT. Invalid JSON yields no findings — a manifest that does
60
+ * not parse is a different problem, and guessing at its contents is how a scanner
61
+ * earns false positives.
62
+ */
63
+ export declare function findScriptEscapesInManifest(manifestText: string): ScriptEscapeFinding[];
64
+ /**
65
+ * Scan ARBITRARY text (a spec, a design doc) for script definitions written as JSON
66
+ * pairs — the form a spec uses when it dictates a script for the implementer to add
67
+ * (`"lint": "tsc --noEmit || true"`). This is what lets the critique catch the
68
+ * neutered script at SPEC time, before any task writes it into a manifest.
69
+ *
70
+ * Deliberately narrow: only `"name": "body"` pairs on one line, only check-class
71
+ * names. Prose describing a script in words extracts nothing.
72
+ */
73
+ export declare function findScriptEscapesInText(text: string): ScriptEscapeFinding[];
74
+ /**
75
+ * Verify-child prompt lines (probe+rule pattern): one per neutered script, naming
76
+ * the script, its body, and why it cannot fail.
77
+ */
78
+ export declare function scriptEscapeVerifyFindings(findings: ScriptEscapeFinding[]): string[];
79
+ /**
80
+ * Render findings as a critique/rewrite defect block — same shape as
81
+ * skipEscapeDefectText: a numbered list the rewrite must resolve.
82
+ */
83
+ export declare function scriptEscapeDefectText(findings: ScriptEscapeFinding[]): string;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * script-escape — deterministic detection of CHECK SCRIPTS THAT CANNOT FAIL.
3
+ *
4
+ * The failure this closes (mx5 run 13, PROMPT 4 item 4): the shipped package.json
5
+ * declared, verbatim,
6
+ * "lint": "prettier … && eslint --fix … && (tsc --noEmit 2>&1 | grep -qv 'TS18003' || true)"
7
+ * The typecheck is neutered twice over — its output is piped into an INVERTED grep
8
+ * (so the status becomes "some line did not match", never tsc's verdict), and the
9
+ * whole group is closed with `|| true` (so the script exits 0 unconditionally). Every
10
+ * consumer of that script — the repo-health verify gate, the final integration gate,
11
+ * a human reading a green CI line — is reading a constant, not a measurement.
12
+ *
13
+ * It was harmless in run 13 only by luck: tsc happened to be clean (validated). The
14
+ * class is not harmless — this is the same defect as run-8's F2 skip-escape, moved
15
+ * one level out. findSkipEscapes (skip-escape.ts) scans a spec's own VERIFY block;
16
+ * nothing scanned the SCRIPT DEFINITIONS those VERIFY blocks then invoke by name, so
17
+ * `bun run lint` could be authored into a no-op and every gate would salute it.
18
+ *
19
+ * TWO SHAPES, both crisp, both scoped to CHECK-CLASS script names:
20
+ *
21
+ * A. ALWAYS-ZERO TAIL — the script's last command cannot fail (`… || true`,
22
+ * `… || :`, `… || exit 0`, `…; exit 0`, a trailing `|| echo` fallback). A
23
+ * shell script's status is its last command's status, so this is a proof, not
24
+ * a heuristic: the script exits 0 no matter what the checker found.
25
+ *
26
+ * B. INVERTED-GREP LAUNDERING — a checker piped into `grep -qv` / `grep -vq`.
27
+ * "Some line of the output does NOT match X" is never a checker's verdict; it
28
+ * is true of virtually any non-empty output, including a wall of errors.
29
+ *
30
+ * Deliberately NOT flagged, because each is legitimate and FP-measured against the
31
+ * real corpus (pi-task, aiz-server, aiz-client, gofer, mx5):
32
+ * - `|| exit 1` — a HARDENING, the opposite of an escape (aiz-server).
33
+ * - any `||`/pipe in a NON-check script (`clean`, `dev`, `start`, `copy-fonts`) —
34
+ * a teardown `rm -rf dist || true` is correct and common.
35
+ * - pipes into formatters/reporters (`| tap-spec`, `| tee`) — those propagate
36
+ * status or are presentational; only inverted grep is unambiguous.
37
+ * - a plain `| grep -q "expected"` — that is a real assertion on output.
38
+ */
39
+ /**
40
+ * Script names whose whole purpose is to FAIL when something is wrong. A neutered
41
+ * `clean` or `dev` is nobody's business; a neutered `lint` silently disarms every
42
+ * gate that runs it. Suffixed variants (`test:ct`, `lint:fix`, `check-types`) match.
43
+ */
44
+ const CHECK_SCRIPT_RE = /^(?:lint|test|check|verify|validate|typecheck|types?|tsc|ci|audit|coverage|e2e|build|compile|fmt:check|format:check)(?:[:_-].*)?$/i;
45
+ /** `|| exit 1` and friends HARDEN a script — never mistake one for an escape. */
46
+ const HARDENING_TAIL_RE = /\|\|\s*exit\s+[1-9]\d*\s*$/;
47
+ /** Tails that make the script's exit status unconditionally 0. */
48
+ const ALWAYS_ZERO_TAILS = [
49
+ { re: /\|\|\s*true\s*$/, what: '`|| true`' },
50
+ { re: /\|\|\s*:\s*$/, what: '`|| :`' },
51
+ { re: /\|\|\s*exit\s+0\s*$/, what: '`|| exit 0`' },
52
+ { re: /;\s*true\s*$/, what: '`; true`' },
53
+ { re: /;\s*exit\s+0\s*$/, what: '`; exit 0`' },
54
+ { re: /\|\|\s*echo\b[^|&;]*$/, what: 'a trailing `|| echo …` fallback' }
55
+ ];
56
+ /** A checker piped into an INVERTED grep: status becomes "something didn't match". */
57
+ const INVERTED_GREP_RE = /\|\s*grep\s+(?:-\w*v\w*|-\w+\s+-\w*v\w*)/;
58
+ /**
59
+ * Strip trailing subshell/group closers and separators so the tail patterns see the
60
+ * real last command. mx5's script ends `… || true)` — without this the `)` hides it.
61
+ */
62
+ function tailOf(body) {
63
+ let s = body.trim();
64
+ while (true) {
65
+ const next = s.replace(/[)\s;]+$/, '').trimEnd();
66
+ if (next === s)
67
+ return s;
68
+ s = next;
69
+ }
70
+ }
71
+ /**
72
+ * Judge one script definition. Returns null when the script is not check-class, or
73
+ * is check-class but can genuinely fail.
74
+ */
75
+ export function judgeScript(name, body) {
76
+ if (!CHECK_SCRIPT_RE.test(name))
77
+ return null;
78
+ if (typeof body !== 'string' || body.trim().length === 0)
79
+ return null;
80
+ const reasons = [];
81
+ const tail = tailOf(body);
82
+ if (!HARDENING_TAIL_RE.test(tail)) {
83
+ for (const { re, what } of ALWAYS_ZERO_TAILS) {
84
+ if (re.test(tail)) {
85
+ reasons.push(`it ends in ${what}, so the script exits 0 whatever the check reports`);
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ if (INVERTED_GREP_RE.test(body)) {
91
+ reasons.push('it pipes a check into an INVERTED grep (`grep -v`), so the exit status becomes '
92
+ + '"some output line did not match" — true of almost any output, including errors');
93
+ }
94
+ if (reasons.length === 0)
95
+ return null;
96
+ return {
97
+ name,
98
+ body: body.trim(),
99
+ reason: reasons.join('; ')
100
+ };
101
+ }
102
+ /**
103
+ * Scan a parsed `scripts` map (package.json shape) for neutered check scripts.
104
+ * A non-object, or a map with no check-class scripts, yields no findings.
105
+ */
106
+ export function findScriptEscapes(scripts) {
107
+ if (typeof scripts !== 'object' || scripts === null || Array.isArray(scripts))
108
+ return [];
109
+ const out = [];
110
+ for (const [name, body] of Object.entries(scripts)) {
111
+ const f = judgeScript(name, typeof body === 'string' ? body : '');
112
+ if (f)
113
+ out.push(f);
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * Scan a package.json's TEXT. Invalid JSON yields no findings — a manifest that does
119
+ * not parse is a different problem, and guessing at its contents is how a scanner
120
+ * earns false positives.
121
+ */
122
+ export function findScriptEscapesInManifest(manifestText) {
123
+ try {
124
+ const parsed = JSON.parse(manifestText);
125
+ if (typeof parsed !== 'object' || parsed === null)
126
+ return [];
127
+ return findScriptEscapes(parsed.scripts);
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ }
133
+ /**
134
+ * Scan ARBITRARY text (a spec, a design doc) for script definitions written as JSON
135
+ * pairs — the form a spec uses when it dictates a script for the implementer to add
136
+ * (`"lint": "tsc --noEmit || true"`). This is what lets the critique catch the
137
+ * neutered script at SPEC time, before any task writes it into a manifest.
138
+ *
139
+ * Deliberately narrow: only `"name": "body"` pairs on one line, only check-class
140
+ * names. Prose describing a script in words extracts nothing.
141
+ */
142
+ export function findScriptEscapesInText(text) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ for (const m of text.matchAll(/"([A-Za-z0-9:_-]{1,40})"\s*:\s*"((?:[^"\\]|\\.)*)"/g)) {
146
+ const name = m[1];
147
+ // Unescape the JSON string body so shell operators read normally.
148
+ let body;
149
+ try {
150
+ body = JSON.parse(`"${m[2]}"`);
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ const key = `${name}=${body}`;
156
+ if (seen.has(key))
157
+ continue;
158
+ const f = judgeScript(name, body);
159
+ if (!f)
160
+ continue;
161
+ seen.add(key);
162
+ out.push(f);
163
+ }
164
+ return out;
165
+ }
166
+ /**
167
+ * Verify-child prompt lines (probe+rule pattern): one per neutered script, naming
168
+ * the script, its body, and why it cannot fail.
169
+ */
170
+ export function scriptEscapeVerifyFindings(findings) {
171
+ return findings.map(f => `\`${f.name}\`: ${f.body} — ${f.reason}`);
172
+ }
173
+ /**
174
+ * Render findings as a critique/rewrite defect block — same shape as
175
+ * skipEscapeDefectText: a numbered list the rewrite must resolve.
176
+ */
177
+ export function scriptEscapeDefectText(findings) {
178
+ return [
179
+ 'NEUTERED CHECK SCRIPT — this spec defines a check script that CANNOT FAIL, so every',
180
+ 'gate that runs it reads a constant instead of a measurement (mx5 run 13 shipped',
181
+ '`"lint": "… && (tsc --noEmit 2>&1 | grep -qv \'TS18003\' || true)"` — the typecheck',
182
+ 'was fully disarmed, and it went unnoticed only because tsc happened to be clean).',
183
+ "Rewrite each so it PROPAGATES the checker's exit status: drop the `|| true` /",
184
+ '`; exit 0` tail, and never launder a checker through an inverted grep. If a',
185
+ 'specific diagnostic must genuinely be tolerated, suppress THAT diagnostic in the',
186
+ "checker's own config — never blanket-zero the whole script's exit code:",
187
+ ...findings.map((f, i) => ` ${i + 1}. "${f.name}": "${f.body}" — ${f.reason}`)
188
+ ].join('\n');
189
+ }
@@ -68,7 +68,20 @@ export declare function extractSpecForVerification(taskBody: string): string | n
68
68
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
69
69
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
70
70
  */
71
- export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string, testAssemblyFindings?: string[], probeGamingFindings?: string[], crossTaskDeletionFindings?: string[]): string;
71
+ export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[], skipEscapeFindings?: string[], contracts?: string, testAssemblyFindings?: string[], probeGamingFindings?: string[], crossTaskDeletionFindings?: string[],
72
+ /**
73
+ * The mx5 run-13 (PROMPT 4) probes, grouped rather than appended as three more
74
+ * positional parameters — this signature was already at its limit. Each key is
75
+ * an independent finding list; absent/empty emits no block.
76
+ */
77
+ projectSurface?: {
78
+ /** Sandbox-leaked absolute paths (rule 4e) — see foreign-path.ts. */
79
+ foreignPaths?: string[];
80
+ /** Check scripts that cannot fail (rule 4f) — see script-escape.ts. */
81
+ scriptEscapes?: string[];
82
+ /** Colliding test-runner globs (rule 4g) — see runner-globs.ts. */
83
+ runnerGlobs?: string[];
84
+ }): string;
72
85
  /**
73
86
  * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL|UNOBSERVED`
74
87
  * marker (the model discusses before concluding, and bash output may echo the word
@@ -146,6 +159,31 @@ export interface VerificationDeps {
146
159
  * 0/5), and carried structurally on a FAIL outcome so an ACCEPT records each as
147
160
  * a durable debt. ABSENT or empty → no block. */
148
161
  crossTaskDeletionProbe?: () => Promise<CrossTaskDeletion[]>;
162
+ /**
163
+ * DETERMINISTIC sandbox-path-leak probe (see foreign-path.ts, mx5 run 13
164
+ * PROMPT 4 item 1): absolute paths this task committed that exist only inside
165
+ * the authoring child's own environment — `/workspace/src/shared` in a vite
166
+ * alias — while the real file sits at `src/shared` here. The probe REPAIRS
167
+ * what it can deterministically first; only leaks it could not repair reach
168
+ * this hook, injected under rule 4e (MANDATORY + verdict-gating, the same
169
+ * shape as 4d — a leak makes the affected command fail to BUILD, so the
170
+ * checks that would notice never run at all). ABSENT or empty → no block. */
171
+ foreignPathProbe?: () => Promise<string[]>;
172
+ /**
173
+ * DETERMINISTIC neutered-check-script probe (see script-escape.ts, mx5 run 13
174
+ * PROMPT 4 item 4): check-class scripts in a manifest THIS task changed whose
175
+ * exit status cannot be non-zero (`… || true`, an inverted-grep launder). The
176
+ * damage is second-order — the script still "passes", so the gates that run it
177
+ * report success without measuring anything — which is exactly why the child
178
+ * cannot discover it by running the check. ABSENT or empty → no block. */
179
+ scriptEscapeProbe?: () => Promise<string[]>;
180
+ /**
181
+ * DETERMINISTIC test-runner glob-collision probe (see runner-globs.ts, mx5 runs
182
+ * 7 AND 13, PROMPT 4 item 2): the manifest declares two runners whose file sets
183
+ * are not provably disjoint, so the scanning one imports the other's specs and
184
+ * dies during COLLECTION. Injected under rule 4g. UNKNOWN (one runner, or
185
+ * disjointness proven) yields nothing. ABSENT or empty → no block. */
186
+ runnerGlobProbe?: () => Promise<string[]>;
149
187
  /**
150
188
  * Result of the git-state guard for the MOST RECENT runChild call (see
151
189
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file
@@ -140,7 +140,14 @@ export function extractSpecForVerification(taskBody) {
140
140
  * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
141
141
  * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
142
142
  */
143
- export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings, crossTaskDeletionFindings) {
143
+ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings, skipEscapeFindings, contracts, testAssemblyFindings, probeGamingFindings, crossTaskDeletionFindings,
144
+ /**
145
+ * The mx5 run-13 (PROMPT 4) probes, grouped rather than appended as three more
146
+ * positional parameters — this signature was already at its limit. Each key is
147
+ * an independent finding list; absent/empty emits no block.
148
+ */
149
+ projectSurface = {}) {
150
+ const { foreignPaths: foreignPathFindings, scriptEscapes: scriptEscapeFindings, runnerGlobs: runnerGlobFindings } = projectSurface;
144
151
  const probeBlock = probeFindings && probeFindings.length > 0 ?
145
152
  [
146
153
  'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
@@ -216,6 +223,57 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
216
223
  ''
217
224
  ]
218
225
  : [];
226
+ const foreignPathBlock = foreignPathFindings && foreignPathFindings.length > 0 ?
227
+ [
228
+ 'SANDBOX PATH LEAK NOTICE (deterministic, computed by the orchestrator by',
229
+ "resolving every absolute path in the task's diff against THIS machine): this",
230
+ 'task committed absolute paths that do not exist here, while the real file they',
231
+ 'name sits inside this repo:',
232
+ ...foreignPathFindings.map(f => `- ${f}`),
233
+ "These are paths from the authoring agent's OWN environment, baked into a file",
234
+ 'that ships. The command that reads such a path does not fail a check — it fails',
235
+ 'to BUILD, so the checks that would have caught it never run and report nothing',
236
+ '(mx5 run 13: a leaked `/workspace` vite alias made `test:ct` collect 63 tests',
237
+ 'and run 0, and the suite stayed dead for the rest of the run). A green or',
238
+ 'EMPTY result from any command that reads these files is therefore NOT evidence.',
239
+ 'Run the affected command yourself and confirm it actually EXECUTES work — count',
240
+ 'the tests/steps that ran, not the exit code. Unless the leaked path resolves on',
241
+ 'this machine, the verdict is FAIL naming the file and the path (rule 4e).',
242
+ ''
243
+ ]
244
+ : [];
245
+ const scriptEscapeBlock = scriptEscapeFindings && scriptEscapeFindings.length > 0 ?
246
+ [
247
+ 'NEUTERED CHECK SCRIPT NOTICE (deterministic, computed by the orchestrator from',
248
+ 'the manifest THIS task changed): these check scripts cannot report failure —',
249
+ 'their exit status is 0 no matter what the checker finds:',
250
+ ...scriptEscapeFindings.map(f => `- ${f}`),
251
+ 'You CANNOT discover this by running the script: it passes, which is the whole',
252
+ 'defect (mx5 run 13 shipped a `lint` whose typecheck was disarmed by an inverted',
253
+ 'grep and a `|| true` tail; every gate that ran it reported success without',
254
+ 'measuring anything). A green result from one of these scripts is NOT evidence',
255
+ 'for any acceptance criterion. To judge the area it claims to cover, run the',
256
+ 'underlying checker DIRECTLY and unmodified (e.g. `tsc --noEmit` rather than',
257
+ '`npm run lint`) and judge THAT output. Unless the task spec explicitly requires',
258
+ 'the script to tolerate failure, the verdict is FAIL naming the script (rule 4f).',
259
+ ''
260
+ ]
261
+ : [];
262
+ const runnerGlobBlock = runnerGlobFindings && runnerGlobFindings.length > 0 ?
263
+ [
264
+ 'TEST-RUNNER GLOB COLLISION NOTICE (deterministic, computed by the orchestrator',
265
+ "from the manifest's declared runners and their config): two test runners claim",
266
+ 'the same files:',
267
+ ...runnerGlobFindings.map(f => `- ${f}`),
268
+ "The scanning runner will import the other's spec files and abort on a module",
269
+ 'loaded outside its own runner — so the suite dies WHOLESALE rather than',
270
+ 'reporting failures (this is the THIRD occurrence: mx5 runs 7 and 13). Run both',
271
+ 'test commands yourself and confirm each collects and runs its own files and only',
272
+ 'its own. A run that errors during collection has verified nothing, whatever its',
273
+ 'exit code says (rule 4g).',
274
+ ''
275
+ ]
276
+ : [];
219
277
  const testAssemblyBlock = testAssemblyFindings && testAssemblyFindings.length > 0 ?
220
278
  [
221
279
  'TEST-ASSEMBLY NOTICE (deterministic, computed by the orchestrator from pure',
@@ -255,6 +313,9 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
255
313
  ...crossTaskDeletionBlock,
256
314
  ...probeGamingBlock,
257
315
  ...skipEscapeBlock,
316
+ ...foreignPathBlock,
317
+ ...scriptEscapeBlock,
318
+ ...runnerGlobBlock,
258
319
  ...testAssemblyBlock,
259
320
  'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
260
321
  'checkout (or CI run) would experience it:',
@@ -388,6 +449,32 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFind
388
449
  ' tree). Otherwise the verdict is FAIL naming the deleted file and the task that',
389
450
  ' owns it.',
390
451
  '',
452
+ '4e. AN ABSOLUTE PATH TO PROJECT FILES IS A DEFECT — a committed path like',
453
+ " `/workspace/src/shared` or `/home/<someone>/proj/src` names the authoring agent's",
454
+ ' OWN machine, not this one. Its distinctive damage is that it breaks the run BEFORE',
455
+ ' any check reports: a bad alias/config path makes the tool fail to RESOLVE or BUILD,',
456
+ ' so a suite "passes" having executed nothing. Treat a command that reports no',
457
+ ' failures but also no WORK — 0 tests run, 0 files emitted, an empty report — as',
458
+ ' unverified, never as green. Confirm the count of things that actually ran. A path',
459
+ ' to project-internal files must be relative to the file that carries it, or computed',
460
+ ' at runtime; if one does not resolve here, the verdict is FAIL naming file and path.',
461
+ '',
462
+ '4f. A CHECK THAT CANNOT FAIL PROVES NOTHING — before you cite a check script',
463
+ " (`npm run lint`, `bun run test`) as evidence, read its DEFINITION in the project's",
464
+ ' manifest. A script ending in `|| true`, `; exit 0`, or piping a checker into an',
465
+ ' inverted grep exits 0 unconditionally: its green result is a constant, not a',
466
+ ' measurement, and running it again only reproduces the constant. When a script is',
467
+ ' built that way, run the underlying checker directly and judge its real output; and',
468
+ ' unless the spec required that tolerance, the script itself is a defect — the',
469
+ ' verdict is FAIL naming it.',
470
+ '',
471
+ '4g. TWO RUNNERS, ONE FILE SET, NO RESULTS — when a project declares more than one',
472
+ ' test runner, check that each collects only its own files. A runner that scans for',
473
+ " `*.test.*` / `*.spec.*` project-wide will import another runner's specs and abort",
474
+ ' during COLLECTION. That failure mode looks nothing like a test failure: you get an',
475
+ ' import error, or a suite that reports zero tests. Always read how many tests each',
476
+ ' command actually COLLECTED and RAN; zero collected is never a pass.',
477
+ '',
391
478
  '5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
392
479
  ' service or network resource (a database server, an API host) that the project',
393
480
  ' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
@@ -572,6 +659,39 @@ export async function runWorkVerification(deps) {
572
659
  crossDeletions = [];
573
660
  }
574
661
  }
662
+ // Sandbox-path-leak findings the deterministic repair could NOT fix, injected
663
+ // under rule 4e. A probe failure must never block verification.
664
+ let foreignPaths = [];
665
+ if (deps.foreignPathProbe) {
666
+ try {
667
+ foreignPaths = await deps.foreignPathProbe();
668
+ }
669
+ catch {
670
+ foreignPaths = [];
671
+ }
672
+ }
673
+ // Neutered check scripts in a manifest this task changed, injected under rule
674
+ // 4f. A probe failure must never block verification.
675
+ let scriptEscapes = [];
676
+ if (deps.scriptEscapeProbe) {
677
+ try {
678
+ scriptEscapes = await deps.scriptEscapeProbe();
679
+ }
680
+ catch {
681
+ scriptEscapes = [];
682
+ }
683
+ }
684
+ // Colliding test-runner globs, injected under rule 4g. A probe failure must
685
+ // never block verification.
686
+ let runnerGlobs = [];
687
+ if (deps.runnerGlobProbe) {
688
+ try {
689
+ runnerGlobs = await deps.runnerGlobProbe();
690
+ }
691
+ catch {
692
+ runnerGlobs = [];
693
+ }
694
+ }
575
695
  // Environment facts from earlier gate children (best-effort; a cache failure
576
696
  // must never block verification).
577
697
  let envNotes = '';
@@ -607,7 +727,11 @@ export async function runWorkVerification(deps) {
607
727
  for (let attempt = 1;; attempt++) {
608
728
  let text;
609
729
  try {
610
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming, crossTaskDeletionVerifyFindings(crossDeletions)), deps.signal);
730
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions, skipEscapes, contracts, testAssembly, probeGaming, crossTaskDeletionVerifyFindings(crossDeletions), {
731
+ foreignPaths,
732
+ scriptEscapes,
733
+ runnerGlobs
734
+ }), deps.signal);
611
735
  }
612
736
  catch (err) {
613
737
  if (err instanceof Error && err.message === USER_CANCELLED)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.30",
3
+ "version": "0.18.31",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",