@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.
- package/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +93 -116
- package/dist/task/boot-probe.d.ts +298 -0
- package/dist/task/boot-probe.js +806 -0
- package/dist/task/child-runner.d.ts +56 -25
- package/dist/task/child-runner.js +65 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +38 -432
- package/dist/task/final-gate.js +105 -1213
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/launch-manifest.d.ts +5 -0
- package/dist/task/launch-manifest.js +21 -0
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +70 -297
- package/dist/task/phases.d.ts +18 -0
- package/dist/task/phases.js +4 -3
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/task-gates.d.ts +69 -0
- package/dist/task/task-gates.js +114 -90
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/docs-core.d.ts +0 -4
- package/dist/workers/docs-core.js +10 -34
- package/dist/workers/docs-project.js +3 -3
- package/dist/workers/docs-resolve.d.ts +18 -0
- package/dist/workers/docs-resolve.js +39 -0
- package/dist/workers/docs-retrieve.d.ts +13 -0
- package/dist/workers/docs-retrieve.js +17 -2
- package/dist/workers/fetch-core.d.ts +0 -4
- package/dist/workers/fetch-core.js +2 -5
- package/dist/workers/phantom-imports.d.ts +3 -3
- package/dist/workers/phantom-imports.js +16 -29
- package/dist/workers/pi-worker-docs.d.ts +49 -0
- package/dist/workers/pi-worker-docs.js +33 -9
- package/dist/workers/pi-worker-fetch.d.ts +18 -0
- package/dist/workers/pi-worker-fetch.js +19 -4
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- 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 {};
|
package/dist/task/task-gates.js
CHANGED
|
@@ -67,60 +67,20 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
|
|
|
67
67
|
return classifyResolutionAnswer(answer);
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
520
|
-
|
|
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
|
|
59
|
-
* becomes finding lines, the notice block those
|
|
60
|
-
* rule the notice routes the child to. The loop
|
|
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 =
|
|
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
|
|
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
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
*
|
|
149
|
-
|
|
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
|