@evomap/evolver-core 2.0.0-beta.5 → 2.0.0-beta.6

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.
@@ -1,4 +1,6 @@
1
+ import { dirname, join } from 'node:path';
1
2
  import { EventStore } from './eventStore.js';
3
+ import { createIssueDraftForEventBestEffort } from '../issueReporter/index.js';
2
4
  /** 已知事件类型 (军杰 §9; 可 registerEventType 扩展). */
3
5
  export const EVENT_TYPES = [
4
6
  'cycle.started', 'cycle.signals_collected', 'cycle.solidified', 'cycle.failed',
@@ -51,6 +53,11 @@ export class Ingestor {
51
53
  if (raw.actor?.kind === 'human' && !raw.actor.id)
52
54
  throw new IngestValidationError('actor.kind=human 必带 actor.id (审计, 军杰 §9.7)');
53
55
  const evt = await this.store.append(raw);
56
+ createIssueDraftForEventBestEffort(evt, {
57
+ rootDir: join(dirname(this.store.path), 'issue-reporter'),
58
+ workspaceScope: process.cwd(),
59
+ env: process.env,
60
+ });
54
61
  this.sink?.dispatch(evt);
55
62
  return evt;
56
63
  }
@@ -4,13 +4,16 @@ import type { ExecutionResult } from '../algo/cycleEngine.js';
4
4
  import { type GeneStrategyInfo } from './prompt.js';
5
5
  import type { PersonalityStore } from '../personality/store.js';
6
6
  import { type AgentRunner, type AgentRunnerOptions, type RunnerName } from './runnerRegistry.js';
7
- export { resolveSpawnCommand, spawnCapture, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, } from './runnerRegistry.js';
7
+ export { resolveSpawnCommand, spawnCapture, DEFAULT_MAX_CAPTURE_BYTES, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, classifyGeminiRunnerResult, } from './runnerRegistry.js';
8
8
  export type { AgentRunContext, AgentRunResult, AgentRunner, RunnerName, AgentRunnerOptions, ClaudeRunnerOptions, CodexRunnerOptions, AgentRunnerSpec, } from './runnerRegistry.js';
9
9
  export interface GitRunnerOptions {
10
10
  processSignalMode?: 'cancel' | 'ignore';
11
11
  }
12
12
  /** Run a git subcommand in cwd and return its stdout. */
13
13
  export type GitRunner = (args: readonly string[], cwd: string, signal?: AbortSignal, options?: GitRunnerOptions) => Promise<string>;
14
+ export type GitPatchWriter = (args: readonly string[], cwd: string, destination: string, signal?: AbortSignal,
15
+ /** Call immediately after this writer exclusively creates `destination`. */
16
+ onDestinationOpened?: () => void) => Promise<void>;
14
17
  /** Resolve the selected gene's learned strategy (for prompt enrichment). */
15
18
  export type GeneResolver = (geneId: string) => Promise<GeneStrategyInfo | null> | GeneStrategyInfo | null;
16
19
  /** Decide success from the post-run working tree (e.g. run the gene's validation plan). */
@@ -32,6 +35,12 @@ export interface ExecBridgeOptions {
32
35
  agentOptions?: AgentRunnerOptions;
33
36
  /** Default: spawns `git`. Inject a fake in tests. */
34
37
  git?: GitRunner;
38
+ /** Optional complete-patch sink. The default streams built-in git output directly to disk. */
39
+ gitPatchWriter?: GitPatchWriter;
40
+ /** Test seam for fallback patch persistence; production callers should use the exclusive private writer. */
41
+ writePatchFile?: (path: string, patch: string) => void;
42
+ /** Test seam for temporary patch cleanup; production callers should use the filesystem default. */
43
+ removePatchFile?: (path: string) => void;
35
44
  /** Default: EVOLVE_EXEC_BRIDGE === '1'. Set true to force-enable (e.g. integration tests). */
36
45
  enabled?: boolean;
37
46
  /**
@@ -108,8 +117,9 @@ export declare function scrubAgentEnv(env: NodeJS.ProcessEnv, opts?: {
108
117
  allowKeys?: readonly string[];
109
118
  allowPrefixes?: readonly string[];
110
119
  }): NodeJS.ProcessEnv;
111
- /** Default git runner: spawn `git <args>` in cwd, return stdout (empty string on error). Env scrubbed — git never needs evolver/hub secrets. */
120
+ /** Default git runner: every incomplete command result fails closed. */
112
121
  export declare const defaultGitRunner: GitRunner;
122
+ export declare const defaultGitPatchWriter: GitPatchWriter;
113
123
  /**
114
124
  * Build the `execute` function CycleEngine/runEvolutionCycle consume. Default-off: throws
115
125
  * ExecBridgeDisabledError on first call unless enabled.
@@ -11,19 +11,20 @@
11
11
  // SAFETY: default-OFF. The factory throws ExecBridgeDisabledError unless explicitly enabled (opts.enabled)
12
12
  // or EVOLVE_EXEC_BRIDGE === '1'. Wiring it in by accident must fail loudly rather than silently spawn an
13
13
  // autonomous agent.
14
+ import { randomUUID } from 'node:crypto';
14
15
  import { resolve as resolvePath, sep, join as joinPath } from 'node:path';
15
16
  import { tmpdir } from 'node:os';
16
- import { writeFileSync } from 'node:fs';
17
+ import { closeSync, existsSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs';
17
18
  import { renderExecPrompt } from './prompt.js';
18
19
  import { parseGitShortstat, gitDiffProof } from './proofOfWork.js';
19
20
  // Policy enforcement core (#107): checkPolicy runs the always-on global guards (blast hard cap +
20
21
  // protected paths + destructive deletes) on EVERY exec — gene or not — plus the per-gene constraints when a
21
22
  // gene supplies them. It supersedes the old gene-gated `checkChangeConstraints` call (the no-gene-no-guard hole).
22
23
  import { checkPolicy, summarizeViolations } from './policy/index.js';
23
- import { spawnCapture, getRunnerSpec, DEFAULT_TIMEOUT_MS } from './runnerRegistry.js';
24
+ import { spawnCapture, SpawnCaptureFinalizeError, getRunnerSpec, DEFAULT_TIMEOUT_MS } from './runnerRegistry.js';
24
25
  // Re-export the runner layer so existing importers of ./claudeBridge.js (and the `exec` namespace) keep their
25
26
  // surface after the #91-6 split — the registry simply has a clearer home now.
26
- export { resolveSpawnCommand, spawnCapture, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, } from './runnerRegistry.js';
27
+ export { resolveSpawnCommand, spawnCapture, DEFAULT_MAX_CAPTURE_BYTES, UnboundedSkipPermissionsError, UnsupportedCursorSkipPermissionsError, UnsupportedGeminiPermissionOptionsError, claudeRunnerArgs, makeClaudeHeadlessRunner, claudeHeadlessRunner, codexRunnerArgs, makeCodexHeadlessRunner, cursorRunnerArgs, makeCursorHeadlessRunner, getRunnerSpec, geminiRunnerArgs, makeGeminiHeadlessRunner, classifyGeminiRunnerResult, } from './runnerRegistry.js';
27
28
  export class ExecBridgeDisabledError extends Error {
28
29
  constructor() {
29
30
  super('exec bridge is disabled — set EVOLVE_EXEC_BRIDGE=1 or pass { enabled: true } to enable agent execution');
@@ -130,6 +131,18 @@ class ExecBridgeRunCancelledError extends Error {
130
131
  this.name = 'ExecBridgeRunCancelledError';
131
132
  }
132
133
  }
134
+ class GitProofError extends Error {
135
+ constructor(message) {
136
+ super(message);
137
+ this.name = 'GitProofError';
138
+ }
139
+ }
140
+ class GitOutputTruncatedError extends GitProofError {
141
+ constructor(bytes) {
142
+ super(`git output exceeded the capture limit (${String(bytes ?? 'unknown')} bytes)`);
143
+ this.name = 'GitOutputTruncatedError';
144
+ }
145
+ }
133
146
  function cancelledExecutionResult(run) {
134
147
  return {
135
148
  outcome: { status: 'failed', score: 0.1, reason: 'execution cancelled' },
@@ -139,26 +152,85 @@ function cancelledExecutionResult(run) {
139
152
  ...(run ? { sessionLog: run.error ? `${run.output}\n${run.error}` : run.output } : {}),
140
153
  };
141
154
  }
142
- /** Default git runner: spawn `git <args>` in cwd, return stdout (empty string on error). Env scrubbed — git never needs evolver/hub secrets. */
155
+ function failedProofExecutionResult(run, error, proofOfWork) {
156
+ return {
157
+ outcome: { status: 'failed', score: 0.1, reason: `execution proof failed: ${error.message}` },
158
+ ...(proofOfWork ? { proofOfWork } : {}),
159
+ strongEvidence: false,
160
+ failureKind: run.failureKind ?? 'runtime_error',
161
+ exitCode: run.exitCode ?? null,
162
+ sessionLog: run.error ? `${run.output}\n${run.error}` : run.output,
163
+ };
164
+ }
165
+ /** Default git runner: every incomplete command result fails closed. */
143
166
  export const defaultGitRunner = async (args, cwd, signal, options) => {
167
+ let result;
144
168
  try {
145
- const r = await spawnCapture('git', args, {
169
+ result = await spawnCapture('git', args, {
146
170
  cwd,
147
171
  timeoutMs: 30_000,
148
172
  env: scrubAgentEnv(process.env),
149
173
  ...(signal ? { signal } : {}),
150
174
  ...(options?.processSignalMode ? { processSignalMode: options.processSignalMode } : {}),
151
175
  });
152
- if (r.termination === 'cancelled')
153
- throw new ExecBridgeRunCancelledError();
154
- return r.stdout;
155
176
  }
156
- catch (error) {
157
- if (error instanceof ExecBridgeRunCancelledError)
158
- throw error;
159
- return '';
177
+ catch {
178
+ throw new GitProofError('git command failed to start or capture output');
160
179
  }
180
+ if (result.termination === 'cancelled')
181
+ throw new ExecBridgeRunCancelledError();
182
+ if (result.termination === 'timeout')
183
+ throw new GitProofError('git command timed out');
184
+ if (result.stdoutTruncated)
185
+ throw new GitOutputTruncatedError(result.stdoutBytes);
186
+ if (result.code !== 0)
187
+ throw new GitProofError(`git command exited with code ${result.code}`);
188
+ return result.stdout;
161
189
  };
190
+ /** Stream a complete git patch to disk so large diffs never need to be retained in the Node heap. */
191
+ async function writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened) {
192
+ const result = await spawnCapture('git', args, {
193
+ cwd,
194
+ timeoutMs: 30_000,
195
+ env: scrubAgentEnv(process.env),
196
+ stdoutFile: destination,
197
+ ...(onDestinationOpened ? { onStdoutFileOpened: onDestinationOpened } : {}),
198
+ ...(signal ? { signal } : {}),
199
+ });
200
+ if (result.termination === 'cancelled')
201
+ throw new ExecBridgeRunCancelledError();
202
+ if (result.termination === 'timeout')
203
+ throw new GitProofError('git patch capture timed out');
204
+ if (result.code !== 0)
205
+ throw new GitProofError(`git patch capture exited with code ${result.code}`);
206
+ }
207
+ export const defaultGitPatchWriter = (args, cwd, destination, signal, onDestinationOpened) => (writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened));
208
+ function writePrivatePatchFile(path, patch) {
209
+ let fd;
210
+ let ownsFile = false;
211
+ try {
212
+ fd = openSync(path, 'wx', 0o600);
213
+ ownsFile = true;
214
+ writeFileSync(fd, patch, { encoding: 'utf8' });
215
+ closeSync(fd);
216
+ fd = undefined;
217
+ }
218
+ catch (error) {
219
+ if (fd !== undefined) {
220
+ try {
221
+ closeSync(fd);
222
+ }
223
+ catch { /* best-effort close before removing our artifact */ }
224
+ }
225
+ if (ownsFile) {
226
+ try {
227
+ rmSync(path, { force: true });
228
+ }
229
+ catch { /* preserve the persistence failure */ }
230
+ }
231
+ throw error;
232
+ }
233
+ }
162
234
  /**
163
235
  * Build the `execute` function CycleEngine/runEvolutionCycle consume. Default-off: throws
164
236
  * ExecBridgeDisabledError on first call unless enabled.
@@ -168,6 +240,7 @@ export function makeClaudeExecBridge(opts) {
168
240
  const spec = getRunnerSpec(opts.runner); // #66: claude (default, byte-identical) | codex
169
241
  const agent = opts.agent ?? spec.makeRunner(opts.agentOptions);
170
242
  const git = opts.git ?? defaultGitRunner;
243
+ const gitPatchWriter = opts.gitPatchWriter ?? (git === defaultGitRunner ? defaultGitPatchWriter : undefined);
171
244
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
172
245
  // secure by default; only THIS runner's auth env reaches it (claude never gets OPENAI_, codex never ANTHROPIC_, #66)
173
246
  const agentEnv = opts.scrubEnv === false ? undefined : scrubAgentEnv(process.env, { allowPrefixes: spec.envAllow.prefixes, ...(spec.envAllow.keys ? { allowKeys: spec.envAllow.keys } : {}) });
@@ -207,21 +280,40 @@ export function makeClaudeExecBridge(opts) {
207
280
  });
208
281
  // Isolation: run in a throwaway git worktree so the agent's edits never touch the real working tree.
209
282
  const isolate = opts.isolation === 'worktree';
210
- const workDir = isolate ? joinPath(tmpdir(), `evolver-wt-${mutation.id}`) : opts.cwd;
211
283
  if (opts.signal?.aborted)
212
284
  return cancelledExecutionResult(undefined);
285
+ // Reserve a parent namespace atomically before handing its child path to Git. The parent stays owned until
286
+ // cleanup completes, so another run cannot reuse the worktree path between Git removal and disk cleanup.
287
+ const worktreeReservation = isolate ? mkdtempSync(joinPath(tmpdir(), 'evolver-wt-')) : undefined;
288
+ const workDir = worktreeReservation ? joinPath(worktreeReservation, 'worktree') : opts.cwd;
289
+ let worktreeRegistered = false;
213
290
  let observedRun;
214
- const proofGit = async (args, cwd) => {
215
- if (opts.signal?.aborted)
216
- throw new ExecBridgeRunCancelledError();
217
- const output = await git(args, cwd, opts.signal);
291
+ let patchRef;
292
+ let ownsPatchRef = false;
293
+ let preservePatchRef = false;
294
+ let failedProof;
295
+ const proofGit = async (args, cwd, onResolved) => {
218
296
  if (opts.signal?.aborted)
219
297
  throw new ExecBridgeRunCancelledError();
220
- return output;
298
+ try {
299
+ const output = await git(args, cwd, opts.signal);
300
+ onResolved?.();
301
+ if (opts.signal?.aborted)
302
+ throw new ExecBridgeRunCancelledError();
303
+ return output;
304
+ }
305
+ catch (error) {
306
+ if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
307
+ throw error;
308
+ if (error instanceof GitProofError)
309
+ throw error;
310
+ throw new GitProofError('git state proof failed');
311
+ }
221
312
  };
222
313
  try {
223
- if (isolate)
224
- await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd);
314
+ if (isolate) {
315
+ await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd, () => { worktreeRegistered = true; });
316
+ }
225
317
  const run = await agent(prompt, {
226
318
  cwd: workDir,
227
319
  timeoutMs,
@@ -245,17 +337,69 @@ export function makeClaudeExecBridge(opts) {
245
337
  let changedFiles;
246
338
  let numstat;
247
339
  let patch = '';
340
+ const resetIntentToAdd = async () => {
341
+ if (untrackedFiles.length > 0) {
342
+ await git(['reset', '--quiet', '--', ...untrackedFiles], workDir);
343
+ }
344
+ };
248
345
  try {
249
346
  stat = parseGitShortstat(await proofGit(['diff', '--shortstat', 'HEAD'], workDir));
250
347
  changedFiles = (await proofGit(['diff', '--name-only', 'HEAD'], workDir)).split('\n').map((s) => s.trim()).filter(Boolean);
251
348
  numstat = await proofGit(['diff', '--numstat', 'HEAD'], workDir);
252
- if (isolate && stat.files > 0)
253
- patch = await proofGit(['diff', '--binary', '--full-index', 'HEAD'], workDir);
349
+ if (isolate && stat.files > 0) {
350
+ if (gitPatchWriter) {
351
+ patchRef = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
352
+ try {
353
+ await gitPatchWriter(['diff', '--binary', '--full-index', 'HEAD'], workDir, patchRef, opts.signal, () => { ownsPatchRef = true; });
354
+ // A successful writer owns its result even when an older injected implementation ignores the
355
+ // optional callback. On rejection, only the callback can prove that a partial file is ours.
356
+ ownsPatchRef = true;
357
+ }
358
+ catch (error) {
359
+ if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
360
+ throw error;
361
+ if (error instanceof SpawnCaptureFinalizeError) {
362
+ if (error.result.termination === 'cancelled')
363
+ throw new ExecBridgeRunCancelledError();
364
+ if (error.result.termination === 'timeout')
365
+ throw new GitProofError('git patch capture timed out');
366
+ }
367
+ if (error instanceof GitProofError)
368
+ throw error;
369
+ throw new GitProofError('git patch capture failed');
370
+ }
371
+ }
372
+ else {
373
+ patch = await proofGit(['diff', '--binary', '--full-index', 'HEAD'], workDir);
374
+ }
375
+ }
254
376
  }
255
- finally {
256
- if (untrackedFiles.length > 0)
257
- await git(['reset', '--quiet', '--', ...untrackedFiles], workDir);
377
+ catch (error) {
378
+ try {
379
+ await resetIntentToAdd();
380
+ }
381
+ catch (cleanupError) {
382
+ if (cleanupError instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
383
+ throw new ExecBridgeRunCancelledError();
384
+ }
385
+ // Preserve the primary proof failure for non-cancellation cleanup errors.
386
+ }
387
+ throw error;
388
+ }
389
+ try {
390
+ await resetIntentToAdd();
391
+ }
392
+ catch (error) {
393
+ if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
394
+ throw error;
395
+ if (patchRef && ownsPatchRef) {
396
+ failedProof = gitDiffProof(stat, patchRef);
397
+ preservePatchRef = true;
398
+ }
399
+ throw new GitProofError('git index cleanup failed');
258
400
  }
401
+ if (opts.signal?.aborted)
402
+ throw new ExecBridgeRunCancelledError();
259
403
  // ENFORCE policy against the ACTUAL diff (finding: prompt.ts only ADVISES the agent "touch at most N
260
404
  // file(s) / never modify X"; this is the hard gate). checkPolicy ALWAYS runs the global guards — the
261
405
  // system blast hard cap (EVOLVER_HARD_CAP_FILES/LINES), the critical-protected paths (.env, MEMORY.md,
@@ -263,11 +407,19 @@ export function makeClaudeExecBridge(opts) {
263
407
  // constraints run is no longer un-guarded. The gene's max_files/max_lines/forbidden_paths layer on top.
264
408
  // Any violation fails the cycle no matter what the agent did — even when validation would pass.
265
409
  const violations = checkPolicy({ stat, changedFiles, numstat, ...(gene?.constraints ? { constraints: gene.constraints } : {}) });
266
- let patchRef;
267
- if (isolate && stat.files > 0) {
410
+ if (isolate && stat.files > 0 && !patchRef) {
268
411
  // preserve the isolated edits as a patch (the worktree itself is removed); the real repo is untouched
269
- patchRef = joinPath(tmpdir(), `evolver-patch-${mutation.id}.diff`);
270
- writeFileSync(patchRef, patch);
412
+ const destination = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
413
+ try {
414
+ (opts.writePatchFile ?? writePrivatePatchFile)(destination, patch);
415
+ patchRef = destination;
416
+ ownsPatchRef = true;
417
+ }
418
+ catch (error) {
419
+ if (error instanceof GitProofError)
420
+ throw error;
421
+ throw new GitProofError('git patch persistence failed');
422
+ }
271
423
  }
272
424
  const proof = gitDiffProof(stat, patchRef);
273
425
  // Success: prefer the authoritative validation hook; otherwise "agent succeeded AND produced a diff".
@@ -290,6 +442,7 @@ export function makeClaudeExecBridge(opts) {
290
442
  score = Math.min(score, 0.1);
291
443
  reason = summarizeViolations(violations);
292
444
  }
445
+ preservePatchRef = patchRef !== undefined && ownsPatchRef;
293
446
  return {
294
447
  outcome: { status: passed ? 'success' : 'failed', score, ...(reason ? { reason } : {}) },
295
448
  proofOfWork: proof,
@@ -306,14 +459,32 @@ export function makeClaudeExecBridge(opts) {
306
459
  if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
307
460
  return cancelledExecutionResult(observedRun);
308
461
  }
462
+ if (error instanceof GitProofError && observedRun) {
463
+ return failedProofExecutionResult(observedRun, error, failedProof);
464
+ }
309
465
  throw error;
310
466
  }
311
467
  finally {
468
+ if (!preservePatchRef && ownsPatchRef && patchRef) {
469
+ try {
470
+ (opts.removePatchFile ?? ((path) => rmSync(path, { force: true })))(patchRef);
471
+ }
472
+ catch { /* best-effort cleanup must not replace the execution result or its primary failure */ }
473
+ }
312
474
  if (isolate) {
475
+ let worktreeRemoved = false;
313
476
  try {
314
477
  await git(['worktree', 'remove', '--force', workDir], opts.cwd, undefined, { processSignalMode: 'ignore' });
478
+ worktreeRemoved = true;
315
479
  }
316
480
  catch { /* best-effort cleanup */ }
481
+ const addFailedBeforeCreatingWorktree = !worktreeRegistered && !existsSync(workDir);
482
+ if (worktreeReservation && (worktreeRemoved || addFailedBeforeCreatingWorktree)) {
483
+ try {
484
+ rmSync(worktreeReservation, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
485
+ }
486
+ catch { /* best-effort cleanup of the owned reservation */ }
487
+ }
317
488
  }
318
489
  }
319
490
  };
@@ -44,9 +44,15 @@ export declare function findSignalHints(signals: readonly string[], prs: readonl
44
44
  threshold?: number;
45
45
  topN?: number;
46
46
  }): SignalHint[];
47
+ export declare function parseGhOpenPrListResult(result: {
48
+ code: number | null;
49
+ stdout: string;
50
+ stdoutTruncated?: boolean;
51
+ termination?: 'exit' | 'timeout' | 'cancelled';
52
+ }): OpenPr[];
47
53
  /**
48
- * Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`. Graceful —
49
- * returns [] on any failure (gh missing, unauthenticated, timeout, bad JSON) so dedup just turns off. gh is a
54
+ * Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`.
55
+ * Legacy fetch/parse failures return []; proven incomplete bounded capture rejects so dedup fails closed. gh is a
50
56
  * trusted infra tool, so its own auth (GH_TOKEN/GITHUB_TOKEN, or the gh config under $HOME) is passed through.
51
57
  */
52
58
  export declare function makeGhPrLister(): OpenPrLister;
@@ -77,34 +77,44 @@ export function findSignalHints(signals, prs, opts = {}) {
77
77
  }
78
78
  // ── gh lister seam + TTL cache ─────────────────────────────────────────────
79
79
  const GH_TIMEOUT_MS = 5000;
80
+ export function parseGhOpenPrListResult(result) {
81
+ if (result.termination !== undefined && result.termination !== 'exit') {
82
+ throw new Error(`gh open PR list did not complete (${result.termination})`);
83
+ }
84
+ if (result.stdoutTruncated)
85
+ throw new Error('gh open PR list exceeded the capture limit');
86
+ if (result.code !== 0)
87
+ return [];
88
+ try {
89
+ const arr = JSON.parse(result.stdout || '[]');
90
+ if (!Array.isArray(arr))
91
+ return [];
92
+ return arr.map((pr) => ({
93
+ number: Number(pr.number),
94
+ title: String(pr.title ?? ''),
95
+ headRefName: String(pr.headRefName ?? ''),
96
+ files: Array.isArray(pr.files) ? pr.files.map((f) => String(f.path ?? '')).filter(Boolean) : [],
97
+ }));
98
+ }
99
+ catch {
100
+ return [];
101
+ }
102
+ }
80
103
  /**
81
- * Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`. Graceful —
82
- * returns [] on any failure (gh missing, unauthenticated, timeout, bad JSON) so dedup just turns off. gh is a
104
+ * Default lister: `gh pr list --state=open --json number,title,headRefName,files --limit 50`.
105
+ * Legacy fetch/parse failures return []; proven incomplete bounded capture rejects so dedup fails closed. gh is a
83
106
  * trusted infra tool, so its own auth (GH_TOKEN/GITHUB_TOKEN, or the gh config under $HOME) is passed through.
84
107
  */
85
108
  export function makeGhPrLister() {
86
109
  return async (cwd) => {
87
- try {
88
- const r = await spawnCapture('gh', ['pr', 'list', '--state=open', '--json', 'number,title,headRefName,files', '--limit', '50'], {
89
- cwd: cwd ?? process.cwd(),
90
- timeoutMs: GH_TIMEOUT_MS,
91
- env: scrubAgentEnv(process.env, { allowKeys: ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_HOST', 'GH_CONFIG_DIR'] }),
92
- });
93
- if (r.code !== 0)
94
- return [];
95
- const arr = JSON.parse(r.stdout || '[]');
96
- if (!Array.isArray(arr))
97
- return [];
98
- return arr.map((pr) => ({
99
- number: Number(pr.number),
100
- title: String(pr.title ?? ''),
101
- headRefName: String(pr.headRefName ?? ''),
102
- files: Array.isArray(pr.files) ? pr.files.map((f) => String(f.path ?? '')).filter(Boolean) : [],
103
- }));
104
- }
105
- catch {
110
+ const r = await spawnCapture('gh', ['pr', 'list', '--state=open', '--json', 'number,title,headRefName,files', '--limit', '50'], {
111
+ cwd: cwd ?? process.cwd(),
112
+ timeoutMs: GH_TIMEOUT_MS,
113
+ env: scrubAgentEnv(process.env, { allowKeys: ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_HOST', 'GH_CONFIG_DIR'] }),
114
+ }).catch(() => null);
115
+ if (!r)
106
116
  return [];
107
- }
117
+ return parseGhOpenPrListResult(r);
108
118
  };
109
119
  }
110
120
  /**
@@ -1,4 +1,6 @@
1
1
  export declare const DEFAULT_TIMEOUT_MS = 600000;
2
+ /** Per-stream stdout/stderr capture ceiling. A child can emit indefinitely without growing the parent heap. */
3
+ export declare const DEFAULT_MAX_CAPTURE_BYTES = 1048576;
2
4
  export interface AgentRunContext {
3
5
  cwd: string;
4
6
  timeoutMs?: number;
@@ -73,6 +75,17 @@ export interface SpawnCaptureOptions {
73
75
  signal?: AbortSignal;
74
76
  /** Cleanup subprocesses can shield themselves from repeated SIGINT/SIGTERM instead of cancelling. */
75
77
  processSignalMode?: 'cancel' | 'ignore';
78
+ /** Maximum retained bytes for each of stdout and stderr. The original byte count is still reported. */
79
+ maxOutputBytes?: number;
80
+ /** Stream stdout directly to a file when the complete artifact must outlive the subprocess. */
81
+ stdoutFile?: string;
82
+ /** Ownership hook fired only after an exclusive redirected stdout artifact is opened successfully. */
83
+ onStdoutFileOpened?: (path: string) => void;
84
+ /** Test seam for redirected stdout finalization; production callers should use the filesystem defaults. */
85
+ stdoutFileOps?: {
86
+ size(fd: number): number;
87
+ close(fd: number): void;
88
+ };
76
89
  resolvePlatform?: NodeJS.Platform;
77
90
  /** Test seam for Windows process behavior; production callers should use the default. */
78
91
  processPlatform?: NodeJS.Platform;
@@ -84,6 +97,17 @@ export interface SpawnCaptureResult {
84
97
  stdout: string;
85
98
  stderr: string;
86
99
  termination: 'exit' | 'timeout' | 'cancelled';
100
+ /** Present on real spawn results; optional so injected legacy test seams remain source-compatible. */
101
+ stdoutBytes?: number;
102
+ stderrBytes?: number;
103
+ stdoutTruncated?: boolean;
104
+ stderrTruncated?: boolean;
105
+ stdoutRedirected?: boolean;
106
+ }
107
+ /** A redirected stdout artifact could not be finalized; the subprocess outcome remains available for classification. */
108
+ export declare class SpawnCaptureFinalizeError extends Error {
109
+ readonly result: SpawnCaptureResult;
110
+ constructor(result: SpawnCaptureResult, cause?: unknown);
87
111
  }
88
112
  /**
89
113
  * Promise wrapper over spawn (shell:false). Optionally writes `input` to stdin; resolves with stdout/exit.
@@ -136,6 +160,8 @@ export declare const claudeHeadlessRunner: AgentRunner;
136
160
  export declare function codexRunnerArgs(opts?: AgentRunnerOptions): string[];
137
161
  /** Headless `codex exec` runner. Working root pinned with `--cd`; prompt is the trailing positional arg (shell:false). */
138
162
  export declare function makeCodexHeadlessRunner(opts?: AgentRunnerOptions): AgentRunner;
163
+ /** Interpret one bounded Gemini subprocess result. Structured output and diagnostics require complete capture. */
164
+ export declare function classifyGeminiRunnerResult(result: SpawnCaptureResult, timeoutMs: number): AgentRunResult;
139
165
  /** Build verified Gemini CLI argv. The prompt is appended separately as one argv element with shell:false. */
140
166
  export declare function geminiRunnerArgs(opts?: AgentRunnerOptions): string[];
141
167
  /** Headless Gemini runner with structured failure classification; stdout text alone never proves execution success. */