@mjasnikovs/pi-task 0.18.29 → 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.
@@ -61,6 +61,21 @@ export interface PiTaskConfig {
61
61
  * a true hang doesn't cost half an hour of dead time.
62
62
  */
63
63
  requestTimeoutMs: number;
64
+ /**
65
+ * UNATTENDED AUTO-PICK (see task/yolo.ts): wherever pi-task would stop and ask,
66
+ * take the option it already marks RECOMMENDED, stamp the artifact `(YOLO)` so
67
+ * an audit can tell a machine decided, and never notify. Lets a local model run
68
+ * a throwaway/test project end to end with nobody watching.
69
+ *
70
+ * Decided PER SITE, before the prompt is built — so the lone prompt notification
71
+ * (SessionUI.ask) is suppressed structurally, and the existing unattended budgets
72
+ * (MAX_AUTO_AUTOFIX, MAX_FINAL_GATE_AUTOFIX) still bound the loops they were
73
+ * written to bound. An auto-pick may cost time, never work: a question with no
74
+ * recommendation, or one the anti-synthesis guard demoted, is SKIPPED, not
75
+ * invented.
76
+ * DEFAULT OFF — this is never the behaviour of a normal, watched run.
77
+ */
78
+ yoloMode: boolean;
64
79
  }
65
80
  /**
66
81
  * The command-watchdog timeout choices offered by /task-config, newest-first in
@@ -38,7 +38,9 @@ const DEFAULTS = {
38
38
  researchCache: true,
39
39
  searchProvider: 'exa',
40
40
  extensionWhitelist: [],
41
- requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
41
+ requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
42
+ // OFF: auto-answering is for unattended throwaway runs only.
43
+ yoloMode: false
42
44
  };
43
45
  /**
44
46
  * A hand-edited config can hold anything; keep only string entries so a stray
@@ -67,6 +69,11 @@ if (!G.loaded) {
67
69
  delete parsed.searchProvider;
68
70
  parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
69
71
  parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
72
+ // A hand-edited `"yoloMode": "false"` is a truthy string — it must not
73
+ // silently switch a watched run into unattended auto-pick. Only a real
74
+ // boolean counts; anything else falls back to the OFF default.
75
+ if (typeof parsed.yoloMode !== 'boolean')
76
+ delete parsed.yoloMode;
70
77
  G.config = { ...DEFAULTS, ...parsed };
71
78
  }
72
79
  catch {
@@ -107,6 +107,16 @@ const ITEMS = [
107
107
  + 'unbounded',
108
108
  // Display human labels; the stored config value stays the ms number.
109
109
  values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
110
+ },
111
+ {
112
+ id: 'yoloMode',
113
+ label: 'yolo mode',
114
+ description: 'UNATTENDED: auto-answer every question with the option pi already recommends, '
115
+ + 'and show no prompts at all — clarify/grill answers, the verify-FAIL picker '
116
+ + '(auto-ACCEPT, recorded as a yolo debt), and the final-gate picker (autofix '
117
+ + 'while the budget lasts, then leave the run FAILED). Every auto-pick is stamped '
118
+ + '(YOLO) in the task file and debt ledger. For THROWAWAY/TEST projects you are '
119
+ + 'not watching — a real run should decide these itself'
110
120
  }
111
121
  ];
112
122
  /** Human label for the stored command-timeout ms (falls back to the raw ms). */
@@ -11,6 +11,11 @@
11
11
  * created files need a tsconfig registration every spec forbids). Cross-task
12
12
  * contradiction: no unattended re-run can converge, so the gate loop records the
13
13
  * defect and routes to the human picker instead of burning AUTOFIX rounds.
14
+ * - 'yolo-accepted' — YOLO MODE (unattended auto-pick, see yolo.ts) took the
15
+ * verify-FAIL picker's terminal option with nobody watching. It is NOT the
16
+ * 'accepted' class and must never collapse into it: 'accepted' asserts a HUMAN
17
+ * weighed this failing artifact and blessed it, which is exactly the assurance
18
+ * an auto-pick cannot give. Same re-check treatment, honest provenance.
14
19
  * - 'cross-task-deletion' — the task's work DELETED a sibling task's committed
15
20
  * deliverable (mx5 run 12 PROMPT 2: a fix child deleted TASK_0020's playwright ct
16
21
  * files to green a lint) and the user ACCEPTed the verify-FAIL anyway, so the
@@ -18,7 +23,7 @@
18
23
  * resolved iff the named file is back in the tree (a later task restored it),
19
24
  * otherwise surfaced.
20
25
  */
21
- export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion';
26
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted';
22
27
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
23
28
  export interface AcceptDebt {
24
29
  taskId: string;
@@ -77,6 +82,13 @@ export declare function recordCrossTaskDeletionDebt(cwd: string, taskId: string,
77
82
  path: string;
78
83
  owner: string;
79
84
  }): Promise<void>;
85
+ /**
86
+ * Record a YOLO-ACCEPTED debt: unattended auto-pick took the verify-FAIL picker's
87
+ * ACCEPT branch because there was nobody to ask (yolo.ts). Its own origin — and
88
+ * therefore its own line in the final gate's surfaced report — so a later audit
89
+ * reading only the artifacts can never read it as "a human decided this".
90
+ */
91
+ export declare function recordYoloAcceptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
80
92
  /**
81
93
  * The deleted path a cross-task-deletion debt names (the fixed shape
82
94
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -76,7 +76,8 @@ export function parseAcceptDebts(raw) {
76
76
  reason: parts[1].trim(),
77
77
  ...((origin === 'enforce-revert'
78
78
  || origin === 'frozen-blocked'
79
- || origin === 'cross-task-deletion') ?
79
+ || origin === 'cross-task-deletion'
80
+ || origin === 'yolo-accepted') ?
80
81
  { origin: origin }
81
82
  : {})
82
83
  });
@@ -173,6 +174,19 @@ export async function recordCrossTaskDeletionDebt(cwd, taskId, deletion) {
173
174
  origin: 'cross-task-deletion'
174
175
  });
175
176
  }
177
+ /**
178
+ * Record a YOLO-ACCEPTED debt: unattended auto-pick took the verify-FAIL picker's
179
+ * ACCEPT branch because there was nobody to ask (yolo.ts). Its own origin — and
180
+ * therefore its own line in the final gate's surfaced report — so a later audit
181
+ * reading only the artifacts can never read it as "a human decided this".
182
+ */
183
+ export async function recordYoloAcceptDebt(cwd, taskId, reason) {
184
+ await appendDebt(cwd, {
185
+ taskId: taskId.trim(),
186
+ reason: normaliseReason(reason),
187
+ origin: 'yolo-accepted'
188
+ });
189
+ }
176
190
  /**
177
191
  * The deleted path a cross-task-deletion debt names (the fixed shape
178
192
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -324,5 +338,8 @@ export function describeDebt(d) {
324
338
  if (d.origin === 'cross-task-deletion') {
325
339
  return "a sibling task's committed deliverable was DELETED by this task's work and the deletion was accepted (still missing from the tree)";
326
340
  }
341
+ if (d.origin === 'yolo-accepted') {
342
+ return 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)';
343
+ }
327
344
  return 'accepted despite verify-FAIL';
328
345
  }
@@ -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,13 +24,14 @@ 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
+ import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './yolo.js';
34
35
  import { configureResearchRun } from '../workers/research-cache.js';
35
36
  import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
36
37
  import { reconcileTitleSources } from './decompose-fidelity.js';
@@ -409,6 +410,20 @@ export async function planAuto(ctx, cwd, feature, deps) {
409
410
  // the recommended one tinted green; an open question shows the bare text
410
411
  // prompt. No verbose "Recommended:" / "press Enter to accept" scaffolding.
411
412
  const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
413
+ // YOLO: take the recommended option (index 0 / the green card) without ever
414
+ // building the prompt. Clarify has no anti-synthesis channel — it runs before
415
+ // any research — so the only step-aside here is a question that carries no
416
+ // recommendation to take; that one is skipped rather than guessed.
417
+ const yolo = yoloPickAnswer(isYoloMode(), {
418
+ ...(plainSuggested !== undefined && { suggested: plainSuggested }),
419
+ ...(plainAlt !== undefined && { alt: plainAlt })
420
+ });
421
+ if (yolo !== null) {
422
+ const auto = yolo.kind === 'answer' ? yolo.answer : `(skipped — ${yolo.note})`;
423
+ answers.push(`Q${answers.length + 1}: ${plainQ}\n`
424
+ + `A${answers.length + 1}: ${auto} ${YOLO_STAMP}`);
425
+ continue;
426
+ }
412
427
  const options = twoOption ?
413
428
  [
414
429
  {
@@ -889,7 +904,14 @@ function defaultDeps(ctx, cwd, signal, title) {
889
904
  // run-level half of the same verification story.
890
905
  finalGate: (cwd2, planText) => getConfig().verifyWork ?
891
906
  runFinalIntegrationGate(cwd2, undefined, undefined, undefined, planText)
892
- : 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
+ }
893
915
  };
894
916
  }
895
917
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -994,6 +1016,21 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
994
1016
  // after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
995
1017
  // autofix card is withdrawn so the loop cannot run unbounded.
996
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
+ };
997
1034
  while (!fin.ok) {
998
1035
  const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
999
1036
  // The picker question shows the debts (the HUMAN weighs them);
@@ -1004,26 +1041,73 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1004
1041
  + '(the project’s own test/build/static commands, run unaided).'
1005
1042
  + (fixAttempts > 0 ?
1006
1043
  `\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
1007
- : '');
1008
- const answer = await new SessionUI(active).ask({
1009
- localTitle: 'Final integration gate failed — how should pi proceed?',
1010
- displayQuestion: question,
1011
- question,
1012
- recommended: FINAL_LEAVE_LABEL,
1013
- recommended2: canAutofix ? FINAL_AUTOFIX_LABEL : FINAL_ACCEPT_LABEL,
1014
- allowSkip: false,
1015
- options: [
1016
- { label: FINAL_LEAVE_LABEL, value: FINAL_LEAVE_VALUE },
1017
- ...(canAutofix ?
1018
- [{ label: FINAL_AUTOFIX_LABEL, value: FINAL_AUTOFIX_VALUE }]
1019
- : []),
1020
- { label: FINAL_ACCEPT_LABEL, value: FINAL_ACCEPT_VALUE }
1021
- ]
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);
1049
+ // YOLO: keep autofixing WHILE the card is still offered — the
1050
+ // loop withdraws it after MAX_FINAL_GATE_AUTOFIX, so the cap
1051
+ // that bounds a non-converging fix pass still bounds this —
1052
+ // then LEAVE the run failed. Never 'accept': an unattended run
1053
+ // that could not green the whole-repo gate has not produced a
1054
+ // working project, and mx5 run 13 shows what an accepted FAIL
1055
+ // looks like afterwards (a shipped app that 404s at `/`).
1056
+ const yoloFinal = yoloFinalGateChoice(isYoloMode(), canAutofix);
1057
+ if (yoloFinal !== null) {
1058
+ await recGate(`final-gate: auto-chose ${yoloFinal.action.toUpperCase()} ${YOLO_STAMP}`);
1059
+ }
1060
+ const answer = yoloFinal !== null ?
1061
+ yoloFinal.action === 'autofix' ?
1062
+ FINAL_AUTOFIX_VALUE
1063
+ : FINAL_LEAVE_VALUE
1064
+ : await new SessionUI(active).ask({
1065
+ localTitle: 'Final integration gate failed — how should pi proceed?',
1066
+ displayQuestion: question,
1067
+ question,
1068
+ recommended: FINAL_LEAVE_LABEL,
1069
+ recommended2: canAutofix ? FINAL_AUTOFIX_LABEL : FINAL_ACCEPT_LABEL,
1070
+ allowSkip: false,
1071
+ options: [
1072
+ { label: FINAL_LEAVE_LABEL, value: FINAL_LEAVE_VALUE },
1073
+ ...(canAutofix ?
1074
+ [
1075
+ {
1076
+ label: FINAL_AUTOFIX_LABEL,
1077
+ value: FINAL_AUTOFIX_VALUE
1078
+ }
1079
+ ]
1080
+ : []),
1081
+ { label: FINAL_ACCEPT_LABEL, value: FINAL_ACCEPT_VALUE }
1082
+ ]
1083
+ });
1023
1084
  const choice = classifyFinalGateAnswer(answer);
1024
1085
  if (choice.action === 'accept') {
1025
1086
  await recGate('final-gate: FAIL accepted by user');
1026
- 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');
1027
1111
  break;
1028
1112
  }
1029
1113
  if (choice.action === 'autofix' && canAutofix) {
@@ -1042,6 +1126,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1042
1126
  break;
1043
1127
  }
1044
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
+ }
1045
1137
  active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
1046
1138
  // Work from the FRESH gate failure when the fix pass got
1047
1139
  // as far as re-running the gate; otherwise keep the last.
@@ -1064,9 +1156,22 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1064
1156
  }
1065
1157
  // Leave failed — the dismissal default, unchanged from the
1066
1158
  // two-option picker (an unavailable autofix demotes here too).
1067
- await recGate('final-gate: left failed (user)');
1159
+ await recGate(yoloFinal !== null ?
1160
+ `final-gate: left failed — autofix budget spent, nobody to ask ${YOLO_STAMP}`
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
+ }
1068
1170
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
1069
- 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');
1070
1175
  return;
1071
1176
  }
1072
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