@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.
- package/dist/algo/conversationSniffer.js +6 -2
- package/dist/events/ingest.js +7 -0
- package/dist/events/paths.d.ts +9 -9
- package/dist/events/paths.js +18 -18
- package/dist/exec/claudeBridge.d.ts +15 -2
- package/dist/exec/claudeBridge.js +322 -36
- package/dist/exec/openPrRegistry.d.ts +8 -2
- package/dist/exec/openPrRegistry.js +32 -22
- package/dist/exec/runnerRegistry.d.ts +26 -0
- package/dist/exec/runnerRegistry.js +305 -47
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/issueReporter/index.d.ts +156 -0
- package/dist/issueReporter/index.js +1688 -0
- package/dist/personality/schema.d.ts +12 -12
- package/dist/util/fetchPort.d.ts +1 -0
- package/dist/util/fetchPort.js +11 -0
- package/dist/util/fileLock.d.ts +5 -3
- package/dist/util/fileLock.js +131 -50
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +1 -0
- package/dist/workflow/dsl.d.ts +24 -3
- package/dist/workflow/dsl.js +4 -0
- package/dist/workflow/engine.d.ts +5 -1
- package/dist/workflow/engine.js +3 -0
- package/dist/workflow/index.d.ts +3 -1
- package/dist/workflow/index.js +3 -1
- package/dist/workflow/runtime.d.ts +110 -0
- package/dist/workflow/runtime.js +1298 -0
- package/dist/workflow/stateStore.d.ts +172 -0
- package/dist/workflow/stateStore.js +1044 -0
- package/package.json +1 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { redactString } from '../hub/sanitize.js';
|
|
3
|
-
|
|
4
|
-
|
|
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;
|
package/dist/events/ingest.js
CHANGED
|
@@ -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
|
}
|
package/dist/events/paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/events/paths.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
586
|
+
if (!preservePatchRef && ownsPatchRef && patchRef) {
|
|
313
587
|
try {
|
|
314
|
-
|
|
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
|
}
|