@mjasnikovs/pi-task 0.38.10 → 0.38.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +7 -3
  2. package/dist/shared/child-process.d.ts +8 -0
  3. package/dist/shared/command-watchdog.d.ts +1 -1
  4. package/dist/shared/command-watchdog.js +1 -1
  5. package/dist/task/accept-debt.d.ts +47 -0
  6. package/dist/task/accept-debt.js +127 -28
  7. package/dist/task/auto-orchestrator.js +93 -116
  8. package/dist/task/boot-probe.d.ts +298 -0
  9. package/dist/task/boot-probe.js +806 -0
  10. package/dist/task/child-runner.d.ts +56 -25
  11. package/dist/task/child-runner.js +65 -31
  12. package/dist/task/child-status.d.ts +95 -0
  13. package/dist/task/child-status.js +99 -0
  14. package/dist/task/command-run.d.ts +36 -0
  15. package/dist/task/command-run.js +48 -1
  16. package/dist/task/command-watchdog.js +1 -1
  17. package/dist/task/context-usage.d.ts +4 -3
  18. package/dist/task/context-usage.js +4 -3
  19. package/dist/task/contracts.js +18 -35
  20. package/dist/task/deep-render-check.d.ts +47 -0
  21. package/dist/task/deep-render-check.js +110 -65
  22. package/dist/task/env-notes.d.ts +3 -3
  23. package/dist/task/env-notes.js +24 -35
  24. package/dist/task/final-gate-fix.d.ts +1 -1
  25. package/dist/task/final-gate-fix.js +1 -1
  26. package/dist/task/final-gate.d.ts +38 -432
  27. package/dist/task/final-gate.js +105 -1213
  28. package/dist/task/gate-child.d.ts +8 -10
  29. package/dist/task/gate-child.js +15 -19
  30. package/dist/task/gate-deps.d.ts +29 -0
  31. package/dist/task/gate-deps.js +192 -206
  32. package/dist/task/gate-tally.d.ts +189 -0
  33. package/dist/task/gate-tally.js +249 -0
  34. package/dist/task/implementation-turn.d.ts +201 -0
  35. package/dist/task/implementation-turn.js +263 -0
  36. package/dist/task/launch-contract.js +27 -43
  37. package/dist/task/launch-manifest.d.ts +5 -0
  38. package/dist/task/launch-manifest.js +21 -0
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +70 -297
  45. package/dist/task/phases.d.ts +18 -0
  46. package/dist/task/phases.js +4 -3
  47. package/dist/task/plan-orchestrator.js +34 -33
  48. package/dist/task/requirements.d.ts +1 -1
  49. package/dist/task/requirements.js +50 -66
  50. package/dist/task/root-cause-repair.js +20 -32
  51. package/dist/task/run-bracket.d.ts +75 -0
  52. package/dist/task/run-bracket.js +41 -0
  53. package/dist/task/stall-detector.d.ts +110 -0
  54. package/dist/task/stall-detector.js +159 -0
  55. package/dist/task/task-gates.d.ts +69 -0
  56. package/dist/task/task-gates.js +114 -90
  57. package/dist/task/verify-work.d.ts +53 -67
  58. package/dist/task/verify-work.js +15 -11
  59. package/dist/workers/docs-core.d.ts +0 -4
  60. package/dist/workers/docs-core.js +10 -34
  61. package/dist/workers/docs-project.js +3 -3
  62. package/dist/workers/docs-resolve.d.ts +18 -0
  63. package/dist/workers/docs-resolve.js +39 -0
  64. package/dist/workers/docs-retrieve.d.ts +13 -0
  65. package/dist/workers/docs-retrieve.js +17 -2
  66. package/dist/workers/fetch-core.d.ts +0 -4
  67. package/dist/workers/fetch-core.js +2 -5
  68. package/dist/workers/phantom-imports.d.ts +3 -3
  69. package/dist/workers/phantom-imports.js +16 -29
  70. package/dist/workers/pi-worker-docs.d.ts +49 -0
  71. package/dist/workers/pi-worker-docs.js +33 -9
  72. package/dist/workers/pi-worker-fetch.d.ts +18 -0
  73. package/dist/workers/pi-worker-fetch.js +19 -4
  74. package/dist/workers/single-read-extension.d.ts +1 -1
  75. package/dist/workers/single-read-extension.js +5 -4
  76. package/dist/workers/single-read-guard.d.ts +32 -10
  77. package/dist/workers/single-read-guard.js +67 -16
  78. package/package.json +1 -1
@@ -27,9 +27,7 @@
27
27
  * Stack-agnostic: an "interface fact" is any pinned boundary string; the guard is
28
28
  * pure substring matching over the source text, with no assumption about its shape.
29
29
  */
30
- import * as fsp from 'node:fs/promises';
31
- import * as path from 'node:path';
32
- import { tasksDir } from './task-io.js';
30
+ import { makeLedger } from './ledger.js';
33
31
  const CONTRACTS_FILE = 'contracts.md';
34
32
  /** Cap kept entries so the injected block stays bounded on a large design. */
35
33
  const MAX_CONTRACTS = 40;
@@ -37,17 +35,26 @@ const MAX_CONTRACTS = 40;
37
35
  const MAX_CONTRACT_LENGTH = 300;
38
36
  /** A quote shorter than this is too generic to anchor a contract (and to match). */
39
37
  const MIN_QUOTE_LENGTH = 6;
38
+ function lineKey(line) {
39
+ const q = /"([^"]+)"/.exec(line);
40
+ return normalise(q ? q[1] : line);
41
+ }
42
+ const ledger = makeLedger({
43
+ file: CONTRACTS_FILE,
44
+ max: MAX_CONTRACTS,
45
+ key: c => c.key,
46
+ serialize: c => c.line,
47
+ parse: raw => raw
48
+ .split('\n')
49
+ .filter(l => l.trim().length > 0)
50
+ .map(line => ({ line, key: lineKey(line) }))
51
+ });
40
52
  export function contractsFile(cwd) {
41
- return path.join(tasksDir(cwd), CONTRACTS_FILE);
53
+ return ledger.path(cwd);
42
54
  }
43
55
  /** The stored registry text ('' when none recorded yet). */
44
56
  export async function readContracts(cwd) {
45
- try {
46
- return (await fsp.readFile(contractsFile(cwd), 'utf8')).trim();
47
- }
48
- catch {
49
- return '';
50
- }
57
+ return ledger.readRaw(cwd);
51
58
  }
52
59
  /**
53
60
  * Normalise for substring matching: collapse all whitespace runs to one space and
@@ -114,31 +121,7 @@ function formatEntry(e) {
114
121
  * are swallowed — the registry is a sharpener, never a blocker.
115
122
  */
116
123
  export async function appendContracts(cwd, entries) {
117
- if (entries.length === 0)
118
- return;
119
- try {
120
- const existingLines = (await readContracts(cwd))
121
- .split('\n')
122
- .filter(l => l.trim().length > 0);
123
- const seen = new Set(existingLines.map(l => {
124
- const q = /"([^"]+)"/.exec(l);
125
- return normalise(q ? q[1] : l);
126
- }));
127
- const merged = [...existingLines];
128
- for (const e of entries) {
129
- const key = normalise(e.quote);
130
- if (seen.has(key))
131
- continue;
132
- seen.add(key);
133
- merged.push(formatEntry(e));
134
- }
135
- const kept = merged.slice(-MAX_CONTRACTS);
136
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
137
- await fsp.writeFile(contractsFile(cwd), kept.join('\n') + '\n', 'utf8');
138
- }
139
- catch {
140
- // best-effort registry
141
- }
124
+ await ledger.append(cwd, entries.map(e => ({ line: formatEntry(e), key: normalise(e.quote) })));
142
125
  }
143
126
  /**
144
127
  * The read-only prompt block a downstream slice (refine/compose) receives when the
@@ -177,3 +177,50 @@ export declare function runDeepRenderCheck(url: string, cwd: string, opts?: {
177
177
  * is the whole runtime of a driver test. The gate never passes it. */
178
178
  quietMs?: number;
179
179
  }): Promise<DeepRenderOutcome>;
180
+ /** A launched, connected browser: the client to speak to it and the one way to
181
+ * tear it down. `close` is idempotent and never throws. */
182
+ export interface LaunchedBrowser {
183
+ cdp: Cdp;
184
+ close: () => Promise<void>;
185
+ }
186
+ /**
187
+ * Everything that touches a real process or the filesystem: spawn the Chrome-family
188
+ * binary at `bin` with a throwaway profile at `userDataDir`, read the DevTools ws
189
+ * URL off its output, connect. Rejects if the browser exits or errors before it
190
+ * listens, or the socket cannot open; whatever was started by then is torn down.
191
+ * `signal` is the caller's teardown: aborting it closes whatever exists, mid-launch
192
+ * or after, which is how a budget timeout reaches a browser that never listened.
193
+ * The caller owns `userDataDir` (creation and removal) — this only points Chrome
194
+ * at it.
195
+ *
196
+ * @internal Exported for `drive` and its own harness; the gate goes through
197
+ * `runDeepRenderCheck`.
198
+ */
199
+ export declare function launchBrowser(bin: string, userDataDir: string, { signal }?: {
200
+ signal?: AbortSignal;
201
+ }): Promise<LaunchedBrowser>;
202
+ /** The subset of `Cdp` the session drives: request/response and event fan-out.
203
+ * Defined from what `driveSession` calls, so a scripted fake is a dozen lines. */
204
+ export interface CdpLike {
205
+ send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
206
+ on(method: string, cb: (params: Record<string, unknown>) => void): void;
207
+ }
208
+ export interface DriveSessionOptions {
209
+ url: string;
210
+ credentials: LoginCredentials | null;
211
+ /** Turns the facts the session gathered into the verdict. `drive` wraps
212
+ * `judgeDeepSession` with the recorder hook here. */
213
+ judge: (f: DeepSessionFacts) => DeepRenderOutcome;
214
+ /** Settle quiet window; defaults to QUIET_MS. */
215
+ quietMs?: number;
216
+ }
217
+ /**
218
+ * The session over an already-connected browser: navigate, inspect, sign in if the
219
+ * landing is a wall and credentials exist, settle, phase the same-origin request
220
+ * log against the sign-in request, re-enter once the sign-in was accepted, and
221
+ * hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
222
+ * socket — so every branch is testable against a fake `CdpLike`.
223
+ *
224
+ * @internal Exported for its own tests.
225
+ */
226
+ export declare function driveSession(cdp: CdpLike, { url, credentials, judge, quietMs }: DriveSessionOptions): Promise<DeepRenderOutcome>;
@@ -494,35 +494,11 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
494
494
  }
495
495
  const budget = opts.timeoutMs ?? DEEP_RENDER_TIMEOUT_MS;
496
496
  const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'pi-task-deep-render-'));
497
- let child = null;
498
- let socket = null;
499
- const cleanup = () => {
500
- try {
501
- socket?.close();
502
- }
503
- catch {
504
- // socket already gone
505
- }
506
- try {
507
- if (child?.pid)
508
- process.kill(-child.pid, 'SIGKILL');
509
- }
510
- catch {
511
- // group already gone
512
- }
513
- try {
514
- rmSync(userDataDir, { recursive: true, force: true });
515
- }
516
- catch {
517
- // best-effort temp cleanup
518
- }
519
- };
497
+ // Aborted in `finally`, so a browser still mid-launch when the budget expires is
498
+ // torn down too — the launch owns the process, and this is how it hears about it.
499
+ const teardown = new AbortController();
520
500
  try {
521
- return await withTimeout(drive(url, bin, userDataDir, credentials, c => {
522
- child = c;
523
- }, s => {
524
- socket = s;
525
- }, opts.onFacts, opts.quietMs), budget);
501
+ return await withTimeout(drive(url, bin, userDataDir, credentials, opts.onFacts, opts.quietMs, teardown.signal), budget);
526
502
  }
527
503
  catch (e) {
528
504
  const why = e instanceof Error ? e.message : String(e);
@@ -532,7 +508,13 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
532
508
  };
533
509
  }
534
510
  finally {
535
- cleanup();
511
+ teardown.abort();
512
+ try {
513
+ rmSync(userDataDir, { recursive: true, force: true });
514
+ }
515
+ catch {
516
+ // best-effort temp cleanup
517
+ }
536
518
  }
537
519
  }
538
520
  function withTimeout(p, ms) {
@@ -548,48 +530,111 @@ function withTimeout(p, ms) {
548
530
  });
549
531
  });
550
532
  }
551
- async function drive(url, bin, userDataDir, credentials, holdChild, holdSocket, onFacts, quietMs) {
552
- const origin = new URL(url).origin;
533
+ /** launch session close. The two halves are separately testable: the launch
534
+ * against a fake browser on disk, the session against an in-process fake CDP. */
535
+ async function drive(url, bin, userDataDir, credentials, onFacts, quietMs, signal) {
553
536
  /** Every verdict goes through here, so a recorder sees the same facts the judge
554
537
  * does — the corpus is what the gate itself read, not a reconstruction. */
555
538
  const judge = (f) => {
556
539
  onFacts?.(f);
557
540
  return judgeDeepSession(f);
558
541
  };
559
- const child = spawn(bin, [
560
- '--headless',
561
- '--disable-gpu',
562
- '--no-sandbox',
563
- '--disable-dev-shm-usage',
564
- '--no-first-run',
565
- '--no-default-browser-check',
566
- '--disable-extensions',
567
- `--user-data-dir=${userDataDir}`,
568
- '--remote-debugging-port=0',
569
- 'about:blank'
570
- ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
571
- holdChild(child);
572
- child.unref();
573
- const wsUrl = await new Promise((resolve, reject) => {
574
- let buf = '';
575
- const onData = (d) => {
576
- buf += String(d);
577
- const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
578
- if (m)
579
- resolve(m[1]);
580
- };
581
- child.stderr?.on('data', onData);
582
- child.stdout?.on('data', onData);
583
- child.on('error', e => reject(e));
584
- child.on('exit', code => reject(new Error(`browser exited ${code} before listening`)));
585
- });
586
- const socket = new WebSocket(wsUrl, { perMessageDeflate: false, maxPayload: 128 * 1024 * 1024 });
587
- holdSocket(socket);
588
- await new Promise((resolve, reject) => {
589
- socket.once('open', () => resolve());
590
- socket.once('error', e => reject(e instanceof Error ? e : new Error(String(e))));
591
- });
592
- const cdp = new Cdp(socket);
542
+ const browser = await launchBrowser(bin, userDataDir, { signal });
543
+ try {
544
+ return await driveSession(browser.cdp, { url, credentials, judge, quietMs });
545
+ }
546
+ finally {
547
+ await browser.close();
548
+ }
549
+ }
550
+ /**
551
+ * Everything that touches a real process or the filesystem: spawn the Chrome-family
552
+ * binary at `bin` with a throwaway profile at `userDataDir`, read the DevTools ws
553
+ * URL off its output, connect. Rejects if the browser exits or errors before it
554
+ * listens, or the socket cannot open; whatever was started by then is torn down.
555
+ * `signal` is the caller's teardown: aborting it closes whatever exists, mid-launch
556
+ * or after, which is how a budget timeout reaches a browser that never listened.
557
+ * The caller owns `userDataDir` (creation and removal) — this only points Chrome
558
+ * at it.
559
+ *
560
+ * @internal Exported for `drive` and its own harness; the gate goes through
561
+ * `runDeepRenderCheck`.
562
+ */
563
+ export async function launchBrowser(bin, userDataDir, { signal } = {}) {
564
+ let child = null;
565
+ let socket = null;
566
+ const close = () => {
567
+ try {
568
+ socket?.close();
569
+ }
570
+ catch {
571
+ // socket already gone
572
+ }
573
+ try {
574
+ if (child?.pid)
575
+ process.kill(-child.pid, 'SIGKILL');
576
+ }
577
+ catch {
578
+ // group already gone
579
+ }
580
+ return Promise.resolve();
581
+ };
582
+ signal?.addEventListener('abort', () => void close(), { once: true });
583
+ if (signal?.aborted) {
584
+ throw new Error('aborted before launch');
585
+ }
586
+ try {
587
+ child = spawn(bin, [
588
+ '--headless',
589
+ '--disable-gpu',
590
+ '--no-sandbox',
591
+ '--disable-dev-shm-usage',
592
+ '--no-first-run',
593
+ '--no-default-browser-check',
594
+ '--disable-extensions',
595
+ `--user-data-dir=${userDataDir}`,
596
+ '--remote-debugging-port=0',
597
+ 'about:blank'
598
+ ], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
599
+ const proc = child;
600
+ proc.unref();
601
+ const wsUrl = await new Promise((resolve, reject) => {
602
+ let buf = '';
603
+ const onData = (d) => {
604
+ buf += String(d);
605
+ const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
606
+ if (m)
607
+ resolve(m[1]);
608
+ };
609
+ proc.stderr?.on('data', onData);
610
+ proc.stdout?.on('data', onData);
611
+ proc.on('error', e => reject(e));
612
+ proc.on('exit', code => reject(new Error(`browser exited ${code} before listening`)));
613
+ });
614
+ const ws = new WebSocket(wsUrl, { perMessageDeflate: false, maxPayload: 128 * 1024 * 1024 });
615
+ socket = ws;
616
+ await new Promise((resolve, reject) => {
617
+ ws.once('open', () => resolve());
618
+ ws.once('error', e => reject(e instanceof Error ? e : new Error(String(e))));
619
+ });
620
+ return { cdp: new Cdp(ws), close };
621
+ }
622
+ catch (e) {
623
+ await close();
624
+ throw e;
625
+ }
626
+ }
627
+ /**
628
+ * The session over an already-connected browser: navigate, inspect, sign in if the
629
+ * landing is a wall and credentials exist, settle, phase the same-origin request
630
+ * log against the sign-in request, re-enter once the sign-in was accepted, and
631
+ * hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
632
+ * socket — so every branch is testable against a fake `CdpLike`.
633
+ *
634
+ * @internal Exported for its own tests.
635
+ */
636
+ export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
637
+ const origin = new URL(url).origin;
593
638
  const requests = new Map();
594
639
  let lastActivity = Date.now();
595
640
  cdp.on('Network.requestWillBeSent', p => {
@@ -1,6 +1,3 @@
1
- export declare function envNotesFile(cwd: string): string;
2
- /** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
3
- export declare function readEnvNotes(cwd: string): Promise<string>;
4
1
  /** One recorded fact plus the origin task that established it (may be ''). */
5
2
  export interface EnvNote {
6
3
  fact: string;
@@ -11,6 +8,9 @@ export interface EnvNote {
11
8
  * provenance (no separator) parse with an empty origin, so old caches still read.
12
9
  */
13
10
  export declare function parseEnvNotes(raw: string): EnvNote[];
11
+ export declare function envNotesFile(cwd: string): string;
12
+ /** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
13
+ export declare function readEnvNotes(cwd: string): Promise<string>;
14
14
  /**
15
15
  * Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
16
16
  * length-capped; verdict markers can never match (different prefix).
@@ -32,9 +32,7 @@
32
32
  * exactly that scrutiny, and forbids treating a grep of a generated artifact as
33
33
  * evidence of absence. Provenance is mechanical; re-validation is prompt-level.
34
34
  */
35
- import * as fsp from 'node:fs/promises';
36
- import * as path from 'node:path';
37
- import { tasksDir } from './task-io.js';
35
+ import { makeLedger } from './ledger.js';
38
36
  const ENV_NOTES_FILE = 'env-notes.md';
39
37
  /** Cap kept notes so a chatty run cannot grow the prompt block unboundedly. */
40
38
  const MAX_NOTES = 40;
@@ -46,18 +44,6 @@ const MAX_NOTE_LENGTH = 240;
46
44
  * stray tab in an emitted fact is normalised to a space before storage.
47
45
  */
48
46
  const ORIGIN_SEP = '\t';
49
- export function envNotesFile(cwd) {
50
- return path.join(tasksDir(cwd), ENV_NOTES_FILE);
51
- }
52
- /** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
53
- export async function readEnvNotes(cwd) {
54
- try {
55
- return (await fsp.readFile(envNotesFile(cwd), 'utf8')).trim();
56
- }
57
- catch {
58
- return '';
59
- }
60
- }
61
47
  /**
62
48
  * Parse the stored file into fact+origin records. Legacy lines written before
63
49
  * provenance (no separator) parse with an empty origin, so old caches still read.
@@ -79,6 +65,22 @@ export function parseEnvNotes(raw) {
79
65
  function serializeNote(n) {
80
66
  return n.origin ? `${n.fact}${ORIGIN_SEP}${n.origin}` : n.fact;
81
67
  }
68
+ /** Keyed on the fact alone (case-insensitive): a fact already present keeps its
69
+ * ORIGINAL origin — provenance traces to who first established it. */
70
+ const ledger = makeLedger({
71
+ file: ENV_NOTES_FILE,
72
+ max: MAX_NOTES,
73
+ key: n => n.fact.toLowerCase(),
74
+ serialize: serializeNote,
75
+ parse: parseEnvNotes
76
+ });
77
+ export function envNotesFile(cwd) {
78
+ return ledger.path(cwd);
79
+ }
80
+ /** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
81
+ export async function readEnvNotes(cwd) {
82
+ return ledger.readRaw(cwd);
83
+ }
82
84
  /**
83
85
  * Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
84
86
  * length-capped; verdict markers can never match (different prefix).
@@ -134,27 +136,14 @@ export function isExcuseNote(fact) {
134
136
  * blocker.
135
137
  */
136
138
  export async function appendEnvNotes(cwd, notes, origin = '') {
137
- if (notes.length === 0)
138
- return;
139
- try {
140
- const existing = parseEnvNotes(await readEnvNotes(cwd));
141
- const seen = new Set(existing.map(n => n.fact.toLowerCase()));
142
- const merged = [...existing];
143
- for (const note of notes) {
144
- const fact = note.trim().replace(/\t/g, ' ');
145
- const key = fact.toLowerCase();
146
- if (key.length === 0 || seen.has(key))
147
- continue;
148
- seen.add(key);
149
- merged.push({ fact, origin: origin.trim() });
150
- }
151
- const kept = merged.slice(-MAX_NOTES);
152
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
153
- await fsp.writeFile(envNotesFile(cwd), kept.map(serializeNote).join('\n') + '\n', 'utf8');
154
- }
155
- catch {
156
- // best-effort cache
139
+ const fresh = [];
140
+ for (const note of notes) {
141
+ const fact = note.trim().replace(/\t/g, ' ');
142
+ if (fact.length === 0)
143
+ continue;
144
+ fresh.push({ fact, origin: origin.trim() });
157
145
  }
146
+ await ledger.append(cwd, fresh);
158
147
  }
159
148
  /**
160
149
  * The prompt block a gate child receives when notes exist. Two things are
@@ -38,7 +38,7 @@ export declare function classifyFinalGateAnswer(answer: string | undefined): Fin
38
38
  * Extract it for reporting; the shrink guard itself compares the FULL
39
39
  * discovered-command sets, so a reason this cannot parse still guards.
40
40
  */
41
- export declare function extractFailingCommand(reason: string): string | null;
41
+ export declare function exitedCommandFromReason(reason: string): string | null;
42
42
  /**
43
43
  * Build the fix child's prompt. Generic by construction: the only project facts
44
44
  * in it are the gate's own failure text — the command comes from the project's
@@ -90,7 +90,7 @@ export function classifyFinalGateAnswer(answer) {
90
90
  * Extract it for reporting; the shrink guard itself compares the FULL
91
91
  * discovered-command sets, so a reason this cannot parse still guards.
92
92
  */
93
- export function extractFailingCommand(reason) {
93
+ export function exitedCommandFromReason(reason) {
94
94
  const m = /`([^`]+)`\s+exited\b/.exec(reason);
95
95
  return m ? m[1] : null;
96
96
  }