@mjasnikovs/pi-task 0.18.39 → 0.18.40

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.
@@ -29,8 +29,16 @@
29
29
  * `lsof` and the sandbox had neither) and no further attempt can move it. The
30
30
  * run is allowed to converge on the REMAINING checks, carrying this one here
31
31
  * so the next run's gate re-checks and re-surfaces it rather than losing it.
32
+ * - 'root-cause' — the task's verify FAILed because of a PRE-EXISTING defect in a
33
+ * file a DIFFERENT task created, which this task's own work never touched (mx5
34
+ * run 14: TASK_0007's `test/teardown.ts` TRUNCATE bug FAILed TASK_0013 and
35
+ * TASK_0019). The current task is not at fault, so its work — and, at the
36
+ * enforce site, the enforce pass's edits — are KEPT rather than reverted; the
37
+ * defect is recorded here and a scoped repair task is queued into the plan
38
+ * (root-cause-repair.ts). Before this class existed the ledger recorded the same
39
+ * root cause twice and nothing ever scheduled a fix, so it survived ~24h.
32
40
  */
33
- export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate';
41
+ export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
34
42
  /** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
35
43
  export interface AcceptDebt {
36
44
  taskId: string;
@@ -105,6 +113,15 @@ export declare function recordYoloAcceptDebt(cwd: string, taskId: string, reason
105
113
  * The taskId is the run's parent id — the demotion is a run-level decision.
106
114
  */
107
115
  export declare function recordFinalGateUnobservedDebt(cwd: string, taskId: string, reason: string): Promise<void>;
116
+ /**
117
+ * Record a ROOT-CAUSE debt (mx5 run 14 / PROMPT item 5): this task's verify FAILed
118
+ * on a pre-existing defect in a file ANOTHER task created and this task never
119
+ * touched. The current task is not at fault — its work (and, at the enforce site,
120
+ * the enforce pass's edits) is KEPT — but the defect is real and still in the tree,
121
+ * so it is recorded here and a scoped repair task is queued (root-cause-repair.ts).
122
+ * Behavioral/model-judged, so the final gate surfaces it rather than auto-closing it.
123
+ */
124
+ export declare function recordRootCauseDebt(cwd: string, taskId: string, reason: string): Promise<void>;
108
125
  /**
109
126
  * The deleted path a cross-task-deletion debt names (the fixed shape
110
127
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -78,7 +78,8 @@ export function parseAcceptDebts(raw) {
78
78
  || origin === 'frozen-blocked'
79
79
  || origin === 'cross-task-deletion'
80
80
  || origin === 'yolo-accepted'
81
- || origin === 'final-gate') ?
81
+ || origin === 'final-gate'
82
+ || origin === 'root-cause') ?
82
83
  { origin: origin }
83
84
  : {})
84
85
  });
@@ -203,6 +204,21 @@ export async function recordFinalGateUnobservedDebt(cwd, taskId, reason) {
203
204
  origin: 'final-gate'
204
205
  });
205
206
  }
207
+ /**
208
+ * Record a ROOT-CAUSE debt (mx5 run 14 / PROMPT item 5): this task's verify FAILed
209
+ * on a pre-existing defect in a file ANOTHER task created and this task never
210
+ * touched. The current task is not at fault — its work (and, at the enforce site,
211
+ * the enforce pass's edits) is KEPT — but the defect is real and still in the tree,
212
+ * so it is recorded here and a scoped repair task is queued (root-cause-repair.ts).
213
+ * Behavioral/model-judged, so the final gate surfaces it rather than auto-closing it.
214
+ */
215
+ export async function recordRootCauseDebt(cwd, taskId, reason) {
216
+ await appendDebt(cwd, {
217
+ taskId: taskId.trim(),
218
+ reason: normaliseReason(reason),
219
+ origin: 'root-cause'
220
+ });
221
+ }
206
222
  /**
207
223
  * The deleted path a cross-task-deletion debt names (the fixed shape
208
224
  * recordCrossTaskDeletionDebt writes). Null on any other reason text — an
@@ -357,6 +373,9 @@ export function describeDebt(d) {
357
373
  if (d.origin === 'yolo-accepted') {
358
374
  return 'auto-ACCEPTED by YOLO mode despite verify-FAIL (unattended — no human weighed this)';
359
375
  }
376
+ if (d.origin === 'root-cause') {
377
+ return "verify FAILed on a PRE-EXISTING defect in another task's file that this task never touched (this task's work was kept; a scoped repair task was queued for the root cause)";
378
+ }
360
379
  if (d.origin === 'final-gate') {
361
380
  return 'final-gate check DEMOTED to UNOBSERVED (identical failure across two tree-changing fix attempts — unfalsifiable in that environment, never proven passing)';
362
381
  }
@@ -42,5 +42,21 @@ export declare function checkOffTask(cwd: string, id: string, index: number, pro
42
42
  * of starting a brand-new task — matching how /task-resume behaves.
43
43
  */
44
44
  export declare function stampTaskInProgress(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
45
+ /**
46
+ * Insert a NEW unchecked entry directly after the `afterIndex`th checkbox — the
47
+ * mid-run plan mutation the root-cause repair channel needs (mx5 run 14 item 5:
48
+ * a repair task must land BEFORE the next dependent task, not at the end of the
49
+ * plan, or the defect keeps failing everything in between).
50
+ *
51
+ * MONOTONIC by construction (the run-12 replacement lesson): this only ever
52
+ * SPLICES a line in. No existing entry is rewritten, reordered, or dropped, and
53
+ * an already-present title is a no-op — so a plan can grow mid-run but never
54
+ * shrink, and a retried insert cannot duplicate. Returns whether a line was added.
55
+ *
56
+ * Later entries shift down by one, which is safe because the /task-auto loop
57
+ * re-reads and re-parses the plan at the top of every iteration and locates its
58
+ * next step by "first unchecked" rather than by a cached index.
59
+ */
60
+ export declare function insertTaskAfter(cwd: string, id: string, afterIndex: number, title: string): Promise<boolean>;
45
61
  /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
46
62
  export declare function findResumableAuto(cwd: string): Promise<string | null>;
@@ -140,6 +140,60 @@ export async function checkOffTask(cwd, id, index, producedId, title) {
140
140
  export async function stampTaskInProgress(cwd, id, index, producedId, title) {
141
141
  await rewriteTaskLine(cwd, id, index, () => `- [ ] ${producedId} ${title}`, 'stampTaskInProgress');
142
142
  }
143
+ /** The bare title of a checkbox line (id stamp stripped), or null if not one. */
144
+ function entryTitle(line) {
145
+ const m = CHECKBOX_RE.exec(line.trim());
146
+ if (!m)
147
+ return null;
148
+ const rest = m[2].trim();
149
+ const idm = PRODUCED_ID_RE.exec(rest);
150
+ return idm ? idm[2].trim() : rest;
151
+ }
152
+ /**
153
+ * Insert a NEW unchecked entry directly after the `afterIndex`th checkbox — the
154
+ * mid-run plan mutation the root-cause repair channel needs (mx5 run 14 item 5:
155
+ * a repair task must land BEFORE the next dependent task, not at the end of the
156
+ * plan, or the defect keeps failing everything in between).
157
+ *
158
+ * MONOTONIC by construction (the run-12 replacement lesson): this only ever
159
+ * SPLICES a line in. No existing entry is rewritten, reordered, or dropped, and
160
+ * an already-present title is a no-op — so a plan can grow mid-run but never
161
+ * shrink, and a retried insert cannot duplicate. Returns whether a line was added.
162
+ *
163
+ * Later entries shift down by one, which is safe because the /task-auto loop
164
+ * re-reads and re-parses the plan at the top of every iteration and locates its
165
+ * next step by "first unchecked" rather than by a cached index.
166
+ */
167
+ export async function insertTaskAfter(cwd, id, afterIndex, title) {
168
+ const clean = title.trim();
169
+ if (clean.length === 0)
170
+ return false;
171
+ const { body } = await readTaskFile(cwd, id);
172
+ const section = extractSection(body, 'tasks') ?? '';
173
+ const lines = section.split('\n');
174
+ // Duplicate check scans the WHOLE list first: an existing entry with this exact
175
+ // title (checked or not) means the plan already carries this step, wherever it
176
+ // sits relative to afterIndex — never add a second one.
177
+ if (lines.some(l => entryTitle(l) === clean))
178
+ return false;
179
+ let seen = -1;
180
+ let insertAt = -1;
181
+ for (let i = 0; i < lines.length; i++) {
182
+ if (!CHECKBOX_RE.test(lines[i].trim()))
183
+ continue;
184
+ seen++;
185
+ insertAt = i + 1;
186
+ if (seen === afterIndex)
187
+ break;
188
+ }
189
+ // An out-of-range index appends after the LAST checkbox rather than throwing:
190
+ // a plan that grew underneath the caller must still receive the entry.
191
+ if (insertAt === -1)
192
+ return false;
193
+ lines.splice(insertAt, 0, `- [ ] ${clean}`);
194
+ await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
195
+ return true;
196
+ }
143
197
  /** Find the most-recently-updated resumable TASK_AUTO_* file, or null. */
144
198
  export async function findResumableAuto(cwd) {
145
199
  await ensureTasksDir(cwd);
@@ -14,7 +14,8 @@ import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js'
14
14
  import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
15
15
  import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
16
16
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
17
- import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
17
+ import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAuto } from './auto-io.js';
18
+ import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
18
19
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
19
20
  import { readTextFile } from '../shared/fs-text.js';
20
21
  import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
@@ -320,6 +321,63 @@ export function buildScopeFence(titles, currentIndex) {
320
321
  + `components, or flows owned by a later step.\n\n`
321
322
  + `The full plan (these run separately — do NOT implement them here):\n${listing}`);
322
323
  }
324
+ /**
325
+ * The scope fence for step `currentIndex`, plus the REPAIR fence when that step is
326
+ * a queued root-cause repair. A repair title ("repair test/teardown.ts: …") reads
327
+ * to refine like any other feature step, and refine's job is to expand a title into
328
+ * a full spec — which is exactly how "repair the teardown" becomes "overhaul the
329
+ * test infrastructure" (the /task-auto drift lesson). The extra fence pins the one
330
+ * editable file and pins VERIFY to the command the defect was failing.
331
+ */
332
+ function buildStepFence(titles, currentIndex) {
333
+ const base = buildScopeFence(titles, currentIndex);
334
+ const repairFile = parseRepairTitleFile(titles[currentIndex] ?? '');
335
+ if (!repairFile)
336
+ return base;
337
+ return `${base}\n\n${buildRepairScopeFence(repairFile, extractFailingCommand(titles[currentIndex] ?? ''))}`;
338
+ }
339
+ /**
340
+ * Drain the gate's root-cause repair queue into the running plan: one scoped
341
+ * repair step per accused FILE, spliced in directly after the step that just
342
+ * finished.
343
+ *
344
+ * Three bounds, all mandatory (mx5 run 14 item 5 gray areas):
345
+ * - DEDUP by file — run 14's two `test/teardown.ts` debts must yield ONE repair
346
+ * step, not two. mergeRepairCandidates collapses the drained queue, and
347
+ * planHasRepairFor rejects a file the plan already carries a repair for.
348
+ * - CAP 1 per file per RUN — planHasRepairFor counts CHECKED-OFF entries too, so
349
+ * a repair step that itself failed is never re-spawned; it lands in the
350
+ * accept-debt ledger like any other task. That is what stops a repair loop.
351
+ * - MONOTONIC — insertTaskAfter only splices; no existing entry is rewritten,
352
+ * reordered or dropped (the run-12 replacement lesson).
353
+ *
354
+ * Best-effort throughout: a fault here must never fail the run that produced the
355
+ * finding — the debt is already durably recorded either way.
356
+ */
357
+ async function schedulePendingRepairs(cwd, id, afterIndex, ctx, deps) {
358
+ try {
359
+ const pending = await drainRepairQueue(cwd);
360
+ if (pending.length === 0)
361
+ return;
362
+ const { body } = await readTaskFile(cwd, id);
363
+ const titles = parseTaskList(body).map(e => e.title);
364
+ let at = afterIndex;
365
+ for (const repair of mergeRepairCandidates(pending)) {
366
+ if (planHasRepairFor(titles, repair.file))
367
+ continue;
368
+ const title = buildRepairTitle(repair);
369
+ if (!(await insertTaskAfter(cwd, id, at, title)))
370
+ continue;
371
+ titles.splice(at + 1, 0, title);
372
+ at += 1;
373
+ await deps.record?.(cwd, id, `plan: inserted scoped repair step after step ${afterIndex + 1} — ${title}`);
374
+ ctx.ui.notify(`${id}: queued a scoped repair for ${repair.file} (${repair.owner}'s file — root cause of ${repair.blamed.join(', ')}).`, 'warning');
375
+ }
376
+ }
377
+ catch {
378
+ // the plan is best-effort here; the underlying debt is already recorded
379
+ }
380
+ }
323
381
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
324
382
  export async function planAuto(ctx, cwd, feature, deps) {
325
383
  // clarify — sequential & adaptive: ask one question at a time, feeding every
@@ -1341,7 +1399,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1341
1399
  // matters when refine runs fresh (a resumed task past refine ignores
1342
1400
  // it), but always supplied so a resume that restarts at refine is
1343
1401
  // fenced too.
1344
- planContext: buildScopeFence(entries.map(e => e.title), next.index),
1402
+ planContext: buildStepFence(entries.map(e => e.title), next.index),
1345
1403
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
1346
1404
  });
1347
1405
  active = res.ctx ?? active;
@@ -1396,7 +1454,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1396
1454
  title: next.title,
1397
1455
  tag: id,
1398
1456
  // Fence an AUTOFIX re-run against re-expanding the whole spec.
1399
- planContext: buildScopeFence(entries.map(e => e.title), next.index),
1457
+ planContext: buildStepFence(entries.map(e => e.title), next.index),
1400
1458
  // res.ok === true means runner.run() completed, so res.taskId is the
1401
1459
  // allocated TASK_NNNN id (never empty here). The parent task-list
1402
1460
  // check-off runs after verify passes/accepts and before the commit,
@@ -1426,6 +1484,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1426
1484
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
1427
1485
  return;
1428
1486
  }
1487
+ // ROOT-CAUSE REPAIR (mx5 run 14 item 5): the gate may have attributed a
1488
+ // FAIL to a pre-existing defect in a file some OTHER task created. It can
1489
+ // only QUEUE that finding — mutating the plan is this loop's job. Drain
1490
+ // the queue and splice a scoped repair step in right after the step that
1491
+ // just finished, so the defect is fixed BEFORE the next dependent task
1492
+ // trips over it too (run 14 recorded the same `test/teardown.ts` cause
1493
+ // twice, scheduled nothing, and the bug outlived ~24h of the run).
1494
+ await schedulePendingRepairs(cwd, id, next.index, active, deps);
1429
1495
  // gate.kind === 'done' → fall through to the next task, after checking
1430
1496
  // no landmine stash was left behind by anything that ran in between.
1431
1497
  if (deps.stashRef && stashBefore !== undefined) {
@@ -22,7 +22,8 @@ import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-
22
22
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
23
23
  import { readEnvNotes, appendEnvNotes } from './env-notes.js';
24
24
  import { readContracts } from './contracts.js';
25
- import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt } from './accept-debt.js';
25
+ import { recordAcceptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
26
+ import { recordRepairCandidate } from './root-cause-repair.js';
26
27
  import { runRepoHealthCheck } from './repo-health-check.js';
27
28
  import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
28
29
  import { runFinalGateAutofix } from './final-gate-fix.js';
@@ -471,6 +472,40 @@ export function buildGateDeps(params) {
471
472
  // deliverable this task's diff deletes, ACCEPTed into a commit anyway —
472
473
  // the final gate re-checks it (resolved iff the file is back in the tree).
473
474
  recordCrossTaskDeletionDebt: (cwd2, taskId, deletion) => recordCrossTaskDeletionDebt(cwd2, taskId, deletion),
475
+ // ROOT-CAUSE channel (mx5 run 14 item 5): a FAIL another task's untouched
476
+ // file caused is recorded as its own debt class and queued as a scoped
477
+ // repair task, instead of being blamed on — and reverted out of — the task
478
+ // that merely tripped over it.
479
+ recordRootCauseDebt: (cwd2, taskId, reason) => recordRootCauseDebt(cwd2, taskId, reason),
480
+ recordRepairCandidate: (cwd2, candidate) => recordRepairCandidate(cwd2, candidate),
481
+ // file → introducing task, the provenance half of the discriminator.
482
+ introducedBy: (cwd2, rel) => Promise.resolve(taskThatIntroduced(cwd2, rel)),
483
+ // The authorship half: which files THIS task's work touched. `worktree` is
484
+ // the pre-commit verify site (uncommitted changes); `committed` is the
485
+ // post-commit enforce site, where the task snapshot and the ENFORCE commit
486
+ // are the last two commits. Any git fault returns null, which stands the
487
+ // channel down entirely rather than guessing.
488
+ touchedFiles: async (cwd2, scope) => {
489
+ try {
490
+ if (scope === 'worktree') {
491
+ const r = await git(cwd2, ['status', '--porcelain'], signal);
492
+ if (r.exitCode !== 0)
493
+ return null;
494
+ const c = parseTreeChanges(r.stdout);
495
+ return [...c.modified, ...c.added, ...c.deleted];
496
+ }
497
+ const r = await git(cwd2, ['log', '-n', '2', '--name-only', '--format=', 'HEAD'], signal);
498
+ if (r.exitCode !== 0)
499
+ return null;
500
+ return r.stdout
501
+ .split('\n')
502
+ .map(l => l.trim())
503
+ .filter(l => l.length > 0);
504
+ }
505
+ catch {
506
+ return null;
507
+ }
508
+ },
474
509
  // Frozen-path write-deny (see frozen-path-guard.ts): the concrete paths this
475
510
  // task's spec forbids modifying, so the gate sequence can UNDO any edit the
476
511
  // enforce EDIT pass makes to them before those edits are committed. Reads the
@@ -0,0 +1,122 @@
1
+ /** One file accused of causing another task's verify FAIL. */
2
+ export interface RepairCandidate {
3
+ /** Repo-relative path of the accused file. */
4
+ file: string;
5
+ /** The task whose commit introduced `file` (provenance). */
6
+ owner: string;
7
+ /** One-line summary of the defect, lifted from the FAIL text. */
8
+ defect: string;
9
+ /** The task whose verify FAILed because of it. */
10
+ blamedTask: string;
11
+ /** The failing command from the debt — becomes the repair task's VERIFY. */
12
+ verifyCommand?: string;
13
+ }
14
+ /** True when the FAIL text blames the ENVIRONMENT rather than a file. */
15
+ export declare function isEnvironmentAttributed(text: string): boolean;
16
+ interface Accusation {
17
+ file: string;
18
+ /** Character distance between the blame cue and the path token. */
19
+ distance: number;
20
+ /** The clause the accusation was made in — the defect summary source. */
21
+ clause: string;
22
+ }
23
+ /**
24
+ * The single file a FAIL text accuses, or null. Scans every blame cue, pairs it
25
+ * with the NEAREST path token within {@link BLAME_WINDOW} characters (a cue and its
26
+ * subject sit adjacent in practice: "pre-existing teardown bug in
27
+ * `test/teardown.ts`"), and keeps the closest pair overall. One FAIL has one root
28
+ * cause, so this deliberately returns at most one accusation rather than every
29
+ * path the reason happens to name.
30
+ */
31
+ export declare function findAccusedFile(text: string): Accusation | null;
32
+ /**
33
+ * Collapse whitespace and clamp — a stored defect summary is one short line that
34
+ * has to read well inside a plan title. The gate's own verdict boilerplate ("work
35
+ * did not verify: ") and a leading repeat of the accused file are stripped: the
36
+ * title already names both the step kind and the file, so repeating them there
37
+ * spends the clamp budget on nothing.
38
+ */
39
+ export declare function summariseDefect(clause: string, file?: string): string;
40
+ /**
41
+ * A runnable command quoted in the FAIL text — the repair task's VERIFY, per the
42
+ * requirement that it re-run the exact command the debt failed on. Only the first
43
+ * backticked token that STARTS like a shell command (optionally env-prefixed)
44
+ * qualifies, so prose in backticks is never mistaken for a command.
45
+ */
46
+ export declare function extractFailingCommand(text: string): string | undefined;
47
+ export interface RootCauseInput {
48
+ /** The verify gate's FAIL reason. */
49
+ failReason: string;
50
+ /** The resolution research's rationale, when one ran ('' otherwise). */
51
+ rationale?: string;
52
+ /** The task whose verify FAILed. */
53
+ currentTaskId: string;
54
+ /**
55
+ * Paths the CURRENT task's own work touches. `null` means unknown (git
56
+ * unavailable) — the channel stands down rather than guessing, so an
57
+ * unreadable tree can only cost a repair task, never spawn a wrong one.
58
+ */
59
+ touched: string[] | null;
60
+ /** file → introducing task (task-provenance.ts). May throw; a throw reads
61
+ * as unknown provenance. */
62
+ introducedBy: (rel: string) => string | null | Promise<string | null>;
63
+ }
64
+ /**
65
+ * The repair candidate a verify FAIL justifies, or null. All three conditions from
66
+ * the module header must hold; anything unknown or environment-shaped returns null.
67
+ */
68
+ export declare function findRepairCandidate(input: RootCauseInput): Promise<RepairCandidate | null>;
69
+ export declare function repairQueueFile(cwd: string): string;
70
+ /** Parse the stored queue. Malformed lines are skipped, never thrown on. */
71
+ export declare function parseRepairQueue(raw: string): RepairCandidate[];
72
+ /** Append one candidate. Best-effort — the queue never blocks a gate. */
73
+ export declare function recordRepairCandidate(cwd: string, c: RepairCandidate): Promise<void>;
74
+ /**
75
+ * Read the queue and CLEAR it. Draining is what makes the "cap 1 repair task per
76
+ * file per run" bound hold without a second ledger: whatever is drained either
77
+ * becomes a plan entry (which is then itself the dedup key — see
78
+ * {@link planHasRepairFor}) or was already covered by one.
79
+ */
80
+ export declare function drainRepairQueue(cwd: string): Promise<RepairCandidate[]>;
81
+ /** One merged repair per file, carrying every task the defect FAILed. */
82
+ export interface MergedRepair {
83
+ file: string;
84
+ owner: string;
85
+ defect: string;
86
+ blamed: string[];
87
+ verifyCommand?: string;
88
+ }
89
+ /**
90
+ * Collapse candidates by file — MANDATORY dedup: run 14's two teardown.ts debts
91
+ * (TASK_0013, TASK_0019) must yield exactly ONE repair task naming both. First
92
+ * record wins for defect/command (they describe the same fault); blamed tasks
93
+ * accumulate in first-seen order.
94
+ */
95
+ export declare function mergeRepairCandidates(candidates: RepairCandidate[]): MergedRepair[];
96
+ /** Machine-recognisable prefix, so a repair entry can be found in a plan again. */
97
+ export declare const REPAIR_TITLE_PREFIX = "repair ";
98
+ /**
99
+ * The plan title for a repair task, in the fixed shape
100
+ * `repair <file>: <defect> (root cause of TASK_A, TASK_B debts)`. The file sits
101
+ * immediately after the prefix so {@link parseRepairTitleFile} can recover it —
102
+ * that recovery is both the dedup key and how the loop knows to attach the
103
+ * repair scope fence.
104
+ */
105
+ export declare function buildRepairTitle(r: MergedRepair): string;
106
+ /** The file a repair title names, or null when the title is not a repair entry. */
107
+ export declare function parseRepairTitleFile(title: string): string | null;
108
+ /**
109
+ * Is a repair for `file` ALREADY in the plan? This is the cap-1-per-file-per-run
110
+ * bound: it counts checked-off entries too, so a repair task that ran and FAILed
111
+ * is never re-spawned — it lands in the accept-debt ledger like any other task.
112
+ */
113
+ export declare function planHasRepairFor(titles: string[], file: string): boolean;
114
+ /**
115
+ * The extra scope fence a repair entry carries into refine. Without it, refine
116
+ * re-expands "repair test/teardown.ts: parameterized table names in TRUNCATE"
117
+ * into "overhaul the test infrastructure" — the /task-auto drift lesson. The
118
+ * fence pins the single editable file and pins the VERIFY to the exact command
119
+ * the debt failed on.
120
+ */
121
+ export declare function buildRepairScopeFence(file: string, verifyCommand?: string): string;
122
+ export {};
@@ -0,0 +1,378 @@
1
+ /**
2
+ * root-cause-repair — turn a verify-FAIL that was CAUSED by another task's file
3
+ * into a scoped repair task in the running plan (mx5 run 14, PROMPT item 5).
4
+ *
5
+ * The failure class this closes, observed end-to-end in run 14: TASK_0007 shipped
6
+ * `test/teardown.ts` with parameterized table names in its TRUNCATE statements.
7
+ * That bug then FAILED two later, unrelated tasks —
8
+ *
9
+ * TASK_0013 "test/teardown.ts has a pre-existing bug (parameterized table names
10
+ * in TRUNCATE statements) … despite all 10 actual tests passing"
11
+ * TASK_0019 "… due to a pre-existing teardown bug in `test/teardown.ts`
12
+ * (created by TASK_0007) … this task did not modify the teardown"
13
+ *
14
+ * — and BOTH ended in an enforce-revert, so the enforce pass's edits were destroyed
15
+ * over a defect the current task did not create. The ledger recorded the root cause
16
+ * twice and NOTHING ever scheduled a fix: the bug survived ~24h, until the final
17
+ * gate's fix child happened to patch it, and it also generated the "2 pre-existing
18
+ * failures / state pollution" excuse notes repeated across TASK_0033…0038.
19
+ *
20
+ * The channel here is deliberately narrow, because a false positive costs a whole
21
+ * task slot and mutates a running plan. Three independent conditions must ALL hold:
22
+ *
23
+ * 1. TEXT — the FAIL reason (or the resolution research's rationale) carries an
24
+ * explicit blame cue ("pre-existing", "created by TASK_nnnn", "bug in", "this
25
+ * task did not modify") and a path token near it. Merely MENTIONING a path is
26
+ * not blame: run 14's TASK_0010 FAIL lists `src/server/db.ts` inside the
27
+ * spec's own "Preserve all existing files on disk" quote, and must not spawn
28
+ * a repair task for it.
29
+ * 2. PROVENANCE — the blamed file was introduced by a DIFFERENT task's commit
30
+ * (task-provenance.ts). Unknown provenance is never evidence.
31
+ * 3. AUTHORSHIP — the current task's own work does not touch the blamed file. If
32
+ * this task edited it, the defect may well be its own and the ordinary
33
+ * autofix/revert path is right.
34
+ *
35
+ * Environment-attributed failures are vetoed outright (run 14's TASK_0006: "no
36
+ * PostgreSQL database server is available in this environment"). No file edit can
37
+ * repair a missing daemon, so a repair task would be a guaranteed non-converging
38
+ * yolo-FAIL.
39
+ *
40
+ * Everything downstream is deduplicated by FILE: N debts naming one root file yield
41
+ * exactly ONE repair task per run (run 14 would otherwise have spawned two for
42
+ * `test/teardown.ts`). The repair task itself is just an ordinary plan entry — if it
43
+ * fails, it lands in the ledger like any other task and is never re-spawned, which
44
+ * is what keeps this from looping.
45
+ */
46
+ import * as fsp from 'node:fs/promises';
47
+ import * as path from 'node:path';
48
+ import { tasksDir } from './task-io.js';
49
+ /** A path-like token: at least one directory separator, ending in a file name. */
50
+ const PATH_TOKEN_RE = /(?:[\w.@-]+\/)+[\w.@-]+\.\w+/g;
51
+ /**
52
+ * Phrases that ATTRIBUTE a failure to something that predates the current task.
53
+ * Each is a blame cue: the path token nearest a cue is the accused file. Kept
54
+ * deliberately specific — a bare "existing" matches the spec boilerplate
55
+ * "Preserve all existing files on disk" that run 14's TASK_0010 FAIL quotes.
56
+ */
57
+ const BLAME_CUE_RE = /pre-?\s?existing|existing (?:bug|defect|failure|issue|fault)|(?:created|introduced|added|written) (?:by|in) TASK_\d+|(?:bug|defect|fault|error) in\b|already (?:broken|failing|red)|(?:this task )?did not (?:modify|touch|create|change)|not (?:modified|touched|created|introduced) by this task|unrelated to this task/gi;
58
+ /**
59
+ * Failures the environment causes, not a file. No edit to any file repairs a
60
+ * missing database server, so these must never open the repair channel — run 14's
61
+ * TASK_0006 ("no PostgreSQL database server is available in this environment (no
62
+ * binary, no Docker, no listener on port 5432)") is the canonical shape.
63
+ */
64
+ const ENVIRONMENT_BLAME_RE = /no (?:postgres|postgresql|mysql|database|redis|docker|network|internet|display)\b|not (?:installed|available|running|present) (?:in|on|for) (?:this |the )?(?:env|environment|sandbox|container|machine|image|system)|(?:environment|env|sandbox|container) (?:gap|limitation|lacks|has no|does not (?:have|provide))|no (?:binary|listener|daemon|server) (?:on|for|available)|missing (?:binary|executable|system (?:tool|package)|runtime)|is not installed|command not found/i;
65
+ /** How far from a blame cue a path token may sit and still be the accused file. */
66
+ const BLAME_WINDOW = 160;
67
+ /** A recorded defect summary is one clamped line, not prose. */
68
+ const MAX_DEFECT_LENGTH = 160;
69
+ /** Ledger ceiling — a pathological run cannot grow the queue unboundedly. */
70
+ const MAX_QUEUED = 40;
71
+ const REPAIR_QUEUE_FILE = 'repair-queue.md';
72
+ const FIELD_SEP = '\t';
73
+ /** True when the FAIL text blames the ENVIRONMENT rather than a file. */
74
+ export function isEnvironmentAttributed(text) {
75
+ return ENVIRONMENT_BLAME_RE.test(text);
76
+ }
77
+ /**
78
+ * The single file a FAIL text accuses, or null. Scans every blame cue, pairs it
79
+ * with the NEAREST path token within {@link BLAME_WINDOW} characters (a cue and its
80
+ * subject sit adjacent in practice: "pre-existing teardown bug in
81
+ * `test/teardown.ts`"), and keeps the closest pair overall. One FAIL has one root
82
+ * cause, so this deliberately returns at most one accusation rather than every
83
+ * path the reason happens to name.
84
+ */
85
+ export function findAccusedFile(text) {
86
+ if (text.trim().length === 0)
87
+ return null;
88
+ const paths = [...text.matchAll(PATH_TOKEN_RE)].map(m => ({
89
+ value: m[0],
90
+ start: m.index,
91
+ end: m.index + m[0].length
92
+ }));
93
+ if (paths.length === 0)
94
+ return null;
95
+ let best = null;
96
+ // matchAll on a /g regex is safe here (fresh iterator, no shared lastIndex).
97
+ for (const cue of text.matchAll(BLAME_CUE_RE)) {
98
+ const cueStart = cue.index;
99
+ const cueEnd = cueStart + cue[0].length;
100
+ for (const p of paths) {
101
+ const distance = p.start >= cueEnd ? p.start - cueEnd
102
+ : p.end <= cueStart ? cueStart - p.end
103
+ : 0;
104
+ if (distance > BLAME_WINDOW)
105
+ continue;
106
+ if (best === null || distance < best.distance) {
107
+ best = { file: p.value, distance, clause: clauseAround(text, cueStart) };
108
+ }
109
+ }
110
+ }
111
+ return best;
112
+ }
113
+ /**
114
+ * The sentence/clause a character offset sits in. Splitting on sentence and dash
115
+ * boundaries keeps the defect summary to the accusation itself instead of the
116
+ * whole multi-clause FAIL reason.
117
+ */
118
+ function clauseAround(text, offset) {
119
+ const before = text.slice(0, offset);
120
+ const startMatch = /[.;]\s|\s—\s/g;
121
+ let start = 0;
122
+ for (const m of before.matchAll(startMatch))
123
+ start = m.index + m[0].length;
124
+ const after = text.slice(offset);
125
+ const endMatch = /[.;]\s|\s—\s/.exec(after);
126
+ const end = endMatch ? offset + endMatch.index : text.length;
127
+ return text.slice(start, end).trim();
128
+ }
129
+ /**
130
+ * Collapse whitespace and clamp — a stored defect summary is one short line that
131
+ * has to read well inside a plan title. The gate's own verdict boilerplate ("work
132
+ * did not verify: ") and a leading repeat of the accused file are stripped: the
133
+ * title already names both the step kind and the file, so repeating them there
134
+ * spends the clamp budget on nothing.
135
+ */
136
+ export function summariseDefect(clause, file) {
137
+ let out = clause.replace(/\s+/g, ' ').trim();
138
+ out = out.replace(/^work (?:did not verify|unobserved|is unverified)\s*:\s*/i, '');
139
+ if (file) {
140
+ const escaped = file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
141
+ out = out.replace(new RegExp(`^\`?${escaped}\`?\\s+(?:has|had|contains)\\s+an?\\s+`, 'i'), '');
142
+ }
143
+ out = out.replace(/^[,\-—:\s]+/, '').trim();
144
+ // Drop trailing consequence clauses — "X is broken, which means the VERIFY
145
+ // command fails" restates the FAIL; the repair task only needs the X.
146
+ out = out.replace(/,\s*(?:which|so|meaning|causing the VERIFY)\b.*$/i, '');
147
+ if (out.length <= MAX_DEFECT_LENGTH)
148
+ return out;
149
+ const cut = out.slice(0, MAX_DEFECT_LENGTH);
150
+ const lastSpace = cut.lastIndexOf(' ');
151
+ return `${(lastSpace > MAX_DEFECT_LENGTH / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
152
+ }
153
+ /**
154
+ * A runnable command quoted in the FAIL text — the repair task's VERIFY, per the
155
+ * requirement that it re-run the exact command the debt failed on. Only the first
156
+ * backticked token that STARTS like a shell command (optionally env-prefixed)
157
+ * qualifies, so prose in backticks is never mistaken for a command.
158
+ */
159
+ export function extractFailingCommand(text) {
160
+ const RUNNER = /^(?:[A-Z][A-Z0-9_]*=\S+\s+)*(?:bun|npm|pnpm|yarn|npx|node|deno|make|cargo|go|python3?|pytest|dotnet|mvn|gradle)\b\s+\S/;
161
+ for (const m of text.matchAll(/`([^`\n]+)`/g)) {
162
+ const cmd = m[1].trim();
163
+ if (RUNNER.test(cmd))
164
+ return cmd;
165
+ }
166
+ return undefined;
167
+ }
168
+ /**
169
+ * The repair candidate a verify FAIL justifies, or null. All three conditions from
170
+ * the module header must hold; anything unknown or environment-shaped returns null.
171
+ */
172
+ export async function findRepairCandidate(input) {
173
+ const current = input.currentTaskId.trim();
174
+ if (current.length === 0)
175
+ return null;
176
+ if (input.touched === null)
177
+ return null;
178
+ const text = [input.failReason, input.rationale ?? '']
179
+ .filter(t => t.trim().length > 0)
180
+ .join(' ');
181
+ if (text.trim().length === 0)
182
+ return null;
183
+ if (isEnvironmentAttributed(text))
184
+ return null;
185
+ const accused = findAccusedFile(text);
186
+ if (!accused)
187
+ return null;
188
+ // AUTHORSHIP: a file this task's own work touches is not somebody else's bug.
189
+ // Suffix-compare so an absolute or `./`-prefixed status path still matches.
190
+ const touchedSet = input.touched.map(t => normalisePath(t));
191
+ const file = normalisePath(accused.file);
192
+ if (touchedSet.some(t => t === file || t.endsWith(`/${file}`) || file.endsWith(`/${t}`))) {
193
+ return null;
194
+ }
195
+ let owner;
196
+ try {
197
+ owner = await input.introducedBy(accused.file);
198
+ }
199
+ catch {
200
+ owner = null;
201
+ }
202
+ if (!owner || owner === current)
203
+ return null;
204
+ return {
205
+ file: accused.file,
206
+ owner,
207
+ defect: summariseDefect(accused.clause, accused.file),
208
+ blamedTask: current,
209
+ ...(extractFailingCommand(text) ? { verifyCommand: extractFailingCommand(text) } : {})
210
+ };
211
+ }
212
+ function normalisePath(p) {
213
+ return p.replace(/^\.\//, '').replace(/^\/+/, '').trim();
214
+ }
215
+ // ─── Durable queue ───────────────────────────────────────────────────────────
216
+ //
217
+ // The gate sequence (task-gates.ts) DETECTS candidates; the /task-auto loop is
218
+ // what may mutate the plan. They are decoupled through a small ledger under
219
+ // `.pi-tasks/` — the same durability contract as accept-debt.ts: it survives
220
+ // discardEdits and the git-state guard, and a resume picks up what a crash left.
221
+ export function repairQueueFile(cwd) {
222
+ return path.join(tasksDir(cwd), REPAIR_QUEUE_FILE);
223
+ }
224
+ function serialize(c) {
225
+ return [c.file, c.owner, c.blamedTask, c.verifyCommand ?? '', c.defect]
226
+ .map(f => f.replace(/[\t\n]+/g, ' ').trim())
227
+ .join(FIELD_SEP);
228
+ }
229
+ /** Parse the stored queue. Malformed lines are skipped, never thrown on. */
230
+ export function parseRepairQueue(raw) {
231
+ const out = [];
232
+ for (const line of raw.split('\n')) {
233
+ const t = line.trim();
234
+ if (t.length === 0)
235
+ continue;
236
+ const parts = t.split(FIELD_SEP);
237
+ if (parts.length < 5)
238
+ continue;
239
+ const [file, owner, blamedTask, verifyCommand, defect] = parts;
240
+ if (!file.trim() || !owner.trim())
241
+ continue;
242
+ out.push({
243
+ file: file.trim(),
244
+ owner: owner.trim(),
245
+ blamedTask: blamedTask.trim(),
246
+ defect: defect.trim(),
247
+ ...(verifyCommand.trim() ? { verifyCommand: verifyCommand.trim() } : {})
248
+ });
249
+ }
250
+ return out;
251
+ }
252
+ /** Append one candidate. Best-effort — the queue never blocks a gate. */
253
+ export async function recordRepairCandidate(cwd, c) {
254
+ try {
255
+ const existing = parseRepairQueue(await readQueueRaw(cwd));
256
+ const key = (x) => `${x.file.toLowerCase()} ${x.blamedTask.toLowerCase()}`;
257
+ if (existing.some(e => key(e) === key(c)))
258
+ return;
259
+ const kept = [...existing, c].slice(-MAX_QUEUED);
260
+ await fsp.mkdir(tasksDir(cwd), { recursive: true });
261
+ await fsp.writeFile(repairQueueFile(cwd), kept.map(serialize).join('\n') + '\n', 'utf8');
262
+ }
263
+ catch {
264
+ // best-effort ledger
265
+ }
266
+ }
267
+ async function readQueueRaw(cwd) {
268
+ try {
269
+ return (await fsp.readFile(repairQueueFile(cwd), 'utf8')).trim();
270
+ }
271
+ catch {
272
+ return '';
273
+ }
274
+ }
275
+ /**
276
+ * Read the queue and CLEAR it. Draining is what makes the "cap 1 repair task per
277
+ * file per run" bound hold without a second ledger: whatever is drained either
278
+ * becomes a plan entry (which is then itself the dedup key — see
279
+ * {@link planHasRepairFor}) or was already covered by one.
280
+ */
281
+ export async function drainRepairQueue(cwd) {
282
+ const parsed = parseRepairQueue(await readQueueRaw(cwd));
283
+ if (parsed.length === 0)
284
+ return [];
285
+ try {
286
+ await fsp.writeFile(repairQueueFile(cwd), '', 'utf8');
287
+ }
288
+ catch {
289
+ // best-effort; a failed clear at worst re-offers candidates that
290
+ // planHasRepairFor then rejects.
291
+ }
292
+ return parsed;
293
+ }
294
+ /**
295
+ * Collapse candidates by file — MANDATORY dedup: run 14's two teardown.ts debts
296
+ * (TASK_0013, TASK_0019) must yield exactly ONE repair task naming both. First
297
+ * record wins for defect/command (they describe the same fault); blamed tasks
298
+ * accumulate in first-seen order.
299
+ */
300
+ export function mergeRepairCandidates(candidates) {
301
+ const byFile = new Map();
302
+ for (const c of candidates) {
303
+ const key = normalisePath(c.file).toLowerCase();
304
+ const prev = byFile.get(key);
305
+ if (!prev) {
306
+ byFile.set(key, {
307
+ file: c.file,
308
+ owner: c.owner,
309
+ defect: c.defect,
310
+ blamed: c.blamedTask ? [c.blamedTask] : [],
311
+ ...(c.verifyCommand ? { verifyCommand: c.verifyCommand } : {})
312
+ });
313
+ continue;
314
+ }
315
+ if (c.blamedTask && !prev.blamed.includes(c.blamedTask))
316
+ prev.blamed.push(c.blamedTask);
317
+ if (!prev.verifyCommand && c.verifyCommand)
318
+ prev.verifyCommand = c.verifyCommand;
319
+ }
320
+ return [...byFile.values()];
321
+ }
322
+ // ─── Plan entry ──────────────────────────────────────────────────────────────
323
+ /** Machine-recognisable prefix, so a repair entry can be found in a plan again. */
324
+ export const REPAIR_TITLE_PREFIX = 'repair ';
325
+ /**
326
+ * The plan title for a repair task, in the fixed shape
327
+ * `repair <file>: <defect> (root cause of TASK_A, TASK_B debts)`. The file sits
328
+ * immediately after the prefix so {@link parseRepairTitleFile} can recover it —
329
+ * that recovery is both the dedup key and how the loop knows to attach the
330
+ * repair scope fence.
331
+ */
332
+ export function buildRepairTitle(r) {
333
+ const blame = r.blamed.length > 0 ? ` (root cause of ${r.blamed.join(', ')} debts)` : '';
334
+ return `${REPAIR_TITLE_PREFIX}${r.file}: ${r.defect}${blame}`;
335
+ }
336
+ /** The file a repair title names, or null when the title is not a repair entry. */
337
+ export function parseRepairTitleFile(title) {
338
+ const m = /^repair\s+((?:[\w.@-]+\/)*[\w.@-]+\.\w+)\s*:/.exec(title.trim());
339
+ return m ? m[1] : null;
340
+ }
341
+ /**
342
+ * Is a repair for `file` ALREADY in the plan? This is the cap-1-per-file-per-run
343
+ * bound: it counts checked-off entries too, so a repair task that ran and FAILed
344
+ * is never re-spawned — it lands in the accept-debt ledger like any other task.
345
+ */
346
+ export function planHasRepairFor(titles, file) {
347
+ const want = normalisePath(file).toLowerCase();
348
+ return titles.some(t => {
349
+ const f = parseRepairTitleFile(t);
350
+ return f !== null && normalisePath(f).toLowerCase() === want;
351
+ });
352
+ }
353
+ /**
354
+ * The extra scope fence a repair entry carries into refine. Without it, refine
355
+ * re-expands "repair test/teardown.ts: parameterized table names in TRUNCATE"
356
+ * into "overhaul the test infrastructure" — the /task-auto drift lesson. The
357
+ * fence pins the single editable file and pins the VERIFY to the exact command
358
+ * the debt failed on.
359
+ */
360
+ export function buildRepairScopeFence(file, verifyCommand) {
361
+ return [
362
+ `REPAIR TASK — this step exists ONLY to fix one specific pre-existing defect in`,
363
+ `\`${file}\`. It was created because that file made OTHER tasks' verification fail;`,
364
+ `it is not a feature step and must not grow into one.`,
365
+ '',
366
+ 'HARD CONSTRAINTS for this step (they override any broader reading of the title):',
367
+ ` - \`${file}\` is the ONLY file you may modify. Do not refactor, restructure, or`,
368
+ ' "improve" anything else, and do not create new files.',
369
+ ' - Fix the named defect and nothing more. Do NOT redesign the test harness, the',
370
+ ' build, the schema, or any shared infrastructure — a wider change here would',
371
+ " silently overwrite sibling tasks' shipped work.",
372
+ ' - Do not delete or weaken any existing test to make the command pass.',
373
+ verifyCommand ?
374
+ ` - The VERIFY block MUST be exactly: \`${verifyCommand}\` — the command this`
375
+ + ' defect was failing. It passing is the whole acceptance criterion.'
376
+ : ' - The VERIFY block MUST re-run the command the defect was failing, unaided.'
377
+ ].join('\n');
378
+ }
@@ -34,6 +34,7 @@ import type { CommitResult } from './auto-commit.js';
34
34
  import type { VerifyOutcome } from './verify-work.js';
35
35
  import type { EnforceOutcome } from './enforce-guidelines.js';
36
36
  import { type ResolutionOutcome, type ResolutionChoice } from './verify-resolution.js';
37
+ import { type RepairCandidate } from './root-cause-repair.js';
37
38
  /**
38
39
  * The deps the gate sequence drives. A superset of these is built once per command
39
40
  * by buildGateDeps; AutoDeps extends this with the planning-only `runChild`. Every
@@ -162,6 +163,36 @@ export interface GateDeps {
162
163
  path: string;
163
164
  owner: string;
164
165
  }) => Promise<void>;
166
+ /**
167
+ * Record a durable ROOT-CAUSE debt (mx5 run 14 item 5): this task's verify
168
+ * FAILed on a pre-existing defect in a file ANOTHER task created and this task
169
+ * never touched. Its work is kept (it is not at fault) but the defect is real,
170
+ * so the final gate must re-check and surface it. Best-effort; absent in tests.
171
+ */
172
+ recordRootCauseDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
173
+ /**
174
+ * Queue a scoped repair task for a root-caused defect. The gate DETECTS the
175
+ * cause; only the /task-auto loop may mutate the plan, so the two are decoupled
176
+ * through the durable `.pi-tasks/repair-queue.md` ledger this writes (see
177
+ * root-cause-repair.ts). Absent (bare `/task`, tests) → detection still records
178
+ * the debt, nothing is scheduled.
179
+ */
180
+ recordRepairCandidate?: (cwd: string, candidate: RepairCandidate) => Promise<void>;
181
+ /**
182
+ * Paths the CURRENT task's own work touches — the AUTHORSHIP discriminator for
183
+ * the root-cause channel: a FAIL blamed on a file this task edited may well be
184
+ * this task's own fault, and only a file it never touched can be somebody
185
+ * else's pre-existing bug. `worktree` = uncommitted changes (the pre-commit
186
+ * verify site); `committed` = the files the task snapshot + the ENFORCE commit
187
+ * changed (the post-commit enforce site). `null` means UNKNOWN (git
188
+ * unavailable) and stands the whole channel down — inconclusive is never
189
+ * evidence, so an unreadable tree can only cost a repair task, never spawn a
190
+ * wrong one or wrongly keep a regression.
191
+ */
192
+ touchedFiles?: (cwd: string, scope: 'worktree' | 'committed') => Promise<string[] | null>;
193
+ /** The task whose commit INTRODUCED a file (task-provenance.ts). Null for a
194
+ * file predating the run or any git error → unknown provenance. */
195
+ introducedBy?: (cwd: string, rel: string) => Promise<string | null>;
165
196
  /**
166
197
  * The concrete paths this task's spec forbids modifying (its `Do NOT modify`
167
198
  * CONSTRAINTS — see frozen-path-guard.ts / prohibition-probe.ts). Used to
@@ -1,6 +1,7 @@
1
1
  import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
2
2
  import { SessionUI } from '../remote/bridge.js';
3
3
  import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
4
+ import { findRepairCandidate } from './root-cause-repair.js';
4
5
  /**
5
6
  * How many times a verify FAIL may be auto-fixed UNATTENDED (the research
6
7
  * recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
@@ -67,6 +68,39 @@ export async function runGatesForTask(ctxIn, deps, p) {
67
68
  // recording must never break the gate sequence
68
69
  }
69
70
  };
71
+ /**
72
+ * ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
73
+ * pre-existing defect in a file some OTHER task created and this task never
74
+ * touched. On a hit: record the durable debt (so the final gate surfaces it)
75
+ * and queue a scoped repair task (so something finally FIXES it — run 14
76
+ * recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
77
+ * the bug survived ~24h). Returns the candidate so the caller can also decide
78
+ * NOT to punish the current task for it. Never throws: any fault degrades to
79
+ * null, i.e. exactly the pre-existing behavior.
80
+ */
81
+ const routeRootCause = async (failReason, rationale, scope) => {
82
+ if (!deps.touchedFiles || !deps.introducedBy)
83
+ return null;
84
+ try {
85
+ const candidate = await findRepairCandidate({
86
+ failReason,
87
+ rationale,
88
+ currentTaskId: p.taskId,
89
+ touched: await deps.touchedFiles(p.cwd, scope),
90
+ introducedBy: rel => deps.introducedBy(p.cwd, rel)
91
+ });
92
+ if (!candidate)
93
+ return null;
94
+ await deps.recordRootCauseDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`);
95
+ await deps.recordRepairCandidate?.(p.cwd, candidate);
96
+ await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
97
+ + 'recorded as durable debt and a scoped repair task queued');
98
+ return candidate;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ };
70
104
  const verdictLine = (v) => v.ok ?
71
105
  v.reason ?
72
106
  `verify: PASS (${v.reason})`
@@ -230,6 +264,11 @@ export async function runGatesForTask(ctxIn, deps, p) {
230
264
  // recording must never break the gate sequence
231
265
  }
232
266
  }
267
+ // ROOT CAUSE: an accepted FAIL that some OTHER task's file caused is
268
+ // not fixed by accepting it — run 14 accepted this shape repeatedly
269
+ // and the causing bug outlived the whole run. Queue the scoped repair
270
+ // so the plan actually closes it.
271
+ await routeRootCause(failReason, recOutcome.rationale, 'worktree');
233
272
  // Cross-task deletions the verify probe detected ship in the next
234
273
  // commit with this ACCEPT — record each as its own durable debt so
235
274
  // the final gate re-checks them (mx5 run 12 PROMPT 2). Best-effort.
@@ -413,7 +452,25 @@ export async function runGatesForTask(ctxIn, deps, p) {
413
452
  const after = deps.verify ?
414
453
  await deps.verify(active, p.cwd, p.title, p.taskId)
415
454
  : { ok: true };
416
- if (!after.ok) {
455
+ const afterReason = after.reason ?? 'enforce re-verify failed';
456
+ // PRE-EXISTING-CAUSE KEEP PATH (mx5 run 14 item 5b). Both of run
457
+ // 14's enforce-reverts were this shape: the re-verify FAILed on
458
+ // TASK_0007's `test/teardown.ts` TRUNCATE bug — a file neither the
459
+ // task's work nor the enforce pass touched — and the differential
460
+ // reverted enforce's edits anyway, destroying good work over a fault
461
+ // it did not cause AND leaving the actual cause unscheduled. When the
462
+ // FAIL is attributed to another task's untouched file, KEEP the edits
463
+ // and route the real defect to a repair task instead. Everything
464
+ // unknown (git unavailable, no provenance, this task touched the file,
465
+ // an environment-blamed FAIL) falls through to the revert below —
466
+ // the conservative pre-existing behavior.
467
+ const rootCause = after.ok ? null : await routeRootCause(afterReason, '', 'committed');
468
+ if (!after.ok && rootCause) {
469
+ await rec(`enforce: re-verify FAILED (${afterReason.slice(0, 200)}) but the failure is attributed to a PRE-EXISTING defect in \`${rootCause.file}\` `
470
+ + `(${rootCause.owner}'s file, untouched by this task and by the enforce pass) — edits KEPT, not reverted; repair task queued`);
471
+ active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on a pre-existing defect in ${rootCause.file} (${rootCause.owner}'s file) — keeping the fixes, queued a repair task.`, 'warning');
472
+ }
473
+ else if (!after.ok) {
417
474
  if (deps.revert)
418
475
  await deps.revert(p.cwd);
419
476
  await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.39",
3
+ "version": "0.18.40",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",