@mjasnikovs/pi-task 0.18.30 → 0.18.32

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,15 +24,15 @@ 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
- import { configureResearchRun } from '../workers/research-cache.js';
35
+ import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
36
36
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
37
37
  import { reconcileTitleSources } from './decompose-fidelity.js';
38
38
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.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
  }
@@ -1328,9 +1396,14 @@ async function handleTaskAutoResume(_args, ctx) {
1328
1396
  ctx.ui.notify(`Resuming ${id}…`, 'info');
1329
1397
  await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
1330
1398
  autoRunning = true;
1331
- // Fresh per-run research-cache id for the resumed run (F10); a resume re-fetches
1332
- // rather than reusing the interrupted run's digest — safe, only slightly less reuse.
1333
- configureResearchRun(getConfig().researchCache);
1399
+ // Reuse the interrupted run's research-cache id when the cache proves it still
1400
+ // describes the same dependency surface (F10). mx5 run 13 resumed three times and
1401
+ // each resume's fresh id discarded a working 201-entry cache; anything inconclusive
1402
+ // still falls back to a fresh id and a re-fetch. See resumeResearchRun.
1403
+ const research = await resumeResearchRun(cwd, getConfig().researchCache);
1404
+ if (research.reused) {
1405
+ logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies)`);
1406
+ }
1334
1407
  const abort = new AbortController();
1335
1408
  // Resume only runs the loop (runTask); no planning children, so the loader
1336
1409
  // title is unused here — pass the id for clarity if that ever changes.
@@ -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();