@mjasnikovs/pi-task 0.17.27 → 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.
@@ -5,6 +5,13 @@ export interface PiTaskConfig {
5
5
  orientation: boolean;
6
6
  enforceGuidelines: boolean;
7
7
  verifyWork: boolean;
8
+ /**
9
+ * Run the four research workers concurrently instead of one at a time.
10
+ * DEFAULT OFF: serial was A/B-proven faster on a single-GPU local backend
11
+ * (concurrent streams split the GPU and slow each other ~4×, see
12
+ * phases.ts). Turn on only for a parallel-capable backend.
13
+ */
14
+ parallelResearchWorkers: boolean;
8
15
  }
9
16
  export declare function getConfig(): PiTaskConfig;
10
17
  export declare function saveConfig(config: PiTaskConfig): Promise<void>;
@@ -8,7 +8,8 @@ const DEFAULTS = {
8
8
  autoCommit: true,
9
9
  orientation: true,
10
10
  enforceGuidelines: true,
11
- verifyWork: true
11
+ verifyWork: true,
12
+ parallelResearchWorkers: false
12
13
  };
13
14
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
14
15
  const _g = globalThis;
@@ -72,6 +72,11 @@ const ITEMS = [
72
72
  id: 'enforceGuidelines',
73
73
  label: 'enforce guidelines',
74
74
  description: "Check each /task and /task-auto commit against AGENTS.md/CLAUDE.md. Needs 'verify work' to FIX drift (fixes are reverted if they regress verification); without it, only reports violations. Enabling it makes /task wait for the implementation"
75
+ },
76
+ {
77
+ id: 'parallelResearchWorkers',
78
+ label: 'parallel research',
79
+ description: 'Run the 4 research workers concurrently. Leave OFF on a single-GPU local server (serial is measurably faster there); turn on only for a parallel-capable model backend'
75
80
  }
76
81
  ];
77
82
  function makeTheme(theme) {
@@ -39,6 +39,13 @@ export interface ChildResult {
39
39
  * Only populated in json-events mode.
40
40
  */
41
41
  modelError?: string;
42
+ /**
43
+ * true when the stall guard killed the child: no output for the stall
44
+ * window AND the model endpoint probe found the backend unreachable.
45
+ * Callers must check this BEFORE `aborted` — the kill sets aborted too,
46
+ * and without the flag it would mislabel as a user cancel.
47
+ */
48
+ stalled?: boolean;
42
49
  }
43
50
  export interface ToolCall {
44
51
  name: string;
@@ -76,6 +83,20 @@ export interface RunChildJsonEventsOptions {
76
83
  onContextUsage?: (snapshot: ContextSnapshot) => void;
77
84
  onToolCall?: (call: ToolCall) => LoopHit | null;
78
85
  onFirstByte?: () => void;
86
+ /**
87
+ * Dead-backend stall guard (mx5 run 7: model server died mid-child, the
88
+ * child hung MUTE for 64 minutes). Liveness is OUTPUT PROGRESS, not wall
89
+ * time: any stdout/stderr chunk resets the window, so honest long work is
90
+ * never killed. Only when nothing arrived for `afterMs` is `probe` asked
91
+ * whether the model backend is reachable — reachable → keep waiting
92
+ * (prompt processing legitimately emits nothing for minutes); unreachable
93
+ * → the child is killed and the result carries `stalled: true` so callers
94
+ * report the real cause instead of hanging or mislabeling a user cancel.
95
+ */
96
+ stall?: {
97
+ afterMs: number;
98
+ probe: () => Promise<boolean>;
99
+ };
79
100
  }
80
101
  export type RunChildOptions = RunChildTextOptions | RunChildJsonEventsOptions;
81
102
  /**
@@ -197,8 +197,40 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
197
197
  }, KILL_GRACE_MS);
198
198
  };
199
199
  const sink = opts?.mode === 'json-events' ? new JsonEventSink(opts, killProc) : null;
200
+ // Dead-backend stall guard (json-events children only; see the option
201
+ // docs). Any output resets the window; a reachable probe also resets it
202
+ // so the next probe is a full window away, not every tick.
203
+ const stall = opts?.mode === 'json-events' ? opts.stall : undefined;
204
+ let lastActivity = Date.now();
205
+ let stalled = false;
206
+ let probing = false;
207
+ const stallTimer = stall ?
208
+ setInterval(() => {
209
+ if (probing || Date.now() - lastActivity < stall.afterMs)
210
+ return;
211
+ probing = true;
212
+ stall
213
+ .probe()
214
+ .then(reachable => {
215
+ probing = false;
216
+ if (reachable || stalled) {
217
+ lastActivity = Date.now();
218
+ return;
219
+ }
220
+ stalled = true;
221
+ killProc();
222
+ })
223
+ .catch(() => {
224
+ // A probe that itself crashed proves nothing —
225
+ // benefit of the doubt, keep waiting.
226
+ probing = false;
227
+ lastActivity = Date.now();
228
+ });
229
+ }, Math.max(50, Math.min(stall.afterMs / 2, 15_000)))
230
+ : undefined;
200
231
  let firstByteFired = false;
201
232
  proc.stdout?.on('data', (d) => {
233
+ lastActivity = Date.now();
202
234
  if (!firstByteFired) {
203
235
  firstByteFired = true;
204
236
  opts?.onFirstByte?.();
@@ -212,9 +244,12 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
212
244
  stdout += chunk;
213
245
  });
214
246
  proc.stderr?.on('data', (d) => {
247
+ lastActivity = Date.now();
215
248
  stderr += d.toString();
216
249
  });
217
250
  proc.on('close', (code) => {
251
+ if (stallTimer)
252
+ clearInterval(stallTimer);
218
253
  if (sink)
219
254
  sink.flush();
220
255
  const text = sink ? sink.text : undefined;
@@ -224,10 +259,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
224
259
  exitCode: code ?? 0,
225
260
  aborted,
226
261
  text,
227
- modelError: sink?.modelError
262
+ modelError: sink?.modelError,
263
+ ...(stalled ? { stalled: true } : {})
228
264
  });
229
265
  });
230
266
  proc.on('error', () => {
267
+ if (stallTimer)
268
+ clearInterval(stallTimer);
231
269
  resolve({ stdout, stderr, exitCode: 1, aborted });
232
270
  });
233
271
  if (signal) {
@@ -247,15 +285,25 @@ export function summarizeToolArgs(toolName, args) {
247
285
  if (!args || typeof args !== 'object')
248
286
  return '';
249
287
  const a = args;
288
+ const clip = (s) => {
289
+ const one = s.replace(/\s+/g, ' ').trim();
290
+ return one.length > 60 ? one.slice(0, 59) + '…' : one;
291
+ };
250
292
  if (toolName === 'bash' && typeof a.command === 'string') {
251
293
  return a.command.replace(/\s+/g, ' ').trim();
252
294
  }
253
295
  if (toolName === 'pi-worker-docs'
254
296
  && typeof a.module === 'string'
255
297
  && typeof a.query === 'string') {
256
- const q = a.query.replace(/\s+/g, ' ').trim();
257
- const truncated = q.length > 60 ? q.slice(0, 59) + '…' : q;
258
- return `${a.module} "${truncated}"`;
298
+ return `${a.module} "${clip(a.query)}"`;
299
+ }
300
+ // Search/fetch workers: without these the debug log shows a bare tool name
301
+ // and a run audit cannot tell WHAT was searched or fetched.
302
+ if (toolName === 'pi-worker-search' && typeof a.query === 'string') {
303
+ return `"${clip(a.query)}"`;
304
+ }
305
+ if (toolName === 'pi-worker-fetch' && typeof a.url === 'string') {
306
+ return clip(a.url);
259
307
  }
260
308
  if (typeof a.file_path === 'string')
261
309
  return a.file_path;
@@ -0,0 +1,8 @@
1
+ /** Base URLs of every custom provider pi is configured with (possibly none). */
2
+ export declare function discoverModelEndpoints(agentDir?: string): string[];
3
+ /**
4
+ * true → at least one endpoint ANSWERED (any HTTP status counts — a 404 still
5
+ * proves the server is alive); false → every probe was refused or hung past the
6
+ * timeout. An empty list is true: nothing to probe means never kill.
7
+ */
8
+ export declare function probeModelEndpoints(urls: string[], timeoutMs?: number): Promise<boolean>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * model-endpoint — discovery + reachability probe for the model backend(s) a
3
+ * child pi process talks to.
4
+ *
5
+ * The failure this serves (mx5 run 7, validated): the model server went down
6
+ * mid-gate-child and the child hung MUTE for 64 minutes — pi's own
7
+ * connection-error handling only fires when a request FAILS, not when the
8
+ * backend freezes and the open request simply never answers. The stall guard in
9
+ * runChild uses this module to tell the two apart: no output could be honest
10
+ * long work (prompt processing emits nothing for minutes), so only "no output
11
+ * AND the endpoint does not answer" is treated as a dead backend.
12
+ *
13
+ * Discovery is generic: the custom providers pi itself is configured with
14
+ * (models.json `providers.*.baseUrl`), no provider or server names hardcoded.
15
+ * No discoverable endpoint → nothing to probe → the guard NEVER kills (a child
16
+ * on a backend we cannot see must get the benefit of the doubt).
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as os from 'node:os';
20
+ import * as path from 'node:path';
21
+ /** Base URLs of every custom provider pi is configured with (possibly none). */
22
+ export function discoverModelEndpoints(agentDir = path.join(os.homedir(), '.pi', 'agent')) {
23
+ try {
24
+ const j = JSON.parse(fs.readFileSync(path.join(agentDir, 'models.json'), 'utf8'));
25
+ const urls = [];
26
+ for (const p of Object.values(j.providers ?? {})) {
27
+ if (typeof p?.baseUrl === 'string' && p.baseUrl.length > 0)
28
+ urls.push(p.baseUrl);
29
+ }
30
+ return [...new Set(urls)];
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ /**
37
+ * true → at least one endpoint ANSWERED (any HTTP status counts — a 404 still
38
+ * proves the server is alive); false → every probe was refused or hung past the
39
+ * timeout. An empty list is true: nothing to probe means never kill.
40
+ */
41
+ export async function probeModelEndpoints(urls, timeoutMs = 5_000) {
42
+ if (urls.length === 0)
43
+ return true;
44
+ const results = await Promise.all(urls.map(async (u) => {
45
+ try {
46
+ await fetch(new URL('models', u.endsWith('/') ? u : `${u}/`), {
47
+ signal: AbortSignal.timeout(timeoutMs)
48
+ });
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }));
55
+ return results.some(Boolean);
56
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
+ import { type FinalGateFixFn } from './gate-deps.js';
2
3
  import { type GateDeps } from './task-gates.js';
3
4
  /**
4
5
  * Injectable seams so the planner and loop are testable without spawning pi.
@@ -32,6 +33,14 @@ export interface AutoDeps extends GateDeps {
32
33
  ok: boolean;
33
34
  reason: string;
34
35
  }>;
36
+ /**
37
+ * Bounded model-driven fix pass for a final-gate FAIL (see final-gate-fix.ts),
38
+ * offered as the picker's third option. Runs the fix child, applies the
39
+ * command-shrink guard, and re-runs the gate; the result's `ok` means the gate
40
+ * now passes. Absent (tests / no fix wiring) → the picker keeps only
41
+ * Leave-failed / Accept, exactly the pre-autofix behavior.
42
+ */
43
+ finalGateFix?: FinalGateFixFn;
35
44
  }
36
45
  /**
37
46
  * Expand any @file references in the feature text by appending each referenced
@@ -27,6 +27,7 @@ import { buildGateDeps } from './gate-deps.js';
27
27
  import { runGatesForTask } from './task-gates.js';
28
28
  import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
29
29
  import { runFinalIntegrationGate } from './final-gate.js';
30
+ import { classifyFinalGateAnswer, MAX_FINAL_GATE_AUTOFIX, FINAL_LEAVE_LABEL, FINAL_LEAVE_VALUE, FINAL_ACCEPT_LABEL, FINAL_ACCEPT_VALUE, FINAL_AUTOFIX_LABEL, FINAL_AUTOFIX_VALUE } from './final-gate-fix.js';
30
31
  import { getConfig } from '../config/config.js';
31
32
  // Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
32
33
  // when the model emits NONE), but a model that never says NONE would otherwise
@@ -547,7 +548,7 @@ function defaultDeps(ctx, cwd, signal, title) {
547
548
  // The final integration gate follows the `verify work` switch: it is the
548
549
  // run-level half of the same verification story.
549
550
  finalGate: cwd2 => getConfig().verifyWork ?
550
- Promise.resolve(runFinalIntegrationGate(cwd2))
551
+ runFinalIntegrationGate(cwd2)
551
552
  : Promise.resolve({ ok: true, reason: 'disabled' })
552
553
  };
553
554
  }
@@ -598,32 +599,82 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
598
599
  // the gate, so fixing and resuming converges.
599
600
  if (deps.finalGate) {
600
601
  active.ui.notify(`${id}: running final integration gate…`, 'info');
601
- const fin = await deps.finalGate(cwd);
602
- if (!fin.ok) {
602
+ // Run-level gate trail on the parent task file — same durable
603
+ // auditability contract as the per-task `## gates` records.
604
+ const recGate = async (line) => {
605
+ try {
606
+ await deps.record?.(cwd, id, line);
607
+ }
608
+ catch {
609
+ // recording must never break the gate
610
+ }
611
+ };
612
+ let fin = await deps.finalGate(cwd);
613
+ if (!fin.ok)
614
+ await recGate(`final-gate: FAIL — ${fin.reason.slice(0, 300)}`);
615
+ // Resolution loop: Leave-failed (recommended) / Autofix (bounded,
616
+ // model-driven fix pass + gate re-run — run 7's gap: the picker
617
+ // had NO automated fix path) / Accept. The user always decides;
618
+ // after MAX_FINAL_GATE_AUTOFIX attempts that still FAIL the
619
+ // autofix card is withdrawn so the loop cannot run unbounded.
620
+ let fixAttempts = 0;
621
+ while (!fin.ok) {
622
+ const canAutofix = deps.finalGateFix !== undefined && fixAttempts < MAX_FINAL_GATE_AUTOFIX;
603
623
  const question = `Final integration gate FAILED for ${id}.\n\n${fin.reason}\n\n`
604
624
  + 'All tasks are checked off — this is the whole-repo check '
605
- + '(the project’s own test/build/static commands, run unaided).';
625
+ + '(the project’s own test/build/static commands, run unaided).'
626
+ + (fixAttempts > 0 ?
627
+ `\n\nAutofix attempts so far: ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}.`
628
+ : '');
606
629
  const answer = await new SessionUI(active).ask({
607
630
  localTitle: 'Final integration gate failed — how should pi proceed?',
608
631
  displayQuestion: question,
609
632
  question,
610
- recommended: 'Leave failed — I will fix and /task-auto-resume',
611
- recommended2: 'Accept — complete the run anyway',
633
+ recommended: FINAL_LEAVE_LABEL,
634
+ recommended2: canAutofix ? FINAL_AUTOFIX_LABEL : FINAL_ACCEPT_LABEL,
612
635
  allowSkip: false,
613
636
  options: [
614
- {
615
- label: 'Leave failed — I will fix and /task-auto-resume',
616
- value: 'fail'
617
- },
618
- { label: 'Accept — complete the run anyway', value: 'accept' }
637
+ { label: FINAL_LEAVE_LABEL, value: FINAL_LEAVE_VALUE },
638
+ ...(canAutofix ?
639
+ [{ label: FINAL_AUTOFIX_LABEL, value: FINAL_AUTOFIX_VALUE }]
640
+ : []),
641
+ { label: FINAL_ACCEPT_LABEL, value: FINAL_ACCEPT_VALUE }
619
642
  ]
620
643
  });
621
- if (answer === undefined || !/^accept\b/i.test(answer.trim())) {
622
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
623
- announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`, 'error');
624
- return;
644
+ const choice = classifyFinalGateAnswer(answer);
645
+ if (choice.action === 'accept') {
646
+ await recGate('final-gate: FAIL accepted by user');
647
+ active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`, 'warning');
648
+ break;
649
+ }
650
+ if (choice.action === 'autofix' && canAutofix) {
651
+ fixAttempts += 1;
652
+ await recGate(`final-gate: user chose AUTOFIX (attempt ${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX})`);
653
+ active.ui.notify(`${id}: final-gate autofix (${fixAttempts}/${MAX_FINAL_GATE_AUTOFIX}) — bounded fix pass, then the gate re-runs…`, 'info');
654
+ const seed = choice.guidance ?
655
+ `${fin.reason}\n\nUser guidance: ${choice.guidance}`
656
+ : fin.reason;
657
+ const fix = await deps.finalGateFix(active, cwd, seed);
658
+ if (fix.ok) {
659
+ await deps.commit(cwd, `FINAL GATE AUTOFIX (${id})`);
660
+ await recGate(`final-gate: autofix converged — ${fix.reason.slice(0, 200)}`);
661
+ active.ui.notify(`${id}: final integration gate PASSES after autofix — ${fix.reason.slice(0, 140)}`, 'info');
662
+ fin = { ok: true, reason: fix.reason };
663
+ break;
664
+ }
665
+ await recGate(`final-gate: autofix attempt ${fixAttempts} failed — ${fix.reason.slice(0, 200)}`);
666
+ active.ui.notify(`${id}: final-gate autofix did not converge — ${fix.reason.slice(0, 140)}`, 'warning');
667
+ // Work from the FRESH gate failure when the fix pass got
668
+ // as far as re-running the gate; otherwise keep the last.
669
+ fin = { ok: false, reason: fix.gateReason ?? fin.reason };
670
+ continue;
625
671
  }
626
- active.ui.notify(`${id}: final integration gate FAIL accepted by user — completing.`, 'warning');
672
+ // Leave failed — the dismissal default, unchanged from the
673
+ // two-option picker (an unavailable autofix demotes here too).
674
+ await recGate('final-gate: left failed (user)');
675
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
676
+ announceDone(active, `${id} finished all tasks but FAILED the final integration gate — ${fin.reason.slice(0, 200)} — fix and /task-auto-resume (the gate re-runs).`, 'error');
677
+ return;
627
678
  }
628
679
  }
629
680
  await updateTaskFrontMatter(cwd, id, { state: 'completed' });
@@ -33,7 +33,7 @@ export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 rest
33
33
  * provider 5xx that names a real fault) still fails fast: re-spawning against
34
34
  * the same request won't fix it, so burning the budget only delays the report.
35
35
  */
36
- const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|fetch failed|network (?:error|timeout)|premature close|request timed out|terminated)\b/i;
36
+ const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|fetch failed|network (?:error|timeout)|premature close|request timed out|terminated|unreachable)\b/i;
37
37
  export function isConnectionError(cause) {
38
38
  return CONNECTION_ERROR_RE.test(cause);
39
39
  }
@@ -89,6 +89,7 @@ export interface EnforceChildResult {
89
89
  timedOut?: boolean;
90
90
  loopHit?: unknown;
91
91
  leakedToolCall?: unknown;
92
+ stalled?: boolean;
92
93
  }
93
94
  /**
94
95
  * Map the enforcement child's runWorker result to a fatal error message, or null
@@ -191,6 +191,12 @@ export function parseEnforceVerdict(text) {
191
191
  * effects don't get re-classified as a user cancel or a crash.
192
192
  */
193
193
  export function classifyEnforceChildFailure(r) {
194
+ // Stall-kill must be matched BEFORE `aborted`: the kill sets aborted too,
195
+ // and mislabeling a dead model backend as a user cancel hides the cause
196
+ // (mx5 run 7: 64 minutes of silence).
197
+ if (r.stalled) {
198
+ return 'model server unreachable — the child produced no output and the model endpoint did not respond';
199
+ }
194
200
  if (r.timedOut)
195
201
  return 'enforcement child timed out';
196
202
  if (r.loopHit)
@@ -0,0 +1,23 @@
1
+ export declare function envNotesFile(cwd: string): string;
2
+ /** The cached notes, one fact per line ('' when none were recorded yet). */
3
+ export declare function readEnvNotes(cwd: string): Promise<string>;
4
+ /**
5
+ * Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
6
+ * length-capped; verdict markers can never match (different prefix).
7
+ */
8
+ export declare function extractEnvNotes(text: string): string[];
9
+ /**
10
+ * Append newly discovered facts to the cache, deduplicated against what is
11
+ * already there (case-insensitive full-line match), keeping the newest
12
+ * MAX_NOTES. Failures are swallowed — the cache is a sharpener, never a
13
+ * blocker.
14
+ */
15
+ export declare function appendEnvNotes(cwd: string, notes: string[]): Promise<void>;
16
+ /**
17
+ * The prompt block a gate child receives when notes exist. The caveat is
18
+ * load-bearing: facts save re-discovery time but grant no waiver from the
19
+ * verify-as-shipped rule.
20
+ */
21
+ export declare function buildEnvNotesBlock(notes: string): string;
22
+ /** The emit instruction appended to bash-capable gate-child prompts. */
23
+ export declare const ENV_NOTE_EMIT_INSTRUCTION: string;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * env-notes — a per-run cache of ENVIRONMENT FACTS shared across gate children.
3
+ *
4
+ * The failure this serves (mx5 run 7, F8): every gate child re-discovers the
5
+ * same environment facts from scratch — where the DB credentials live, which
6
+ * services are reachable, which tools are installed — burning minutes of
7
+ * archaeology per child through the serial model bottleneck.
8
+ *
9
+ * Mechanism: children EMIT facts as `ENV-NOTE: <fact>` lines in their answer
10
+ * text; the HOST parses and appends them to `.pi-tasks/env-notes.md` (children
11
+ * never write the file — no artifact corruption, host-side dedupe). The file
12
+ * lives under `.pi-tasks/`, so it survives discardEdits and the git-state
13
+ * guard, both of which exclude that directory by design.
14
+ *
15
+ * SCOPE — facts only, never verdicts, never spec content: an endpoint, a
16
+ * credential LOCATION, a tool's presence/version, a service's reachability.
17
+ * And the cache must not become a pre-prepared runway that masks missing
18
+ * project setup: the verify-as-shipped rule ("any prep you needed IS the
19
+ * defect") still governs every verdict — the block injected into prompts says
20
+ * so explicitly. The cache only kills re-discovery time.
21
+ */
22
+ import * as fsp from 'node:fs/promises';
23
+ import * as path from 'node:path';
24
+ import { tasksDir } from './task-io.js';
25
+ const ENV_NOTES_FILE = 'env-notes.md';
26
+ /** Cap kept notes so a chatty run cannot grow the prompt block unboundedly. */
27
+ const MAX_NOTES = 40;
28
+ /** A single fact is one line; anything longer is prose, not a fact. */
29
+ const MAX_NOTE_LENGTH = 240;
30
+ export function envNotesFile(cwd) {
31
+ return path.join(tasksDir(cwd), ENV_NOTES_FILE);
32
+ }
33
+ /** The cached notes, one fact per line ('' when none were recorded yet). */
34
+ export async function readEnvNotes(cwd) {
35
+ try {
36
+ return (await fsp.readFile(envNotesFile(cwd), 'utf8')).trim();
37
+ }
38
+ catch {
39
+ return '';
40
+ }
41
+ }
42
+ /**
43
+ * Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
44
+ * length-capped; verdict markers can never match (different prefix).
45
+ */
46
+ export function extractEnvNotes(text) {
47
+ const notes = [];
48
+ const seen = new Set();
49
+ for (const m of text.matchAll(/^[ \t]*ENV-NOTE:[ \t]*(.+)$/gm)) {
50
+ const note = m[1].trim();
51
+ if (note.length === 0 || note.length > MAX_NOTE_LENGTH)
52
+ continue;
53
+ const key = note.toLowerCase();
54
+ if (seen.has(key))
55
+ continue;
56
+ seen.add(key);
57
+ notes.push(note);
58
+ }
59
+ return notes;
60
+ }
61
+ /**
62
+ * Append newly discovered facts to the cache, deduplicated against what is
63
+ * already there (case-insensitive full-line match), keeping the newest
64
+ * MAX_NOTES. Failures are swallowed — the cache is a sharpener, never a
65
+ * blocker.
66
+ */
67
+ export async function appendEnvNotes(cwd, notes) {
68
+ if (notes.length === 0)
69
+ return;
70
+ try {
71
+ const existing = (await readEnvNotes(cwd)).split('\n').filter(l => l.trim().length > 0);
72
+ const seen = new Set(existing.map(l => l.trim().toLowerCase()));
73
+ const merged = [...existing];
74
+ for (const note of notes) {
75
+ const key = note.trim().toLowerCase();
76
+ if (seen.has(key))
77
+ continue;
78
+ seen.add(key);
79
+ merged.push(note.trim());
80
+ }
81
+ const kept = merged.slice(-MAX_NOTES);
82
+ await fsp.mkdir(tasksDir(cwd), { recursive: true });
83
+ await fsp.writeFile(envNotesFile(cwd), kept.join('\n') + '\n', 'utf8');
84
+ }
85
+ catch {
86
+ // best-effort cache
87
+ }
88
+ }
89
+ /**
90
+ * The prompt block a gate child receives when notes exist. The caveat is
91
+ * load-bearing: facts save re-discovery time but grant no waiver from the
92
+ * verify-as-shipped rule.
93
+ */
94
+ export function buildEnvNotesBlock(notes) {
95
+ if (notes.trim().length === 0)
96
+ return '';
97
+ return [
98
+ 'KNOWN ENVIRONMENT FACTS — discovered by earlier verification passes in this run',
99
+ '(informational, may be stale):',
100
+ ...notes
101
+ .trim()
102
+ .split('\n')
103
+ .map(n => `- ${n}`),
104
+ 'These facts only save you re-discovery time (where credentials/config live, which',
105
+ 'tools are installed, which services are reachable). They are NOT a license to',
106
+ 'prepare or repair the run: the verify-as-shipped rules below still govern the',
107
+ 'verdict — if the project needs something its own committed files do not provide,',
108
+ 'that remains the defect no matter what is listed here.',
109
+ ''
110
+ ].join('\n');
111
+ }
112
+ /** The emit instruction appended to bash-capable gate-child prompts. */
113
+ export const ENV_NOTE_EMIT_INSTRUCTION = [
114
+ 'ENVIRONMENT FACTS — share what you discover: when you establish a durable fact about',
115
+ 'THIS MACHINE or the project environment (a service reachable/absent at an address, where',
116
+ 'credentials/config live, a tool or runtime present/missing and its version), emit a line',
117
+ ' ENV-NOTE: <one-line fact>',
118
+ 'anywhere in your answer, one per fact. Facts about the ENVIRONMENT only — never task',
119
+ 'verdicts, never spec content, never code judgments. These are cached for later',
120
+ 'verification passes in this run so they do not re-discover the same things.'
121
+ ].join('\n');
@@ -0,0 +1,91 @@
1
+ /** Same bounded-fix contract as lint-fix: edit in place, bash exists to RUN the
2
+ * failing command (and the project's own tooling), not to mutate git state. */
3
+ export declare const FINAL_FIX_TOOLS = "read,edit,bash";
4
+ /**
5
+ * How many fix passes the user may launch from the picker before the option is
6
+ * withdrawn (mirrors the per-task MAX_AUTO_AUTOFIX budget). Each attempt is a
7
+ * full model child plus a full gate re-run — after this many that still FAIL,
8
+ * only Leave-failed / Accept remain so a person breaks the loop.
9
+ */
10
+ export declare const MAX_FINAL_GATE_AUTOFIX = 3;
11
+ /** Picker labels/values. Classification accepts the value token, the label, or
12
+ * free text (→ autofix guidance), same contract as the per-task resolution. */
13
+ export declare const FINAL_LEAVE_VALUE = "fail";
14
+ export declare const FINAL_ACCEPT_VALUE = "accept";
15
+ export declare const FINAL_AUTOFIX_VALUE = "autofix";
16
+ export declare const FINAL_LEAVE_LABEL = "Leave failed \u2014 I will fix and /task-auto-resume";
17
+ export declare const FINAL_ACCEPT_LABEL = "Accept \u2014 complete the run anyway";
18
+ export declare const FINAL_AUTOFIX_LABEL = "Autofix \u2014 run a bounded fix pass and re-run the gate";
19
+ export interface FinalGateChoice {
20
+ /** What the user decided. 'leave' = leave the run failed (also the dismissal
21
+ * default — identical to the pre-autofix behavior). */
22
+ action: 'leave' | 'accept' | 'autofix';
23
+ /** Free-text guidance typed instead of picking a card; folded into the fix
24
+ * child's failure seed. Only set with 'autofix'. */
25
+ guidance?: string;
26
+ }
27
+ /**
28
+ * Map a final-gate picker answer to an action. Dismissal (undefined/empty) and
29
+ * an explicit leave stay "leave" — exactly what the two-option picker did.
30
+ * Free text becomes autofix guidance, mirroring classifyResolutionAnswer; the
31
+ * caller demotes autofix back to "leave" when the option was not offered.
32
+ */
33
+ export declare function classifyFinalGateAnswer(answer: string | undefined): FinalGateChoice;
34
+ /**
35
+ * The gate's fail reason always carries the failing command in backticks
36
+ * (`` `bun run test` exited 1 — … `` / ``static checks: `make lint` exited 2``).
37
+ * Extract it for reporting; the shrink guard itself compares the FULL
38
+ * discovered-command sets, so a reason this cannot parse still guards.
39
+ */
40
+ export declare function extractFailingCommand(reason: string): string | null;
41
+ /**
42
+ * Build the fix child's prompt. Generic by construction: the only project facts
43
+ * in it are the gate's own failure text — the command comes from the project's
44
+ * discovered manifest, never from a hardcoded ecosystem.
45
+ */
46
+ export declare function buildFinalFixPrompt(failReason: string): string;
47
+ /**
48
+ * Parse the child's final marker. Last match wins (the model reasons before
49
+ * concluding and bash output can echo the words). No marker → treated as DONE:
50
+ * the gate re-run is the arbiter either way, so a missing marker only skips the
51
+ * early-out on a self-declared BLOCKED.
52
+ */
53
+ export declare function parseFinalFixMarker(text: string): {
54
+ blocked: boolean;
55
+ note?: string;
56
+ };
57
+ export interface FinalFixResult {
58
+ /** true → the fix child ran AND the re-run gate passed. */
59
+ ok: boolean;
60
+ /** Human-readable outcome (converged gate reason, or why the attempt failed). */
61
+ reason: string;
62
+ /** On a did-not-converge outcome: the FRESH gate failure, so the caller's next
63
+ * picker (and next fix attempt) works from the current state, not the stale one. */
64
+ gateReason?: string;
65
+ }
66
+ export interface FinalFixDeps {
67
+ cwd: string;
68
+ signal?: AbortSignal;
69
+ /** The gate's FAIL reason (command + exit code + output tail), plus any
70
+ * user-typed guidance the caller folded in. */
71
+ failReason: string;
72
+ /** Run the fix child; same closure shape the other gate children use. */
73
+ runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
74
+ /** Re-run the final integration gate — the only arbiter of convergence. */
75
+ gate: (cwd: string) => Promise<{
76
+ ok: boolean;
77
+ reason: string;
78
+ }>;
79
+ /** Labels of every currently-discoverable gate command (static + integration),
80
+ * for the shrink guard. Pure discovery — nothing is executed. */
81
+ discoverLabels: (cwd: string) => string[];
82
+ /** Discard the fix child's working-tree edits (shrink-guard trip only). Absent
83
+ * → the violation is still rejected, edits are left for inspection. */
84
+ discard?: (cwd: string) => Promise<void>;
85
+ }
86
+ /**
87
+ * Run one bounded final-gate fix attempt: snapshot discovery → child → shrink
88
+ * guard → gate re-run. Never throws for an outcome; only a user cancel inside
89
+ * runChild propagates (the caller's USER_CANCELLED path handles it).
90
+ */
91
+ export declare function runFinalGateAutofix(deps: FinalFixDeps): Promise<FinalFixResult>;