@mjasnikovs/pi-task 0.38.15 → 0.38.17

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 (86) hide show
  1. package/dist/config/config.d.ts +26 -0
  2. package/dist/config/config.js +68 -17
  3. package/dist/shared/child-process.js +9 -16
  4. package/dist/task/accept-debt.d.ts +7 -5
  5. package/dist/task/accept-debt.js +18 -14
  6. package/dist/task/artifact-closure.js +18 -63
  7. package/dist/task/auto-orchestrator.js +211 -218
  8. package/dist/task/autofix-ledger.d.ts +113 -0
  9. package/dist/task/autofix-ledger.js +152 -0
  10. package/dist/task/boot-probe.d.ts +109 -1
  11. package/dist/task/boot-probe.js +139 -23
  12. package/dist/task/child-runner.d.ts +50 -6
  13. package/dist/task/child-runner.js +48 -69
  14. package/dist/task/command-run.d.ts +49 -6
  15. package/dist/task/command-run.js +154 -18
  16. package/dist/task/coverage-loop.d.ts +11 -0
  17. package/dist/task/coverage-loop.js +16 -0
  18. package/dist/task/external-context.d.ts +9 -12
  19. package/dist/task/external-context.js +5 -5
  20. package/dist/task/failure-classifier.d.ts +9 -1
  21. package/dist/task/failure-classifier.js +9 -0
  22. package/dist/task/final-gate-fix.d.ts +22 -26
  23. package/dist/task/final-gate-fix.js +16 -31
  24. package/dist/task/final-gate.d.ts +10 -2
  25. package/dist/task/final-gate.js +55 -89
  26. package/dist/task/fix-child.d.ts +64 -0
  27. package/dist/task/fix-child.js +66 -0
  28. package/dist/task/gate-deps.js +20 -13
  29. package/dist/task/lint-fix.d.ts +7 -0
  30. package/dist/task/lint-fix.js +45 -9
  31. package/dist/task/orchestrator.d.ts +33 -24
  32. package/dist/task/orchestrator.js +75 -46
  33. package/dist/task/phases.d.ts +120 -34
  34. package/dist/task/phases.js +221 -134
  35. package/dist/task/plan-orchestrator.js +2 -2
  36. package/dist/task/plan-rounds.d.ts +86 -0
  37. package/dist/task/plan-rounds.js +105 -0
  38. package/dist/task/plan-session.d.ts +31 -21
  39. package/dist/task/plan-session.js +97 -120
  40. package/dist/task/qa-transcript.d.ts +100 -0
  41. package/dist/task/qa-transcript.js +99 -0
  42. package/dist/task/question-source.d.ts +117 -0
  43. package/dist/task/question-source.js +174 -0
  44. package/dist/task/repo-health-check.d.ts +21 -21
  45. package/dist/task/repo-health-check.js +43 -112
  46. package/dist/task/run-end.d.ts +77 -0
  47. package/dist/task/run-end.js +37 -0
  48. package/dist/task/run-final-gate.js +71 -79
  49. package/dist/task/serve-entry.js +6 -57
  50. package/dist/task/shipped-source.d.ts +67 -0
  51. package/dist/task/shipped-source.js +144 -0
  52. package/dist/task/task-gates.d.ts +9 -1
  53. package/dist/task/task-gates.js +27 -6
  54. package/dist/task/terminal-outcome.d.ts +1 -1
  55. package/dist/task/terminal-outcome.js +12 -0
  56. package/dist/task/verify-work.d.ts +46 -0
  57. package/dist/task/verify-work.js +51 -3
  58. package/dist/workers/brave-search.d.ts +7 -0
  59. package/dist/workers/brave-search.js +36 -55
  60. package/dist/workers/ddg-search.d.ts +1 -1
  61. package/dist/workers/ddg-search.js +27 -47
  62. package/dist/workers/docs-core.d.ts +71 -1
  63. package/dist/workers/docs-core.js +131 -71
  64. package/dist/workers/exa-search.d.ts +2 -2
  65. package/dist/workers/exa-search.js +53 -68
  66. package/dist/workers/html-clean.js +67 -88
  67. package/dist/workers/http-request.d.ts +74 -0
  68. package/dist/workers/http-request.js +103 -0
  69. package/dist/workers/npm-version.js +37 -42
  70. package/dist/workers/pi-worker-core.d.ts +13 -2
  71. package/dist/workers/pi-worker-core.js +35 -25
  72. package/dist/workers/pi-worker-docs.d.ts +1 -1
  73. package/dist/workers/pi-worker-docs.js +49 -68
  74. package/dist/workers/pi-worker-fetch.d.ts +1 -1
  75. package/dist/workers/pi-worker-fetch.js +20 -21
  76. package/dist/workers/pi-worker-search.js +6 -4
  77. package/dist/workers/pi-worker.js +5 -4
  78. package/dist/workers/search-core.d.ts +1 -1
  79. package/dist/workers/search-core.js +36 -42
  80. package/dist/workers/search-types.d.ts +13 -0
  81. package/dist/workers/search-types.js +27 -0
  82. package/dist/workers/shared.d.ts +51 -11
  83. package/dist/workers/shared.js +0 -0
  84. package/dist/workers/worker-channels.d.ts +60 -0
  85. package/dist/workers/worker-channels.js +98 -0
  86. package/package.json +1 -1
@@ -7,6 +7,11 @@
7
7
  */
8
8
  import { type SpawnFn, type ContextSnapshot, type ToolCall, type LoopHit } from '../shared/child-process.js';
9
9
  import type { DebugLine } from './debug-log.js';
10
+ import type { RunWorkerInput, RunWorkerResult } from '../workers/pi-worker-core.js';
11
+ import type { docsRaw, docsFocused } from '../workers/docs-core.js';
12
+ import type { fetchRaw, fetchFocused } from '../workers/fetch-core.js';
13
+ import type { npmVersionLookup } from '../workers/npm-version.js';
14
+ import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
10
15
  export declare const LOOP_WINDOW = 20;
11
16
  export declare const LOOP_THRESHOLD = 5;
12
17
  export declare const MAX_LOOP_RESTARTS = 2;
@@ -139,6 +144,38 @@ interface PhaseDeps {
139
144
  * the rungs. This seam is for callers to whom the child is a premise.
140
145
  */
141
146
  runChild?: (name: string, tools: string, prompt: string) => Promise<string>;
147
+ /**
148
+ * Run ONE research worker. Absent (production) → the real `runWorker`.
149
+ *
150
+ * Every decision `runSpec` makes — the three Research retry gates, the
151
+ * fatal/runaway/empty classification, the marker choice, `postProcess` — is a
152
+ * pure function of the returned `RunWorkerResult`, but reaching any of them
153
+ * otherwise requires driving a fake process that emits JSON events.
154
+ *
155
+ * `label` is the worker's name — the same one `recordWorker` trails — because
156
+ * a substitute must answer differently per worker, and the only alternative
157
+ * is matching a marker sentence inside the prompt. Same reason `runChild`
158
+ * takes a name.
159
+ */
160
+ runWorker?: (label: string, input: RunWorkerInput) => Promise<RunWorkerResult>;
161
+ /** The project file inventory handed to every research worker's header. */
162
+ getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
163
+ /** RAW docs lookup — the research phase's EXTERNAL CONTEXT variant. */
164
+ docsRaw?: typeof docsRaw;
165
+ /** RAW url fetch — the research phase's EXTERNAL CONTEXT variant. */
166
+ fetchRaw?: typeof fetchRaw;
167
+ /** Live npm version lookup for the research phase's named deps. */
168
+ npmVersionLookup?: typeof npmVersionLookup;
169
+ /** FOCUSED docs lookup — the grill auto-answer's variant. */
170
+ docsFocused?: typeof docsFocused;
171
+ /** FOCUSED url fetch — the grill auto-answer's variant. */
172
+ fetchFocused?: typeof fetchFocused;
173
+ /**
174
+ * Live web search. ONE field, not two: the research phase and the auto-answer
175
+ * differ in the doc/url worker VARIANT (raw vs focused) and in POLICY, never
176
+ * in how they search — the two dep bags declared it identically.
177
+ */
178
+ searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
142
179
  }
143
180
  export type { PhaseDeps };
144
181
  /**
@@ -165,7 +202,7 @@ export type { PhaseDeps };
165
202
  * All three are checked BEFORE the triage ladder: we killed the child, so its
166
203
  * exit status describes our SIGTERM and says nothing about its verdict.
167
204
  */
168
- export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
205
+ export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string, opts?: PhaseChildOptions): Promise<string>;
169
206
  export declare function formatLoopHint(hit: LoopHit): string;
170
207
  /**
171
208
  * Terminal hint for the degrade attempt: the model has thrashed through the whole
@@ -178,11 +215,19 @@ export declare function formatLoopHint(hit: LoopHit): string;
178
215
  export declare function formatDegradeHint(hit: LoopHit): string;
179
216
  export declare function prependHint(hint: string | null, prompt: string): string;
180
217
  /**
181
- * Run a phase child with loop detection. On a detected loop, kill and re-spawn
182
- * with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
183
- * the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
218
+ * The two things a phase child can disagree about. Everything else the loop
219
+ * and stall detectors, the wall clock, the loop trail, the triage ladder and its
220
+ * budget — is the one loop's, because the two wrappers that used to differ
221
+ * disagreed on nothing else that was ever observable.
184
222
  */
185
- export interface LoopGuardOptions {
223
+ export interface PhaseChildOptions {
224
+ /**
225
+ * The wrapper's own word in the debug log for "we are going round again".
226
+ * An option rather than one word because it is the single externally visible
227
+ * difference between the two loops this collapsed, and the debug trail of a
228
+ * real run is read by a human who knows which phases restart and which retry.
229
+ */
230
+ verb?: 'retry' | 'restart';
186
231
  /**
187
232
  * When the strike budget is exhausted by loops, do NOT fail the phase. Run
188
233
  * ONE final attempt with NO tools and a terminal hint ordering the model to
@@ -194,7 +239,6 @@ export interface LoopGuardOptions {
194
239
  */
195
240
  degradeOnExhaustion?: boolean;
196
241
  }
197
- export declare function runPhaseWithLoopGuard(deps: PhaseDeps, name: string, tools: string, buildPrompt: (loopHint: string | null) => string, opts?: LoopGuardOptions): Promise<string>;
198
242
  /**
199
243
  * Run a child up to twice; the second attempt gets `emphasized=true` to escalate
200
244
  * the prompt. On success, return the validator's value; on two failures, throw
@@ -229,18 +229,18 @@ onToolResult) {
229
229
  * tool call. Callers own the loop, the prompt and the hint; this owns the
230
230
  * verdict, so a fix to any rung lands in every caller at once.
231
231
  *
232
- * `attempt` is the caller's 0-based counter (its attempt/strike), `budget` the
233
- * matching restart allowance (MAX_LEAK_RETRIES for runPhaseChild's leak budget,
234
- * MAX_LOOP_RESTARTS for runPhaseWithLoopGuard's strike budget) so both run
235
- * `budget + 1` attempts in total before a rung gives up and throws.
232
+ * `attempt` is the caller's 0-based counter, `budget` the matching restart
233
+ * allowance (MAX_LEAK_RETRIES, which is also MAX_LOOP_RESTARTS — the loop and
234
+ * leak budgets were separate constants at the same value and are shared by one
235
+ * loop now) — so a phase runs `budget + 1` attempts before a rung gives up.
236
236
  *
237
- * `verb` names the caller's restart in the debug log ("retry" for runPhaseChild,
238
- * "restart" for runPhaseWithLoopGuard). It is the only externally visible thing
239
- * that differs between the two, and the only way to tell from a debug log which
240
- * wrapper produced a given line — so it is passed in rather than hardcoded.
237
+ * `verb` names the restart in the debug log ("retry" by default, "restart" for
238
+ * refine and grill-gen). It is the only externally visible thing that differed
239
+ * between the two loops this collapsed, and the only way to tell from a debug
240
+ * log which phase produced a line — so it is passed in rather than hardcoded.
241
241
  *
242
- * A loop kill (`r.loopHit`) is NOT handled here: only runPhaseWithLoopGuard
243
- * detects loops, and it must consume the hit before calling this.
242
+ * A loop kill (`r.loopHit`) is NOT handled here: the caller detects loops and
243
+ * must consume the hit before calling this.
244
244
  */
245
245
  async function triageChildResult(deps, name, r, attempt, budget, verb) {
246
246
  if (r.exitCode !== 0) {
@@ -303,13 +303,17 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
303
303
  * All three are checked BEFORE the triage ladder: we killed the child, so its
304
304
  * exit status describes our SIGTERM and says nothing about its verdict.
305
305
  */
306
- export async function runPhaseChild(deps, name, tools, prompt) {
306
+ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
307
307
  if (deps.runChild)
308
308
  return await deps.runChild(name, tools, prompt);
309
+ const verb = opts.verb ?? 'retry';
309
310
  let hint = null;
310
311
  const loopHistory = [];
311
312
  const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
312
313
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
314
+ // A cancel between attempts must not buy another spawn.
315
+ if (deps.signal.aborted)
316
+ throw new Error(USER_CANCELLED);
313
317
  const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
314
318
  const stall = new StallDetector();
315
319
  const clock = phaseTimeout(deps.signal, budgetMs);
@@ -327,13 +331,23 @@ export async function runPhaseChild(deps, name, tools, prompt) {
327
331
  if (deps.signal.aborted)
328
332
  throw new Error(USER_CANCELLED);
329
333
  if (r.loopHit) {
334
+ const isLastStrike = attempt === MAX_LEAK_RETRIES;
330
335
  loopHistory.push(r.loopHit);
331
- if (attempt === MAX_LEAK_RETRIES)
336
+ await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, attempt + 1, isLastStrike ?
337
+ opts.degradeOnExhaustion ?
338
+ 'degraded — no-tools final attempt'
339
+ : 'phase failed'
340
+ : 'restarted with hint');
341
+ if (isLastStrike) {
342
+ if (opts.degradeOnExhaustion) {
343
+ return await runDegradedFinalAttempt(deps, name, prompt, r.loopHit, loopHistory);
344
+ }
332
345
  throw new LoopExhaustedError(name, loopHistory);
346
+ }
333
347
  deps.logDebug?.(r.loopHit.stall ?
334
348
  `${name}: stalled (${r.loopHit.stall}) on ${r.loopHit.call.name} — `
335
- + `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`
336
- : `${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
349
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`
350
+ : `${name}: looped on ${r.loopHit.call.name} — ${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
337
351
  hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
338
352
  continue;
339
353
  }
@@ -342,11 +356,11 @@ export async function runPhaseChild(deps, name, tools, prompt) {
342
356
  throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
343
357
  }
344
358
  deps.logDebug?.(`${name}: exceeded its ${Math.round(budgetMs / 1000)}s budget — `
345
- + `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
359
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
346
360
  hint = PHASE_TIMEOUT_HINT;
347
361
  continue;
348
362
  }
349
- const step = await triageChildResult(deps, name, r, attempt, MAX_LEAK_RETRIES, 'retry');
363
+ const step = await triageChildResult(deps, name, r, attempt, MAX_LEAK_RETRIES, verb);
350
364
  if (step.done)
351
365
  return step.text;
352
366
  if (step.hint !== undefined)
@@ -381,63 +395,28 @@ export function formatDegradeHint(hit) {
381
395
  export function prependHint(hint, prompt) {
382
396
  return hint === null ? prompt : `${hint}\n\n${prompt}`;
383
397
  }
398
+ /**
399
+ * Append one line to the task file's `loop events` section.
400
+ *
401
+ * Best-effort by contract: it runs for EVERY phase child now that there is one
402
+ * loop, and the six sites that used to reach the un-trailed wrapper do not all
403
+ * own a task file on disk (a scripted harness, a bare unit deps bag). A trail
404
+ * that cannot be written must cost the phase nothing — the loop kill itself is
405
+ * already reported through the debug log and the thrown LoopExhaustedError.
406
+ */
384
407
  async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
385
408
  const ts = new Date().toISOString();
386
409
  const argsStr = JSON.stringify(hit.call.args);
387
410
  const line = `- ${ts} ${phase} strike ${strike}/${MAX_LOOP_RESTARTS + 1} `
388
411
  + `${hit.call.name}(${argsStr}) ×${hit.count} in last ${hit.windowSize} calls → ${outcome}`;
389
- const existing = (await readSection(cwd, taskId, 'loop events')) ?? '';
390
- const next = existing ? `${existing}\n${line}` : line;
391
- await setTaskSection(cwd, taskId, 'loop events', next);
392
- }
393
- export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts = {}) {
394
- // The substitute stands in for the whole guarded run, so it is handed the
395
- // prompt the first strike would have used (no loop hint in flight yet).
396
- if (deps.runChild)
397
- return await deps.runChild(name, tools, buildPrompt(null));
398
- const loopHistory = [];
399
- // Carries the correction hint (loop OR leaked-tool-call) into the next strike.
400
- let nextHint = null;
401
- for (let strike = 0; strike <= MAX_LOOP_RESTARTS; strike++) {
402
- if (deps.signal.aborted)
403
- throw new Error(USER_CANCELLED);
404
- const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
405
- const stall = new StallDetector();
406
- const prompt = buildPrompt(nextHint);
407
- const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput, snapshot => {
408
- stall.noteContext(snapshot.contextWindow);
409
- deps.onContextUsage?.(snapshot);
410
- }, call => detector.record(call) ?? stall.record(call), deps.spawn, undefined, (text, isError) => stall.noteResult(text, isError));
411
- if (deps.signal.aborted)
412
- throw new Error(USER_CANCELLED);
413
- if (r.loopHit) {
414
- const isLastStrike = strike === MAX_LOOP_RESTARTS;
415
- loopHistory.push(r.loopHit);
416
- const lastOutcome = opts.degradeOnExhaustion ? 'degraded — no-tools final attempt' : 'phase failed';
417
- await appendLoopEvent(deps.cwd, deps.taskId, name, r.loopHit, strike + 1, isLastStrike ? lastOutcome : 'restarted with hint');
418
- if (isLastStrike) {
419
- if (opts.degradeOnExhaustion) {
420
- return await runDegradedFinalAttempt(deps, name, buildPrompt, r.loopHit, loopHistory);
421
- }
422
- throw new LoopExhaustedError(name, loopHistory);
423
- }
424
- nextHint =
425
- r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
426
- continue;
427
- }
428
- // Everything past the loop kill is the shared ladder: exit code, model
429
- // error (connection-class restarts within the strike budget), empty
430
- // completion, leaked tool call. The strike budget is shared with the
431
- // loop restarts above — MAX_LOOP_RESTARTS+1 attempts across all causes.
432
- const step = await triageChildResult(deps, name, r, strike, MAX_LOOP_RESTARTS, 'restart');
433
- if (step.done)
434
- return step.text;
435
- // Only a leak produces a new correction hint; the other rungs have
436
- // nothing to correct and leave any loop hint already in flight alone.
437
- if (step.hint !== undefined)
438
- nextHint = step.hint;
412
+ try {
413
+ const existing = (await readSection(cwd, taskId, 'loop events')) ?? '';
414
+ const next = existing ? `${existing}\n${line}` : line;
415
+ await setTaskSection(cwd, taskId, 'loop events', next);
416
+ }
417
+ catch {
418
+ /* best-effort: a trail is never worth failing a phase for */
439
419
  }
440
- throw new LoopExhaustedError(name, loopHistory);
441
420
  }
442
421
  /**
443
422
  * Final degrade attempt after the loop budget is spent: re-spawn the child with
@@ -448,10 +427,10 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
448
427
  * to the original LoopExhaustedError so the phase still fails honestly when even
449
428
  * the degrade produces nothing.
450
429
  */
451
- async function runDegradedFinalAttempt(deps, name, buildPrompt, hit, loopHistory) {
430
+ async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
452
431
  deps.logDebug?.(`${name}: loop budget exhausted — degrading to a no-tools final attempt`);
453
432
  const r = await runChild(deps.cwd, '', // --no-tools: the model cannot read/grep/list, only answer
454
- buildPrompt(formatDegradeHint(hit)), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
433
+ prependHint(formatDegradeHint(hit), prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
455
434
  if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
456
435
  throw new LoopExhaustedError(name, loopHistory);
457
436
  }
@@ -45,18 +45,41 @@ export interface CommandSpec {
45
45
  args: string[];
46
46
  timeoutMs: number;
47
47
  /**
48
- * Replaces the child's environment wholesale. Passed explicitly because bun's
49
- * spawnSync resolves the binary against a startup snapshot of the environment
48
+ * Replaces the child's environment wholesale. Passed explicitly because a
49
+ * spawn resolves the binary against a startup snapshot of the environment
50
50
  * rather than the live `process.env`.
51
51
  */
52
52
  env?: Record<string, string | undefined>;
53
+ /** The caller's cancel. Kills the child; the run reads as `status: null`. */
54
+ signal?: AbortSignal;
53
55
  }
54
56
  /**
55
57
  * The injectable half. The gate takes one of these so its tests can script
56
58
  * verdicts instead of paying process-spawn cost for every classification case.
59
+ *
60
+ * ASYNC by contract. It was `(spec) => CommandRun`, so the only implementation
61
+ * could be `spawnSync`, and the run-end gate blocked the event loop end to end:
62
+ * repo-health under a 600s cap, then every lockfile/test/build/launch command
63
+ * under a 900s cap, then every ACCEPT-debt re-run under a 300s cap, with no
64
+ * loader able to paint through any of it. That freeze is MEASURED — 0 of 686
65
+ * expected 100ms ticks fired during a 69s run — and `repo-health-check.ts`'s own
66
+ * doc comment already told gate callers not to do it, while `final-gate.ts`'s
67
+ * repo-health call did exactly that.
68
+ */
69
+ export type CommandRunner = (spec: CommandSpec) => Promise<CommandRun>;
70
+ /**
71
+ * The real runner: one bounded child, output collected, never rejects.
72
+ *
73
+ * A kill — by the wall clock or by the caller's cancel — reads as `status: null`,
74
+ * which the gap ladder already treats as "nothing was observed".
75
+ *
76
+ * THE RUN SETTLES ON THE CHILD, NOT ON THE PIPE. `close` fires only once every
77
+ * stdio pipe has reached EOF, and a backgrounded grandchild INHERITS stdout: a
78
+ * seed script that starts a daemon, a build that leaves a watcher, a launch
79
+ * script. Waiting for `close` there is waiting for the grandchild, which no
80
+ * timeout can reach — SIGKILL goes to the direct child and the inherited pipe
81
+ * survives it. So `exit` settles the run, and the deadline settles it itself.
57
82
  */
58
- export type CommandRunner = (spec: CommandSpec) => CommandRun;
59
- /** The real runner. */
60
83
  export declare const spawnCommand: CommandRunner;
61
84
  /**
62
85
  * A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
@@ -94,6 +117,26 @@ export type CommandVerdict =
94
117
  };
95
118
  /** Last ~`limit` chars of the command's combined output, one line, for the reason. */
96
119
  export declare function outputTail(stdout: string, stderr: string, limit?: number): string;
120
+ /**
121
+ * Which gap rows may be claimed by a command's OUTPUT rather than by the fact
122
+ * that it never ran. Only these can be WRONG about a command that did run, which
123
+ * is why they are the ones a caller opts into.
124
+ */
125
+ export interface ClassifyOptions {
126
+ /**
127
+ * May this command's output claim a MISSING BROWSER/RUNTIME?
128
+ *
129
+ * True by default: the row exists for the gate's TEST commands, where a
130
+ * Playwright suite on a box with no browsers is an environment gap.
131
+ *
132
+ * False for the static ladder. `ENV_GAP_OUTPUT_RE` matches ordinary English
133
+ * — `browsers are not installed`, `wasn't installed` — and repo-health runs
134
+ * lint and typecheck only, which have no browsers to miss. A real lint report
135
+ * that happens to quote that wording would otherwise SKIP the static check
136
+ * and tell the gate the repo is healthy.
137
+ */
138
+ runtimeGap?: boolean;
139
+ }
97
140
  /**
98
141
  * Decide what one finished command proved. Pure — no spawning, no filesystem, no
99
142
  * clock — so every case is stateable as a literal.
@@ -101,7 +144,7 @@ export declare function outputTail(stdout: string, stderr: string, limit?: numbe
101
144
  * `gapPatterns` are the EXTRA output shapes this particular command may treat as
102
145
  * an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
103
146
  */
104
- export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[]): CommandVerdict;
147
+ export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[], opts?: ClassifyOptions): CommandVerdict;
105
148
  /**
106
149
  * How a re-run of ONE recorded VERIFY command line ended.
107
150
  * pass — it ran and exited 0. The ONLY outcome that may close a debt.
@@ -137,4 +180,4 @@ export type VerifyRerunOutcome = {
137
180
  */
138
181
  export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
139
182
  /** The spawner. Injected so a re-run's outcome can be tested without one. */
140
- run?: CommandRunner): VerifyRerunOutcome;
183
+ run?: CommandRunner, signal?: AbortSignal): Promise<VerifyRerunOutcome>;
@@ -27,24 +27,156 @@
27
27
  * carried nine injectable probes for the gate's boot half while its command half
28
28
  * had none.
29
29
  */
30
- import { spawnSync } from 'node:child_process';
30
+ import { spawn } from 'node:child_process';
31
31
  import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
32
- /** The real runner. */
33
- export const spawnCommand = spec => {
34
- const r = spawnSync(spec.bin, spec.args, {
32
+ /**
33
+ * How much of ONE stream may be held in the HOST process, and how it is split.
34
+ *
35
+ * `spawnSync` bounded this at its 1 MB default `maxBuffer`. The async runner had
36
+ * no bound at all: two strings grew in the TUI's own process for as long as a
37
+ * command under the 900s cap kept talking.
38
+ *
39
+ * BOTH ENDS are kept, because both are read. `isCommandNotFound` and the two gap
40
+ * regexes match wording a runner prints FIRST; `outputTail` and every failure
41
+ * reason take the LAST 400 characters. A single-ended cap loses one of them.
42
+ */
43
+ const OUTPUT_HEAD_CAP = 256 * 1024;
44
+ const OUTPUT_TAIL_CAP = 768 * 1024;
45
+ /** One stream, bounded, keeping its head and its tail with the middle elided. */
46
+ class BoundedOutput {
47
+ head = '';
48
+ tail = '';
49
+ total = 0;
50
+ push(chunk) {
51
+ this.total += chunk.length;
52
+ let rest = chunk;
53
+ if (this.head.length < OUTPUT_HEAD_CAP) {
54
+ const room = OUTPUT_HEAD_CAP - this.head.length;
55
+ this.head += rest.slice(0, room);
56
+ rest = rest.slice(room);
57
+ }
58
+ if (rest.length === 0)
59
+ return;
60
+ this.tail = (this.tail + rest).slice(-OUTPUT_TAIL_CAP);
61
+ }
62
+ toString() {
63
+ const elided = this.total - this.head.length - this.tail.length;
64
+ return elided > 0 ?
65
+ `${this.head}\n…[${elided} characters elided]…\n${this.tail}`
66
+ : this.head + this.tail;
67
+ }
68
+ }
69
+ /**
70
+ * After the child EXITS, how long its pipes may still deliver buffered data
71
+ * before the run is reported. Not a wait for the pipes to CLOSE — that is the
72
+ * bug below — just the turn or two the reader needs to hand over what it has.
73
+ */
74
+ const DRAIN_MS = 50;
75
+ /**
76
+ * The real runner: one bounded child, output collected, never rejects.
77
+ *
78
+ * A kill — by the wall clock or by the caller's cancel — reads as `status: null`,
79
+ * which the gap ladder already treats as "nothing was observed".
80
+ *
81
+ * THE RUN SETTLES ON THE CHILD, NOT ON THE PIPE. `close` fires only once every
82
+ * stdio pipe has reached EOF, and a backgrounded grandchild INHERITS stdout: a
83
+ * seed script that starts a daemon, a build that leaves a watcher, a launch
84
+ * script. Waiting for `close` there is waiting for the grandchild, which no
85
+ * timeout can reach — SIGKILL goes to the direct child and the inherited pipe
86
+ * survives it. So `exit` settles the run, and the deadline settles it itself.
87
+ */
88
+ export const spawnCommand = spec => new Promise(resolve => {
89
+ const out = new BoundedOutput();
90
+ const err = new BoundedOutput();
91
+ let settled = false;
92
+ let exitStatus = null;
93
+ let exited = false;
94
+ let endedStreams = 0;
95
+ let drain;
96
+ const child = spawn(spec.bin, spec.args, {
35
97
  cwd: spec.cwd,
36
- encoding: 'utf8',
37
- timeout: spec.timeoutMs,
98
+ // stdin CLOSED. `spawnSync` gave the child none; the default `spawn`
99
+ // stdio is a live pipe nobody ever ends, so a check that reads stdin —
100
+ // a `cat`-style pipeline, a tool that prompts, a pager — blocked until
101
+ // the kill timer: 600s for repo-health, 900s for a gate command.
102
+ stdio: ['ignore', 'pipe', 'pipe'],
38
103
  ...(spec.env ? { env: spec.env } : {})
39
104
  });
40
- return {
41
- failedToStart: r.error !== undefined && r.error !== null,
42
- ...(r.error ? { failureMessage: r.error.message } : {}),
43
- status: r.status,
44
- stdout: r.stdout ?? '',
45
- stderr: r.stderr ?? ''
105
+ const done = (status, failure) => {
106
+ if (settled)
107
+ return;
108
+ settled = true;
109
+ clearTimeout(timer);
110
+ clearTimeout(drain);
111
+ spec.signal?.removeEventListener('abort', killAndSettle);
112
+ resolve({
113
+ failedToStart: failure !== undefined,
114
+ ...(failure === undefined ? {} : { failureMessage: failure }),
115
+ status,
116
+ stdout: out.toString(),
117
+ stderr: err.toString()
118
+ });
119
+ };
120
+ const expectedStreams = (child.stdout ? 1 : 0) + (child.stderr ? 1 : 0);
121
+ const settleIfDrained = () => {
122
+ if (exited && endedStreams >= expectedStreams)
123
+ done(exitStatus);
124
+ };
125
+ const kill = () => {
126
+ try {
127
+ child.kill('SIGKILL');
128
+ }
129
+ catch {
130
+ /* already gone */
131
+ }
132
+ };
133
+ /**
134
+ * The deadline and the cancel both END the run. The kill only reaches the
135
+ * direct child, so this cannot wait to observe its effect — it kills, gives
136
+ * the pipes one drain, and reports `status: null` regardless.
137
+ */
138
+ const killAndSettle = () => {
139
+ kill();
140
+ clearTimeout(drain);
141
+ drain = setTimeout(() => done(null), DRAIN_MS);
46
142
  };
47
- };
143
+ // NOT unref'd. With `spawnSync`'s own `timeout` gone this timer is the only
144
+ // bound left on every gate command, repo-health command and ACCEPT-debt
145
+ // re-run — and an unref'd timer is MEASURED in this repo never to fire at
146
+ // all on Windows (0/20s), which would leave all of them unbounded. It is
147
+ // cleared the moment the run settles, so it holds the loop open only while
148
+ // a command the caller is awaiting anyway is still running.
149
+ const timer = setTimeout(killAndSettle, spec.timeoutMs);
150
+ if (spec.signal) {
151
+ if (spec.signal.aborted)
152
+ killAndSettle();
153
+ else
154
+ spec.signal.addEventListener('abort', killAndSettle, { once: true });
155
+ }
156
+ child.stdout?.on('data', (d) => out.push(d.toString()));
157
+ child.stderr?.on('data', (d) => err.push(d.toString()));
158
+ child.stdout?.on('end', () => {
159
+ endedStreams++;
160
+ settleIfDrained();
161
+ });
162
+ child.stderr?.on('end', () => {
163
+ endedStreams++;
164
+ settleIfDrained();
165
+ });
166
+ child.on('error', (e) => done(null, e.message));
167
+ child.on('exit', (code) => {
168
+ exited = true;
169
+ exitStatus = code;
170
+ // Both ends of the same question: settle now if the pipes are already
171
+ // at EOF, otherwise settle after one short drain rather than waiting on
172
+ // whoever else is holding them.
173
+ settleIfDrained();
174
+ if (!settled) {
175
+ clearTimeout(drain);
176
+ drain = setTimeout(() => done(exitStatus), DRAIN_MS);
177
+ }
178
+ });
179
+ });
48
180
  /**
49
181
  * A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
50
182
  * a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
@@ -116,14 +248,17 @@ export function outputTail(stdout, stderr, limit = 400) {
116
248
  * `gapPatterns` are the EXTRA output shapes this particular command may treat as
117
249
  * an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
118
250
  */
119
- export function classifyCommandRun(run, gapPatterns = []) {
251
+ export function classifyCommandRun(run, gapPatterns = [], opts = {}) {
120
252
  // A clean exit is a pass before any gap shape is consulted: gap patterns
121
253
  // describe output, and passing output can legitimately mention a database or
122
254
  // a browser.
123
255
  if (!run.failedToStart && run.status === 0)
124
256
  return { outcome: 'pass' };
125
257
  const output = `${run.stdout}\n${run.stderr}`;
258
+ const runtimeGap = opts.runtimeGap ?? true;
126
259
  for (const rule of GAP_RULES) {
260
+ if (rule.id === 'missing-runtime' && !runtimeGap)
261
+ continue;
127
262
  if (rule.applies(run, output, gapPatterns)) {
128
263
  return { outcome: 'gap', gap: rule.id, detail: rule.detail(run) };
129
264
  }
@@ -160,19 +295,20 @@ function leadingBin(line) {
160
295
  * failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
161
296
  * debt exactly as open as it was.
162
297
  */
163
- export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
298
+ export async function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
164
299
  /** The spawner. Injected so a re-run's outcome can be tested without one. */
165
- run = spawnCommand) {
300
+ run = spawnCommand, signal) {
166
301
  const bin = leadingBin(line);
167
302
  const runner = bin === null ? null : resolveRunner(bin);
168
303
  // A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
169
304
  // redirects are all ordinary there — so the runner spawns `sh -c`.
170
- const verdict = classifyCommandRun(run({
305
+ const verdict = classifyCommandRun(await run({
171
306
  cwd,
172
307
  bin: 'sh',
173
308
  args: ['-c', line],
174
309
  timeoutMs,
175
- env: runner ? runnerEnv(runner) : { ...process.env }
310
+ env: runner ? runnerEnv(runner) : { ...process.env },
311
+ ...(signal === undefined ? {} : { signal })
176
312
  }),
177
313
  // Infrastructure counts as a gap on EVERY debt re-run, not only on
178
314
  // request: an unreachable database cannot tell us whether the code is
@@ -139,3 +139,14 @@ export interface ScoredPlan {
139
139
  */
140
140
  judgeMissing: string[];
141
141
  }
142
+ /**
143
+ * Normalise a missing-area string for cross-round identity — lowercased alnum
144
+ * words, punctuation and quote-wrapping collapsed. Used only to tell whether an
145
+ * adopted plan introduced a NEW gap versus re-surfacing the same one (the bonus
146
+ * round); intentionally coarse, so trivial rewording of the same area does not
147
+ * read as new and buy an extra round.
148
+ *
149
+ * Lives here rather than in `auto-orchestrator.ts` because its only consumer is
150
+ * `CoverageLedger.consider`, which is the adoption rule this file owns.
151
+ */
152
+ export declare function normMissingArea(s: string): string;
@@ -290,3 +290,19 @@ export function decideAdoption(current, retry, hasRequirements) {
290
290
  }
291
291
  return { adopt: true, reason: 'count floor met, no coverage regression', dropped: [] };
292
292
  }
293
+ /**
294
+ * Normalise a missing-area string for cross-round identity — lowercased alnum
295
+ * words, punctuation and quote-wrapping collapsed. Used only to tell whether an
296
+ * adopted plan introduced a NEW gap versus re-surfacing the same one (the bonus
297
+ * round); intentionally coarse, so trivial rewording of the same area does not
298
+ * read as new and buy an extra round.
299
+ *
300
+ * Lives here rather than in `auto-orchestrator.ts` because its only consumer is
301
+ * `CoverageLedger.consider`, which is the adoption rule this file owns.
302
+ */
303
+ export function normMissingArea(s) {
304
+ return s
305
+ .toLowerCase()
306
+ .replace(/[^a-z0-9]+/g, ' ')
307
+ .trim();
308
+ }
@@ -19,12 +19,16 @@
19
19
  * {@link ExternalContextLookups} — an adapter, expressible only since the
20
20
  * focused-extractor seam landed.
21
21
  */
22
- import { docsRaw } from '../workers/docs-core.js';
23
- import { fetchRaw } from '../workers/fetch-core.js';
24
- import { npmVersionLookup, type NpmVersionInfo } from '../workers/npm-version.js';
22
+ import { type NpmVersionInfo } from '../workers/npm-version.js';
25
23
  import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
26
24
  import type { PhaseDeps } from './child-runner.js';
27
- type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep'>;
25
+ /**
26
+ * What assembling the block needs off the phase's deps: where it runs, when to
27
+ * stop, where to trail a sub-step — and, for the research binding, the four
28
+ * lookup seams. They are fields on `PhaseDeps` (not a second bag) because the
29
+ * bag was a trailing parameter no production caller could reach.
30
+ */
31
+ type GatherDeps = Pick<PhaseDeps, 'cwd' | 'signal' | 'recordSubStep' | 'docsRaw' | 'fetchRaw' | 'npmVersionLookup' | 'searchFn'>;
28
32
  /** What a target lookup contributes to the block. */
29
33
  export interface ExternalTargetResult {
30
34
  /** Emitted as an `### npm:` block ahead of every body. Absent for url targets. */
@@ -89,13 +93,6 @@ export interface ExternalContextPolicy {
89
93
  * is nothing to enrich (no targets, or every lookup failed).
90
94
  */
91
95
  export declare function buildExternalContext(source: string, deps: GatherDeps, lookups: ExternalContextLookups, policy?: ExternalContextPolicy): Promise<string>;
92
- /** Injectable workers so enrichment is testable without spawning real lookups. */
93
- export interface ExternalContextDeps {
94
- docsRaw?: typeof docsRaw;
95
- fetchRaw?: typeof fetchRaw;
96
- searchFn?: (input: SearchCoreInput) => Promise<SearchCoreResult>;
97
- npmVersionLookup?: typeof npmVersionLookup;
98
- }
99
96
  /**
100
97
  * The RESEARCH-phase binding: raw workers, no caps, live versions for every
101
98
  * named dep, truncated bodies, timed, and short-circuited when there is nothing
@@ -103,5 +100,5 @@ export interface ExternalContextDeps {
103
100
  *
104
101
  * Returns the `EXTERNAL CONTEXT\n…\n\n` block for the refined spec, or `''`.
105
102
  */
106
- export declare function gatherExternalContext(refined: string, deps: GatherDeps, researchDeps?: ExternalContextDeps): Promise<string>;
103
+ export declare function gatherExternalContext(refined: string, deps: GatherDeps): Promise<string>;
107
104
  export {};