@mjasnikovs/pi-task 0.38.10 → 0.38.12

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.
Files changed (78) hide show
  1. package/README.md +7 -3
  2. package/dist/shared/child-process.d.ts +8 -0
  3. package/dist/shared/command-watchdog.d.ts +1 -1
  4. package/dist/shared/command-watchdog.js +1 -1
  5. package/dist/task/accept-debt.d.ts +47 -0
  6. package/dist/task/accept-debt.js +127 -28
  7. package/dist/task/auto-orchestrator.js +93 -116
  8. package/dist/task/boot-probe.d.ts +298 -0
  9. package/dist/task/boot-probe.js +806 -0
  10. package/dist/task/child-runner.d.ts +56 -25
  11. package/dist/task/child-runner.js +65 -31
  12. package/dist/task/child-status.d.ts +95 -0
  13. package/dist/task/child-status.js +99 -0
  14. package/dist/task/command-run.d.ts +36 -0
  15. package/dist/task/command-run.js +48 -1
  16. package/dist/task/command-watchdog.js +1 -1
  17. package/dist/task/context-usage.d.ts +4 -3
  18. package/dist/task/context-usage.js +4 -3
  19. package/dist/task/contracts.js +18 -35
  20. package/dist/task/deep-render-check.d.ts +47 -0
  21. package/dist/task/deep-render-check.js +110 -65
  22. package/dist/task/env-notes.d.ts +3 -3
  23. package/dist/task/env-notes.js +24 -35
  24. package/dist/task/final-gate-fix.d.ts +1 -1
  25. package/dist/task/final-gate-fix.js +1 -1
  26. package/dist/task/final-gate.d.ts +38 -432
  27. package/dist/task/final-gate.js +105 -1213
  28. package/dist/task/gate-child.d.ts +8 -10
  29. package/dist/task/gate-child.js +15 -19
  30. package/dist/task/gate-deps.d.ts +29 -0
  31. package/dist/task/gate-deps.js +192 -206
  32. package/dist/task/gate-tally.d.ts +189 -0
  33. package/dist/task/gate-tally.js +249 -0
  34. package/dist/task/implementation-turn.d.ts +201 -0
  35. package/dist/task/implementation-turn.js +263 -0
  36. package/dist/task/launch-contract.js +27 -43
  37. package/dist/task/launch-manifest.d.ts +5 -0
  38. package/dist/task/launch-manifest.js +21 -0
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +70 -297
  45. package/dist/task/phases.d.ts +18 -0
  46. package/dist/task/phases.js +4 -3
  47. package/dist/task/plan-orchestrator.js +34 -33
  48. package/dist/task/requirements.d.ts +1 -1
  49. package/dist/task/requirements.js +50 -66
  50. package/dist/task/root-cause-repair.js +20 -32
  51. package/dist/task/run-bracket.d.ts +75 -0
  52. package/dist/task/run-bracket.js +41 -0
  53. package/dist/task/stall-detector.d.ts +110 -0
  54. package/dist/task/stall-detector.js +159 -0
  55. package/dist/task/task-gates.d.ts +69 -0
  56. package/dist/task/task-gates.js +114 -90
  57. package/dist/task/verify-work.d.ts +53 -67
  58. package/dist/task/verify-work.js +15 -11
  59. package/dist/workers/docs-core.d.ts +0 -4
  60. package/dist/workers/docs-core.js +10 -34
  61. package/dist/workers/docs-project.js +3 -3
  62. package/dist/workers/docs-resolve.d.ts +18 -0
  63. package/dist/workers/docs-resolve.js +39 -0
  64. package/dist/workers/docs-retrieve.d.ts +13 -0
  65. package/dist/workers/docs-retrieve.js +17 -2
  66. package/dist/workers/fetch-core.d.ts +0 -4
  67. package/dist/workers/fetch-core.js +2 -5
  68. package/dist/workers/phantom-imports.d.ts +3 -3
  69. package/dist/workers/phantom-imports.js +16 -29
  70. package/dist/workers/pi-worker-docs.d.ts +49 -0
  71. package/dist/workers/pi-worker-docs.js +33 -9
  72. package/dist/workers/pi-worker-fetch.d.ts +18 -0
  73. package/dist/workers/pi-worker-fetch.js +19 -4
  74. package/dist/workers/single-read-extension.d.ts +1 -1
  75. package/dist/workers/single-read-extension.js +5 -4
  76. package/dist/workers/single-read-guard.d.ts +32 -10
  77. package/dist/workers/single-read-guard.js +67 -16
  78. package/package.json +1 -1
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Progress-based runaway guard for phase children — the replacement for a
3
+ * wall-clock cap.
4
+ *
5
+ * WHY NOT SECONDS. PHASE_CHILD_TIMEOUT_MS was sized against "measured HEALTHY
6
+ * planning children" on one local backend: decompose 89s, so 600s looked like a
7
+ * 6x margin. That sizing is not a property of the pathology, it is a property of
8
+ * that day's model, that day's samplers and that day's design doc. Measured on
9
+ * the same 27B backend with reasoning ON (2026-08-17, n=10 replays of one
10
+ * captured auto-decompose request, everything else byte-identical): every single
11
+ * healthy run took 610-927s and produced 26-42 correct titles. The cap would
12
+ * have killed 10 out of 10 GOOD runs. A slower model, a bigger design doc or a
13
+ * longer reasoning budget moves that number again — so any constant in seconds
14
+ * is wrong for someone.
15
+ *
16
+ * WHAT REPLACES IT. Two bounds, both dimensionless — invariant to model speed,
17
+ * project size and reasoning budget:
18
+ *
19
+ * 1. NO NEW GROUND. Count CONSECUTIVE tool calls whose RESULT taught the child
20
+ * nothing: an error, or bytes it has already been given. A child paging
21
+ * forward through a 25 KB design file gets different bytes every call and
22
+ * never trips, however slow it is. A child re-opening the same four files
23
+ * trips after NO_PROGRESS_LIMIT of them, however fast it is.
24
+ *
25
+ * Judged on the RESULT, not the arguments, because the arguments lie. The
26
+ * thrash measured on 2026-08-17 was 197 of 200 calls REFUSED by the
27
+ * single-read guard, each at a different rising offset — so by arguments it
28
+ * looked like textbook forward paging, and both an offset rule and the loop
29
+ * detector's path rule waved all 200 through. By result it is 197 identical
30
+ * refusals in a row, which is what it actually was.
31
+ *
32
+ * 2. CONTEXT CHURN. Sum the bytes of tool RESULTS the child has pulled in. Once
33
+ * that exceeds CONTEXT_CHURN_FACTOR times its own context window and it
34
+ * still has not answered, it has necessarily forgotten what it read first
35
+ * and is re-reading to fill a window pi keeps compacting. That is the
36
+ * mx5-n 2026-08-14 shape: 16m23s at 117,370 of a 120,064-token window,
37
+ * ~56k tokens of tool output per minute, forward-paging the whole time so
38
+ * rule 1 alone would not have caught it. The bound scales with the model's
39
+ * OWN window, so a 1M-context model gets a 1M-context allowance.
40
+ *
41
+ * Neither rule can fire on a child that is thinking rather than calling tools:
42
+ * that case is bounded by the model's max tokens (server-enforced) and by the
43
+ * stream watchdog if the stream goes silent. Between the three there is no
44
+ * runaway left that needs a clock.
45
+ *
46
+ * Pure logic, no I/O, no timers. LoopDetector (loop-detector.ts) is the
47
+ * short-window sibling that trips FAST on an exact repeat; this is the
48
+ * whole-run backstop that trips on sustained non-progress.
49
+ */
50
+ import { stableStringify } from './loop-detector.js';
51
+ /**
52
+ * Consecutive no-new-ground tool results before the child is killed.
53
+ *
54
+ * Eight, because the honest reasons to get back something you have already seen
55
+ * are few and bounded: re-checking a file after an edit, a grep that lands in a
56
+ * file already read, a retry after a malformed call, a missing path. A child
57
+ * doing real work interleaves those with progress and resets the counter. In the
58
+ * replayed thrash the counter never resets at all — the observed runs made
59
+ * 188-201 consecutive dead calls. The gap between "a handful" and "two hundred"
60
+ * is wide enough that the exact value is not load-bearing.
61
+ */
62
+ export const NO_PROGRESS_LIMIT = 8;
63
+ /**
64
+ * Multiples of the child's OWN context window of tool output it may pull before
65
+ * being called stuck. Two, so a child is allowed to fill its window once and
66
+ * still have a whole window of budget left for legitimate re-reading after pi
67
+ * compacts. Past that it is provably re-reading what it can no longer hold.
68
+ */
69
+ export const CONTEXT_CHURN_FACTOR = 2;
70
+ /** Chars per token. Rough on purpose — the bound is a factor of 2, not a budget. */
71
+ const CHARS_PER_TOKEN = 4;
72
+ export class StallDetector {
73
+ limit;
74
+ churnFactor;
75
+ /** Every distinct result the child has been handed, for the WHOLE run. */
76
+ seenResults = new Set();
77
+ /** Exact (name, args) keys already issued — the fallback signal for a
78
+ * transport that reports calls but not results. */
79
+ seenCalls = new Set();
80
+ deadStreak = 0;
81
+ resultChars = 0;
82
+ contextWindow = 0;
83
+ constructor(limit = NO_PROGRESS_LIMIT, churnFactor = CONTEXT_CHURN_FACTOR) {
84
+ this.limit = limit;
85
+ this.churnFactor = churnFactor;
86
+ }
87
+ /**
88
+ * Record a tool call. Returns a LoopHit (tagged with `stall`) when either
89
+ * rule has tripped, so it rides the kill/restart path the loop detector
90
+ * already has, else null.
91
+ *
92
+ * The verdict is read here but EARNED in noteResult: this is the hook the
93
+ * child runner can kill from, and a result only arrives after its call has
94
+ * been let through.
95
+ */
96
+ record(call) {
97
+ if (this.churnTripped()) {
98
+ return {
99
+ call,
100
+ count: Math.round(this.resultChars / CHARS_PER_TOKEN),
101
+ windowSize: this.contextWindow,
102
+ stall: 'context-churn'
103
+ };
104
+ }
105
+ const key = `${call.name}\x00${stableStringify(call.args)}`;
106
+ // A verbatim repeat is dead ground whatever its result turns out to be,
107
+ // and this is the only signal available if results are not reported.
108
+ if (this.seenCalls.has(key))
109
+ this.deadStreak++;
110
+ this.seenCalls.add(key);
111
+ if (this.deadStreak >= this.limit) {
112
+ return { call, count: this.deadStreak, windowSize: 0, stall: 'no-new-ground' };
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * A tool call finished. Its result is what actually entered the context, so
118
+ * it — not the arguments — decides whether the child learned anything. An
119
+ * error, or bytes already handed over earlier in this run, is dead ground.
120
+ */
121
+ noteResult(text, isError = false) {
122
+ this.resultChars += text.length;
123
+ if (isError || this.seenResults.has(text)) {
124
+ this.deadStreak++;
125
+ return;
126
+ }
127
+ this.seenResults.add(text);
128
+ this.deadStreak = 0;
129
+ }
130
+ /** Latest context-window size reported by the child. 0 until one arrives. */
131
+ noteContext(contextWindow) {
132
+ if (contextWindow > 0)
133
+ this.contextWindow = contextWindow;
134
+ }
135
+ churnTripped() {
136
+ if (this.contextWindow <= 0)
137
+ return false;
138
+ return this.resultChars / CHARS_PER_TOKEN > this.contextWindow * this.churnFactor;
139
+ }
140
+ }
141
+ /**
142
+ * Restart hint for a child killed by the stall detector. Names the specific
143
+ * mistake — re-reading covered ground vs pulling in more than it can hold —
144
+ * because "you ran out of time" (the old wall-clock hint) told a model that was
145
+ * working correctly but slowly to truncate its work for no reason.
146
+ */
147
+ export function formatStallHint(kind) {
148
+ if (kind === 'context-churn') {
149
+ return ('[SYSTEM NOTE: Your previous attempt pulled in more file content than '
150
+ + 'its context window can hold, so the earliest material was dropped and '
151
+ + 'you began re-reading it. Do not re-open files. Read only what you have '
152
+ + 'not read yet, and write your answer from what you have.]');
153
+ }
154
+ return ('[SYSTEM NOTE: Your previous attempt made a run of tool calls that returned '
155
+ + 'nothing you had not already seen — you were re-opening files you had '
156
+ + 'already read. Read each region of a file AT MOST ONCE, and when a file is '
157
+ + 'too large to read whole, page FORWARD through it rather than re-opening '
158
+ + 'the start. Write your answer from what you have gathered.]');
159
+ }
@@ -63,6 +63,18 @@ export interface GateDeps {
63
63
  * tests or when `verify work` is off → the sequence treats it as a pass.
64
64
  */
65
65
  verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
66
+ /**
67
+ * The DIFFERENTIAL re-verify the enforce pass runs against the enforced tree,
68
+ * to decide whether its own commit survives. Absent → `verify`, which is what
69
+ * it has always been, so production wiring is untouched.
70
+ *
71
+ * Its own field because it answers a different question from the gate above.
72
+ * While the two shared one field the only way to answer them differently was to
73
+ * count invocations — a `verifyCalls` state machine whose FIRST return existed
74
+ * solely to unlock `mode === 'edit'`, re-invented in the suite and again in
75
+ * scripts/enforce-revert-attribution-replay-ab.ts.
76
+ */
77
+ reVerify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
66
78
  /**
67
79
  * Hold the committed work to AGENTS.md / CLAUDE.md. `edit` (fix in place) only
68
80
  * with a clean verify signal to guard against; otherwise `flag` (report only).
@@ -293,4 +305,61 @@ export declare function askVerifyResolution(ctx: ExtensionCommandContext, title:
293
305
  * only a user cancel inside a gate child propagates (handled by the caller's
294
306
  * USER_CANCELLED path).
295
307
  */
308
+ /** The durable per-task gate trail: every outcome is appended to the task file so
309
+ * the sequence is auditable from artifacts alone. Best-effort — recording must
310
+ * never break the gate sequence. */
311
+ type Recorder = (line: string) => Promise<void>;
312
+ /**
313
+ * The root-cause channel: was this FAIL caused by a pre-existing defect in a file
314
+ * some OTHER task created and this task never touched?
315
+ */
316
+ type RootCauseRouter = (failReason: string, rationale: string, scope: 'worktree' | 'committed' | 'enforce-commit') => Promise<RepairCandidate | null>;
317
+ /**
318
+ * What the VERIFY half settled. Either the sequence is over (`stop` carries the
319
+ * terminal GateResult — dismissed picker, cancelled session, interrupted or failed
320
+ * autofix) or the task proceeds to commit + enforce.
321
+ *
322
+ * `cleanPass` is the ONE fact that crosses to the enforce half: a GENUINE clean
323
+ * pass (a real signal ran and the work met it) is the only thing that gives
324
+ * enforce a signal to revert against, so only then may it edit in place. A no-op
325
+ * pass, a disabled gate or an accept-override leaves it false → flag-only.
326
+ */
327
+ type VerifyGateStep = {
328
+ stop: GateResult;
329
+ } | {
330
+ proceed: {
331
+ ctx: ExtensionCommandContext;
332
+ cleanPass: boolean;
333
+ };
334
+ };
335
+ /**
336
+ * The VERIFY resolution loop: run the task's verification against the finished
337
+ * work, and negotiate a FAIL through the graduated ladder (bounded lint fix →
338
+ * recommendation → unattended autofix → picker) until it verifies, is accepted,
339
+ * or terminates.
340
+ *
341
+ * Split out of `runGatesForTask` at the single boolean that crosses to the
342
+ * ENFORCE half. It carries 8 mutable locals over ~290 lines and has four terminal
343
+ * exits; enforce carries one local and always falls through. Joining them meant a
344
+ * test of the enforce differential had to traverse this whole loop first, which is
345
+ * why `deps.verify` was driven by an invocation counter whose first return existed
346
+ * only to unlock `mode === 'edit'`.
347
+ */
348
+ export declare function resolveVerifyGate(ctxIn: ExtensionCommandContext, deps: GateDeps, p: GateParams, rec: Recorder, routeRootCause: RootCauseRouter): Promise<VerifyGateStep>;
349
+ /**
350
+ * The ENFORCE differential: hold the committed work to AGENTS.md / CLAUDE.md,
351
+ * then decide whether the pass\'s own commit survives.
352
+ *
353
+ * `reVerify` is deliberately NOT `deps.verify`. They answer two different
354
+ * questions — the gate above, and this differential — and while they shared one
355
+ * field the only way to answer them differently was to count invocations.
356
+ *
357
+ * Reads `active` and never reassigns it: nothing here can replace the live
358
+ * session, unlike the autofix in the verify half.
359
+ */
360
+ export declare function runEnforcePass(active: ExtensionCommandContext, deps: GateDeps, p: GateParams, rec: Recorder, routeRootCause: RootCauseRouter, args: {
361
+ cleanPass: boolean;
362
+ commit: CommitResult;
363
+ }): Promise<void>;
296
364
  export declare function runGatesForTask(ctxIn: ExtensionCommandContext, deps: GateDeps, p: GateParams): Promise<GateResult>;
365
+ export {};
@@ -67,60 +67,20 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
67
67
  return classifyResolutionAnswer(answer);
68
68
  }
69
69
  /**
70
- * Run the verify + enforce gates against a task's just-finished implementation.
70
+ * The VERIFY resolution loop: run the task's verification against the finished
71
+ * work, and negotiate a FAIL through the graduated ladder (bounded lint fix →
72
+ * recommendation → unattended autofix → picker) until it verifies, is accepted,
73
+ * or terminates.
71
74
  *
72
- * Lifted verbatim from /task-auto's per-task loop so the two commands gate
73
- * identically. Returns a GateResult; `done` means the caller should proceed (the
74
- * work is verified-or-accepted, checked off, committed, and enforced), every other
75
- * kind is a terminal stop the caller announces. Never throws for a gate outcome —
76
- * only a user cancel inside a gate child propagates (handled by the caller's
77
- * USER_CANCELLED path).
75
+ * Split out of `runGatesForTask` at the single boolean that crosses to the
76
+ * ENFORCE half. It carries 8 mutable locals over ~290 lines and has four terminal
77
+ * exits; enforce carries one local and always falls through. Joining them meant a
78
+ * test of the enforce differential had to traverse this whole loop first, which is
79
+ * why `deps.verify` was driven by an invocation counter whose first return existed
80
+ * only to unlock `mode === 'edit'`.
78
81
  */
79
- export async function runGatesForTask(ctxIn, deps, p) {
82
+ export async function resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause) {
80
83
  let active = ctxIn;
81
- // Durable per-task gate trail — every outcome below is also appended to the
82
- // task file so the sequence is auditable from artifacts alone. Best-effort.
83
- const rec = async (line) => {
84
- try {
85
- await deps.record?.(p.cwd, p.taskId, line);
86
- }
87
- catch {
88
- // recording must never break the gate sequence
89
- }
90
- };
91
- /**
92
- * ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
93
- * pre-existing defect in a file some OTHER task created and this task never
94
- * touched. On a hit: record the durable debt (so the final gate surfaces it)
95
- * and queue a scoped repair task (so something finally FIXES it — run 14
96
- * recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
97
- * the bug survived ~24h). Returns the candidate so the caller can also decide
98
- * NOT to punish the current task for it. Never throws: any fault degrades to
99
- * null, i.e. exactly the pre-existing behavior.
100
- */
101
- const routeRootCause = async (failReason, rationale, scope) => {
102
- if (!deps.touchedFiles || !deps.introducedBy)
103
- return null;
104
- try {
105
- const candidate = await findRepairCandidate({
106
- failReason,
107
- rationale,
108
- currentTaskId: p.taskId,
109
- touched: await deps.touchedFiles(p.cwd, scope),
110
- introducedBy: rel => deps.introducedBy(p.cwd, rel)
111
- });
112
- if (!candidate)
113
- return null;
114
- await deps.recordDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`, 'root-cause');
115
- await deps.recordRepairCandidate?.(p.cwd, candidate);
116
- await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
117
- + 'recorded as durable debt and a scoped repair task queued');
118
- return candidate;
119
- }
120
- catch {
121
- return null;
122
- }
123
- };
124
84
  const verdictLine = (v) => v.ok ?
125
85
  v.reason ?
126
86
  `verify: PASS (${v.reason})`
@@ -289,7 +249,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
289
249
  }
290
250
  if (choice.action === 'cancel') {
291
251
  await rec('resolution: user dismissed the verify-FAIL picker — paused');
292
- return { kind: 'paused', ctx: active, reason: failReason };
252
+ return { stop: { kind: 'paused', ctx: active, reason: failReason } };
293
253
  }
294
254
  if (choice.action === 'accept') {
295
255
  const byYolo = yoloChoice !== null;
@@ -360,11 +320,11 @@ export async function runGatesForTask(ctxIn, deps, p) {
360
320
  });
361
321
  active = fixRes.ctx ?? active;
362
322
  if (fixRes.sessionCancelled)
363
- return { kind: 'session-cancelled', ctx: active };
323
+ return { stop: { kind: 'session-cancelled', ctx: active } };
364
324
  if (fixRes.interrupted)
365
- return { kind: 'interrupted', ctx: active };
325
+ return { stop: { kind: 'interrupted', ctx: active } };
366
326
  if (!fixRes.ok)
367
- return { kind: 'failed', ctx: active, reason: fixRes.reason };
327
+ return { stop: { kind: 'failed', ctx: active, reason: fixRes.reason } };
368
328
  // Resume reuses the same inner task id, so p.taskId is stable.
369
329
  verified = await deps.verify(active, p.cwd, p.title, p.taskId);
370
330
  await rec(verdictLine(verified));
@@ -374,39 +334,21 @@ export async function runGatesForTask(ctxIn, deps, p) {
374
334
  // accept-override (verified.ok still false at break) is NOT a guardable signal.
375
335
  verifyCleanPass = verified.ok && !verified.reason;
376
336
  }
377
- // Mark the work verified (parent task-list check-off for /task-auto; no-op for
378
- // /task) BEFORE committing, so the commit captures the check-off too.
379
- await p.onVerified?.();
380
- // Commit the task's work as one snapshot FIRST before guideline enforcement —
381
- // so a passing task is durably recorded no matter what enforcement later finds.
382
- const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
383
- if (commit.committed) {
384
- await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
385
- // SAY WHAT WAS LEFT OUT. The stage skips untracked regenerable test-runner
386
- // output (mx5 run 20: TASK_0027's `git add -A` swept in three Playwright
387
- // failure screenshots and two later fix attempts were rejected for deleting
388
- // them). A SILENT exclusion is the same failure class as the silent
389
- // ignored-path write nexttask 4 closed, so it gets its own trail line.
390
- if (commit.excluded && commit.excluded.length > 0) {
391
- await rec(`commit: left ${commit.excluded.length} untracked test-runner artifact(s) out of the `
392
- + `snapshot — regenerable output, not deliverables: `
393
- + `${commit.excluded.slice(0, 8).join(', ')}`
394
- + `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
395
- }
396
- active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
397
- }
398
- else {
399
- await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
400
- // A benign skip ("nothing to commit", auto-commit off) is a warning. A real
401
- // git failure is louder: it silently disables enforce AND every commit-based
402
- // guard — mx5 run 4 lost all 10 commits (no container git identity) with only
403
- // per-task warnings to show for it. "blocked" is the unmerged-index refusal
404
- // (gitCommitAll) — the same severity: nothing can commit until it's resolved.
405
- const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
406
- active.ui.notify(gitFailure ?
407
- `${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
408
- : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
409
- }
337
+ return { proceed: { ctx: active, cleanPass: verifyCleanPass } };
338
+ }
339
+ /**
340
+ * The ENFORCE differential: hold the committed work to AGENTS.md / CLAUDE.md,
341
+ * then decide whether the pass\'s own commit survives.
342
+ *
343
+ * `reVerify` is deliberately NOT `deps.verify`. They answer two different
344
+ * questions the gate above, and this differential — and while they shared one
345
+ * field the only way to answer them differently was to count invocations.
346
+ *
347
+ * Reads `active` and never reassigns it: nothing here can replace the live
348
+ * session, unlike the autofix in the verify half.
349
+ */
350
+ export async function runEnforcePass(active, deps, p, rec, routeRootCause, args) {
351
+ const { cleanPass: verifyCleanPass, commit } = args;
410
352
  // With the task committed, hold its work to AGENTS.md / CLAUDE.md — but as a step
411
353
  // INSIDE the validation gate, gated by the verify signal (see GateDeps.enforce).
412
354
  // Skipped when nothing was committed this round, when enforce is off, or in tests
@@ -516,8 +458,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
516
458
  if (enforceCommit.committed) {
517
459
  // Differential guard: re-run the verify signal against the enforced
518
460
  // tree. A regression ⇒ drop the enforce commit, keep the verified work.
519
- const after = deps.verify ?
520
- await deps.verify(active, p.cwd, p.title, p.taskId)
461
+ const differential = deps.reVerify ?? deps.verify;
462
+ const after = differential ?
463
+ await differential(active, p.cwd, p.title, p.taskId)
521
464
  : { ok: true };
522
465
  const afterReason = after.reason ?? 'enforce re-verify failed';
523
466
  // PRE-EXISTING-CAUSE KEEP PATH (mx5 run 14 item 5b). Both of run
@@ -632,5 +575,86 @@ export async function runGatesForTask(ctxIn, deps, p) {
632
575
  // so a missing enforce run is explainable from the trail (mx5 audit gap).
633
576
  await rec('enforce: skipped (nothing committed this round)');
634
577
  }
578
+ }
579
+ export async function runGatesForTask(ctxIn, deps, p) {
580
+ const rec = async (line) => {
581
+ try {
582
+ await deps.record?.(p.cwd, p.taskId, line);
583
+ }
584
+ catch {
585
+ // recording must never break the gate sequence
586
+ }
587
+ };
588
+ /**
589
+ * ROOT-CAUSE CHANNEL (mx5 run 14 item 5). Ask whether a FAIL was caused by a
590
+ * pre-existing defect in a file some OTHER task created and this task never
591
+ * touched. On a hit: record the durable debt (so the final gate surfaces it)
592
+ * and queue a scoped repair task (so something finally FIXES it — run 14
593
+ * recorded the same `test/teardown.ts` cause twice and scheduled nothing, and
594
+ * the bug survived ~24h). Returns the candidate so the caller can also decide
595
+ * NOT to punish the current task for it. Never throws: any fault degrades to
596
+ * null, i.e. exactly the pre-existing behavior.
597
+ */
598
+ const routeRootCause = async (failReason, rationale, scope) => {
599
+ if (!deps.touchedFiles || !deps.introducedBy)
600
+ return null;
601
+ try {
602
+ const candidate = await findRepairCandidate({
603
+ failReason,
604
+ rationale,
605
+ currentTaskId: p.taskId,
606
+ touched: await deps.touchedFiles(p.cwd, scope),
607
+ introducedBy: rel => deps.introducedBy(p.cwd, rel)
608
+ });
609
+ if (!candidate)
610
+ return null;
611
+ await deps.recordDebt?.(p.cwd, p.taskId, `${failReason} — ROOT CAUSE: \`${candidate.file}\` (introduced by ${candidate.owner}, not touched by this task)`, 'root-cause');
612
+ await deps.recordRepairCandidate?.(p.cwd, candidate);
613
+ await rec(`root-cause: FAIL attributed to \`${candidate.file}\` — a pre-existing defect in ${candidate.owner}'s file that this task never touched; `
614
+ + 'recorded as durable debt and a scoped repair task queued');
615
+ return candidate;
616
+ }
617
+ catch {
618
+ return null;
619
+ }
620
+ };
621
+ const step = await resolveVerifyGate(ctxIn, deps, p, rec, routeRootCause);
622
+ if ('stop' in step)
623
+ return step.stop;
624
+ const { ctx: active, cleanPass } = step.proceed;
625
+ // Mark the work verified (parent task-list check-off for /task-auto; no-op for
626
+ // /task) BEFORE committing, so the commit captures the check-off too.
627
+ await p.onVerified?.();
628
+ // Commit the task's work as one snapshot FIRST — before guideline enforcement —
629
+ // so a passing task is durably recorded no matter what enforcement later finds.
630
+ const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
631
+ if (commit.committed) {
632
+ await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
633
+ // SAY WHAT WAS LEFT OUT. The stage skips untracked regenerable test-runner
634
+ // output (mx5 run 20: TASK_0027's `git add -A` swept in three Playwright
635
+ // failure screenshots and two later fix attempts were rejected for deleting
636
+ // them). A SILENT exclusion is the same failure class as the silent
637
+ // ignored-path write nexttask 4 closed, so it gets its own trail line.
638
+ if (commit.excluded && commit.excluded.length > 0) {
639
+ await rec(`commit: left ${commit.excluded.length} untracked test-runner artifact(s) out of the `
640
+ + `snapshot — regenerable output, not deliverables: `
641
+ + `${commit.excluded.slice(0, 8).join(', ')}`
642
+ + `${commit.excluded.length > 8 ? `, +${commit.excluded.length - 8} more` : ''}`);
643
+ }
644
+ active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
645
+ }
646
+ else {
647
+ await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
648
+ // A benign skip ("nothing to commit", auto-commit off) is a warning. A real
649
+ // git failure is louder: it silently disables enforce AND every commit-based
650
+ // guard — mx5 run 4 lost all 10 commits (no container git identity) with only
651
+ // per-task warnings to show for it. "blocked" is the unmerged-index refusal
652
+ // (gitCommitAll) — the same severity: nothing can commit until it's resolved.
653
+ const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
654
+ active.ui.notify(gitFailure ?
655
+ `${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
656
+ : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
657
+ }
658
+ await runEnforcePass(active, deps, p, rec, routeRootCause, { cleanPass, commit });
635
659
  return { kind: 'done', ctx: active };
636
660
  }
@@ -55,9 +55,10 @@ export declare function extractSpecForVerification(taskBody: string): string | n
55
55
  * three of them).
56
56
  *
57
57
  * Each probe is now an ADAPTER: one row carrying only what is specific to it —
58
- * the dep field it reads, the empty value it degrades to, how its raw result
59
- * becomes finding lines, the notice block those lines produce, and the numbered
60
- * rule the notice routes the child to. The loop owns the ritual. (Same shape as
58
+ * its key (which names the thunk it reads from `deps.probes`), the empty value it
59
+ * degrades to, how its raw result becomes finding lines, the notice block those
60
+ * lines produce, and the numbered rule the notice routes the child to. The loop
61
+ * owns the ritual. (Same shape as
61
62
  * LOCKFILE_CHECKS in final-gate.ts, where a whole package ecosystem is one row.)
62
63
  *
63
64
  * THIS PROMPT IS A MEASURED ARTIFACT — its wording is A/B-tested on the live
@@ -71,10 +72,48 @@ export declare function extractSpecForVerification(taskBody: string): string | n
71
72
  * A row whose rule is woven into the numbered narrative elsewhere (3b inside the
72
73
  * substitution rules, 3f, 5c) carries `ruleId` for the reader and no `rule` text.
73
74
  */
75
+ /**
76
+ * What each probe channel's RAW probe returns — the one place a channel's shape
77
+ * is declared. `ProbeKey` is derived from it, so a channel exists exactly when it
78
+ * has a row here; delete one and the compiler names its table row and its single
79
+ * binding line in gate-deps' `buildVerifyProbes`.
80
+ */
81
+ export interface ProbeRaw {
82
+ substitution: string[];
83
+ prohibition: string[];
84
+ crossTaskDeletion: CrossTaskDeletion[];
85
+ probeGaming: string[];
86
+ skipEscape: string[];
87
+ foreignPath: string[];
88
+ scriptEscape: string[];
89
+ runnerGlob: string[];
90
+ testAssembly: string[];
91
+ }
74
92
  /** Stable identity of one probe channel: table row ↔ findings-bag key. */
75
- export type ProbeKey = 'substitution' | 'prohibition' | 'crossTaskDeletion' | 'probeGaming' | 'skipEscape' | 'foreignPath' | 'scriptEscape' | 'runnerGlob' | 'testAssembly';
93
+ export type ProbeKey = keyof ProbeRaw;
94
+ /**
95
+ * The channels a CALLER binds — every key but `skipEscape`, which is pure text
96
+ * analysis of `deps.spec` and is sourced inside its own table row, so it is never
97
+ * absent and never bound from outside.
98
+ */
99
+ export type BoundProbeKey = Exclude<ProbeKey, 'skipEscape'>;
100
+ /**
101
+ * The bound probes: one optional thunk per channel, typed to that channel's raw
102
+ * shape. Built in ONE place (gate-deps' `buildVerifyProbes`); an absent key means
103
+ * the row is skipped. Adding a probe is a `ProbeRaw` line, a table row, and a
104
+ * binding line — nothing else.
105
+ */
106
+ export type VerifyProbes = {
107
+ [K in BoundProbeKey]?: () => Promise<ProbeRaw[K]>;
108
+ };
76
109
  /** The finding lines each probe channel contributed. Absent/empty ⇒ no block. */
77
110
  export type ProbeFindings = Partial<Record<ProbeKey, string[]>>;
111
+ /**
112
+ * The channels a caller must bind — the table rows that read `deps.probes`, in
113
+ * table order. Exported so the one binder (gate-deps' `buildVerifyProbes`) can be
114
+ * checked against the table rather than against a hand-kept list.
115
+ */
116
+ export declare const BOUND_PROBE_KEYS: readonly BoundProbeKey[];
78
117
  /**
79
118
  * Build the verification child's prompt. Kept pure so the wording is unit-tested
80
119
  * without spawning pi. The contract: run the spec's own verification in the real
@@ -131,75 +170,22 @@ export interface VerificationDeps {
131
170
  reason: string;
132
171
  }>;
133
172
  /**
134
- * Progress hook for the DETERMINISTIC stage — the repo-health run plus the ten
135
- * probes below, all of which run BEFORE the child (and therefore before the
173
+ * Progress hook for the DETERMINISTIC stage — the repo-health run plus the
174
+ * `probes` below, all of which run BEFORE the child (and therefore before the
136
175
  * child's own status widget exists). Called with a short label as each step
137
176
  * starts, so the caller can keep a live line on screen through what was
138
177
  * otherwise the run's longest stretch of dead air (MEASURED at 15–69s per
139
178
  * repo-health run). ABSENT → no progress reporting, same behaviour as before. */
140
179
  onStage?: (stage: string) => void;
141
180
  /**
142
- * DETERMINISTIC substitution probe (see substitution-probe.ts): scans the task's
143
- * changed test files for test-the-copy shapes and returns finding lines to inject
144
- * into the child's prompt. A/B-proven load-bearing: the prompt rule alone caught
145
- * the class 2/5, rule + probe finding 5/5. ABSENT or empty → no probe block. */
146
- probe?: () => Promise<string[]>;
147
- /**
148
- * DETERMINISTIC prohibition probe (see prohibition-probe.ts): spec-forbidden
149
- * paths the task's diff modified anyway, injected as prompt findings under the
150
- * no-waiver rule (4b). Advisory, never auto-FAIL — real prohibitions can be
151
- * conditional prose. ABSENT or empty → no prohibition block. */
152
- prohibitionProbe?: () => Promise<string[]>;
153
- /**
154
- * DETERMINISTIC test-assembly probe (see test-assembly.ts): authored test files
155
- * that rebuild production WIRING — importing the leaf modules the shipped entry
156
- * composes and assembling their own copy instead of the real assembly — become
157
- * prompt findings under rule 3f (F4 test-the-copy, 3rd recurrence). Pure import-
158
- * graph shape; the child then drives the real assembly before trusting the copy.
159
- * ABSENT or empty → no test-assembly block. */
160
- testAssemblyProbe?: () => Promise<string[]>;
161
- /**
162
- * DETERMINISTIC probe-gaming probe (see probe-gaming.ts, run-8 F6): added lines
163
- * in the task's diff whose stated purpose is to make a CHECK pass instead of
164
- * meeting the requirement it stands for ("return 401 so the verification test
165
- * passes"). Injected as findings under rule 4c so the child confirms the
166
- * underlying requirement is genuinely met rather than trusting the green check.
167
- * Pure diff-text analysis; ABSENT or empty → no probe block. */
168
- probeGamingProbe?: () => Promise<string[]>;
169
- /**
170
- * DETERMINISTIC cross-task deletion probe (see task-provenance.ts, mx5 run 12
171
- * PROMPT 2): tracked files the task's diff DELETES whose introducing task (git
172
- * provenance) differs from the current task — a sibling's committed deliverable
173
- * destroyed, typically to green a check. Injected as prompt findings under rule
174
- * 4d (MANDATORY + verdict-gating, the A/B-proven shape — buried rules score
175
- * 0/5), and carried structurally on a FAIL outcome so an ACCEPT records each as
176
- * a durable debt. ABSENT or empty → no block. */
177
- crossTaskDeletionProbe?: () => Promise<CrossTaskDeletion[]>;
178
- /**
179
- * DETERMINISTIC sandbox-path-leak probe (see foreign-path.ts, mx5 run 13
180
- * PROMPT 4 item 1): absolute paths this task committed that exist only inside
181
- * the authoring child's own environment — `/workspace/src/shared` in a vite
182
- * alias — while the real file sits at `src/shared` here. The probe REPAIRS
183
- * what it can deterministically first; only leaks it could not repair reach
184
- * this hook, injected under rule 4e (MANDATORY + verdict-gating, the same
185
- * shape as 4d — a leak makes the affected command fail to BUILD, so the
186
- * checks that would notice never run at all). ABSENT or empty → no block. */
187
- foreignPathProbe?: () => Promise<string[]>;
188
- /**
189
- * DETERMINISTIC neutered-check-script probe (see script-escape.ts, mx5 run 13
190
- * PROMPT 4 item 4): check-class scripts in a manifest THIS task changed whose
191
- * exit status cannot be non-zero (`… || true`, an inverted-grep launder). The
192
- * damage is second-order — the script still "passes", so the gates that run it
193
- * report success without measuring anything — which is exactly why the child
194
- * cannot discover it by running the check. ABSENT or empty → no block. */
195
- scriptEscapeProbe?: () => Promise<string[]>;
196
- /**
197
- * DETERMINISTIC test-runner glob-collision probe (see runner-globs.ts, mx5 runs
198
- * 7 AND 13, PROMPT 4 item 2): the manifest declares two runners whose file sets
199
- * are not provably disjoint, so the scanning one imports the other's specs and
200
- * dies during COLLECTION. Injected under rule 4g. UNKNOWN (one runner, or
201
- * disjointness proven) yields nothing. ABSENT or empty → no block. */
202
- runnerGlobProbe?: () => Promise<string[]>;
181
+ * The DETERMINISTIC probes, one optional thunk per channel (see `PROBE_ADAPTERS`
182
+ * above for what each channel is and which rule its findings cite; the
183
+ * substitution, prohibition, test-assembly, probe-gaming, cross-task-deletion,
184
+ * foreign-path, script-escape and runner-glob probes all live here). Bound in
185
+ * ONE place — gate-deps' `buildVerifyProbes` — and consumed by the table: an
186
+ * absent key skips its row, a throwing thunk degrades to the row's empty value.
187
+ * ABSENT no probe blocks at all (tests / callers that wire none). */
188
+ probes?: VerifyProbes;
203
189
  /**
204
190
  * Result of the git-state guard for the MOST RECENT runChild call (see
205
191
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file