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

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,7 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { redactString } from '../hub/sanitize.js';
3
- const REUSABLE_RE = /\b(reusable|repeatable|workflow|playbook|runbook|capability|procedure|pattern|recipe|documented|future runs?|next time|can reuse|reuse this)\b|可复用|复用|能力|流程|工作流/i;
4
- const PROOF_RE = /\b(validated|verified|passed|green|success(?:ful|fully)?|succeeded|works?|completed|published|uploaded|recorded:true|exit code:?\s*0|all tests passed)\b|验证|通过|成功|已发布|可用/i;
3
+ // The Chinese branches require compound/intentful phrases on purpose: everyday words (能力/流程/成功/通过)
4
+ // occur in nearly every Chinese dev session, and matching them bare made this fallback the DOMINANT distill
5
+ // path — 105 of 118 drafts in one bulk ingest (#562). English gets specificity from \b word boundaries;
6
+ // Chinese has no \b, so specificity must come from the phrase itself.
7
+ const REUSABLE_RE = /\b(reusable|repeatable|workflow|playbook|runbook|capability|procedure|pattern|recipe|documented|future runs?|next time|can reuse|reuse this)\b|复用|可重用|工作流|方法论|沉淀/i;
8
+ const PROOF_RE = /\b(validated|verified|passed|green|success(?:ful|fully)?|succeeded|works?|completed|published|uploaded|recorded:true|exit code:?\s*0|all tests passed)\b|(?:验证|校验|测试|检查|构建|编译|运行|执行|部署|发布)(?:都|均|全部)?(?:通过|成功)|全部通过|跑通|已(?:验证|发布|上线)/i;
5
9
  const FAILURE_RE = /\b(failed|failure|error|exception|traceback|exit code:?\s*[1-9]|not working|unable to)\b|失败|错误|报错/i;
6
10
  const EXIT_ZERO_RE = /\bexit code:?\s*0\b/i;
7
11
  const EXIT_NON_ZERO_RE = /\bexit code:?\s*[1-9]\d*\b/i;
@@ -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
  }
@@ -1,22 +1,22 @@
1
1
  /** ~/.evomap, 可 EVOLVER_HOME/EVOMAP_HOME 覆盖. */
2
2
  export declare function evomapHome(env?: Readonly<Record<string, string | undefined>>): string;
3
- export declare function rootEventsPath(): string;
4
- export declare function mvDir(): string;
3
+ export declare function rootEventsPath(env?: Readonly<Record<string, string | undefined>>): string;
4
+ export declare function mvDir(env?: Readonly<Record<string, string | undefined>>): string;
5
5
  /** 可进化人格模型持久化文件 (五维向量 + 各键统计 + 变更历史). v1 personality_state.json 的 v2 落点. */
6
- export declare function personalityStatePath(): string;
7
- export declare function assetsDir(): string;
6
+ export declare function personalityStatePath(env?: Readonly<Record<string, string | undefined>>): string;
7
+ export declare function assetsDir(env?: Readonly<Record<string, string | undefined>>): string;
8
8
  /** M1 raw-material substrate (append-only jsonl consumed by the cycle). */
9
- export declare function materialDir(): string;
9
+ export declare function materialDir(env?: Readonly<Record<string, string | undefined>>): string;
10
10
  /** MaterialStore backing file. */
11
- export declare function materialStorePath(): string;
11
+ export declare function materialStorePath(env?: Readonly<Record<string, string | undefined>>): string;
12
12
  /** Per-source watermark cursor used to make re-ingest idempotent (file-level dedup). */
13
- export declare function materialWatermarkPath(): string;
13
+ export declare function materialWatermarkPath(env?: Readonly<Record<string, string | undefined>>): string;
14
14
  /** Proxy LLM-trace day-files dir (`llm-trace-YYYYMMDD.jsonl`), the route-savings source for the value ledger.
15
15
  * Mirrors the proxy's EVOLVER_LLM_TRACE_DIR default so the outreach layer reads what the proxy wrote. */
16
- export declare function tracesDir(): string;
16
+ export declare function tracesDir(env?: Readonly<Record<string, string | undefined>>): string;
17
17
  /** Hub-asset audit trail (`asset_call_log.jsonl`): one JSONL line per hub-asset interaction (search hit/miss,
18
18
  * reuse/reference, publish, review). Written best-effort by AssetCallLog; the desktop's RecallHistory / 召回历程
19
19
  * reads it. Defaults to the evomap home root, beside assets/ + evolution/.
20
20
  * NOTE(reuse/#234): confirm this matches where the proxy/RecallHistory reads — best-effort append, so a path
21
21
  * mismatch degrades the audit line, never the reuse write itself. */
22
- export declare function assetCallLogPath(): string;
22
+ export declare function assetCallLogPath(env?: Readonly<Record<string, string | undefined>>): string;
@@ -4,41 +4,41 @@ import { join } from 'node:path';
4
4
  export function evomapHome(env = process.env) {
5
5
  return env['EVOLVER_HOME'] ?? env['EVOMAP_HOME'] ?? join(homedir(), '.evomap');
6
6
  }
7
- export function rootEventsPath() {
8
- return join(evomapHome(), 'evolution', 'root_events.jsonl');
7
+ export function rootEventsPath(env = process.env) {
8
+ return join(evomapHome(env), 'evolution', 'root_events.jsonl');
9
9
  }
10
- export function mvDir() {
11
- return join(evomapHome(), 'evolution', 'mv');
10
+ export function mvDir(env = process.env) {
11
+ return join(evomapHome(env), 'evolution', 'mv');
12
12
  }
13
13
  /** 可进化人格模型持久化文件 (五维向量 + 各键统计 + 变更历史). v1 personality_state.json 的 v2 落点. */
14
- export function personalityStatePath() {
15
- return join(evomapHome(), 'evolution', 'personality_state.json');
14
+ export function personalityStatePath(env = process.env) {
15
+ return join(evomapHome(env), 'evolution', 'personality_state.json');
16
16
  }
17
- export function assetsDir() {
18
- return join(evomapHome(), 'assets');
17
+ export function assetsDir(env = process.env) {
18
+ return join(evomapHome(env), 'assets');
19
19
  }
20
20
  /** M1 raw-material substrate (append-only jsonl consumed by the cycle). */
21
- export function materialDir() {
22
- return join(evomapHome(), 'evolution', 'material');
21
+ export function materialDir(env = process.env) {
22
+ return join(evomapHome(env), 'evolution', 'material');
23
23
  }
24
24
  /** MaterialStore backing file. */
25
- export function materialStorePath() {
26
- return join(materialDir(), 'material.jsonl');
25
+ export function materialStorePath(env = process.env) {
26
+ return join(materialDir(env), 'material.jsonl');
27
27
  }
28
28
  /** Per-source watermark cursor used to make re-ingest idempotent (file-level dedup). */
29
- export function materialWatermarkPath() {
30
- return join(materialDir(), 'watermark.json');
29
+ export function materialWatermarkPath(env = process.env) {
30
+ return join(materialDir(env), 'watermark.json');
31
31
  }
32
32
  /** Proxy LLM-trace day-files dir (`llm-trace-YYYYMMDD.jsonl`), the route-savings source for the value ledger.
33
33
  * Mirrors the proxy's EVOLVER_LLM_TRACE_DIR default so the outreach layer reads what the proxy wrote. */
34
- export function tracesDir() {
35
- return process.env['EVOLVER_LLM_TRACE_DIR'] ?? join(evomapHome(), 'proxy', 'traces');
34
+ export function tracesDir(env = process.env) {
35
+ return env['EVOLVER_LLM_TRACE_DIR'] ?? join(evomapHome(env), 'proxy', 'traces');
36
36
  }
37
37
  /** Hub-asset audit trail (`asset_call_log.jsonl`): one JSONL line per hub-asset interaction (search hit/miss,
38
38
  * reuse/reference, publish, review). Written best-effort by AssetCallLog; the desktop's RecallHistory / 召回历程
39
39
  * reads it. Defaults to the evomap home root, beside assets/ + evolution/.
40
40
  * NOTE(reuse/#234): confirm this matches where the proxy/RecallHistory reads — best-effort append, so a path
41
41
  * mismatch degrades the audit line, never the reuse write itself. */
42
- export function assetCallLogPath() {
43
- return join(evomapHome(), 'asset_call_log.jsonl');
42
+ export function assetCallLogPath(env = process.env) {
43
+ return join(evomapHome(env), 'asset_call_log.jsonl');
44
44
  }
@@ -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
  /**
@@ -99,6 +108,9 @@ export declare class ExecBridgeForbiddenError extends Error {
99
108
  export declare class UnsandboxedFullAccessRequiresIsolationError extends Error {
100
109
  constructor();
101
110
  }
111
+ export declare class UnsafeWorktreePathError extends Error {
112
+ constructor(reason: string);
113
+ }
102
114
  /**
103
115
  * Whitelist-filter `env` for a spawned agent/tool: keep ONLY the minimal runtime env + the caller-declared
104
116
  * extras (the runner's own auth via allowPrefixes/allowKeys); drop everything else. Fail-safe by construction —
@@ -108,8 +120,9 @@ export declare function scrubAgentEnv(env: NodeJS.ProcessEnv, opts?: {
108
120
  allowKeys?: readonly string[];
109
121
  allowPrefixes?: readonly string[];
110
122
  }): 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. */
123
+ /** Default git runner: every incomplete command result fails closed. */
112
124
  export declare const defaultGitRunner: GitRunner;
125
+ export declare const defaultGitPatchWriter: GitPatchWriter;
113
126
  /**
114
127
  * Build the `execute` function CycleEngine/runEvolutionCycle consume. Default-off: throws
115
128
  * 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, lstatSync, mkdtempSync, openSync, realpathSync, rmdirSync, 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');
@@ -54,6 +55,112 @@ export class UnsandboxedFullAccessRequiresIsolationError extends Error {
54
55
  this.name = 'UnsandboxedFullAccessRequiresIsolationError';
55
56
  }
56
57
  }
58
+ export class UnsafeWorktreePathError extends Error {
59
+ constructor(reason) {
60
+ super(`worktree isolation refused: ${reason}`);
61
+ this.name = 'UnsafeWorktreePathError';
62
+ }
63
+ }
64
+ function errorCode(error) {
65
+ return typeof error === 'object' && error !== null && 'code' in error
66
+ ? String(error.code)
67
+ : undefined;
68
+ }
69
+ function assertPathAbsent(path) {
70
+ try {
71
+ lstatSync(path);
72
+ }
73
+ catch (error) {
74
+ if (errorCode(error) === 'ENOENT')
75
+ return;
76
+ throw error;
77
+ }
78
+ throw new UnsafeWorktreePathError('reserved destination already exists');
79
+ }
80
+ function reserveWorktreePath() {
81
+ // The random 0700 container is atomically created by the OS. The git destination remains absent inside it,
82
+ // so `git worktree add` cannot adopt an attacker-precreated path from the shared temp directory.
83
+ const container = mkdtempSync(joinPath(tmpdir(), 'evolver-wt-'));
84
+ const containerStat = lstatSync(container);
85
+ if (containerStat.isSymbolicLink() || !containerStat.isDirectory()) {
86
+ throw new UnsafeWorktreePathError('temporary reservation is not a real directory');
87
+ }
88
+ const workDir = joinPath(container, 'worktree');
89
+ assertPathAbsent(workDir);
90
+ return {
91
+ container,
92
+ containerDev: containerStat.dev,
93
+ containerIno: containerStat.ino,
94
+ workDir,
95
+ expectedRealPath: joinPath(realpathSync(container), 'worktree'),
96
+ };
97
+ }
98
+ function verifyWorktreePath(reservation) {
99
+ const stat = lstatSync(reservation.workDir);
100
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
101
+ throw new UnsafeWorktreePathError('git worktree destination is not a real directory');
102
+ }
103
+ if (realpathSync(reservation.workDir) !== reservation.expectedRealPath) {
104
+ throw new UnsafeWorktreePathError('git worktree destination resolves outside its reservation');
105
+ }
106
+ return { dev: stat.dev, ino: stat.ino };
107
+ }
108
+ function worktreePathStillOwned(reservation, identity) {
109
+ try {
110
+ const stat = lstatSync(reservation.workDir);
111
+ return !stat.isSymbolicLink()
112
+ && stat.isDirectory()
113
+ && stat.dev === identity.dev
114
+ && stat.ino === identity.ino
115
+ && realpathSync(reservation.workDir) === reservation.expectedRealPath;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ function removeEmptyReservation(reservation) {
122
+ try {
123
+ const stat = lstatSync(reservation.container);
124
+ if (stat.isSymbolicLink() || !stat.isDirectory()
125
+ || stat.dev !== reservation.containerDev || stat.ino !== reservation.containerIno)
126
+ return;
127
+ rmdirSync(reservation.container);
128
+ }
129
+ catch (error) {
130
+ // Never recurse through a path that may have been replaced. Empty, owned reservations are the only thing
131
+ // removed directly; non-empty or already-gone containers are intentionally left for safe diagnosis.
132
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(errorCode(error) ?? ''))
133
+ throw error;
134
+ }
135
+ }
136
+ async function cleanupWorktreeReservation(reservation, identity, git, repoCwd) {
137
+ let cleanupIdentity = identity;
138
+ if (!cleanupIdentity) {
139
+ try {
140
+ cleanupIdentity = verifyWorktreePath(reservation);
141
+ }
142
+ catch (error) {
143
+ if (errorCode(error) !== 'ENOENT')
144
+ throw error;
145
+ // `git worktree add` may register metadata before failing without creating the destination. Give Git a
146
+ // signal-shielded chance to prune that partial registration, then reclaim only the still-empty container.
147
+ try {
148
+ await git(['worktree', 'remove', '--force', reservation.workDir], repoCwd, undefined, { processSignalMode: 'ignore' });
149
+ }
150
+ catch { /* no child exists, so an unregistered cleanup failure is harmless */ }
151
+ removeEmptyReservation(reservation);
152
+ return;
153
+ }
154
+ }
155
+ if (cleanupIdentity) {
156
+ if (!worktreePathStillOwned(reservation, cleanupIdentity)) {
157
+ throw new UnsafeWorktreePathError('verified worktree path changed before cleanup');
158
+ }
159
+ await git(['worktree', 'remove', '--force', reservation.workDir], repoCwd, undefined, { processSignalMode: 'ignore' });
160
+ assertPathAbsent(reservation.workDir);
161
+ }
162
+ removeEmptyReservation(reservation);
163
+ }
57
164
  /** Whether `child` is the same as, or nested under, `root` (both resolved to absolute paths). */
58
165
  function isWithinRoot(child, root) {
59
166
  const c = resolvePath(child);
@@ -130,6 +237,18 @@ class ExecBridgeRunCancelledError extends Error {
130
237
  this.name = 'ExecBridgeRunCancelledError';
131
238
  }
132
239
  }
240
+ class GitProofError extends Error {
241
+ constructor(message) {
242
+ super(message);
243
+ this.name = 'GitProofError';
244
+ }
245
+ }
246
+ class GitOutputTruncatedError extends GitProofError {
247
+ constructor(bytes) {
248
+ super(`git output exceeded the capture limit (${String(bytes ?? 'unknown')} bytes)`);
249
+ this.name = 'GitOutputTruncatedError';
250
+ }
251
+ }
133
252
  function cancelledExecutionResult(run) {
134
253
  return {
135
254
  outcome: { status: 'failed', score: 0.1, reason: 'execution cancelled' },
@@ -139,26 +258,85 @@ function cancelledExecutionResult(run) {
139
258
  ...(run ? { sessionLog: run.error ? `${run.output}\n${run.error}` : run.output } : {}),
140
259
  };
141
260
  }
142
- /** Default git runner: spawn `git <args>` in cwd, return stdout (empty string on error). Env scrubbed — git never needs evolver/hub secrets. */
261
+ function failedProofExecutionResult(run, error, proofOfWork) {
262
+ return {
263
+ outcome: { status: 'failed', score: 0.1, reason: `execution proof failed: ${error.message}` },
264
+ ...(proofOfWork ? { proofOfWork } : {}),
265
+ strongEvidence: false,
266
+ failureKind: run.failureKind ?? 'runtime_error',
267
+ exitCode: run.exitCode ?? null,
268
+ sessionLog: run.error ? `${run.output}\n${run.error}` : run.output,
269
+ };
270
+ }
271
+ /** Default git runner: every incomplete command result fails closed. */
143
272
  export const defaultGitRunner = async (args, cwd, signal, options) => {
273
+ let result;
144
274
  try {
145
- const r = await spawnCapture('git', args, {
275
+ result = await spawnCapture('git', args, {
146
276
  cwd,
147
277
  timeoutMs: 30_000,
148
278
  env: scrubAgentEnv(process.env),
149
279
  ...(signal ? { signal } : {}),
150
280
  ...(options?.processSignalMode ? { processSignalMode: options.processSignalMode } : {}),
151
281
  });
152
- if (r.termination === 'cancelled')
153
- throw new ExecBridgeRunCancelledError();
154
- return r.stdout;
155
282
  }
156
- catch (error) {
157
- if (error instanceof ExecBridgeRunCancelledError)
158
- throw error;
159
- return '';
283
+ catch {
284
+ throw new GitProofError('git command failed to start or capture output');
160
285
  }
286
+ if (result.termination === 'cancelled')
287
+ throw new ExecBridgeRunCancelledError();
288
+ if (result.termination === 'timeout')
289
+ throw new GitProofError('git command timed out');
290
+ if (result.stdoutTruncated)
291
+ throw new GitOutputTruncatedError(result.stdoutBytes);
292
+ if (result.code !== 0)
293
+ throw new GitProofError(`git command exited with code ${result.code}`);
294
+ return result.stdout;
161
295
  };
296
+ /** Stream a complete git patch to disk so large diffs never need to be retained in the Node heap. */
297
+ async function writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened) {
298
+ const result = await spawnCapture('git', args, {
299
+ cwd,
300
+ timeoutMs: 30_000,
301
+ env: scrubAgentEnv(process.env),
302
+ stdoutFile: destination,
303
+ ...(onDestinationOpened ? { onStdoutFileOpened: onDestinationOpened } : {}),
304
+ ...(signal ? { signal } : {}),
305
+ });
306
+ if (result.termination === 'cancelled')
307
+ throw new ExecBridgeRunCancelledError();
308
+ if (result.termination === 'timeout')
309
+ throw new GitProofError('git patch capture timed out');
310
+ if (result.code !== 0)
311
+ throw new GitProofError(`git patch capture exited with code ${result.code}`);
312
+ }
313
+ export const defaultGitPatchWriter = (args, cwd, destination, signal, onDestinationOpened) => (writeDefaultGitPatch(args, cwd, destination, signal, onDestinationOpened));
314
+ function writePrivatePatchFile(path, patch) {
315
+ let fd;
316
+ let ownsFile = false;
317
+ try {
318
+ fd = openSync(path, 'wx', 0o600);
319
+ ownsFile = true;
320
+ writeFileSync(fd, patch, { encoding: 'utf8' });
321
+ closeSync(fd);
322
+ fd = undefined;
323
+ }
324
+ catch (error) {
325
+ if (fd !== undefined) {
326
+ try {
327
+ closeSync(fd);
328
+ }
329
+ catch { /* best-effort close before removing our artifact */ }
330
+ }
331
+ if (ownsFile) {
332
+ try {
333
+ rmSync(path, { force: true });
334
+ }
335
+ catch { /* preserve the persistence failure */ }
336
+ }
337
+ throw error;
338
+ }
339
+ }
162
340
  /**
163
341
  * Build the `execute` function CycleEngine/runEvolutionCycle consume. Default-off: throws
164
342
  * ExecBridgeDisabledError on first call unless enabled.
@@ -168,6 +346,7 @@ export function makeClaudeExecBridge(opts) {
168
346
  const spec = getRunnerSpec(opts.runner); // #66: claude (default, byte-identical) | codex
169
347
  const agent = opts.agent ?? spec.makeRunner(opts.agentOptions);
170
348
  const git = opts.git ?? defaultGitRunner;
349
+ const gitPatchWriter = opts.gitPatchWriter ?? (git === defaultGitRunner ? defaultGitPatchWriter : undefined);
171
350
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
172
351
  // secure by default; only THIS runner's auth env reaches it (claude never gets OPENAI_, codex never ANTHROPIC_, #66)
173
352
  const agentEnv = opts.scrubEnv === false ? undefined : scrubAgentEnv(process.env, { allowPrefixes: spec.envAllow.prefixes, ...(spec.envAllow.keys ? { allowKeys: spec.envAllow.keys } : {}) });
@@ -205,23 +384,44 @@ export function makeClaudeExecBridge(opts) {
205
384
  // use-case ①: inject the personality style block from the state applySelectForRun just persisted.
206
385
  ...(opts.personality ? { personality: opts.personality.currentState() } : {}),
207
386
  });
208
- // Isolation: run in a throwaway git worktree so the agent's edits never touch the real working tree.
387
+ // Isolation: atomically reserve an unpredictable private temp container, then let git create the absent
388
+ // worktree path inside it. Cleanup is armed only after git succeeds and the resulting directory is verified.
209
389
  const isolate = opts.isolation === 'worktree';
210
- const workDir = isolate ? joinPath(tmpdir(), `evolver-wt-${mutation.id}`) : opts.cwd;
211
390
  if (opts.signal?.aborted)
212
391
  return cancelledExecutionResult(undefined);
392
+ const reservation = isolate ? reserveWorktreePath() : undefined;
393
+ const workDir = reservation?.workDir ?? opts.cwd;
394
+ let worktreeIdentity;
395
+ let result;
213
396
  let observedRun;
214
- const proofGit = async (args, cwd) => {
397
+ let patchRef;
398
+ let ownsPatchRef = false;
399
+ let preservePatchRef = false;
400
+ let failedProof;
401
+ const proofGit = async (args, cwd, checkCancellationAfter = true) => {
215
402
  if (opts.signal?.aborted)
216
403
  throw new ExecBridgeRunCancelledError();
217
- const output = await git(args, cwd, opts.signal);
218
- if (opts.signal?.aborted)
219
- throw new ExecBridgeRunCancelledError();
220
- return output;
404
+ try {
405
+ const output = await git(args, cwd, opts.signal);
406
+ if (checkCancellationAfter && opts.signal?.aborted)
407
+ throw new ExecBridgeRunCancelledError();
408
+ return output;
409
+ }
410
+ catch (error) {
411
+ if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
412
+ throw error;
413
+ if (error instanceof GitProofError)
414
+ throw error;
415
+ throw new GitProofError('git state proof failed');
416
+ }
221
417
  };
222
418
  try {
223
- if (isolate)
224
- await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd);
419
+ if (reservation) {
420
+ await proofGit(['worktree', 'add', '--detach', workDir, 'HEAD'], opts.cwd, false);
421
+ worktreeIdentity = verifyWorktreePath(reservation);
422
+ if (opts.signal?.aborted)
423
+ throw new ExecBridgeRunCancelledError();
424
+ }
225
425
  const run = await agent(prompt, {
226
426
  cwd: workDir,
227
427
  timeoutMs,
@@ -245,17 +445,69 @@ export function makeClaudeExecBridge(opts) {
245
445
  let changedFiles;
246
446
  let numstat;
247
447
  let patch = '';
448
+ const resetIntentToAdd = async () => {
449
+ if (untrackedFiles.length > 0) {
450
+ await git(['reset', '--quiet', '--', ...untrackedFiles], workDir);
451
+ }
452
+ };
248
453
  try {
249
454
  stat = parseGitShortstat(await proofGit(['diff', '--shortstat', 'HEAD'], workDir));
250
455
  changedFiles = (await proofGit(['diff', '--name-only', 'HEAD'], workDir)).split('\n').map((s) => s.trim()).filter(Boolean);
251
456
  numstat = await proofGit(['diff', '--numstat', 'HEAD'], workDir);
252
- if (isolate && stat.files > 0)
253
- patch = await proofGit(['diff', '--binary', '--full-index', 'HEAD'], workDir);
457
+ if (isolate && stat.files > 0) {
458
+ if (gitPatchWriter) {
459
+ patchRef = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
460
+ try {
461
+ await gitPatchWriter(['diff', '--binary', '--full-index', 'HEAD'], workDir, patchRef, opts.signal, () => { ownsPatchRef = true; });
462
+ // A successful writer owns its result even when an older injected implementation ignores the
463
+ // optional callback. On rejection, only the callback can prove that a partial file is ours.
464
+ ownsPatchRef = true;
465
+ }
466
+ catch (error) {
467
+ if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
468
+ throw error;
469
+ if (error instanceof SpawnCaptureFinalizeError) {
470
+ if (error.result.termination === 'cancelled')
471
+ throw new ExecBridgeRunCancelledError();
472
+ if (error.result.termination === 'timeout')
473
+ throw new GitProofError('git patch capture timed out');
474
+ }
475
+ if (error instanceof GitProofError)
476
+ throw error;
477
+ throw new GitProofError('git patch capture failed');
478
+ }
479
+ }
480
+ else {
481
+ patch = await proofGit(['diff', '--binary', '--full-index', 'HEAD'], workDir);
482
+ }
483
+ }
254
484
  }
255
- finally {
256
- if (untrackedFiles.length > 0)
257
- await git(['reset', '--quiet', '--', ...untrackedFiles], workDir);
485
+ catch (error) {
486
+ try {
487
+ await resetIntentToAdd();
488
+ }
489
+ catch (cleanupError) {
490
+ if (cleanupError instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
491
+ throw new ExecBridgeRunCancelledError();
492
+ }
493
+ // Preserve the primary proof failure for non-cancellation cleanup errors.
494
+ }
495
+ throw error;
258
496
  }
497
+ try {
498
+ await resetIntentToAdd();
499
+ }
500
+ catch (error) {
501
+ if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted)
502
+ throw error;
503
+ if (patchRef && ownsPatchRef) {
504
+ failedProof = gitDiffProof(stat, patchRef);
505
+ preservePatchRef = true;
506
+ }
507
+ throw new GitProofError('git index cleanup failed');
508
+ }
509
+ if (opts.signal?.aborted)
510
+ throw new ExecBridgeRunCancelledError();
259
511
  // ENFORCE policy against the ACTUAL diff (finding: prompt.ts only ADVISES the agent "touch at most N
260
512
  // file(s) / never modify X"; this is the hard gate). checkPolicy ALWAYS runs the global guards — the
261
513
  // system blast hard cap (EVOLVER_HARD_CAP_FILES/LINES), the critical-protected paths (.env, MEMORY.md,
@@ -263,11 +515,19 @@ export function makeClaudeExecBridge(opts) {
263
515
  // constraints run is no longer un-guarded. The gene's max_files/max_lines/forbidden_paths layer on top.
264
516
  // Any violation fails the cycle no matter what the agent did — even when validation would pass.
265
517
  const violations = checkPolicy({ stat, changedFiles, numstat, ...(gene?.constraints ? { constraints: gene.constraints } : {}) });
266
- let patchRef;
267
- if (isolate && stat.files > 0) {
518
+ if (isolate && stat.files > 0 && !patchRef) {
268
519
  // 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);
520
+ const destination = joinPath(tmpdir(), `evolver-patch-${randomUUID()}.diff`);
521
+ try {
522
+ (opts.writePatchFile ?? writePrivatePatchFile)(destination, patch);
523
+ patchRef = destination;
524
+ ownsPatchRef = true;
525
+ }
526
+ catch (error) {
527
+ if (error instanceof GitProofError)
528
+ throw error;
529
+ throw new GitProofError('git patch persistence failed');
530
+ }
271
531
  }
272
532
  const proof = gitDiffProof(stat, patchRef);
273
533
  // Success: prefer the authoritative validation hook; otherwise "agent succeeded AND produced a diff".
@@ -290,7 +550,8 @@ export function makeClaudeExecBridge(opts) {
290
550
  score = Math.min(score, 0.1);
291
551
  reason = summarizeViolations(violations);
292
552
  }
293
- return {
553
+ preservePatchRef = patchRef !== undefined && ownsPatchRef;
554
+ result = {
294
555
  outcome: { status: passed ? 'success' : 'failed', score, ...(reason ? { reason } : {}) },
295
556
  proofOfWork: proof,
296
557
  strongEvidence: passed && stat.files > 0,
@@ -304,17 +565,42 @@ export function makeClaudeExecBridge(opts) {
304
565
  }
305
566
  catch (error) {
306
567
  if (error instanceof ExecBridgeRunCancelledError || opts.signal?.aborted) {
307
- return cancelledExecutionResult(observedRun);
568
+ result = cancelledExecutionResult(observedRun);
569
+ }
570
+ else if (error instanceof GitProofError && observedRun) {
571
+ result = failedProofExecutionResult(observedRun, error, failedProof);
572
+ }
573
+ else {
574
+ if (reservation) {
575
+ try {
576
+ await cleanupWorktreeReservation(reservation, worktreeIdentity, git, opts.cwd);
577
+ }
578
+ catch {
579
+ // Preserve the primary execution error. The abandoned reservation remains private and diagnosable.
580
+ }
581
+ }
582
+ throw error;
308
583
  }
309
- throw error;
310
584
  }
311
585
  finally {
312
- if (isolate) {
586
+ if (!preservePatchRef && ownsPatchRef && patchRef) {
313
587
  try {
314
- await git(['worktree', 'remove', '--force', workDir], opts.cwd, undefined, { processSignalMode: 'ignore' });
588
+ (opts.removePatchFile ?? ((path) => rmSync(path, { force: true })))(patchRef);
315
589
  }
316
- catch { /* best-effort cleanup */ }
590
+ catch { /* best-effort cleanup must not replace the execution result or its primary failure */ }
591
+ }
592
+ }
593
+ if (reservation) {
594
+ try {
595
+ await cleanupWorktreeReservation(reservation, worktreeIdentity, git, opts.cwd);
596
+ }
597
+ catch (error) {
598
+ // A successful run must not hide abandoned edits/resources. Failed and cancelled results retain their
599
+ // primary classification; the private reservation remains available for diagnosis.
600
+ if (result.outcome.status === 'success')
601
+ throw error;
317
602
  }
318
603
  }
604
+ return result;
319
605
  };
320
606
  }