@mjasnikovs/pi-task 0.38.11 → 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 (55) 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 +91 -114
  8. package/dist/task/child-runner.d.ts +39 -25
  9. package/dist/task/child-runner.js +59 -31
  10. package/dist/task/child-status.d.ts +95 -0
  11. package/dist/task/child-status.js +99 -0
  12. package/dist/task/command-run.d.ts +36 -0
  13. package/dist/task/command-run.js +48 -1
  14. package/dist/task/command-watchdog.js +1 -1
  15. package/dist/task/context-usage.d.ts +4 -3
  16. package/dist/task/context-usage.js +4 -3
  17. package/dist/task/contracts.js +18 -35
  18. package/dist/task/deep-render-check.d.ts +47 -0
  19. package/dist/task/deep-render-check.js +110 -65
  20. package/dist/task/env-notes.d.ts +3 -3
  21. package/dist/task/env-notes.js +24 -35
  22. package/dist/task/final-gate-fix.d.ts +1 -1
  23. package/dist/task/final-gate-fix.js +1 -1
  24. package/dist/task/final-gate.d.ts +5 -151
  25. package/dist/task/final-gate.js +81 -379
  26. package/dist/task/gate-child.d.ts +8 -10
  27. package/dist/task/gate-child.js +15 -19
  28. package/dist/task/gate-deps.d.ts +29 -0
  29. package/dist/task/gate-deps.js +192 -206
  30. package/dist/task/gate-tally.d.ts +189 -0
  31. package/dist/task/gate-tally.js +249 -0
  32. package/dist/task/implementation-turn.d.ts +201 -0
  33. package/dist/task/implementation-turn.js +263 -0
  34. package/dist/task/launch-contract.js +27 -43
  35. package/dist/task/ledger.d.ts +38 -0
  36. package/dist/task/ledger.js +83 -0
  37. package/dist/task/loop-detector.d.ts +14 -8
  38. package/dist/task/loop-detector.js +36 -12
  39. package/dist/task/orchestrator.d.ts +61 -126
  40. package/dist/task/orchestrator.js +67 -294
  41. package/dist/task/plan-orchestrator.js +34 -33
  42. package/dist/task/requirements.d.ts +1 -1
  43. package/dist/task/requirements.js +50 -66
  44. package/dist/task/root-cause-repair.js +20 -32
  45. package/dist/task/run-bracket.d.ts +75 -0
  46. package/dist/task/run-bracket.js +41 -0
  47. package/dist/task/stall-detector.d.ts +110 -0
  48. package/dist/task/stall-detector.js +159 -0
  49. package/dist/task/verify-work.d.ts +53 -67
  50. package/dist/task/verify-work.js +15 -11
  51. package/dist/workers/single-read-extension.d.ts +1 -1
  52. package/dist/workers/single-read-extension.js +5 -4
  53. package/dist/workers/single-read-guard.d.ts +32 -10
  54. package/dist/workers/single-read-guard.js +67 -16
  55. package/package.json +1 -1
@@ -11,26 +11,30 @@ export declare const LOOP_WINDOW = 20;
11
11
  export declare const LOOP_THRESHOLD = 5;
12
12
  export declare const MAX_LOOP_RESTARTS = 2;
13
13
  /**
14
- * Hard wall-clock bound on ONE spawn of a phase child.
14
+ * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
15
15
  *
16
- * The loop detector above only sees IDENTICAL repeated calls; a child that
17
- * re-reads the same design file at varying offsets slips past it and, with pi
18
- * compacting its context whenever the window fills, never exits on its own.
19
- * mx5-n 2026-08-14 is the observed case: a decompose child ran 16m23s at
20
- * 117,370 of a 120,064-token window, adding ~56k tokens of tool output per
21
- * minute, and had to be killed by hand. `streamInactivityMs` cannot catch it —
22
- * that guard fires on SILENCE and this child was the opposite of silent.
16
+ * It used to default to 600_000, sized against measured HEALTHY planning
17
+ * children on one local 27B backend (decompose 89s, whole plan phase 321s) on
18
+ * the reasoning that ten minutes was a 3-6x margin over honest work.
23
19
  *
24
- * Sized against measured HEALTHY planning children on the same local 27B
25
- * backend, which is the slowest thing we run: requirement extraction 54s,
26
- * artifact closure 47s, decompose 89s (22 titles), coverage 17s, and a whole
27
- * plan phase (clarify + two extractions + decompose) 321s end to end. Ten
28
- * minutes is 3-6x the slowest of those and well under the runaway, so it ends
29
- * the pathology without ever trimming honest work. Deliberately far above
30
- * RESEARCH_WORKER_TIMEOUT_MS (240s): a research worker answers one question,
31
- * a planning child reasons over the whole design doc.
20
+ * That premise was measured and is false. Replaying ONE captured auto-decompose
21
+ * request against the same backend with reasoning ON, n=10, everything else
22
+ * byte-identical (2026-08-17): all ten answered correctly with 26-42 titles, and
23
+ * every one took 610-927s. The cap would have killed 10 out of 10 GOOD runs and
24
+ * then failed the phase with PhaseTimeoutError. The number was not measuring the
25
+ * pathology, it was measuring one model's speed on one day.
26
+ *
27
+ * The runaway it was there to catch — a decompose child that ran 16m23s at
28
+ * 117,370 of a 120,064-token window, forward-paging past the loop detector — is
29
+ * now caught by StallDetector (stall-detector.ts), which bounds NON-PROGRESS and
30
+ * CONTEXT CHURN instead of elapsed seconds. Both of those are properties of the
31
+ * pathology, so neither has to be re-tuned for a slower model or a bigger repo.
32
+ *
33
+ * The value and the plumbing stay for a caller that genuinely wants a hard stop
34
+ * (tests inject a short one), but nothing sets it in production. Pass
35
+ * `timeoutMs` explicitly to arm it.
32
36
  */
33
- export declare const PHASE_CHILD_TIMEOUT_MS = 600000;
37
+ export declare const PHASE_CHILD_TIMEOUT_MS = 0;
34
38
  /**
35
39
  * Restart hint after a phase child burns its whole wall-clock budget. It
36
40
  * diagnoses over-exploration, which is what the cap actually catches — the same
@@ -67,7 +71,13 @@ export declare const USER_CANCELLED = "__user_cancelled__";
67
71
  */
68
72
  export declare function runChild(cwd: string, tools: string, prompt: string, signal: AbortSignal, onLine?: (line: string) => void, onContextUsage?: (snapshot: ContextSnapshot) => void, onToolCall?: (call: ToolCall) => LoopHit | null, spawnFn?: SpawnFn,
69
73
  /** Internal `-e` extension paths for in-run guards (see childArgs). */
70
- extensions?: readonly string[]): Promise<PhaseRunResult>;
74
+ extensions?: readonly string[],
75
+ /**
76
+ * Every finished tool call's result text. The StallDetector's churn rule
77
+ * needs the size of what actually entered the child's context, which the
78
+ * CALL alone does not carry (task/stall-detector.ts).
79
+ */
80
+ onToolResult?: (text: string, isError: boolean) => void): Promise<PhaseRunResult>;
71
81
  interface PhaseDeps {
72
82
  cwd: string;
73
83
  taskId: string;
@@ -140,16 +150,20 @@ export type { PhaseDeps };
140
150
  * Empty completions and connection-class model errors share that same budget —
141
151
  * see triageChildResult, which decides every one of those cases.
142
152
  *
143
- * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
153
+ * THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
144
154
  * /task-auto planning child goes through (clarify, decompose, coverage,
145
- * contract-extract) and until mx5-n 2026-08-14 it had neither:
155
+ * contract-extract) and until mx5-n 2026-08-14 it had none:
146
156
  * • a LoopDetector, so an identical repeated tool call is killed and
147
157
  * re-prompted instead of being allowed to fill the context window;
148
- * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
149
- * detector cannot see — the shape that actually cost us a 16-minute
150
- * decompose child that was never going to return.
151
- * Both are checked BEFORE the triage ladder: we killed the child, so its exit
152
- * status describes our SIGTERM and says nothing about its verdict.
158
+ * • a StallDetector, the backstop for the varied-args thrash the loop
159
+ * detector's short window cannot see — the shape that actually cost us a
160
+ * 16-minute decompose child that was never going to return. It bounds
161
+ * consecutive no-new-ground calls and total context churn, NOT elapsed time;
162
+ * PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
163
+ * measured healthy range (610-927s for a reasoning-on decompose) overlaps
164
+ * any value that would catch the pathology. See its comment.
165
+ * All three are checked BEFORE the triage ladder: we killed the child, so its
166
+ * exit status describes our SIGTERM and says nothing about its verdict.
153
167
  */
154
168
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
155
169
  export declare function formatLoopHint(hit: LoopHit): string;
@@ -10,6 +10,7 @@ import { getPiInvocation } from '../shared/pi-invocation.js';
10
10
  import { runChild as runChildUnified } from '../shared/child-process.js';
11
11
  import { childBaseArgs } from '../shared/child-extensions.js';
12
12
  import { LoopDetector } from './loop-detector.js';
13
+ import { StallDetector, formatStallHint } from './stall-detector.js';
13
14
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
14
15
  import { readSection, setTaskSection } from './task-io.js';
15
16
  import { streamStallCause } from '../shared/stream-watchdog.js';
@@ -23,26 +24,30 @@ export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 rest
23
24
  // MAX_LEAK_RETRIES lives in shared/leaked-tool-call.ts (imported above).
24
25
  // ─── Phase-child wall-clock cap ──────────────────────────────────────────────
25
26
  /**
26
- * Hard wall-clock bound on ONE spawn of a phase child.
27
+ * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
27
28
  *
28
- * The loop detector above only sees IDENTICAL repeated calls; a child that
29
- * re-reads the same design file at varying offsets slips past it and, with pi
30
- * compacting its context whenever the window fills, never exits on its own.
31
- * mx5-n 2026-08-14 is the observed case: a decompose child ran 16m23s at
32
- * 117,370 of a 120,064-token window, adding ~56k tokens of tool output per
33
- * minute, and had to be killed by hand. `streamInactivityMs` cannot catch it —
34
- * that guard fires on SILENCE and this child was the opposite of silent.
29
+ * It used to default to 600_000, sized against measured HEALTHY planning
30
+ * children on one local 27B backend (decompose 89s, whole plan phase 321s) on
31
+ * the reasoning that ten minutes was a 3-6x margin over honest work.
35
32
  *
36
- * Sized against measured HEALTHY planning children on the same local 27B
37
- * backend, which is the slowest thing we run: requirement extraction 54s,
38
- * artifact closure 47s, decompose 89s (22 titles), coverage 17s, and a whole
39
- * plan phase (clarify + two extractions + decompose) 321s end to end. Ten
40
- * minutes is 3-6x the slowest of those and well under the runaway, so it ends
41
- * the pathology without ever trimming honest work. Deliberately far above
42
- * RESEARCH_WORKER_TIMEOUT_MS (240s): a research worker answers one question,
43
- * a planning child reasons over the whole design doc.
33
+ * That premise was measured and is false. Replaying ONE captured auto-decompose
34
+ * request against the same backend with reasoning ON, n=10, everything else
35
+ * byte-identical (2026-08-17): all ten answered correctly with 26-42 titles, and
36
+ * every one took 610-927s. The cap would have killed 10 out of 10 GOOD runs and
37
+ * then failed the phase with PhaseTimeoutError. The number was not measuring the
38
+ * pathology, it was measuring one model's speed on one day.
39
+ *
40
+ * The runaway it was there to catch — a decompose child that ran 16m23s at
41
+ * 117,370 of a 120,064-token window, forward-paging past the loop detector — is
42
+ * now caught by StallDetector (stall-detector.ts), which bounds NON-PROGRESS and
43
+ * CONTEXT CHURN instead of elapsed seconds. Both of those are properties of the
44
+ * pathology, so neither has to be re-tuned for a slower model or a bigger repo.
45
+ *
46
+ * The value and the plumbing stay for a caller that genuinely wants a hard stop
47
+ * (tests inject a short one), but nothing sets it in production. Pass
48
+ * `timeoutMs` explicitly to arm it.
44
49
  */
45
- export const PHASE_CHILD_TIMEOUT_MS = 600_000;
50
+ export const PHASE_CHILD_TIMEOUT_MS = 0;
46
51
  /**
47
52
  * Restart hint after a phase child burns its whole wall-clock budget. It
48
53
  * diagnoses over-exploration, which is what the cap actually catches — the same
@@ -161,7 +166,13 @@ export const USER_CANCELLED = '__user_cancelled__';
161
166
  */
162
167
  export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn,
163
168
  /** Internal `-e` extension paths for in-run guards (see childArgs). */
164
- extensions) {
169
+ extensions,
170
+ /**
171
+ * Every finished tool call's result text. The StallDetector's churn rule
172
+ * needs the size of what actually entered the child's context, which the
173
+ * CALL alone does not carry (task/stall-detector.ts).
174
+ */
175
+ onToolResult) {
165
176
  const invocation = getPiInvocation(childArgs(tools, extensions), prompt);
166
177
  let loopHit;
167
178
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
@@ -173,6 +184,7 @@ extensions) {
173
184
  streamInactivityMs: getConfig().streamInactivityMs,
174
185
  onLine,
175
186
  onContextUsage,
187
+ onToolResult: onToolResult ? r => onToolResult(r.text, r.isError) : undefined,
176
188
  onToolCall: call => {
177
189
  if (!onToolCall)
178
190
  return null;
@@ -276,16 +288,20 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
276
288
  * Empty completions and connection-class model errors share that same budget —
277
289
  * see triageChildResult, which decides every one of those cases.
278
290
  *
279
- * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
291
+ * THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
280
292
  * /task-auto planning child goes through (clarify, decompose, coverage,
281
- * contract-extract) and until mx5-n 2026-08-14 it had neither:
293
+ * contract-extract) and until mx5-n 2026-08-14 it had none:
282
294
  * • a LoopDetector, so an identical repeated tool call is killed and
283
295
  * re-prompted instead of being allowed to fill the context window;
284
- * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
285
- * detector cannot see — the shape that actually cost us a 16-minute
286
- * decompose child that was never going to return.
287
- * Both are checked BEFORE the triage ladder: we killed the child, so its exit
288
- * status describes our SIGTERM and says nothing about its verdict.
296
+ * • a StallDetector, the backstop for the varied-args thrash the loop
297
+ * detector's short window cannot see — the shape that actually cost us a
298
+ * 16-minute decompose child that was never going to return. It bounds
299
+ * consecutive no-new-ground calls and total context churn, NOT elapsed time;
300
+ * PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
301
+ * measured healthy range (610-927s for a reasoning-on decompose) overlaps
302
+ * any value that would catch the pathology. See its comment.
303
+ * All three are checked BEFORE the triage ladder: we killed the child, so its
304
+ * exit status describes our SIGTERM and says nothing about its verdict.
289
305
  */
290
306
  export async function runPhaseChild(deps, name, tools, prompt) {
291
307
  if (deps.runChild)
@@ -295,23 +311,30 @@ export async function runPhaseChild(deps, name, tools, prompt) {
295
311
  const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
296
312
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
297
313
  const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
314
+ const stall = new StallDetector();
298
315
  const clock = phaseTimeout(deps.signal, budgetMs);
299
316
  let r;
300
317
  try {
301
- r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, deps.onContextUsage, call => detector.record(call), deps.spawn, deps.childExtensions);
318
+ r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, snapshot => {
319
+ stall.noteContext(snapshot.contextWindow);
320
+ deps.onContextUsage?.(snapshot);
321
+ }, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError));
302
322
  }
303
323
  finally {
304
324
  clock.cleanup();
305
325
  }
306
- // A user cancel must not be mistaken for either guard.
326
+ // A user cancel must not be mistaken for any of the guards.
307
327
  if (deps.signal.aborted)
308
328
  throw new Error(USER_CANCELLED);
309
329
  if (r.loopHit) {
310
330
  loopHistory.push(r.loopHit);
311
331
  if (attempt === MAX_LEAK_RETRIES)
312
332
  throw new LoopExhaustedError(name, loopHistory);
313
- deps.logDebug?.(`${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
314
- hint = formatLoopHint(r.loopHit);
333
+ deps.logDebug?.(r.loopHit.stall ?
334
+ `${name}: stalled (${r.loopHit.stall}) on ${r.loopHit.call.name} — `
335
+ + `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`
336
+ : `${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
337
+ hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
315
338
  continue;
316
339
  }
317
340
  if (clock.timedOut()) {
@@ -379,8 +402,12 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
379
402
  if (deps.signal.aborted)
380
403
  throw new Error(USER_CANCELLED);
381
404
  const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
405
+ const stall = new StallDetector();
382
406
  const prompt = buildPrompt(nextHint);
383
- const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput, deps.onContextUsage, call => detector.record(call), deps.spawn);
407
+ const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput, snapshot => {
408
+ stall.noteContext(snapshot.contextWindow);
409
+ deps.onContextUsage?.(snapshot);
410
+ }, call => detector.record(call) ?? stall.record(call), deps.spawn, undefined, (text, isError) => stall.noteResult(text, isError));
384
411
  if (deps.signal.aborted)
385
412
  throw new Error(USER_CANCELLED);
386
413
  if (r.loopHit) {
@@ -394,7 +421,8 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
394
421
  }
395
422
  throw new LoopExhaustedError(name, loopHistory);
396
423
  }
397
- nextHint = formatLoopHint(r.loopHit);
424
+ nextHint =
425
+ r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
398
426
  continue;
399
427
  }
400
428
  // Everything past the loop kill is the shared ladder: exit code, model
@@ -0,0 +1,95 @@
1
+ /**
2
+ * ChildStatus — the live status of the child pi currently running under a
3
+ * status loader: its latest output line and its context usage.
4
+ *
5
+ * Four sites used to keep this state by hand — `let lastLine; let contextUsage;`
6
+ * plus two callbacks (`onChildOutput` writes the line, `onContextUsage` folds a
7
+ * snapshot through `resolveContextUsage` with the parent window), a reset before
8
+ * every child, and a loader whose every tick read both — in `/task-auto`'s
9
+ * planning `runChild`, `/task-plan`'s `child`, `buildGateDeps` (an accessor box
10
+ * handed to `makeGateChild`), and the single-task `TaskRunner`. The first three
11
+ * are one ritual and are now this class; the fourth stays where it is (see
12
+ * `orchestrator.ts`: its state is the whole-run `WidgetState`, shared by
13
+ * reference with `PhaseContext` and written by the phases themselves).
14
+ *
15
+ * `track` is the loader ritual: reset, raise the loader reading this status on
16
+ * every tick, run, always stop. The status OUTLIVES a track — `buildGateDeps`
17
+ * shares one across every gate child, and the verify gate raises its own
18
+ * gate-wide loader over a child that renders none (`frame: null`), so both must
19
+ * see the same object.
20
+ */
21
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
22
+ import type { ContextSnapshot } from '../shared/child-process.js';
23
+ import { type PhaseDeps } from './child-runner.js';
24
+ import { type AutoLoaderState } from './widget.js';
25
+ export interface ChildStatusDeps {
26
+ /** The parent session's window — the last fallback for the context gauge. */
27
+ parentContextWindow: number;
28
+ /** Raise a loader. Defaults to the real `startAutoLoader`; a test injects a fake. */
29
+ startLoader?: (ctx: ExtensionCommandContext, getState: () => AutoLoaderState | null) => () => void;
30
+ }
31
+ export declare class ChildStatus {
32
+ private _lastLine;
33
+ private _contextUsage;
34
+ private readonly _parentContextWindow;
35
+ private readonly _startLoader;
36
+ constructor(deps: ChildStatusDeps);
37
+ /** The child's latest stream line. Bind as `onChildOutput` / `onLine`. */
38
+ onLine(line: string): void;
39
+ /**
40
+ * Fold a raw context_usage snapshot into the gauge: the child's own window,
41
+ * else the last known one, else the parent's (`resolveContextUsage`).
42
+ */
43
+ onContextUsage(snapshot: ContextSnapshot): void;
44
+ /** Forget the previous child, so its trailer never sits under the next one's block. */
45
+ reset(): void;
46
+ /** The two live fields, as a loader frame reads them. */
47
+ snapshot(): {
48
+ lastLine?: string;
49
+ contextUsage?: ContextSnapshot;
50
+ };
51
+ /**
52
+ * Run `run` under the loader: reset, raise a loader whose every tick is
53
+ * `frame()` plus the live line and gauge, and stop it in a `finally` — a
54
+ * throwing child must not leave the widget up. `frame` wins on a clash, which
55
+ * is how the verify gate shows its deterministic-stage label until the child
56
+ * has a line of its own. `frame: null` renders NO loader (the caller already
57
+ * has one reading this status) but still resets, so the previous child's
58
+ * trailer is cleared either way.
59
+ */
60
+ track<T>(ctx: ExtensionCommandContext, frame: (() => AutoLoaderState) | null, run: () => Promise<T>): Promise<T>;
61
+ }
62
+ /** What a planning child's loader shows: the head-line command, the title, the step. */
63
+ export interface PlanningChildLoader {
64
+ /** Head-line command. Omit for the loader's default (`/task-auto`). */
65
+ command?: string;
66
+ title: string;
67
+ /**
68
+ * The step for THIS child. Read on every tick, because /task-plan renames the
69
+ * step while a child runs (`setStatus`), and /task-auto numbers its steps.
70
+ */
71
+ step: (name: string) => {
72
+ step: string;
73
+ stepNum: number;
74
+ stepTotal: number;
75
+ };
76
+ }
77
+ /**
78
+ * Run one planning child — a phase child (`runPhaseChild`, with its Error-triage
79
+ * ladder) whose only UI is the shared status loader. Both `/task-auto`'s
80
+ * planning `runChild` and `/task-plan`'s `child` are adapters over this: what
81
+ * they disagree on is the phase deps (task id, read-once extension, debug log),
82
+ * the tool set, and the loader's labelling — all parameters here. What
83
+ * `/task-plan` adds around it (the read-only tree diff) stays its own.
84
+ */
85
+ export declare function runPlanningChild(opts: {
86
+ ctx: ExtensionCommandContext;
87
+ status: ChildStatus;
88
+ phaseDeps: PhaseDeps;
89
+ name: string;
90
+ tools: string;
91
+ prompt: string;
92
+ loader: PlanningChildLoader;
93
+ }): Promise<string>;
94
+ /** Wire a `ChildStatus` as a phase child's stream callbacks. */
95
+ export declare function statusCallbacks(status: ChildStatus): Pick<PhaseDeps, 'onChildOutput' | 'onContextUsage'>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * ChildStatus — the live status of the child pi currently running under a
3
+ * status loader: its latest output line and its context usage.
4
+ *
5
+ * Four sites used to keep this state by hand — `let lastLine; let contextUsage;`
6
+ * plus two callbacks (`onChildOutput` writes the line, `onContextUsage` folds a
7
+ * snapshot through `resolveContextUsage` with the parent window), a reset before
8
+ * every child, and a loader whose every tick read both — in `/task-auto`'s
9
+ * planning `runChild`, `/task-plan`'s `child`, `buildGateDeps` (an accessor box
10
+ * handed to `makeGateChild`), and the single-task `TaskRunner`. The first three
11
+ * are one ritual and are now this class; the fourth stays where it is (see
12
+ * `orchestrator.ts`: its state is the whole-run `WidgetState`, shared by
13
+ * reference with `PhaseContext` and written by the phases themselves).
14
+ *
15
+ * `track` is the loader ritual: reset, raise the loader reading this status on
16
+ * every tick, run, always stop. The status OUTLIVES a track — `buildGateDeps`
17
+ * shares one across every gate child, and the verify gate raises its own
18
+ * gate-wide loader over a child that renders none (`frame: null`), so both must
19
+ * see the same object.
20
+ */
21
+ import { runPhaseChild } from './child-runner.js';
22
+ import { resolveContextUsage } from './context-usage.js';
23
+ import { startAutoLoader } from './widget.js';
24
+ export class ChildStatus {
25
+ _lastLine;
26
+ _contextUsage;
27
+ _parentContextWindow;
28
+ _startLoader;
29
+ constructor(deps) {
30
+ this._parentContextWindow = deps.parentContextWindow;
31
+ this._startLoader = deps.startLoader ?? startAutoLoader;
32
+ }
33
+ /** The child's latest stream line. Bind as `onChildOutput` / `onLine`. */
34
+ onLine(line) {
35
+ this._lastLine = line;
36
+ }
37
+ /**
38
+ * Fold a raw context_usage snapshot into the gauge: the child's own window,
39
+ * else the last known one, else the parent's (`resolveContextUsage`).
40
+ */
41
+ onContextUsage(snapshot) {
42
+ this._contextUsage = resolveContextUsage(snapshot, this._contextUsage, this._parentContextWindow);
43
+ }
44
+ /** Forget the previous child, so its trailer never sits under the next one's block. */
45
+ reset() {
46
+ this._lastLine = undefined;
47
+ this._contextUsage = undefined;
48
+ }
49
+ /** The two live fields, as a loader frame reads them. */
50
+ snapshot() {
51
+ return { lastLine: this._lastLine, contextUsage: this._contextUsage };
52
+ }
53
+ /**
54
+ * Run `run` under the loader: reset, raise a loader whose every tick is
55
+ * `frame()` plus the live line and gauge, and stop it in a `finally` — a
56
+ * throwing child must not leave the widget up. `frame` wins on a clash, which
57
+ * is how the verify gate shows its deterministic-stage label until the child
58
+ * has a line of its own. `frame: null` renders NO loader (the caller already
59
+ * has one reading this status) but still resets, so the previous child's
60
+ * trailer is cleared either way.
61
+ */
62
+ async track(ctx, frame, run) {
63
+ this.reset();
64
+ const stop = frame === null ?
65
+ () => { }
66
+ : this._startLoader(ctx, () => ({ ...this.snapshot(), ...frame() }));
67
+ try {
68
+ return await run();
69
+ }
70
+ finally {
71
+ stop();
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Run one planning child — a phase child (`runPhaseChild`, with its Error-triage
77
+ * ladder) whose only UI is the shared status loader. Both `/task-auto`'s
78
+ * planning `runChild` and `/task-plan`'s `child` are adapters over this: what
79
+ * they disagree on is the phase deps (task id, read-once extension, debug log),
80
+ * the tool set, and the loader's labelling — all parameters here. What
81
+ * `/task-plan` adds around it (the read-only tree diff) stays its own.
82
+ */
83
+ export async function runPlanningChild(opts) {
84
+ const { ctx, status, phaseDeps, name, tools, prompt, loader } = opts;
85
+ const startedAt = Date.now();
86
+ return status.track(ctx, () => ({
87
+ ...(loader.command === undefined ? {} : { command: loader.command }),
88
+ title: loader.title,
89
+ ...loader.step(name),
90
+ startedAt
91
+ }), () => runPhaseChild(phaseDeps, name, tools, prompt));
92
+ }
93
+ /** Wire a `ChildStatus` as a phase child's stream callbacks. */
94
+ export function statusCallbacks(status) {
95
+ return {
96
+ onChildOutput: line => status.onLine(line),
97
+ onContextUsage: snapshot => status.onContextUsage(snapshot)
98
+ };
99
+ }
@@ -102,3 +102,39 @@ export declare function outputTail(stdout: string, stderr: string, limit?: numbe
102
102
  * an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
103
103
  */
104
104
  export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[]): CommandVerdict;
105
+ /**
106
+ * How a re-run of ONE recorded VERIFY command line ended.
107
+ * pass — it ran and exited 0. The ONLY outcome that may close a debt.
108
+ * fail — it ran and exited non-zero for a real reason. Debt stays open.
109
+ * gap — nothing was observed: the shell/runner never spawned, 127 inside the
110
+ * chain, a timeout, a missing browser, or absent external infrastructure.
111
+ * INCONCLUSIVE, so the debt stays open (surface, never re-hide).
112
+ */
113
+ export type VerifyRerunOutcome = {
114
+ outcome: 'pass';
115
+ } | {
116
+ outcome: 'fail';
117
+ status: number;
118
+ tail: string;
119
+ } | {
120
+ outcome: 'gap';
121
+ detail: string;
122
+ };
123
+ /**
124
+ * Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
125
+ * env-gap contract, so a debt whose reason NAMES that command can be closed by the
126
+ * command itself rather than by a judgement about it.
127
+ *
128
+ * Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
129
+ * is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
130
+ * are all ordinary there. The leading command word is still resolved through
131
+ * runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
132
+ * gap (mx5 run 16's blindness, one level down).
133
+ *
134
+ * The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
135
+ * failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
136
+ * debt exactly as open as it was.
137
+ */
138
+ export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
139
+ /** The spawner. Injected so a re-run's outcome can be tested without one. */
140
+ run?: CommandRunner): VerifyRerunOutcome;
@@ -28,7 +28,7 @@
28
28
  * had none.
29
29
  */
30
30
  import { spawnSync } from 'node:child_process';
31
- import { isCommandNotFound } from './runner-resolve.js';
31
+ import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
32
32
  /** The real runner. */
33
33
  export const spawnCommand = spec => {
34
34
  const r = spawnSync(spec.bin, spec.args, {
@@ -136,3 +136,50 @@ export function classifyCommandRun(run, gapPatterns = []) {
136
136
  tail: outputTail(run.stdout, run.stderr)
137
137
  };
138
138
  }
139
+ /** The command word of a shell line, past any leading `VAR=value` assignments. */
140
+ function leadingBin(line) {
141
+ for (const tok of line.trim().split(/\s+/)) {
142
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
143
+ continue;
144
+ return tok;
145
+ }
146
+ return null;
147
+ }
148
+ /**
149
+ * Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
150
+ * env-gap contract, so a debt whose reason NAMES that command can be closed by the
151
+ * command itself rather than by a judgement about it.
152
+ *
153
+ * Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
154
+ * is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
155
+ * are all ordinary there. The leading command word is still resolved through
156
+ * runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
157
+ * gap (mx5 run 16's blindness, one level down).
158
+ *
159
+ * The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
160
+ * failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
161
+ * debt exactly as open as it was.
162
+ */
163
+ export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
164
+ /** The spawner. Injected so a re-run's outcome can be tested without one. */
165
+ run = spawnCommand) {
166
+ const bin = leadingBin(line);
167
+ const runner = bin === null ? null : resolveRunner(bin);
168
+ // A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
169
+ // redirects are all ordinary there — so the runner spawns `sh -c`.
170
+ const verdict = classifyCommandRun(run({
171
+ cwd,
172
+ bin: 'sh',
173
+ args: ['-c', line],
174
+ timeoutMs,
175
+ env: runner ? runnerEnv(runner) : { ...process.env }
176
+ }),
177
+ // Infrastructure counts as a gap on EVERY debt re-run, not only on
178
+ // request: an unreachable database cannot tell us whether the code is
179
+ // fixed, and the asymmetry below means an inconclusive re-run simply
180
+ // leaves the debt as open as it was.
181
+ extraGapRe ? [INFRA_GAP_OUTPUT_RE, extraGapRe] : [INFRA_GAP_OUTPUT_RE]);
182
+ if (verdict.outcome === 'gap')
183
+ return { outcome: 'gap', detail: verdict.detail };
184
+ return verdict;
185
+ }
@@ -34,7 +34,7 @@ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WA
34
34
  /**
35
35
  * One-shot marker: the most recent turn abort was issued BY THE WATCHDOG, not by
36
36
  * a human ESC. Both end the assistant turn with stopReason 'aborted' — the only
37
- * signal steerUntilDone's wasInterrupted() can read — so without this flag the
37
+ * signal steerUntilDone's classifyTurnEnd() can read — so without this flag the
38
38
  * steer loop can win the race against the watchdog's queued follow-up turn and
39
39
  * show a steering prompt to an empty room (wedging an unattended run).
40
40
  *
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Context-usage resolution — shared by the single-task widget (TaskRunner) and
3
- * the /task-auto planning loader (defaultDeps), which both mirror a child's
4
- * context_usage events into a display snapshot with identical math.
2
+ * Context-usage resolution — the one piece of math every child-status mirror
3
+ * shares. `ChildStatus` (child-status.ts) folds it into its `onContextUsage` for
4
+ * the planning and gate children; the single-task widget (TaskRunner) calls it
5
+ * directly, because its state is the whole-run `WidgetState`, not one child's.
5
6
  */
6
7
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
7
8
  import type { ContextSnapshot } from '../shared/child-process.js';
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Context-usage resolution — shared by the single-task widget (TaskRunner) and
3
- * the /task-auto planning loader (defaultDeps), which both mirror a child's
4
- * context_usage events into a display snapshot with identical math.
2
+ * Context-usage resolution — the one piece of math every child-status mirror
3
+ * shares. `ChildStatus` (child-status.ts) folds it into its `onContextUsage` for
4
+ * the planning and gate children; the single-task widget (TaskRunner) calls it
5
+ * directly, because its state is the whole-run `WidgetState`, not one child's.
5
6
  */
6
7
  /** The parent session's context window, or 0 when the model doesn't expose it. */
7
8
  export function getParentContextWindow(ctx) {