@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.
- package/dist/task/auto-orchestrator.d.ts +8 -0
- package/dist/task/auto-orchestrator.js +83 -10
- package/dist/task/final-gate-fix.d.ts +25 -0
- package/dist/task/final-gate-fix.js +33 -0
- package/dist/task/foreign-path.d.ts +96 -0
- package/dist/task/foreign-path.js +0 -0
- package/dist/task/gate-deps.js +141 -0
- package/dist/task/phases.js +29 -7
- package/dist/task/runner-globs.d.ts +74 -0
- package/dist/task/runner-globs.js +155 -0
- package/dist/task/script-escape.d.ts +83 -0
- package/dist/task/script-escape.js +189 -0
- package/dist/task/verify-work.d.ts +39 -1
- package/dist/task/verify-work.js +126 -2
- package/dist/workers/research-cache.d.ts +27 -0
- package/dist/workers/research-cache.js +94 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
|
|
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
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -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
|
package/dist/task/phases.js
CHANGED
|
@@ -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,
|
|
960
|
-
//
|
|
961
|
-
//
|
|
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
|
|
978
|
-
// rewrite (all are forced FOCUS items).
|
|
979
|
-
const rewriteDefects = [
|
|
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();
|