@mjasnikovs/pi-task 0.38.23 → 0.38.25

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 (48) hide show
  1. package/README.md +2 -2
  2. package/dist/config/reasoning-args.d.ts +12 -1
  3. package/dist/config/reasoning-args.js +5 -2
  4. package/dist/config/reasoning.d.ts +47 -6
  5. package/dist/config/reasoning.js +84 -9
  6. package/dist/config/register.d.ts +50 -26
  7. package/dist/config/register.js +96 -80
  8. package/dist/shared/reasoning-capability.d.ts +2 -5
  9. package/dist/shared/reasoning-capability.js +31 -4
  10. package/dist/task/auto-orchestrator.d.ts +2 -0
  11. package/dist/task/auto-orchestrator.js +28 -41
  12. package/dist/task/child-runner.d.ts +89 -24
  13. package/dist/task/child-runner.js +67 -46
  14. package/dist/task/gate-child.js +11 -11
  15. package/dist/task/orchestrator.d.ts +14 -20
  16. package/dist/task/orchestrator.js +12 -9
  17. package/dist/task/phases.d.ts +0 -23
  18. package/dist/task/phases.js +48 -464
  19. package/dist/task/question-dialog.d.ts +56 -0
  20. package/dist/task/question-dialog.js +53 -0
  21. package/dist/task/research-fanout-budget.d.ts +20 -0
  22. package/dist/task/research-fanout-budget.js +29 -0
  23. package/dist/task/research-worker.d.ts +183 -0
  24. package/dist/task/research-worker.js +429 -0
  25. package/dist/workers/brave-warning.js +4 -30
  26. package/dist/workers/docs-core.d.ts +8 -4
  27. package/dist/workers/docs-core.js +30 -21
  28. package/dist/workers/docs-lookup.d.ts +72 -0
  29. package/dist/workers/docs-lookup.js +53 -0
  30. package/dist/workers/docs-project.d.ts +9 -0
  31. package/dist/workers/docs-project.js +15 -0
  32. package/dist/workers/pi-worker-core.d.ts +112 -109
  33. package/dist/workers/pi-worker-core.js +33 -48
  34. package/dist/workers/pi-worker-docs.js +27 -31
  35. package/dist/workers/pi-worker.js +6 -0
  36. package/dist/workers/reasoning-warning.d.ts +10 -16
  37. package/dist/workers/reasoning-warning.js +25 -57
  38. package/dist/workers/session-hint.d.ts +37 -0
  39. package/dist/workers/session-hint.js +82 -0
  40. package/dist/workers/worker-failure.d.ts +34 -0
  41. package/dist/workers/worker-failure.js +27 -16
  42. package/dist/workers/worker-kill.d.ts +84 -0
  43. package/dist/workers/worker-kill.js +124 -0
  44. package/dist/workers/worker-profiles.d.ts +314 -0
  45. package/dist/workers/worker-profiles.js +220 -0
  46. package/package.json +1 -1
  47. package/dist/task/reasoning-groups.d.ts +0 -36
  48. package/dist/task/reasoning-groups.js +0 -36
@@ -1,3 +1,30 @@
1
+ /**
2
+ * What pi will ACTUALLY send, given a model and a requested thinking level.
3
+ *
4
+ * WHY A LOCAL COPY OF PI'S CLAMP
5
+ * ------------------------------
6
+ * pi never reports that it ignored or downgraded a level. Measured live against
7
+ * this machine's llama-server, with a proxy capturing the request body:
8
+ *
9
+ * 1. a model with `reasoning: false` + `--thinking medium`
10
+ * → the body carries NO reasoning field at all. No error, no warning.
11
+ * 2. `thinkingLevelMap: {off: null, ...}` + `--thinking off`
12
+ * → silently clamped UP to `medium`. Thinking stays on.
13
+ * 3. `--thinking low` where `low: null`
14
+ * → silently clamped to `medium`.
15
+ *
16
+ * All three are the same arithmetic, and it is pure: `getSupportedThinkingLevels`
17
+ * / `clampThinkingLevel` in @earendil-works/pi-ai's models module. Reproducing it
18
+ * lets one predicate — `clampToModel(m, wanted) !== wanted` — catch all three
19
+ * host-side, before a single request is sent.
20
+ *
21
+ * Reimplemented rather than imported because `@earendil-works/pi-ai` is neither a
22
+ * dependency nor a peerDependency of pi-task: it is present only because
23
+ * pi-coding-agent hoists it, so importing it would take a hard dependency on a
24
+ * transitive package to get twenty lines of arithmetic. SOURCE OF TRUTH is that
25
+ * module; `reasoning-capability.test.ts` is where a change upstream shows up.
26
+ */
27
+ import { REASONING_GROUPS } from '../config/reasoning.js';
1
28
  /**
2
29
  * pi's own level ladder, in order. The order is the whole algorithm: an
3
30
  * unsupported level is resolved by walking UP first, then down.
@@ -64,16 +91,16 @@ export function clampToModel(model, level) {
64
91
  * about one direction while staying silent about the other would ship this
65
92
  * feature with its own measured failure mode unreported.
66
93
  */
67
- export function reasoningMismatches(model, settings) {
94
+ export function reasoningMismatches(model, levels) {
68
95
  // No model resolved yet (session still starting, or none selected): say
69
96
  // nothing. A warning naming no model is noise, not information.
70
97
  if (!model)
71
98
  return [];
72
99
  const out = [];
73
- for (const { group, setting } of settings) {
74
- if (setting === 'inherit')
100
+ for (const group of REASONING_GROUPS) {
101
+ const wanted = levels[group];
102
+ if (wanted === 'inherit')
75
103
  continue;
76
- const wanted = setting;
77
104
  const actual = clampToModel(model, wanted);
78
105
  if (actual !== wanted)
79
106
  out.push({ group, wanted, actual });
@@ -29,6 +29,8 @@ export interface AutoDeps extends GateDeps, FinalGateStageDeps {
29
29
  */
30
30
  stashRef?: (cwd: string) => Promise<string | null>;
31
31
  }
32
+ /** Wait for every plan-debug line written so far to reach disk. Tests only. */
33
+ export declare function flushPlanDebug(): Promise<unknown>;
32
34
  /**
33
35
  * Expand any @file references in the feature text by appending each referenced
34
36
  * file's contents, so the planning children (clarify, decompose) always see the
@@ -47,7 +47,7 @@ import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoars
47
47
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
48
48
  import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
49
49
  import { groundedCoverage } from './coverage-loop.js';
50
- import { buildOptionCards, resolveAnswer } from './question-dialog.js';
50
+ import { settleQuestion } from './question-dialog.js';
51
51
  import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
52
52
  import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
53
53
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
@@ -149,12 +149,28 @@ function mentionPath(token) {
149
149
  * default level. It is also the only channel the plan phase has: it runs before
150
150
  * any task file, hence any `TASK_NNNN-debug.log`, exists.
151
151
  */
152
+ /**
153
+ * Every plan-debug write not yet on disk, chained.
154
+ *
155
+ * Fire-and-forget is right for production — a plan must never wait on its own
156
+ * trail — but it leaves nothing to synchronise on, and the twelve tests that
157
+ * read `plan-debug.log` back were racing the append that writes it. They failed
158
+ * intermittently on ENOENT, ~4ms in, at a rate that moved with how many other
159
+ * files the suite was running beside them. Chaining also serialises concurrent
160
+ * appends, which is what keeps a line whole.
161
+ */
162
+ let planDebugChain = Promise.resolve();
163
+ /** Wait for every plan-debug line written so far to reach disk. Tests only. */
164
+ export function flushPlanDebug() {
165
+ return planDebugChain;
166
+ }
152
167
  function logPlanDebug(cwd, msg) {
153
168
  if (!shouldLogDebug('event', debugLogLevel()))
154
169
  return;
155
170
  const line = `${new Date().toISOString()} ${msg}\n`;
156
171
  const dir = tasksDir(cwd);
157
- fsp.mkdir(dir, { recursive: true })
172
+ planDebugChain = planDebugChain
173
+ .then(() => fsp.mkdir(dir, { recursive: true }))
158
174
  .then(() => fsp.appendFile(path.join(dir, 'plan-debug.log'), line))
159
175
  .catch(() => { });
160
176
  }
@@ -559,56 +575,27 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
559
575
  transcript.add('auto-resolved', plainQ, autoResolved);
560
576
  continue;
561
577
  }
562
- const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
563
- const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
564
578
  // YOLO: take the recommended option (index 0 / the green card) without ever
565
579
  // building the prompt. Clarify has no anti-synthesis channel — it runs before
566
580
  // any research — so the only step-aside here is a question that carries no
567
581
  // recommendation to take; that one is skipped rather than guessed.
568
- const yolo = yoloPickAnswer(isYoloMode(), {
569
- ...(plainSuggested !== undefined && { suggested: plainSuggested }),
570
- ...(plainAlt !== undefined && { alt: plainAlt })
571
- });
572
- if (yolo !== null) {
573
- if (yolo.kind === 'answer')
574
- transcript.add('yolo', plainQ, yolo.answer);
575
- else
576
- transcript.add('yolo-skip', plainQ, `(skipped — ${yolo.note})`);
577
- continue;
578
- }
579
- // The picker cards and the reply mapping are shared with /task's grill
580
- // phase and the plan session (question-dialog.ts) — all three used to
581
- // write them out, and had drifted.
582
- const pending = {
582
+ const outcome = await settleQuestion({
583
+ ui,
584
+ transcript,
583
585
  plain: plainQ,
584
586
  shown: shownQ,
585
- ...(plainSuggested !== undefined && {
586
- suggested: plainSuggested,
587
- shownSuggested: renderInlineMarkdown(suggested, theme)
588
- }),
589
- ...(plainAlt !== undefined && {
590
- alt: plainAlt,
591
- shownAlt: renderInlineMarkdown(alt, theme)
587
+ ...(suggested !== undefined && { suggested }),
588
+ ...(alt !== undefined && { alt }),
589
+ render: md => renderInlineMarkdown(md, theme),
590
+ yolo: yoloPickAnswer(isYoloMode(), {
591
+ ...(suggested !== undefined && { suggested: stripInlineMarkdown(suggested) }),
592
+ ...(alt !== undefined && { alt: stripInlineMarkdown(alt) })
592
593
  })
593
- };
594
- const options = buildOptionCards(pending);
595
- const a = await ui.ask({
596
- localTitle: shownQ,
597
- displayQuestion: shownQ,
598
- question: plainQ,
599
- recommended: plainSuggested,
600
- ...(plainAlt !== undefined && { recommended2: plainAlt }),
601
- allowSkip: plainSuggested === undefined && plainAlt === undefined,
602
- ...(options && { options })
603
594
  });
604
- if (a === undefined) {
595
+ if (outcome === 'cancelled') {
605
596
  announceDone(ctx, '/task-auto cancelled.', 'warning');
606
597
  return null;
607
598
  }
608
- // An accept covers both routes to it: submitting empty, and pressing the
609
- // single green card. The suffix is the policy's, not this call site's.
610
- const resolved = resolveAnswer(pending, a);
611
- transcript.add(resolved.source === 'accepted' ? 'accepted' : 'typed', plainQ, resolved.answer);
612
599
  }
613
600
  if (transcript.length === 0) {
614
601
  ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
@@ -81,32 +81,49 @@ export declare const USER_CANCELLED = "__user_cancelled__";
81
81
  * context-usage tracking. This is the typed convenience wrapper used by
82
82
  * phase-level code.
83
83
  */
84
- 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,
85
- /** Internal `-e` extension paths for in-run guards (see childArgs). */
86
- extensions?: readonly string[],
87
84
  /**
88
- * Every finished tool call's result text. The StallDetector's churn rule
89
- * needs the size of what actually entered the child's context, which the
90
- * CALL alone does not carry (task/stall-detector.ts).
91
- */
92
- onToolResult?: (text: string, isError: boolean) => void,
93
- /**
94
- * The child's context window in tokens. Nothing in pi's event stream reports
95
- * one (issue #16), so the parent hands its own down — children carry no `-m`
96
- * and resolve the same default model. 0 / omitted = unknown, as before.
97
- */
98
- contextWindow?: number,
99
- /**
100
- * The resolved `['--thinking', level]` fragment for this child's reasoning
101
- * group, or `[]`/omitted to inherit the session default as before.
85
+ * One child-pi invocation, as a value.
102
86
  *
103
- * A 13th trailing positional is not pretty. It is how `contextWindow` (the
104
- * 12th) was added, and an options-bag refactor of this signature is a
105
- * separate change from wiring one flag doing both at once would put a
106
- * behaviour change inside a mechanical one. DEBT: convert to an options bag.
87
+ * WHY A RECORD. This was thirteen ordered positionals, and the thirteenth
88
+ * carried its own DEBT note saying so. Two production callers reached it, and
89
+ * they had already drifted: the degrade attempt wrote three bare `undefined`s to
90
+ * reach the later slots and passed the RAW signal, so it escaped the wall clock
91
+ * its own strike siblings run under — the same defect class this file already
92
+ * recorded for `runAutoInstall` and for the deleted `runPhaseWithLoopGuard`.
93
+ * Adjacent optionals of the same type can no longer swap without a type error.
107
94
  */
108
- thinking?: readonly string[]): Promise<PhaseRunResult>;
109
- interface PhaseDeps {
95
+ export interface ChildRun {
96
+ cwd: string;
97
+ /** `''` means `--no-tools`. See childArgs. */
98
+ tools: string;
99
+ prompt: string;
100
+ signal: AbortSignal;
101
+ onLine?: (line: string) => void;
102
+ onContextUsage?: (snapshot: ContextSnapshot) => void;
103
+ onToolCall?: (call: ToolCall) => LoopHit | null;
104
+ spawn?: SpawnFn;
105
+ /** Internal `-e` extension paths for in-run guards (see childArgs). */
106
+ extensions?: readonly string[];
107
+ /**
108
+ * Every finished tool call's result text. The StallDetector's churn rule
109
+ * needs the size of what actually entered the child's context, which the
110
+ * CALL alone does not carry (task/stall-detector.ts).
111
+ */
112
+ onToolResult?: (text: string, isError: boolean) => void;
113
+ /**
114
+ * The child's context window in tokens. Nothing in pi's event stream reports
115
+ * one (issue #16), so the parent hands its own down — children carry no `-m`
116
+ * and resolve the same default model. 0 / omitted = unknown, as before.
117
+ */
118
+ contextWindow?: number;
119
+ /**
120
+ * The resolved `['--thinking', level]` fragment for this child's reasoning
121
+ * group, or `[]`/omitted to inherit the session default as before.
122
+ */
123
+ thinking?: readonly string[];
124
+ }
125
+ export declare function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking }: ChildRun): Promise<PhaseRunResult>;
126
+ export interface PhaseDeps {
110
127
  cwd: string;
111
128
  taskId: string;
112
129
  signal: AbortSignal;
@@ -211,7 +228,55 @@ interface PhaseDeps {
211
228
  */
212
229
  searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
213
230
  }
214
- export type { PhaseDeps };
231
+ /**
232
+ * The subset of `PhaseDeps` a CALLER may supply — every injectable seam, derived
233
+ * by naming what the runner owns instead of by listing what it does not.
234
+ *
235
+ * `TaskRunnerOptions` used to declare `spawnFn`, `runChild`, `runWorker` and a
236
+ * seven-name `Pick` called `lookups` separately; `RunSingleTaskOptions` re-picked
237
+ * all four, `runSingleTask` re-forwarded each by name, and the constructor spread
238
+ * them back together. Four coordinated edits, none of which failed to compile if
239
+ * you skipped one — the exact indictment this codebase already recorded against
240
+ * `ConfigItem`. Four seams (`timeoutMs`, `sleepFor`, `childExtensions`,
241
+ * `logDebug`) had in fact been left behind, so a runner-driven test of the
242
+ * connection-error rung really slept and the debug trail could not be asserted at
243
+ * all. Derived by `Omit`, a NEW seam field joins this with no second edit.
244
+ */
245
+ export type PhaseSeams = Omit<PhaseDeps, 'cwd' | 'taskId' | 'signal' | 'onChildOutput' | 'onContextUsage' | 'contextWindow' | 'recordSubStep'>;
246
+ /**
247
+ * Run a child pi and return its assistant text. Throws if exit code != 0.
248
+ *
249
+ * If the child leaks a tool call as plain text (wrong dialect — never executed),
250
+ * re-prompt with a correction hint up to MAX_LEAK_RETRIES times; if it keeps
251
+ * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
252
+ * Empty completions and connection-class model errors share that same budget —
253
+ * see triageChildResult, which decides every one of those cases.
254
+ *
255
+ * THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
256
+ * /task-auto planning child goes through (clarify, decompose, coverage,
257
+ * contract-extract) and until mx5-n 2026-08-14 it had none:
258
+ * • a LoopDetector, so an identical repeated tool call is killed and
259
+ * re-prompted instead of being allowed to fill the context window;
260
+ * • a StallDetector, the backstop for the varied-args thrash the loop
261
+ * detector's short window cannot see — the shape that actually cost us a
262
+ * 16-minute decompose child that was never going to return. It bounds
263
+ * consecutive no-new-ground calls and total context churn, NOT elapsed time;
264
+ * • PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
265
+ * measured healthy range (610-927s for a reasoning-on decompose) overlaps
266
+ * any value that would catch the pathology. See its comment.
267
+ * All three are checked BEFORE the triage ladder: we killed the child, so its
268
+ * exit status describes our SIGTERM and says nothing about its verdict.
269
+ */
270
+ /**
271
+ * The `--thinking` fragment for a named child, or `[]` when the name is unmapped.
272
+ *
273
+ * An unmapped name INHERITS rather than throwing: a child that reaches the model
274
+ * with today's argv is always safe, and aborting a user's task over a missing
275
+ * table row would be a worse failure than the one it reports. The guard that
276
+ * makes the table complete is `reasoning-groups.test.ts`, which fails the BUILD —
277
+ * where someone can actually fix it.
278
+ */
279
+ export declare function thinkingForChild(name: string): string[];
215
280
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string, opts?: PhaseChildOptions): Promise<string>;
216
281
  export declare function formatLoopHint(hit: LoopHit): string;
217
282
  /**
@@ -16,7 +16,7 @@ import { readSection, setTaskSection } from './task-io.js';
16
16
  import { streamStallCause } from '../shared/stream-watchdog.js';
17
17
  import { getConfig } from '../config/config.js';
18
18
  import { groupThinkingArgs } from '../config/reasoning-args.js';
19
- import { reasoningGroupForChild } from './reasoning-groups.js';
19
+ import { reasoningGroupForChild } from '../config/reasoning.js';
20
20
  // ─── Loop detection constants ────────────────────────────────────────────────
21
21
  // Defined here (not in phases.ts) to avoid a circular dependency:
22
22
  // phases.ts → child-runner.ts → phases.ts
@@ -167,37 +167,7 @@ thinking = []) {
167
167
  // Sentinel error thrown when the user dismisses a grill-me dialog.
168
168
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
169
169
  export const USER_CANCELLED = '__user_cancelled__';
170
- // ─── Core child runner (JSON event-stream mode) ─────────────────────────────
171
- /**
172
- * Run a child pi process with JSON event-stream output, loop detection, and
173
- * context-usage tracking. This is the typed convenience wrapper used by
174
- * phase-level code.
175
- */
176
- export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn,
177
- /** Internal `-e` extension paths for in-run guards (see childArgs). */
178
- extensions,
179
- /**
180
- * Every finished tool call's result text. The StallDetector's churn rule
181
- * needs the size of what actually entered the child's context, which the
182
- * CALL alone does not carry (task/stall-detector.ts).
183
- */
184
- onToolResult,
185
- /**
186
- * The child's context window in tokens. Nothing in pi's event stream reports
187
- * one (issue #16), so the parent hands its own down — children carry no `-m`
188
- * and resolve the same default model. 0 / omitted = unknown, as before.
189
- */
190
- contextWindow,
191
- /**
192
- * The resolved `['--thinking', level]` fragment for this child's reasoning
193
- * group, or `[]`/omitted to inherit the session default as before.
194
- *
195
- * A 13th trailing positional is not pretty. It is how `contextWindow` (the
196
- * 12th) was added, and an options-bag refactor of this signature is a
197
- * separate change from wiring one flag — doing both at once would put a
198
- * behaviour change inside a mechanical one. DEBT: convert to an options bag.
199
- */
200
- thinking) {
170
+ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking }) {
201
171
  const invocation = getPiInvocation(childArgs(tools, extensions, thinking), prompt);
202
172
  let loopHit;
203
173
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
@@ -338,10 +308,30 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
338
308
  * makes the table complete is `reasoning-groups.test.ts`, which fails the BUILD —
339
309
  * where someone can actually fix it.
340
310
  */
341
- function thinkingForChild(name) {
311
+ export function thinkingForChild(name) {
342
312
  const group = reasoningGroupForChild(name);
343
313
  return group ? groupThinkingArgs(group) : [];
344
314
  }
315
+ /**
316
+ * What a PHASE child's invocation carries, said once.
317
+ *
318
+ * Both callers of `runChild` in this file are phase children — the strike
319
+ * attempts and the no-tools degrade that rescues them — and everything they
320
+ * disagree about is in `over`. Anything not there is the same by construction,
321
+ * which is what the degrade's own comment ("the degrade changes the TOOLS, not
322
+ * the role") claimed while three bare `undefined`s quietly made it false.
323
+ */
324
+ function phaseChildRun(deps, over) {
325
+ return {
326
+ cwd: deps.cwd,
327
+ onLine: deps.onChildOutput,
328
+ onContextUsage: deps.onContextUsage,
329
+ spawn: deps.spawn,
330
+ extensions: deps.childExtensions,
331
+ contextWindow: deps.contextWindow,
332
+ ...over
333
+ };
334
+ }
345
335
  export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
346
336
  if (deps.runChild)
347
337
  return await deps.runChild(name, tools, prompt);
@@ -369,13 +359,21 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
369
359
  const clock = phaseTimeout(deps.signal, budgetMs);
370
360
  let r;
371
361
  try {
372
- r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, snapshot => {
373
- // Real window or nothing: noteContext ignores 0, and until
374
- // deps.contextWindow existed 0 was all it ever saw, which
375
- // left the churn rule permanently disarmed (issue #16).
376
- stall.noteContext(snapshot.contextWindow);
377
- deps.onContextUsage?.(snapshot);
378
- }, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError), deps.contextWindow, thinking);
362
+ r = await runChild(phaseChildRun(deps, {
363
+ tools,
364
+ prompt: prependHint(hint, prompt),
365
+ signal: clock.signal,
366
+ thinking,
367
+ onContextUsage: snapshot => {
368
+ // Real window or nothing: noteContext ignores 0, and until
369
+ // deps.contextWindow existed 0 was all it ever saw, which
370
+ // left the churn rule permanently disarmed (issue #16).
371
+ stall.noteContext(snapshot.contextWindow);
372
+ deps.onContextUsage?.(snapshot);
373
+ },
374
+ onToolCall: call => detector.record(call) ?? stall.record(call),
375
+ onToolResult: (text, isError) => stall.noteResult(text, isError)
376
+ }));
379
377
  }
380
378
  finally {
381
379
  clock.cleanup();
@@ -482,13 +480,36 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
482
480
  */
483
481
  async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
484
482
  deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
485
- const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
486
- prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn, undefined, undefined, deps.contextWindow,
487
- // Same group as the attempts that led here. The degrade changes the
488
- // TOOLS, not the role running it at a different thinking level would
489
- // make the fallback a different experiment from the thing it rescues.
490
- thinkingForChild(name));
483
+ // BEHAVIOUR DELTA. This attempt now runs under the same wall clock as the
484
+ // strikes that led here. It used to pass `deps.signal` raw — one of the
485
+ // three drifts that came of reaching past bare `undefined`s to the later
486
+ // positionals so the one attempt made after a loop budget was spent was
487
+ // also the one attempt that could hang forever.
488
+ const clock = phaseTimeout(deps.signal, deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS);
489
+ let r;
490
+ try {
491
+ r = await runChild(phaseChildRun(deps, {
492
+ tools: '', // --no-tools: the model cannot read/grep/list, only answer
493
+ prompt: prependHint(formatDegradeHint(hit), prompt),
494
+ signal: clock.signal,
495
+ // Same group as the attempts that led here. The degrade changes the
496
+ // TOOLS, not the role — running it at a different thinking level would
497
+ // make the fallback a different experiment from the thing it rescues.
498
+ thinking: thinkingForChild(name)
499
+ }));
500
+ }
501
+ finally {
502
+ clock.cleanup();
503
+ }
491
504
  if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
505
+ // A wall-clock kill is NOT a loop. The clock above is new here, and
506
+ // without this check a degrade that outran its budget was reported as
507
+ // "loop budget exhausted", carrying a loop history that did not cause it
508
+ // — the same mislabel class the worker-kill roster exists to prevent, on
509
+ // the very path that clock was added to guard.
510
+ if (clock.timedOut()) {
511
+ throw new PhaseTimeoutError(name, deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS, 1);
512
+ }
492
513
  throw new LoopExhaustedError(name, loopHistory);
493
514
  }
494
515
  return r.text;
@@ -88,18 +88,18 @@ export function makeGateChild(deps) {
88
88
  cwd: deps.cwd,
89
89
  ...(sig ? { signal: sig } : {}),
90
90
  tools,
91
- // Run to completion: these passes legitimately read and edit the
92
- // same file many times, and the research-worker guards mislabel
93
- // that as a runaway and kill good work (mx5 TASK_0002).
94
- timeoutMs: 0,
95
- commandTimeoutMs: deps.commandTimeoutMs,
96
- streamInactivityMs: deps.streamInactivityMs,
91
+ // The four guard literals that used to sit here — run to
92
+ // completion, a per-command watchdog, a stream watchdog, and
93
+ // the path rule disabled are the `gate` row of
94
+ // WORKER_PROFILES (workers/worker-profiles.ts), which carries
95
+ // the reasoning for each. The two ceilings stay inputs
96
+ // because they are user config, not policy.
97
+ profile: 'gate',
98
+ policyInputs: {
99
+ commandTimeoutMs: deps.commandTimeoutMs,
100
+ streamInactivityMs: deps.streamInactivityMs
101
+ },
97
102
  thinking: deps.thinking,
98
- // Exact-match loop guard only: pathThreshold Infinity disables
99
- // the path-revisit heuristic, so revisiting one file (which IS
100
- // the job) never trips — only a literally-identical call
101
- // repeated past threshold does.
102
- loop: { pathThreshold: Number.POSITIVE_INFINITY },
103
103
  // A discarded attempt is otherwise invisible: the returned
104
104
  // exitCode/text describe the FINAL attempt, so a child that
105
105
  // burned two attempts reads exactly like one that ran clean.
@@ -19,10 +19,9 @@ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-c
19
19
  import { type WidgetState } from './widget.js';
20
20
  import { type RunTaskFn } from './gate-deps.js';
21
21
  import { type GateDeps } from './task-gates.js';
22
- import { type PhaseDeps } from './child-runner.js';
22
+ import { type PhaseSeams } from './child-runner.js';
23
23
  import { type ThinkingControl } from './implementation-thinking.js';
24
24
  import { type RunEnd } from './run-end.js';
25
- import type { SpawnFn } from '../shared/child-process.js';
26
25
  import { type SuperviseOptions } from './implementation-turn.js';
27
26
  /**
28
27
  * Everything one TaskRunner needs, as one object. The runner is the shared core
@@ -38,25 +37,20 @@ export interface TaskRunnerOptions {
38
37
  resumeId?: string;
39
38
  /** Deliver the finished spec to the main session. Absent → nothing is sent. */
40
39
  sendSpec?: (spec: string) => Promise<void>;
41
- /** Test seam: spawn function forwarded into `PhaseDeps.spawn`. Keep for tests
42
- * that drive the Error-triage ladder or a real process. */
43
- spawnFn?: SpawnFn;
44
40
  /**
45
- * Test seam: `PhaseDeps.runChild(name, tools, prompt)`. Present every phase
46
- * child is answered by name, with none of the ladder's guards. Use this when
47
- * the child is a premise of the test, not its subject.
48
- */
49
- runChild?: PhaseDeps['runChild'];
50
- /**
51
- * Test seam: `PhaseDeps.runWorker(label, input)`. Present every research
52
- * worker is answered BY NAME, so the three Research retry gates and the
53
- * fatal/runaway/empty classification are reachable from a runner-driven test
54
- * without matching a marker sentence inside the prompt.
41
+ * Every injectable phase seam, in one field (`PhaseSeams`, child-runner.ts).
42
+ *
43
+ * `spawn` drives the Error-triage ladder or a real process. `runChild(name,
44
+ * tools, prompt)` answers every phase child BY NAME, with none of the
45
+ * ladder's guards — use it when the child is a premise of the test, not its
46
+ * subject. `runWorker(label, input)` answers every research worker by name,
47
+ * so the three Research retry gates are reachable without matching a marker
48
+ * sentence inside a prompt. The EXTERNAL CONTEXT lookups and the file
49
+ * inventory each default to the real implementation when absent. `timeoutMs`,
50
+ * `sleepFor`, `childExtensions` and `logDebug` are seams too, and were
51
+ * unreachable from here until this became one field.
55
52
  */
56
- runWorker?: PhaseDeps['runWorker'];
57
- /** Test seam: the four EXTERNAL CONTEXT lookups plus the file inventory. Each
58
- * defaults to the real implementation when absent. */
59
- lookups?: Pick<PhaseDeps, 'getFileInventory' | 'docsRaw' | 'fetchRaw' | 'npmVersionLookup' | 'docsFocused' | 'fetchFocused' | 'searchFn'>;
53
+ seams?: PhaseSeams;
60
54
  /** Called with the resolved task id once its file exists, before any phase
61
55
  * work. Lets callers record the id (e.g. stamp the /task-auto entry) so an
62
56
  * interrupted run can be resumed instead of restarted. */
@@ -147,7 +141,7 @@ export declare class TaskRunner {
147
141
  */
148
142
  private _specForDelivery;
149
143
  }
150
- export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId' | 'spawnFn' | 'runChild' | 'runWorker' | 'lookups' | 'onStart' | 'planContext' | 'fixInstruction'> {
144
+ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId' | 'seams' | 'onStart' | 'planContext' | 'fixInstruction'> {
151
145
  /** Await the session going idle after the spec is delivered, so the caller
152
146
  * blocks until the agent has implemented it. Default false. */
153
147
  waitForImplementation?: boolean;
@@ -126,10 +126,7 @@ export class TaskRunner {
126
126
  cwd,
127
127
  taskId: '',
128
128
  signal: this._abort.signal,
129
- spawn: opts.spawnFn,
130
- runChild: opts.runChild,
131
- runWorker: opts.runWorker,
132
- ...opts.lookups,
129
+ ...opts.seams,
133
130
  // Deliberately NOT a ChildStatus (child-status.ts): the phase widget's
134
131
  // state is the whole-run WidgetState — task id, phase, label — shared by
135
132
  // reference with PhaseContext and written by the phases themselves
@@ -231,7 +228,10 @@ export class TaskRunner {
231
228
  const debugLogPath = path.join(tasksDir(cwd), `${id}-debug.log`);
232
229
  // Left UNSET at level `off`, so the ~39 `logDebug?.(…)` sites downstream
233
230
  // short-circuit before they format a string and the file is never created.
234
- this._deps.logDebug = gateDebugWriter((msg) => {
231
+ // A caller-supplied `logDebug` seam WINS: it is the only way to observe
232
+ // the ~39 trail decisions from a runner-driven test, and production never
233
+ // sets one, so the file writer is unaffected.
234
+ this._deps.logDebug ??= gateDebugWriter((msg) => {
235
235
  const line = `${new Date().toISOString()} ${msg}\n`;
236
236
  fsp.appendFile(debugLogPath, line).catch(() => {
237
237
  /* ignore */
@@ -480,10 +480,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
480
480
  release();
481
481
  }
482
482
  },
483
- spawnFn: opts.spawnFn,
484
- runChild: opts.runChild,
485
- runWorker: opts.runWorker,
486
- lookups: opts.lookups,
483
+ seams: opts.seams,
487
484
  onStart: opts.onStart,
488
485
  planContext: opts.planContext,
489
486
  fixInstruction: opts.fixInstruction,
@@ -536,6 +533,12 @@ export const gateRunTask = (c, cwd, t, opts) => runSingleTask(c, cwd, t, {
536
533
  onStart: opts?.onStart,
537
534
  planContext: opts?.planContext,
538
535
  fixInstruction: opts?.fixInstruction
536
+ // NO `seams` here, deliberately. Threading them would need a field on
537
+ // `GateParams` and another on `GateDeps`, and nothing — production or
538
+ // test — would set either: the gate is reached through two orchestrators
539
+ // that build their params from a task file. An unused field on a seam
540
+ // roster is the `WorkerOutcome.reason` shape (written twelve times, read
541
+ // by nothing), so this stays unplumbed until a test actually needs it.
539
542
  });
540
543
  /**
541
544
  * Demote a task file to a resumable state after a gate (or its implementation)
@@ -172,29 +172,6 @@ export declare const SINGLE_READ_EXTENSION_PATH: string;
172
172
  * mentions; the rest are unchanged or only lightly trimmed.
173
173
  */
174
174
  export declare function scopedToolingGoal(refined: string): string;
175
- /**
176
- * Build a degraded section body for a runaway worker: a one-line marker naming
177
- * the failure (so downstream phases and a human reading the task file know this
178
- * section is incomplete) followed by whatever partial answer the worker streamed
179
- * before it was killed. The marker is always present even when there is no
180
- * partial text, so an empty degrade is never mistaken for a real finding.
181
- */
182
- export declare function degradedSectionBody(name: string, reason: string, partial: string): string;
183
- /**
184
- * The body written for a research section the worker confirmed has no entries.
185
- *
186
- * Three states have to stay distinguishable to anyone — human or later phase —
187
- * reading a research section, so each carries its own marker:
188
- * `(none — …)` the worker RAN and answered "nothing applies" (this)
189
- * `(degraded: …)` the worker was killed mid-answer, text may be partial
190
- * (degradedSectionBody)
191
- * section absent the worker never got that far — the phase threw
192
- *
193
- * Naming the worker inside the marker keeps it true after assembly, where the
194
- * section headings are all that separate the four workers' output.
195
- */
196
- export declare function emptySectionBody(name: string): string;
197
- export declare function isBareNoneAnswer(text: string): boolean;
198
175
  export declare function phaseResearch(deps: PhaseDeps, refined: string): Promise<string>;
199
176
  export declare function phaseAutoAnswer(deps: PhaseDeps, refined: string, research: string, question: string): Promise<AutoAnswer>;
200
177
  export declare function phaseGrill(deps: PhaseDeps, ctx: ExtensionCommandContext, widgetState: WidgetState, refined: string, research: string): Promise<string>;