@mjasnikovs/pi-task 0.17.26 → 0.18.0

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.
@@ -19,7 +19,10 @@ import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
19
19
  import { gitCommitAll, gitDropLastCommit, git } from './auto-commit.js';
20
20
  import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
21
21
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
22
+ import { readEnvNotes, appendEnvNotes } from './env-notes.js';
22
23
  import { runRepoHealthCheck } from './repo-health-check.js';
24
+ import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
25
+ import { runFinalGateAutofix } from './final-gate-fix.js';
23
26
  import { researchResolution } from './verify-resolution.js';
24
27
  import { findSubstitutionSuspects } from './substitution-probe.js';
25
28
  import { runBoundedLintFix } from './lint-fix.js';
@@ -88,6 +91,13 @@ export function buildGateDeps(params) {
88
91
  // child run (see git-state-guard.ts). runWorkVerification reads this through its
89
92
  // mutationCheck dep to discard a verdict computed on a mutated tree.
90
93
  let lastGuardReconcile = null;
94
+ // Restore tracked files to HEAD and drop files a pass created; the .pi-tasks
95
+ // trail/log writes made during the pass survive both. Shared by the enforce
96
+ // pre-commit gate (discardEdits) and the final-gate autofix shrink guard.
97
+ const discardTreeEdits = async (cwd2) => {
98
+ await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
99
+ await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
100
+ };
91
101
  // Shared runner for the per-task GATE children (verify + post-FAIL recommend).
92
102
  // Both are read-only passes of the same local model that must run to completion:
93
103
  // unguarded (no wall-clock timeout, exact-match loop guard only, path-revisit
@@ -285,7 +295,15 @@ export function buildGateDeps(params) {
285
295
  // guard already restored the state — see git-state-guard.ts).
286
296
  mutationCheck: () => lastGuardReconcile?.mutated ?
287
297
  { mutated: true, detail: lastGuardReconcile.actions.join('; ') }
288
- : { mutated: false, detail: '' }
298
+ : { mutated: false, detail: '' },
299
+ // Per-run environment-facts cache under .pi-tasks/ (survives
300
+ // discardEdits): earlier children's discoveries save this child
301
+ // the re-archaeology; its own ENV-NOTE lines are stored for the
302
+ // next one. Facts only — verdict rules unaffected.
303
+ envNotes: {
304
+ read: () => readEnvNotes(cwd2),
305
+ append: notes => appendEnvNotes(cwd2, notes)
306
+ }
289
307
  });
290
308
  },
291
309
  lintFix: (fixCtx, cwd2, taskTitle, failReason) => runBoundedLintFix({
@@ -305,12 +323,18 @@ export function buildGateDeps(params) {
305
323
  const r = await git(cwd2, ['status', '--porcelain', '--', '.', EXCLUDE_TASKS_DIR], signal);
306
324
  return r.exitCode === 0 && r.stdout.trim().length > 0;
307
325
  },
308
- discardEdits: async (cwd2) => {
309
- // Restore tracked files to HEAD and drop files the pass created; the
310
- // .pi-tasks trail/log writes made during the pass survive both.
311
- await git(cwd2, ['checkout', '--', '.', EXCLUDE_TASKS_DIR], signal);
312
- await git(cwd2, ['clean', '-fd', '-e', '.pi-tasks'], signal);
313
- },
326
+ discardEdits: discardTreeEdits,
327
+ finalGateFix: (fixCtx, cwd2, failReason) => runFinalGateAutofix({
328
+ cwd: cwd2,
329
+ signal,
330
+ failReason,
331
+ runChild: makeGateChild(fixCtx, cwd2, 'final integration gate', 'final-fix', 'final-gate-debug.log'),
332
+ // The gate re-run is the only arbiter of convergence, and the
333
+ // shrink guard's discovery is the gate's own (see final-gate.ts).
334
+ gate: c => runFinalIntegrationGate(c),
335
+ discoverLabels: discoverGateCommandLabels,
336
+ discard: discardTreeEdits
337
+ }),
314
338
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
315
339
  // Read the same composed spec the verify gate judged against, so the
316
340
  // recommendation reasons over the real contract (degrade to the bare title).
@@ -92,8 +92,9 @@ export interface RunSingleTaskOptions {
92
92
  * Ask the user for a steering message after they interrupt (ESC) the
93
93
  * implementation turn. Return text to continue the same task as another turn,
94
94
  * or undefined/empty to pause the run. Only consulted with
95
- * waitForImplementation. Defaults to a ctx.ui.input prompt; injectable so the
96
- * steer loop is testable without a real dialog.
95
+ * waitForImplementation. Defaults to a bridged SessionUI.ask (local TUI input
96
+ * raced against a remote browser card); injectable so the steer loop is
97
+ * testable without a real dialog.
97
98
  */
98
99
  promptSteer?: (ctx: ExtensionCommandContext) => Promise<string | undefined>;
99
100
  /**
@@ -25,7 +25,7 @@ import { readTextFile } from '../shared/fs-text.js';
25
25
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
26
26
  import { startWidget } from './widget.js';
27
27
  import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
28
- import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge } from '../remote/bridge.js';
28
+ import { publishViewer, publishNotify, publishLifecycleNotice, registerBridgeCommand, getBridge, SessionUI } from '../remote/bridge.js';
29
29
  import { pushNotify } from '../remote/push.js';
30
30
  import { getConfig } from '../config/config.js';
31
31
  import { buildGateDeps } from './gate-deps.js';
@@ -342,6 +342,11 @@ export class TaskRunner {
342
342
  /** Dialog copy for the post-interrupt steering prompt. */
343
343
  const STEER_TITLE = 'Paused — steer the model';
344
344
  const STEER_PLACEHOLDER = 'Type guidance to continue this task, or leave empty to pause';
345
+ /** Remote-card copy for the same prompt. The browser has no placeholder ghost
346
+ * text, so the pause affordance must be spelled out in the question itself
347
+ * (Skip = empty answer = pause, same as an empty local submit). */
348
+ const STEER_QUESTION = 'Paused — the implementation was interrupted.\n'
349
+ + 'Type guidance to continue this task, or Skip to pause the run.';
345
350
  /**
346
351
  * True when the most recent assistant turn ended because the user interrupted it
347
352
  * (pressed ESC). pi records a user abort as stopReason "aborted" on the assistant
@@ -472,7 +477,19 @@ export async function resumeAcrossCompactions(ctx) {
472
477
  * should pause; false when the implementation completed (steered or not).
473
478
  */
474
479
  async function steerUntilDone(ctx, promptSteer) {
475
- const ask = promptSteer ?? (c => c.ui.input(STEER_TITLE, STEER_PLACEHOLDER));
480
+ // Fan the prompt out through the bridge (local TUI input + remote browser
481
+ // card, first answer wins) instead of a raw ctx.ui.input: an interrupt can
482
+ // come from the remote Stop button just as well as a terminal ESC, and a
483
+ // terminal-only dialog leaves the remote viewer staring at a silently
484
+ // paused run. Remote Skip returns '' → same pause path as an empty local
485
+ // submit.
486
+ const ask = promptSteer
487
+ ?? (c => new SessionUI(c).ask({
488
+ localTitle: STEER_TITLE,
489
+ localPlaceholder: STEER_PLACEHOLDER,
490
+ question: STEER_QUESTION,
491
+ allowSkip: true
492
+ }));
476
493
  while (wasInterrupted(ctx)) {
477
494
  const steer = await ask(ctx);
478
495
  if (steer === undefined || steer.trim().length === 0)
@@ -318,10 +318,10 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
318
318
  }
319
319
  };
320
320
  // Per-worker timing split into wait (spawn → first byte) and work (first
321
- // byte → exit). The workers run sequentially below, so each split is a clean
321
+ // byte → exit). With the default serial execution each split is a clean
322
322
  // per-worker measurement — waitMs the worker's own cold-start, workMs its
323
- // generation+tool-call cost — not a Promise.all-relative wall-clock that
324
- // conflates the two.
323
+ // generation+tool-call cost. Under the opt-in parallel mode the numbers are
324
+ // wall-clock-relative (queueing shows up in waitMs).
325
325
  const recordWorker = (label, p) => p.then(r => {
326
326
  deps.recordSubStep?.(`${label} wait`, r.waitMs);
327
327
  deps.recordSubStep?.(`${label} work`, r.workMs);
@@ -337,7 +337,9 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
337
337
  // each other ~4x (context worker measured 27s solo vs 128s under load),
338
338
  // so summed-but-fast (~100s) beats max-of-slowed (~130s).
339
339
  // Every worker runs /no_think (below), so sequential is the faster regime.
340
- // Do NOT switch this back to Promise.all without re-running that A/B.
340
+ // Do NOT switch the DEFAULT back to concurrent without re-running that A/B;
341
+ // the opt-in `parallelResearchWorkers` config flag exists for backends that
342
+ // genuinely serve parallel streams.
341
343
  //
342
344
  // `/no_think` is the big win: these are agentic exploration loops, and on a
343
345
  // reasoning model the child would otherwise emit a full <think> trace at
@@ -358,9 +360,13 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
358
360
  label: 'worker:apis',
359
361
  // Read-heavy: gets the orientation core (see note above). Search/fetch
360
362
  // ride along only when a Brave key exists — see SEARCH_EXTENSION_PATH.
361
- prompt: appendNoThink(orientation.block
363
+ // FILES' finished map rides along when available (serial default), so
364
+ // the worker doesn't re-derive where-things-live via docs-"."
365
+ // queries the FILES worker just answered (run-7 F7: up to 10
366
+ // duplicate `.`-decodes per task through the serial bottleneck).
367
+ prompt: prior => appendNoThink(orientation.block
362
368
  + promptHeader
363
- + RESEARCH_APIS_PROMPT(refined)
369
+ + RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined)
364
370
  + (searchConfigured() ? RESEARCH_SEARCH_HINT : '')),
365
371
  tools: 'read,grep,find,ls,pi-worker-docs'
366
372
  + (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
@@ -390,25 +396,36 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
390
396
  extensions: [SINGLE_READ_EXTENSION_PATH]
391
397
  }
392
398
  ];
393
- // Run workers one at a time, persisting each worker's validated output the
394
- // moment it succeeds. On a resume, a worker whose cached output is already on
395
- // disk is skipped — so when one worker fails and the phase is re-run, the
396
- // others don't burn minutes regenerating work that was already good. Each
397
- // worker is validated inline (not in a second pass) so a failure throws
398
- // before later workers run, and only trustworthy text is ever cached.
399
- const sections = [];
400
- for (const spec of workerSpecs) {
399
+ // Persisting a worker's section is a read-modify-write of the shared task
400
+ // file, so writes are chained through one lock — a no-op in serial mode,
401
+ // load-bearing in parallel mode where two workers can settle together.
402
+ let persistChain = Promise.resolve();
403
+ const persistSection = (heading, text) => {
404
+ const next = persistChain.then(() => setTaskSection(deps.cwd, deps.taskId, heading, text));
405
+ persistChain = next.catch(() => { });
406
+ return next;
407
+ };
408
+ // One worker, cache-skip to persist: on a resume, a worker whose cached
409
+ // output is already on disk is skipped — so when one worker fails and the
410
+ // phase is re-run, the others don't burn minutes regenerating work that was
411
+ // already good. Each worker is validated inline (not in a second pass), so
412
+ // only trustworthy text is ever cached.
413
+ //
414
+ // A fatal failure (crash/empty/leak) still throws — the already-cached
415
+ // workers survive for the resume. A runaway (loop/timeout) degrades to its
416
+ // partial output instead, so one weak worker can't abort a whole auto-run;
417
+ // the degraded section is cached too, so a resume doesn't re-loop it.
418
+ const runSpec = async (spec, prior) => {
401
419
  const cacheHeading = researchWorkerCacheHeading(spec.section);
402
420
  const cached = (await readSection(deps.cwd, deps.taskId, cacheHeading)) ?? '';
403
421
  if (cached.trim().length > 0) {
404
422
  deps.logDebug?.(`${spec.label}: cached — skipping re-run`);
405
423
  updateProgress();
406
- sections.push({ name: spec.section, text: cached.trim() });
407
- continue;
424
+ return { name: spec.section, text: cached.trim() };
408
425
  }
409
426
  deps.logDebug?.(`${spec.label}: start`);
410
427
  const r = await recordWorker(spec.label, runWorker({
411
- prompt: spec.prompt,
428
+ prompt: typeof spec.prompt === 'function' ? spec.prompt(prior) : spec.prompt,
412
429
  cwd: deps.cwd,
413
430
  signal: deps.signal,
414
431
  spawn: deps.spawn,
@@ -423,10 +440,6 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
423
440
  + (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
424
441
  + (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
425
442
  updateProgress();
426
- // A fatal failure (crash/empty/leak) still throws — the already-cached
427
- // workers survive for the resume. A runaway (loop/timeout) degrades to its
428
- // partial output instead, so one weak worker can't abort a whole auto-run;
429
- // the degraded section is cached too, so a resume doesn't re-loop it.
430
443
  const failure = classifyResearchWorker(spec.section, r);
431
444
  if (failure?.kind === 'fatal')
432
445
  throw failure.error;
@@ -436,8 +449,34 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
436
449
  if (failure?.kind === 'runaway') {
437
450
  deps.logDebug?.(`${spec.label}: degraded — ${failure.reason}`);
438
451
  }
439
- await setTaskSection(deps.cwd, deps.taskId, cacheHeading, sectionText);
440
- sections.push({ name: spec.section, text: sectionText });
452
+ await persistSection(cacheHeading, sectionText);
453
+ return { name: spec.section, text: sectionText };
454
+ };
455
+ const sections = [];
456
+ if (!getConfig().parallelResearchWorkers) {
457
+ // Default: ONE AT A TIME (see the A/B note above the specs) — a fatal
458
+ // failure throws before later workers run, and each worker can see the
459
+ // finished sections before it (APIS builds on the FILES map).
460
+ for (const spec of workerSpecs) {
461
+ sections.push(await runSpec(spec, sections));
462
+ }
463
+ }
464
+ else {
465
+ // Opt-in for parallel-capable backends. allSettled (not all): every
466
+ // worker runs to its own outcome first, so one fatal failure cannot
467
+ // orphan the others' output — their sections persist for the resume
468
+ // before the failure is thrown. Assembly order stays the spec order
469
+ // regardless of completion order. No prior sections exist here, so
470
+ // prompt builders get none (APIS runs map-less, as before this option).
471
+ const settled = await Promise.allSettled(workerSpecs.map(spec => runSpec(spec, [])));
472
+ for (const s of settled) {
473
+ if (s.status === 'rejected')
474
+ throw s.reason;
475
+ }
476
+ for (const s of settled) {
477
+ if (s.status === 'fulfilled')
478
+ sections.push(s.value);
479
+ }
441
480
  }
442
481
  // All workers succeeded — the assembled output below becomes the canonical
443
482
  // 'research' section (written by the orchestrator). The per-worker caches
@@ -50,7 +50,7 @@ export declare const COMPRESS_LABEL_PROMPT: (title: string, maxChars: number) =>
50
50
  declare const REFINE_PROMPT: (raw: string, planContext?: string, existingFiles?: string) => string;
51
51
  declare const RESEARCH_READ_ONLY_CONSTRAINT = "IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.";
52
52
  declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
53
- declare const RESEARCH_APIS_PROMPT: (refined: string) => string;
53
+ declare const RESEARCH_APIS_PROMPT: (refined: string, filesMap?: string) => string;
54
54
  declare const RESEARCH_CONTEXT_PROMPT: (refined: string) => string;
55
55
  declare const RESEARCH_TOOLING_PROMPT: (refined: string) => string;
56
56
  declare const GRILL_GEN_PROMPT: (refined: string, research: string, priorQA: string) => string;
@@ -122,7 +122,7 @@ No section header. No other sections. No preamble.
122
122
 
123
123
  Task:
124
124
  ${refined}`;
125
- const RESEARCH_APIS_PROMPT = (refined) => `You are doing targeted research for an AI coding agent. Use the read, grep, find, and ls tools — and \`pi-worker-docs\` for installed npm packages — to identify the commands, functions, types, and interfaces the agent will use for the following task.
125
+ const RESEARCH_APIS_PROMPT = (refined, filesMap) => `You are doing targeted research for an AI coding agent. Use the read, grep, find, and ls tools — and \`pi-worker-docs\` for installed npm packages — to identify the commands, functions, types, and interfaces the agent will use for the following task.
126
126
 
127
127
  NPM PACKAGES — use pi-worker-docs, NOT file reads: for any third-party npm package (e.g. "zod", "hono", "drizzle-orm"), call \`pi-worker-docs(module, query)\` to get its type signatures and API surface. Do NOT open node_modules source files directly — those reads are expensive and produce far more noise than the tool. The tool returns a compact, focused excerpt in a fraction of the token cost.
128
128
 
@@ -133,7 +133,14 @@ RUNTIME BUILTINS — verify, do NOT echo: a task (or the spec doc it references)
133
133
  APIS owns symbols and commands BY NAME ONLY. Do NOT include any file path or path fragment — no \`package.json\`, no \`./src/foo.ts\`, no \`package.json#scripts.lint\`. If the symbol is a script defined in package.json, write the invocation (\`npm run lint\`), not its location. If the symbol is a config file, it does not belong in APIS at all — it belongs in FILES.
134
134
 
135
135
  RELEVANCE — read carefully: list ONLY the symbols the agent will call, implement, modify, or directly depend on for THIS task. Do NOT enumerate the project's entire public surface or dump every exported function in a touched file. A symbol unrelated to the task does not belong here just because it sits in the same module. Keep the smallest sufficient set: include every symbol the task actually exercises and nothing more. There is no fixed limit — list as many as the task truly needs and no padding beyond that.
136
+ ${filesMap ?
137
+ `
138
+ PROJECT FILE MAP — already surveyed for this task by a prior worker (authoritative):
139
+ ${filesMap}
136
140
 
141
+ USE THE MAP: where things live is ALREADY ANSWERED above. Do NOT re-derive it — never call \`pi-worker-docs(".", …)\` (or grep/find) for a question the map already answers: which file holds X, whether a path exists, what a file is for. Reserve \`.\`-queries for symbol-level facts the map cannot carry — signatures, parameter and return types, what a module exports. Go straight to the mapped files' symbols.
142
+ `
143
+ : ''}
137
144
  ${RESEARCH_INPUTS_NOT_DELIVERABLE}
138
145
 
139
146
  ${RESEARCH_READ_ONLY_CONSTRAINT}
@@ -42,7 +42,7 @@ export declare function extractSpecForVerification(taskBody: string): string | n
42
42
  * pure git shape (test files the task itself changed), so the mandate is
43
43
  * language- and framework-agnostic.
44
44
  */
45
- export declare function buildVerifyPrompt(spec: string, probeFindings?: string[]): string;
45
+ export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string): string;
46
46
  /**
47
47
  * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL` marker
48
48
  * (the model discusses before concluding, and bash output may echo the word
@@ -94,6 +94,17 @@ export interface VerificationDeps {
94
94
  mutated: boolean;
95
95
  detail: string;
96
96
  };
97
+ /**
98
+ * Per-run environment-facts cache (see env-notes.ts): `read` supplies the
99
+ * facts earlier gate children discovered (inlined into the prompt with the
100
+ * no-waiver caveat); `append` stores the `ENV-NOTE:` lines this child
101
+ * emitted, host-side. ABSENT → no block, no capture (tests unchanged).
102
+ * Facts only cut re-discovery time; verdict rules are unaffected.
103
+ */
104
+ envNotes?: {
105
+ read: () => Promise<string>;
106
+ append: (notes: string[]) => Promise<void>;
107
+ };
97
108
  }
98
109
  /**
99
110
  * Run the verification pass for one task. A missing spec is a pass. Otherwise run
@@ -68,6 +68,7 @@
68
68
  * verify, this is a pass (ok: true).
69
69
  */
70
70
  import { USER_CANCELLED } from './child-runner.js';
71
+ import { buildEnvNotesBlock, ENV_NOTE_EMIT_INSTRUCTION, extractEnvNotes } from './env-notes.js';
71
72
  /**
72
73
  * The verification child gets exactly two tools: `read` and `bash`.
73
74
  *
@@ -123,7 +124,7 @@ export function extractSpecForVerification(taskBody) {
123
124
  * pure git shape (test files the task itself changed), so the mandate is
124
125
  * language- and framework-agnostic.
125
126
  */
126
- export function buildVerifyPrompt(spec, probeFindings) {
127
+ export function buildVerifyPrompt(spec, probeFindings, envNotes) {
127
128
  const probeBlock = probeFindings && probeFindings.length > 0 ?
128
129
  [
129
130
  'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
@@ -137,6 +138,7 @@ export function buildVerifyPrompt(spec, probeFindings) {
137
138
  ''
138
139
  ]
139
140
  : [];
141
+ const envBlock = envNotes && envNotes.trim().length > 0 ? [buildEnvNotesBlock(envNotes)] : [];
140
142
  return [
141
143
  'You are a strict verification pass running right after an AI coding agent',
142
144
  'finished a task and committed it. The agent is known to mark work "done"',
@@ -150,6 +152,7 @@ export function buildVerifyPrompt(spec, probeFindings) {
150
152
  'THE TASK SPEC (its ACCEPTANCE criteria and VERIFY block are the contract):',
151
153
  spec.trim(),
152
154
  '',
155
+ ...envBlock,
153
156
  ...probeBlock,
154
157
  'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
155
158
  'checkout (or CI run) would experience it:',
@@ -249,6 +252,8 @@ export function buildVerifyPrompt(spec, probeFindings) {
249
252
  'a required behavior missing — the verdict is FAIL, even if typecheck and lint are green',
250
253
  'and even if the gap seems minor. Never downgrade an unmet criterion to a warning note.',
251
254
  '',
255
+ ENV_NOTE_EMIT_INSTRUCTION,
256
+ '',
252
257
  'When you are done, output EXACTLY ONE of these as the final line:',
253
258
  " WORK-VERIFIED: PASS (the project's own command, run unaided, met the spec)",
254
259
  ' WORK-VERIFIED: FAIL <text> (the shipped command failed or did not meet the spec; say what failed)',
@@ -305,6 +310,17 @@ export async function runWorkVerification(deps) {
305
310
  findings = [];
306
311
  }
307
312
  }
313
+ // Environment facts from earlier gate children (best-effort; a cache failure
314
+ // must never block verification).
315
+ let envNotes = '';
316
+ if (deps.envNotes) {
317
+ try {
318
+ envNotes = await deps.envNotes.read();
319
+ }
320
+ catch {
321
+ envNotes = '';
322
+ }
323
+ }
308
324
  // A child that emits NO verdict never judged the work (budget/context death mid-
309
325
  // investigation — seen live: an 11-minute verify wandered, died verdict-less, and
310
326
  // the resulting FAIL burned a full implementation re-run on an unjudged artifact).
@@ -312,7 +328,7 @@ export async function runWorkVerification(deps) {
312
328
  for (let attempt = 1;; attempt++) {
313
329
  let text;
314
330
  try {
315
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings), deps.signal);
331
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes), deps.signal);
316
332
  }
317
333
  catch (err) {
318
334
  if (err instanceof Error && err.message === USER_CANCELLED)
@@ -320,6 +336,16 @@ export async function runWorkVerification(deps) {
320
336
  const msg = err instanceof Error ? err.message : String(err);
321
337
  return { ok: false, reason: `verification pass could not run: ${msg}` };
322
338
  }
339
+ // Capture the environment facts the child shared — regardless of verdict
340
+ // (a FAIL run's discoveries are just as reusable).
341
+ if (deps.envNotes) {
342
+ try {
343
+ await deps.envNotes.append(extractEnvNotes(text));
344
+ }
345
+ catch {
346
+ // best-effort cache
347
+ }
348
+ }
323
349
  // A child that mutated the repo (git-state guard fired) judged a tree it had
324
350
  // itself changed — its verdict is meaningless in both directions, so discard
325
351
  // it BEFORE parsing. The guard already restored the state, so one retry runs
@@ -57,8 +57,9 @@ export interface AutoLoaderState {
57
57
  * pass and 'verify' is the per-task work-verification pass, neither of which
58
58
  * has step numbering. 'recommend' is the read-only research that picks the
59
59
  * recommended action after a verify FAIL. 'lint-fix' is the bounded fix pass
60
- * for a repo-health verify FAIL. */
61
- kind?: 'planning' | 'enforce' | 'verify' | 'recommend' | 'lint-fix';
60
+ * for a repo-health verify FAIL; 'final-fix' the bounded fix pass for a
61
+ * final-integration-gate FAIL. */
62
+ kind?: 'planning' | 'enforce' | 'verify' | 'recommend' | 'lint-fix' | 'final-fix';
62
63
  }
63
64
  export declare function buildAutoLoaderLines(s: AutoLoaderState, theme?: WidgetTheme): string[];
64
65
  /** Structured mirror of buildAutoLoaderLines. Only the numbered planning stage
@@ -148,7 +148,8 @@ export function buildAutoLoaderLines(s, theme) {
148
148
  : s.kind === 'verify' ? `verifying work · ${elapsed}`
149
149
  : s.kind === 'recommend' ? `assessing the failure · ${elapsed}`
150
150
  : s.kind === 'lint-fix' ? `fixing static findings · ${elapsed}`
151
- : `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
151
+ : s.kind === 'final-fix' ? `fixing the final gate · ${elapsed}`
152
+ : `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
152
153
  if (s.contextUsage) {
153
154
  const ctxDetail = formatContextDetail(s.contextUsage, theme);
154
155
  if (ctxDetail)
@@ -167,7 +168,8 @@ export function buildAutoLoaderData(s) {
167
168
  : s.kind === 'verify' ? 'verifying work'
168
169
  : s.kind === 'recommend' ? 'assessing the failure'
169
170
  : s.kind === 'lint-fix' ? 'fixing static findings'
170
- : s.step;
171
+ : s.kind === 'final-fix' ? 'fixing the final gate'
172
+ : s.step;
171
173
  const d = {
172
174
  title: `/task-auto · ${s.title}`,
173
175
  phase,
@@ -36,6 +36,16 @@ export interface RunWorkerInput {
36
36
  threshold?: number;
37
37
  pathThreshold?: number;
38
38
  } | false;
39
+ /**
40
+ * Dead-backend stall guard override. Default ON: no output for
41
+ * STALL_AFTER_MS → probe the model endpoints pi is configured with →
42
+ * unreachable → kill + `stalled: true`. Pass `false` to disable, or
43
+ * override the window/probe (tests, harnesses).
44
+ */
45
+ stall?: {
46
+ afterMs?: number;
47
+ probe?: () => Promise<boolean>;
48
+ } | false;
39
49
  }
40
50
  export interface RunWorkerResult {
41
51
  text: string;
@@ -73,5 +83,11 @@ export interface RunWorkerResult {
73
83
  * the caller must treat it as a failure.
74
84
  */
75
85
  timedOut?: boolean;
86
+ /**
87
+ * Set when the stall guard killed the worker: no output progress AND the
88
+ * model endpoint unreachable. Check BEFORE `aborted` — the kill sets
89
+ * aborted too, and mislabeling this as a user cancel hides a dead backend.
90
+ */
91
+ stalled?: boolean;
76
92
  }
77
93
  export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
@@ -3,6 +3,7 @@ import { CHILD_BASE_ARGS, runChildDefault } from '../shared/child-process.js';
3
3
  import { LoopDetector } from '../task/loop-detector.js';
4
4
  import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
5
5
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
6
+ import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
6
7
  // `--mode json` makes pi emit structured events as they happen instead of
7
8
  // buffering the assistant text and flushing on exit. That matters for the
8
9
  // wait/work timing split: in text mode the first stdout chunk only arrives at
@@ -21,6 +22,15 @@ const DEFAULT_TOOLS = 'read,grep,find,ls';
21
22
  * so it never trips a legitimately slow run.
22
23
  */
23
24
  const RESEARCH_WORKER_TIMEOUT_MS = 240_000;
25
+ /**
26
+ * Output-stall window before the dead-backend probe fires (mx5 run 7: model
27
+ * server died mid-gate-child, the child hung MUTE for 64 minutes). This is NOT
28
+ * a wall-clock cap — output progress resets it, and even a fully stalled child
29
+ * is only killed when the model endpoint is actually unreachable. Sized so a
30
+ * long local prompt-processing pass (minutes of legitimate silence, server
31
+ * alive) just gets probed and waits on.
32
+ */
33
+ const STALL_AFTER_MS = 180_000;
24
34
  /** Restart hint after a wall-clock timeout — distinct from the loop hint. */
25
35
  const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
26
36
  + 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
@@ -95,6 +105,15 @@ export async function runWorker(input) {
95
105
  try {
96
106
  result = await runChildDefault(invocation, input.cwd, timeout.signal, {
97
107
  mode: 'json-events',
108
+ ...(input.stall === false ?
109
+ {}
110
+ : {
111
+ stall: {
112
+ afterMs: input.stall?.afterMs ?? STALL_AFTER_MS,
113
+ probe: input.stall?.probe
114
+ ?? (() => probeModelEndpoints(discoverModelEndpoints()))
115
+ }
116
+ }),
98
117
  onFirstByte: () => (tFirstByte = Date.now()),
99
118
  onToolCall: call => {
100
119
  if (!loopDetector)
@@ -150,7 +169,8 @@ export async function runWorker(input) {
150
169
  workMs,
151
170
  ...(leaked ? { leakedToolCall: leaked } : {}),
152
171
  ...(loopHit ? { loopHit } : {}),
153
- ...(timedOut ? { timedOut: true } : {})
172
+ ...(timedOut ? { timedOut: true } : {}),
173
+ ...(result.stalled ? { stalled: true } : {})
154
174
  };
155
175
  }
156
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.26",
3
+ "version": "0.18.0",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",