@mjasnikovs/pi-task 0.17.27 → 0.18.1

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.
@@ -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
@@ -0,0 +1,53 @@
1
+ /**
2
+ * prohibition-probe — deterministic detection of VIOLATED SPEC PROHIBITIONS,
3
+ * feeding the verify gate's prompt.
4
+ *
5
+ * The failure class (mx5 run 7, "New Listing page" task): the spec's CONSTRAINTS
6
+ * said "**Do NOT modify** any server-side code: `src/server/index.ts`, …"; the
7
+ * implementation modified `src/server/index.ts` anyway; the verify child SAW it
8
+ * ("VIOLATES 'Do NOT modify server-side code'"), waived it ("BUT: this is
9
+ * additive, tests pass with it"), and PASSed. Worse, the reproduction fixture
10
+ * showed the baseline child usually never LOOKS: 5/5 baseline runs consulted no
11
+ * diff at all and several affirmatively claimed the forbidden file was untouched.
12
+ *
13
+ * So — like the substitution probe (see substitution-probe.ts, whose A/B proved
14
+ * prompt language alone gets ~40% attention while a concrete deterministic
15
+ * finding gets 100%) — the fix is a deterministic pre-check whose finding is
16
+ * injected into the prompt: extract the concrete paths the spec forbids
17
+ * modifying, intersect with the task's changed files (pure git shape, already
18
+ * collected for the substitution probe), and hand the child each hit with the
19
+ * exact constraint wording.
20
+ *
21
+ * The finding is advisory, not an auto-FAIL, for one reason: prohibitions in
22
+ * real specs are prose and can be CONDITIONAL ("Do NOT modify `api.ts` beyond
23
+ * what is needed for the new endpoint") — a hard gate on prose extraction would
24
+ * false-FAIL legitimate work. The finding therefore carries the constraint line
25
+ * verbatim and the prompt's no-waiver rule (4b in verify-work.ts) forbids
26
+ * excusing an ABSOLUTE prohibition while directing conditional ones to be judged
27
+ * against their own stated exception. A violation that was fully reverted before
28
+ * verify produces no diff entry, so it never fires — reverted = not violated.
29
+ */
30
+ import type { ChangedFile } from './substitution-probe.js';
31
+ /** One "do not modify X" constraint extracted from the spec text. */
32
+ export interface Prohibition {
33
+ /** The forbidden path exactly as the spec spells it (file or directory). */
34
+ path: string;
35
+ /** The full spec line carrying the prohibition, so the verify child judges
36
+ * against the EXACT wording — including any exception clause it states. */
37
+ constraint: string;
38
+ }
39
+ /**
40
+ * Extract the concrete paths the spec explicitly forbids modifying: every
41
+ * backtick-quoted path-like token on a line that expresses a modification ban.
42
+ * Prose-only prohibitions ("do not modify server-side code" with no path named)
43
+ * extract nothing — the prompt-level rule still covers them.
44
+ */
45
+ export declare function extractProhibitions(spec: string): Prohibition[];
46
+ /**
47
+ * Intersect the spec's prohibitions with the task's changed files (git shape —
48
+ * the same collector the substitution probe uses). A prohibition matches a
49
+ * changed file exactly, or as a directory prefix (`src/server` covers
50
+ * `src/server/index.ts`). One finding line per violated file, carrying the
51
+ * constraint verbatim; empty array → no block in the prompt.
52
+ */
53
+ export declare function findProhibitionViolations(prohibitions: Prohibition[], files: ChangedFile[]): string[];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Does this line express a modification ban? Matches the active forms ("do not
3
+ * modify", "must not touch", "never edit", "don't change") and the passive form
4
+ * ("must not be modified"). Deliberately verb-scoped to modification — a "do not
5
+ * add a dependency" style rule names no path and is the prompt rule's job.
6
+ */
7
+ const PROHIBITION_RE = /\b(?:do\s+not|don'?t|must\s+not|never)\s+(?:be\s+)?(?:modify|modified|touch|touched|edit|edited|change|changed|alter|altered|rewrite|rewritten|overwrite|overwritten|delete|deleted|remove|removed)\b/i;
8
+ /**
9
+ * A backtick token counts as a path only when it is whitespace-free, uses path
10
+ * characters, and either contains a directory separator, has a file extension,
11
+ * or is a dotfile. Bare identifiers (`getOrder`), API routes with spaces
12
+ * (`POST /api/listings`), and code snippets are rejected — the probe would
13
+ * rather miss a `Makefile` than fire on prose.
14
+ */
15
+ function looksLikePath(token) {
16
+ if (!/^[\w.@~/-]+$/.test(token))
17
+ return false;
18
+ return token.includes('/') || /\.[A-Za-z0-9]+$/.test(token) || token.startsWith('.');
19
+ }
20
+ /**
21
+ * Extract the concrete paths the spec explicitly forbids modifying: every
22
+ * backtick-quoted path-like token on a line that expresses a modification ban.
23
+ * Prose-only prohibitions ("do not modify server-side code" with no path named)
24
+ * extract nothing — the prompt-level rule still covers them.
25
+ */
26
+ export function extractProhibitions(spec) {
27
+ const out = [];
28
+ const seen = new Set();
29
+ for (const line of spec.split('\n')) {
30
+ if (!PROHIBITION_RE.test(line))
31
+ continue;
32
+ for (const m of line.matchAll(/`([^`]+)`/g)) {
33
+ const token = m[1].trim();
34
+ if (!looksLikePath(token) || seen.has(token))
35
+ continue;
36
+ seen.add(token);
37
+ out.push({ path: token, constraint: line.trim() });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ /** Normalise a path for comparison: strip leading ./ and trailing /. */
43
+ const norm = (p) => p.replace(/^\.\//, '').replace(/\/+$/, '');
44
+ /**
45
+ * Intersect the spec's prohibitions with the task's changed files (git shape —
46
+ * the same collector the substitution probe uses). A prohibition matches a
47
+ * changed file exactly, or as a directory prefix (`src/server` covers
48
+ * `src/server/index.ts`). One finding line per violated file, carrying the
49
+ * constraint verbatim; empty array → no block in the prompt.
50
+ */
51
+ export function findProhibitionViolations(prohibitions, files) {
52
+ const findings = [];
53
+ for (const f of files) {
54
+ const fp = norm(f.path);
55
+ const hit = prohibitions.find(p => {
56
+ const pp = norm(p.path);
57
+ return fp === pp || fp.startsWith(`${pp}/`);
58
+ });
59
+ if (!hit)
60
+ continue;
61
+ findings.push(`${f.path} — modified by this task, but the spec forbids it: "${hit.constraint}"`);
62
+ }
63
+ return findings;
64
+ }
@@ -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}
@@ -38,11 +38,24 @@ export declare function extractSpecForVerification(taskBody: string): string | n
38
38
  * `probeFindings` are the deterministic self-verification probe results (see
39
39
  * substitution-probe.ts): the TEST-THE-COPY class is caught 5/5 only when the
40
40
  * prompt carries both the rule (3b) AND a concrete finding naming the suspect
41
- * file — the rule alone got 2/5 attention on the live model. The findings are
41
+ * file — the rule alone got 2/5 attention on the local model. The findings are
42
42
  * pure git shape (test files the task itself changed), so the mandate is
43
43
  * language- and framework-agnostic.
44
+ *
45
+ * `prohibitionFindings` are the deterministic prohibition probe results (see
46
+ * prohibition-probe.ts): spec-forbidden paths the task's diff modified anyway.
47
+ * Same probe+rule design, same reason: the VIOLATION-EXCUSAL class (mx5 run 7:
48
+ * child saw "Do NOT modify server-side code" violated, waived it as "additive,
49
+ * tests pass", PASSed) needs both the no-waiver rule (4b) AND the concrete diff
50
+ * fact — the baseline child usually never runs `git diff` at all, so without the
51
+ * finding it cannot even SEE the violation. A/B on the live local model
52
+ * (violated-but-working fixture, everything green, forbidden file modified
53
+ * additively): old prompt 5/5 false-PASS (several runs affirmatively claimed the
54
+ * forbidden file was untouched); rule+finding 5/5 FAIL naming the constraint.
55
+ * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
56
+ * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
44
57
  */
45
- export declare function buildVerifyPrompt(spec: string, probeFindings?: string[]): string;
58
+ export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[]): string;
46
59
  /**
47
60
  * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL` marker
48
61
  * (the model discusses before concluding, and bash output may echo the word
@@ -82,6 +95,12 @@ export interface VerificationDeps {
82
95
  * into the child's prompt. A/B-proven load-bearing: the prompt rule alone caught
83
96
  * the class 2/5, rule + probe finding 5/5. ABSENT or empty → no probe block. */
84
97
  probe?: () => Promise<string[]>;
98
+ /**
99
+ * DETERMINISTIC prohibition probe (see prohibition-probe.ts): spec-forbidden
100
+ * paths the task's diff modified anyway, injected as prompt findings under the
101
+ * no-waiver rule (4b). Advisory, never auto-FAIL — real prohibitions can be
102
+ * conditional prose. ABSENT or empty → no prohibition block. */
103
+ prohibitionProbe?: () => Promise<string[]>;
85
104
  /**
86
105
  * Result of the git-state guard for the MOST RECENT runChild call (see
87
106
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file
@@ -94,6 +113,17 @@ export interface VerificationDeps {
94
113
  mutated: boolean;
95
114
  detail: string;
96
115
  };
116
+ /**
117
+ * Per-run environment-facts cache (see env-notes.ts): `read` supplies the
118
+ * facts earlier gate children discovered (inlined into the prompt with the
119
+ * no-waiver caveat); `append` stores the `ENV-NOTE:` lines this child
120
+ * emitted, host-side. ABSENT → no block, no capture (tests unchanged).
121
+ * Facts only cut re-discovery time; verdict rules are unaffected.
122
+ */
123
+ envNotes?: {
124
+ read: () => Promise<string>;
125
+ append: (notes: string[]) => Promise<void>;
126
+ };
97
127
  }
98
128
  /**
99
129
  * 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
  *
@@ -119,11 +120,24 @@ export function extractSpecForVerification(taskBody) {
119
120
  * `probeFindings` are the deterministic self-verification probe results (see
120
121
  * substitution-probe.ts): the TEST-THE-COPY class is caught 5/5 only when the
121
122
  * prompt carries both the rule (3b) AND a concrete finding naming the suspect
122
- * file — the rule alone got 2/5 attention on the live model. The findings are
123
+ * file — the rule alone got 2/5 attention on the local model. The findings are
123
124
  * pure git shape (test files the task itself changed), so the mandate is
124
125
  * language- and framework-agnostic.
126
+ *
127
+ * `prohibitionFindings` are the deterministic prohibition probe results (see
128
+ * prohibition-probe.ts): spec-forbidden paths the task's diff modified anyway.
129
+ * Same probe+rule design, same reason: the VIOLATION-EXCUSAL class (mx5 run 7:
130
+ * child saw "Do NOT modify server-side code" violated, waived it as "additive,
131
+ * tests pass", PASSed) needs both the no-waiver rule (4b) AND the concrete diff
132
+ * fact — the baseline child usually never runs `git diff` at all, so without the
133
+ * finding it cannot even SEE the violation. A/B on the live local model
134
+ * (violated-but-working fixture, everything green, forbidden file modified
135
+ * additively): old prompt 5/5 false-PASS (several runs affirmatively claimed the
136
+ * forbidden file was untouched); rule+finding 5/5 FAIL naming the constraint.
137
+ * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
138
+ * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
125
139
  */
126
- export function buildVerifyPrompt(spec, probeFindings) {
140
+ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings) {
127
141
  const probeBlock = probeFindings && probeFindings.length > 0 ?
128
142
  [
129
143
  'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
@@ -137,6 +151,20 @@ export function buildVerifyPrompt(spec, probeFindings) {
137
151
  ''
138
152
  ]
139
153
  : [];
154
+ const prohibitionBlock = prohibitionFindings && prohibitionFindings.length > 0 ?
155
+ [
156
+ 'PROHIBITION NOTICE (deterministic, computed by the orchestrator from the',
157
+ "spec's own constraint lines and the task's diff): this task MODIFIED paths",
158
+ 'the spec explicitly forbids modifying:',
159
+ ...prohibitionFindings.map(f => `- ${f}`),
160
+ 'Read the exact constraint wording in the spec. Unless that wording itself',
161
+ 'states an exception that covers this change, this is a violated prohibition:',
162
+ 'rule 4b applies and the verdict is FAIL naming the forbidden path — even if',
163
+ 'every test passes and the change looks harmless.',
164
+ ''
165
+ ]
166
+ : [];
167
+ const envBlock = envNotes && envNotes.trim().length > 0 ? [buildEnvNotesBlock(envNotes)] : [];
140
168
  return [
141
169
  'You are a strict verification pass running right after an AI coding agent',
142
170
  'finished a task and committed it. The agent is known to mark work "done"',
@@ -150,7 +178,9 @@ export function buildVerifyPrompt(spec, probeFindings) {
150
178
  'THE TASK SPEC (its ACCEPTANCE criteria and VERIFY block are the contract):',
151
179
  spec.trim(),
152
180
  '',
181
+ ...envBlock,
153
182
  ...probeBlock,
183
+ ...prohibitionBlock,
154
184
  'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
155
185
  'checkout (or CI run) would experience it:',
156
186
  '',
@@ -215,6 +245,19 @@ export function buildVerifyPrompt(spec, probeFindings) {
215
245
  '4. Treat the ACCEPTANCE criteria as the bar. If a command fails, or its real output',
216
246
  ' contradicts an ACCEPTANCE criterion, the work has NOT verified.',
217
247
  '',
248
+ '4b. SPEC PROHIBITIONS ARE PART OF THE BAR — YOU HAVE NO WAIVER AUTHORITY: when the',
249
+ ' spec explicitly forbids something ("Do NOT modify X", "MUST NOT touch Y") and the',
250
+ ' shipped work does it anyway, that is a FAIL naming the violated constraint. You',
251
+ ' may not excuse a violation because it is additive, small, harmless, an improvement,',
252
+ ' or because every test still passes — "it works anyway" is exactly the waiver you do',
253
+ " not have; relaxing a constraint is the spec owner's call, not yours. Check the",
254
+ " task's own diff (git) against the spec's prohibitions — a forbidden file can be",
255
+ ' modified without any test noticing. Only two outcomes are not a FAIL: the',
256
+ ' violation was fully REVERTED (the shipped tree no longer violates), or the',
257
+ ' prohibition\'s own wording states an exception ("except…", "beyond what is needed',
258
+ ' for…") that covers the change — judged against that stated exception, not against',
259
+ ' your view of harmlessness.',
260
+ '',
218
261
  '5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
219
262
  ' service or network resource (a database server, an API host) that the project',
220
263
  ' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
@@ -249,6 +292,8 @@ export function buildVerifyPrompt(spec, probeFindings) {
249
292
  'a required behavior missing — the verdict is FAIL, even if typecheck and lint are green',
250
293
  'and even if the gap seems minor. Never downgrade an unmet criterion to a warning note.',
251
294
  '',
295
+ ENV_NOTE_EMIT_INSTRUCTION,
296
+ '',
252
297
  'When you are done, output EXACTLY ONE of these as the final line:',
253
298
  " WORK-VERIFIED: PASS (the project's own command, run unaided, met the spec)",
254
299
  ' WORK-VERIFIED: FAIL <text> (the shipped command failed or did not meet the spec; say what failed)',
@@ -305,6 +350,26 @@ export async function runWorkVerification(deps) {
305
350
  findings = [];
306
351
  }
307
352
  }
353
+ let prohibitions = [];
354
+ if (deps.prohibitionProbe) {
355
+ try {
356
+ prohibitions = await deps.prohibitionProbe();
357
+ }
358
+ catch {
359
+ prohibitions = [];
360
+ }
361
+ }
362
+ // Environment facts from earlier gate children (best-effort; a cache failure
363
+ // must never block verification).
364
+ let envNotes = '';
365
+ if (deps.envNotes) {
366
+ try {
367
+ envNotes = await deps.envNotes.read();
368
+ }
369
+ catch {
370
+ envNotes = '';
371
+ }
372
+ }
308
373
  // A child that emits NO verdict never judged the work (budget/context death mid-
309
374
  // investigation — seen live: an 11-minute verify wandered, died verdict-less, and
310
375
  // the resulting FAIL burned a full implementation re-run on an unjudged artifact).
@@ -312,7 +377,7 @@ export async function runWorkVerification(deps) {
312
377
  for (let attempt = 1;; attempt++) {
313
378
  let text;
314
379
  try {
315
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings), deps.signal);
380
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions), deps.signal);
316
381
  }
317
382
  catch (err) {
318
383
  if (err instanceof Error && err.message === USER_CANCELLED)
@@ -320,6 +385,16 @@ export async function runWorkVerification(deps) {
320
385
  const msg = err instanceof Error ? err.message : String(err);
321
386
  return { ok: false, reason: `verification pass could not run: ${msg}` };
322
387
  }
388
+ // Capture the environment facts the child shared — regardless of verdict
389
+ // (a FAIL run's discoveries are just as reusable).
390
+ if (deps.envNotes) {
391
+ try {
392
+ await deps.envNotes.append(extractEnvNotes(text));
393
+ }
394
+ catch {
395
+ // best-effort cache
396
+ }
397
+ }
323
398
  // A child that mutated the repo (git-state guard fired) judged a tree it had
324
399
  // itself changed — its verdict is meaningless in both directions, so discard
325
400
  // 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.27",
3
+ "version": "0.18.1",
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",