From c83014b7b3d5b67e2c95e377e76affa8ebeb6155 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 8 Jul 2026 21:00:33 -0600 Subject: [PATCH 1/4] fix(improvement): claude harness spawn used removed --headless flag improve(surface:'code') -> agenticGenerator -> runLocalHarness spawned 'claude --headless -p ', but --headless is no longer a valid CLI option (exit 1, unknown option), so the code-surface proposer could never edit a candidate worktree -> every candidate empty -> the meta-harness could not run on the code surface at all. Use '-p --dangerously-skip-permissions' (headless print mode + autonomous edits in the isolated candidate worktree). --- src/mcp/local-harness.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mcp/local-harness.ts b/src/mcp/local-harness.ts index 51e6d6cf..c2c399e1 100644 --- a/src/mcp/local-harness.ts +++ b/src/mcp/local-harness.ts @@ -42,7 +42,12 @@ const HARNESS_INVOCATIONS: Record< > = { claude: { command: 'claude', - buildArgs: (taskPrompt) => ['--headless', '-p', taskPrompt], + // `-p` IS headless/print mode; the old `--headless` flag was removed from the CLI + // (a spawn with it exits 1 "unknown option", so the code-surface proposer could + // never edit a candidate worktree). `--dangerously-skip-permissions` lets the + // proposer apply file edits non-interactively inside its ISOLATED candidate + // worktree — the intended autonomous-improvement use. + buildArgs: (taskPrompt) => ['-p', taskPrompt, '--dangerously-skip-permissions'], modelArgs: (model) => ['-m', model], }, codex: { From 668cbab999d0ee372fb407c379c92cac8fbdf5f0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 8 Jul 2026 22:59:44 -0600 Subject: [PATCH 2/4] fix(improvement): surface:'code' generated zero candidates on single-generation runs improvementDriver's guard 'if (findings.length === 0 && report === undefined) return []' short-circuited the agentic code proposer whenever findings were empty. At GENERATIONS=1 the trace-distiller (analyzeGeneration) never runs (gen < maxGen-1 is 0<0), so findings are ALWAYS empty -> the claude code-editing harness was never spawned -> zero candidates, empty worktree, no proposal. The guard was written for the reflective patch-applier (no findings = no patch to draft); it is wrong for an agentic coder whose signal is the repo + raw traces on disk. Fix: agenticGenerator declares proposesWithoutFindings:true; the driver guard honors it and runs populationSize candidates from the seed + raw traces. Also fixes an empty-seed fallback in raw-trace-distiller that dropped the raw-trace instruction. Proven: [proposer:claude] now spawns and Claude Code makes a real scaffold edit grounded in the raw traces (was grep-count 0 before). 32/32 improvement tests pass. --- src/improvement/agentic-generator.ts | 6 ++++ src/improvement/improvement-driver.ts | 27 +++++++++++++-- src/improvement/raw-trace-distiller.test.ts | 27 +++++++++++++++ src/improvement/raw-trace-distiller.ts | 37 ++++++++++++--------- 4 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/improvement/agentic-generator.ts b/src/improvement/agentic-generator.ts index 2f9577ed..872a5266 100644 --- a/src/improvement/agentic-generator.ts +++ b/src/improvement/agentic-generator.ts @@ -85,6 +85,12 @@ export function agenticGenerator(opts: AgenticGeneratorOptions = {}): CandidateG return { kind: `agentic:${harness}`, + // The seed repo + (in rawTraceContext mode) the raw-trace filesystem context + // are the change signal — an agentic coder proposes from them even when the + // distiller yielded zero findings. Without this, the improvementDriver's + // empty-findings guard short-circuits and generates ZERO candidates on the + // first (and, for a single-generation run, only) proposal round. + proposesWithoutFindings: true, async generate({ worktreePath, report, findings, maxShots, signal }) { const basePrompt = buildPrompt({ report, findings }) const needsRawTraceEvidence = requiresRawTraceEvidence(findings) diff --git a/src/improvement/improvement-driver.ts b/src/improvement/improvement-driver.ts index 2f9699a3..60733e15 100644 --- a/src/improvement/improvement-driver.ts +++ b/src/improvement/improvement-driver.ts @@ -35,6 +35,17 @@ import type { * adapter's `finalize`. */ export interface CandidateGenerator { kind: string + /** Whether this generator can produce a candidate from an EMPTY findings set + * and no phase-2 report — i.e. it draws its change signal from the repo and + * the raw-trace filesystem context on disk, not only from pre-summarized + * findings. An agentic coder (`agenticGenerator`) sets this: the seed repo + + * raw traces ARE the signal, so it must still run the full `populationSize` + * when the distiller yielded nothing (this is the meta-harness contract — the + * agent diagnoses from the raw traces itself). A patch-applier + * (`reflectiveGenerator`) leaves it unset — with no findings there is no + * patch to draft, so the driver short-circuits rather than spin up worktrees + * for a guaranteed no-op. Default `false`. */ + proposesWithoutFindings?: boolean generate(args: { /** The candidate worktree — a fresh checkout of baseRef. Write changes here. */ worktreePath: string @@ -66,8 +77,20 @@ export function improvementDriver(opts: ImprovementDriverOptions): SurfacePropos kind: `improvement:${opts.generator.kind}`, async propose(ctx: ProposeContext) { const findings = resolveFindings(ctx) - // No signal to act on — propose nothing rather than spin up worktrees. - if (findings.length === 0 && ctx.report === undefined) return [] + // No findings AND no report AND a generator that can only act on findings + // (the reflective patch-applier) — propose nothing rather than spin up + // worktrees for a guaranteed no-op. An agentic coder draws its signal from + // the repo + raw traces on disk, so it opts in via `proposesWithoutFindings` + // and still runs the full populationSize even on an empty findings set — + // otherwise the FIRST generation (whose seed findings are empty and whose + // rawTraceDistiller has not run yet) would always generate ZERO candidates. + if ( + findings.length === 0 && + ctx.report === undefined && + !opts.generator.proposesWithoutFindings + ) { + return [] + } const surfaces: CodeSurface[] = [] for (let i = 0; i < ctx.populationSize; i++) { diff --git a/src/improvement/raw-trace-distiller.test.ts b/src/improvement/raw-trace-distiller.test.ts index f9097793..e503bc52 100644 --- a/src/improvement/raw-trace-distiller.test.ts +++ b/src/improvement/raw-trace-distiller.test.ts @@ -218,4 +218,31 @@ describe('rawTraceDistiller', () => { const findings = await rawTraceDistiller({ fallbackFindings: seed })(input) expect(findings).toEqual(seed) }) + + it('emits the default raw-trace instruction (not []) when the fallback seed is EMPTY on a clean generation', async () => { + // The runner passes findings:[] as the seed, so fallbackFindings is []. An + // empty fallback must NOT wipe the steering context to nothing — it must fall + // through to the default raw-trace instruction so the meta-harness discipline + // (and the agenticGenerator diagnosis-evidence gate keyed on the + // `raw-trace-context` area) stays armed for the next generation. + const input = { + generation: 0, + runDir: join(root, 'gen-0'), + candidates: [ + { + surfaceHash: 'perfect', + composite: 1, + campaign: { + runDir: join(root, 'gen-0', 'candidate-0'), + cells: [{ cellId: 's:0', scenarioId: 's', judgeScores: { j: { composite: 1 } } }], + }, + }, + ], + history: [], + } as unknown as AnalyzeInput + + const findings = await rawTraceDistiller({ fallbackFindings: [] })(input) + expect(findings).toHaveLength(1) + expect((findings[0] as unknown as { area: string }).area).toBe('raw-trace-context') + }) }) diff --git a/src/improvement/raw-trace-distiller.ts b/src/improvement/raw-trace-distiller.ts index aba84aa1..22f625ae 100644 --- a/src/improvement/raw-trace-distiller.ts +++ b/src/improvement/raw-trace-distiller.ts @@ -111,22 +111,29 @@ export function rawTraceDistiller n + c.cells.length, 0) // A clean generation: keep the proposer's steering context rather than - // wiping it (parity with the default distiller's static-seed fallback). + // wiping it (parity with the default distiller's static-seed fallback). An + // EMPTY fallback array means there is no STATIC seed to preserve — it must NOT + // wipe the context to nothing. Fall through to the default raw-trace + // instruction so the meta-harness discipline stays live (the agent still + // inspects the on-disk traces next round, and the finding's `raw-trace-context` + // area keeps the agenticGenerator's diagnosis-evidence gate armed). A bare + // `??` would return the empty array and silently disable both. if (totalFailingCells === 0) { - return ( - options.fallbackFindings ?? [ - makeFinding({ - analyst_id: ANALYST_ID, - severity: 'info', - area: 'raw-trace-context', - confidence: 1, - claim: `Generation ${input.generation} had no failing cells. The full raw run traces are on disk under ${genRoot}.`, - recommended_action: `To keep improving, grep/cat the raw traces under ${genRoot} (per-cell spans.jsonl + cached-result.json) to find the weakest passing runs, then make a targeted harness-code edit.`, - evidence_refs: [{ kind: 'artifact', uri: genRoot }], - metadata: { generation: input.generation, runDir: genRoot, failingCells: 0 }, - }), - ] - ) + if (options.fallbackFindings && options.fallbackFindings.length > 0) { + return options.fallbackFindings + } + return [ + makeFinding({ + analyst_id: ANALYST_ID, + severity: 'info', + area: 'raw-trace-context', + confidence: 1, + claim: `Generation ${input.generation} had no failing cells. The full raw run traces are on disk under ${genRoot}.`, + recommended_action: `To keep improving, grep/cat the raw traces under ${genRoot} (per-cell spans.jsonl + cached-result.json) to find the weakest passing runs, then make a targeted harness-code edit.`, + evidence_refs: [{ kind: 'artifact', uri: genRoot }], + metadata: { generation: input.generation, runDir: genRoot, failingCells: 0 }, + }), + ] } const findings: AnalystFinding[] = [] From 092fa863bead01239f0dcd49bd9df46ba7ec6791 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 9 Jul 2026 00:57:34 -0600 Subject: [PATCH 3/4] test(improvement): regression test for proposesWithoutFindings guard --- tests/improvement-driver.test.ts | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/improvement-driver.test.ts b/tests/improvement-driver.test.ts index 263b29be..9696999d 100644 --- a/tests/improvement-driver.test.ts +++ b/tests/improvement-driver.test.ts @@ -139,6 +139,41 @@ describe('improvementDriver — reflective generator', () => { expect(git(['worktree', 'list'], repoRoot).split('\n').length).toBe(1) }) + it('still proposes populationSize candidates on EMPTY findings when the generator opts in (proposesWithoutFindings)', async () => { + // The meta-harness contract: an agentic coder draws its signal from the repo + // + raw traces on disk, so it must run even when the distiller yielded no + // findings and there is no report (the first-generation case — its seed + // findings are empty and rawTraceDistiller has not run yet). Regression for + // the "surface:'code' generates ZERO candidates" bug. + let generateCalls = 0 + const seedDriver = improvementDriver({ + generator: { + kind: 'agentic-stub', + proposesWithoutFindings: true, + async generate({ worktreePath }) { + generateCalls++ + // Dirty the worktree so the driver finalizes it into a CodeSurface. + writeFileSync(join(worktreePath, 'prompt.md'), 'edited from raw traces\n') + return { applied: true, summary: 'from-seed edit' } + }, + }, + worktree: gitWorktreeAdapter({ repoRoot }), + baseRef: 'main', + }) + + const surfaces = await seedDriver.propose({ ...ctxWith([]), populationSize: 2 }) + + expect(generateCalls).toBe(2) + expect(surfaces).toHaveLength(2) + for (const surface of surfaces) { + if (typeof surface === 'string') throw new Error('expected CodeSurface') + expect(surface.kind).toBe('code') + expect(readFileSync(join(surface.worktreeRef, 'prompt.md'), 'utf8')).toBe( + 'edited from raw traces\n', + ) + } + }) + it('rethrows and leaves NO orphaned worktree when the generator throws', async () => { // A generator whose generate() throws mid-candidate must not leak the // already-created worktree (the try/finally cleanup in propose()). From 9b37fd2497ca14dea10563a2701f68749882b348 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 9 Jul 2026 14:41:12 -0600 Subject: [PATCH 4/4] fix(improvement): scope Claude permissions to worktrees --- docs/api/index.md | 31 ++++++++++---- docs/api/mcp.md | 45 +++++++++++++-------- src/improvement/agentic-generator.ts | 4 ++ src/mcp/local-harness.ts | 39 +++++++++++++----- src/mcp/worktree-harness.ts | 6 ++- tests/agentic-generator.test.ts | 23 ++++++++--- tests/mcp/local-harness.test.ts | 39 +++++++++++++----- tests/runtime/worktree-cli-executor.test.ts | 1 + 8 files changed, 138 insertions(+), 50 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 4d93ec8d..ae63413e 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -2813,13 +2813,30 @@ The byte-producing seam — the ONE thing that differs between the cheap Defined in: [improvement/improvement-driver.ts:37](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L37) +##### proposesWithoutFindings? + +> `optional` **proposesWithoutFindings?**: `boolean` + +Defined in: [improvement/improvement-driver.ts:48](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L48) + +Whether this generator can produce a candidate from an EMPTY findings set + and no phase-2 report — i.e. it draws its change signal from the repo and + the raw-trace filesystem context on disk, not only from pre-summarized + findings. An agentic coder (`agenticGenerator`) sets this: the seed repo + + raw traces ARE the signal, so it must still run the full `populationSize` + when the distiller yielded nothing (this is the meta-harness contract — the + agent diagnoses from the raw traces itself). A patch-applier + (`reflectiveGenerator`) leaves it unset — with no findings there is no + patch to draft, so the driver short-circuits rather than spin up worktrees + for a guaranteed no-op. Default `false`. + #### Methods ##### generate() > **generate**(`args`): `Promise`\<\{ `applied`: `boolean`; `summary`: `string`; \}\> -Defined in: [improvement/improvement-driver.ts:38](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L38) +Defined in: [improvement/improvement-driver.ts:49](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L49) ###### Parameters @@ -2868,7 +2885,7 @@ DEPTH: max iterations the generator may take (agentic uses this; the ### ImprovementDriverOptions -Defined in: [improvement/improvement-driver.ts:54](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L54) +Defined in: [improvement/improvement-driver.ts:65](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L65) #### Properties @@ -2876,19 +2893,19 @@ Defined in: [improvement/improvement-driver.ts:54](https://github.com/tangle-net > **worktree**: `WorktreeAdapter` -Defined in: [improvement/improvement-driver.ts:55](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L55) +Defined in: [improvement/improvement-driver.ts:66](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L66) ##### generator > **generator**: [`CandidateGenerator`](#candidategenerator) -Defined in: [improvement/improvement-driver.ts:56](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L56) +Defined in: [improvement/improvement-driver.ts:67](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L67) ##### baseRef? > `optional` **baseRef?**: `string` -Defined in: [improvement/improvement-driver.ts:58](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L58) +Defined in: [improvement/improvement-driver.ts:69](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L69) Base ref candidate worktrees fork from. Default `main`. @@ -8460,7 +8477,7 @@ Full-agentic `CandidateGenerator` (the `shots=N, sandbox=on` setting): run a rea > **commandVerifier**(`command`, `args?`, `timeoutMs?`): [`Verifier`](#verifier) -Defined in: [improvement/agentic-generator.ts:237](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/agentic-generator.ts#L237) +Defined in: [improvement/agentic-generator.ts:247](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/agentic-generator.ts#L247) A `Verifier` that runs a command in the worktree: exit 0 ⇒ ok, any other exit ⇒ failed with stdout+stderr as feedback. The common case — verify by @@ -8585,7 +8602,7 @@ Optimize the system prompt, default holdout gate: > **improvementDriver**(`opts`): `SurfaceProposer`\<`AnalystFinding`\> -Defined in: [improvement/improvement-driver.ts:62](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L62) +Defined in: [improvement/improvement-driver.ts:73](https://github.com/tangle-network/agent-runtime/blob/main/src/improvement/improvement-driver.ts#L73) The one reflective/agentic improvement proposer (`SurfaceProposer`): owns the candidate worktree lifecycle and delegates HOW a change is produced to a pluggable `CandidateGenerator`. diff --git a/docs/api/mcp.md b/docs/api/mcp.md index ba9fc764..f9bdbfe4 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -2512,7 +2512,7 @@ Default `[]` (no circular check unless the consumer declares its kinds). ### RunLocalHarnessOptions -Defined in: [mcp/local-harness.ts:108](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L108) +Defined in: [mcp/local-harness.ts:129](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L129) **`Experimental`** @@ -2522,7 +2522,7 @@ Defined in: [mcp/local-harness.ts:108](https://github.com/tangle-network/agent-r > **harness**: [`LocalHarness`](#localharness) -Defined in: [mcp/local-harness.ts:109](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L109) +Defined in: [mcp/local-harness.ts:130](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L130) **`Experimental`** @@ -2530,7 +2530,7 @@ Defined in: [mcp/local-harness.ts:109](https://github.com/tangle-network/agent-r > **cwd**: `string` -Defined in: [mcp/local-harness.ts:111](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L111) +Defined in: [mcp/local-harness.ts:132](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L132) **`Experimental`** @@ -2540,7 +2540,7 @@ Working directory for the subprocess (typically a worktree path). > **taskPrompt**: `string` -Defined in: [mcp/local-harness.ts:113](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L113) +Defined in: [mcp/local-harness.ts:134](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L134) **`Experimental`** @@ -2550,7 +2550,7 @@ Prompt forwarded as the harness CLI's task argument. > `optional` **invocation?**: `object` -Defined in: [mcp/local-harness.ts:121](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L121) +Defined in: [mcp/local-harness.ts:142](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L142) **`Experimental`** @@ -2568,11 +2568,22 @@ is used unchanged. > **args**: readonly `string`[] +##### dangerouslySkipPermissions? + +> `optional` **dangerouslySkipPermissions?**: `boolean` + +Defined in: [mcp/local-harness.ts:145](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L145) + +**`Experimental`** + +Allow autonomous Claude edits without an interactive permission prompt. + Use only when `cwd` is an isolated candidate worktree. + ##### timeoutMs? > `optional` **timeoutMs?**: `number` -Defined in: [mcp/local-harness.ts:123](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L123) +Defined in: [mcp/local-harness.ts:147](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L147) **`Experimental`** @@ -2582,7 +2593,7 @@ Wall-clock kill deadline (ms). Default 5 min. Subprocess SIGTERMed on expiry. > `optional` **signal?**: `AbortSignal` -Defined in: [mcp/local-harness.ts:125](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L125) +Defined in: [mcp/local-harness.ts:149](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L149) **`Experimental`** @@ -2592,7 +2603,7 @@ Caller cancellation. SIGTERM is sent on abort. > `optional` **env?**: `ProcessEnv` -Defined in: [mcp/local-harness.ts:127](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L127) +Defined in: [mcp/local-harness.ts:151](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L151) **`Experimental`** @@ -2602,7 +2613,7 @@ Override env (defaults to inheriting from the parent). > `optional` **spawn?**: (`command`, `args`, `opts`) => `ChildProcess` -Defined in: [mcp/local-harness.ts:132](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L132) +Defined in: [mcp/local-harness.ts:156](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L156) **`Experimental`** @@ -2641,7 +2652,7 @@ readonly `string`[] ### LocalHarnessResult -Defined in: [mcp/local-harness.ts:144](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L144) +Defined in: [mcp/local-harness.ts:168](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L168) **`Experimental`** @@ -2651,7 +2662,7 @@ Defined in: [mcp/local-harness.ts:144](https://github.com/tangle-network/agent-r > **exitCode**: `number` \| `null` -Defined in: [mcp/local-harness.ts:146](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L146) +Defined in: [mcp/local-harness.ts:170](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L170) **`Experimental`** @@ -2661,7 +2672,7 @@ OS exit code. `null` when killed before exit. > **stdout**: `string` -Defined in: [mcp/local-harness.ts:148](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L148) +Defined in: [mcp/local-harness.ts:172](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L172) **`Experimental`** @@ -2671,7 +2682,7 @@ Concatenated stdout. > **stderr**: `string` -Defined in: [mcp/local-harness.ts:150](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L150) +Defined in: [mcp/local-harness.ts:174](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L174) **`Experimental`** @@ -2681,7 +2692,7 @@ Concatenated stderr. > **killedBySignal**: `Signals` \| `null` -Defined in: [mcp/local-harness.ts:152](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L152) +Defined in: [mcp/local-harness.ts:176](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L176) **`Experimental`** @@ -2691,7 +2702,7 @@ Set when the process exited via signal (timeout / abort). > **durationMs**: `number` -Defined in: [mcp/local-harness.ts:154](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L154) +Defined in: [mcp/local-harness.ts:178](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L178) **`Experimental`** @@ -2701,7 +2712,7 @@ Wall-clock duration ms (spawn → exit). > **timedOut**: `boolean` -Defined in: [mcp/local-harness.ts:156](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L156) +Defined in: [mcp/local-harness.ts:180](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L180) **`Experimental`** @@ -7165,7 +7176,7 @@ then any consumer judges, returning on the first veto. > **runLocalHarness**(`options`): `Promise`\<[`LocalHarnessResult`](#localharnessresult)\> -Defined in: [mcp/local-harness.ts:180](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L180) +Defined in: [mcp/local-harness.ts:204](https://github.com/tangle-network/agent-runtime/blob/main/src/mcp/local-harness.ts#L204) **`Experimental`** diff --git a/src/improvement/agentic-generator.ts b/src/improvement/agentic-generator.ts index 872a5266..8ba640ec 100644 --- a/src/improvement/agentic-generator.ts +++ b/src/improvement/agentic-generator.ts @@ -104,6 +104,10 @@ export function agenticGenerator(opts: AgenticGeneratorOptions = {}): CandidateG harness, cwd: worktreePath, taskPrompt: attemptNote ? `${basePrompt}\n\n${attemptNote}` : basePrompt, + // The candidate worktree is isolated and must be editable without an + // interactive permission prompt. Other runLocalHarness callers remain + // permission-safe by default. + dangerouslySkipPermissions: harness === 'claude', timeoutMs: opts.timeoutMs, signal, }) diff --git a/src/mcp/local-harness.ts b/src/mcp/local-harness.ts index c2c399e1..0ea5c403 100644 --- a/src/mcp/local-harness.ts +++ b/src/mcp/local-harness.ts @@ -25,7 +25,7 @@ export type LocalHarness = 'claude' | 'codex' | 'opencode' /** * Default per-harness command + arg shape. `buildArgs` takes ONLY the task prompt and - * emits the prompt-only invocation (no model, no system prompt) — the historical shape + * emits the prompt-only invocation (no model, no system prompt) — the safe default shape * the in-process executor's `streamPrompt` drives. `modelArgs` maps a resolved model to * the harness's selector flag (every supported harness takes `-m `). The §1.5 * profile-aware mapper `harnessInvocation` composes these to thread the full @@ -42,12 +42,9 @@ const HARNESS_INVOCATIONS: Record< > = { claude: { command: 'claude', - // `-p` IS headless/print mode; the old `--headless` flag was removed from the CLI - // (a spawn with it exits 1 "unknown option", so the code-surface proposer could - // never edit a candidate worktree). `--dangerously-skip-permissions` lets the - // proposer apply file edits non-interactively inside its ISOLATED candidate - // worktree — the intended autonomous-improvement use. - buildArgs: (taskPrompt) => ['-p', taskPrompt, '--dangerously-skip-permissions'], + // `-p` IS headless/print mode; the old `--headless` flag was removed from the CLI. + // Permission bypass is an explicit per-run opt-in below, never the public default. + buildArgs: (taskPrompt) => ['-p', taskPrompt], modelArgs: (model) => ['-m', model], }, codex: { @@ -68,6 +65,24 @@ export interface HarnessInvocation { args: string[] } +export interface HarnessInvocationOptions { + /** Allow an unattended Claude process to edit its isolated candidate worktree. + * Ignored by harnesses that do not use Claude's permission prompt. */ + dangerouslySkipPermissions?: boolean +} + +function buildHarnessArgs( + harness: LocalHarness, + taskPrompt: string, + options: HarnessInvocationOptions = {}, +): string[] { + const args = HARNESS_INVOCATIONS[harness].buildArgs(taskPrompt) + if (harness === 'claude' && options.dangerouslySkipPermissions) { + args.push('--dangerously-skip-permissions') + } + return args +} + /** * Map a supervisor-authored `AgentProfile` + the per-task prompt onto a concrete harness * `command` + `args` (the §1.5 fix). UNLIKE the prompt-only `HARNESS_INVOCATIONS.buildArgs` @@ -87,6 +102,7 @@ export function harnessInvocation( harness: LocalHarness, profile: AgentProfile, taskPrompt: string, + options: HarnessInvocationOptions = {}, ): HarnessInvocation { const invocation = HARNESS_INVOCATIONS[harness] if (!invocation) { @@ -99,7 +115,7 @@ export function harnessInvocation( ? `${systemPrompt}\n\n${taskPrompt}` : taskPrompt - const args = invocation.buildArgs(composedPrompt) + const args = buildHarnessArgs(harness, composedPrompt, options) const model = profile.model?.default if (typeof model === 'string' && model.length > 0) { @@ -124,6 +140,9 @@ export interface RunLocalHarnessOptions { * is used unchanged. */ invocation?: { command?: string; args: ReadonlyArray } + /** Allow autonomous Claude edits without an interactive permission prompt. + * Use only when `cwd` is an isolated candidate worktree. */ + dangerouslySkipPermissions?: boolean /** Wall-clock kill deadline (ms). Default 5 min. Subprocess SIGTERMed on expiry. */ timeoutMs?: number /** Caller cancellation. SIGTERM is sent on abort. */ @@ -195,7 +214,9 @@ export function runLocalHarness(options: RunLocalHarnessOptions): Promise((resolve, reject) => { let child: ChildProcess diff --git a/src/mcp/worktree-harness.ts b/src/mcp/worktree-harness.ts index a277d2f1..0431e043 100644 --- a/src/mcp/worktree-harness.ts +++ b/src/mcp/worktree-harness.ts @@ -155,7 +155,11 @@ export async function runWorktreeHarness( try { // §1.5: the authored systemPrompt + model reach the harness (NOT the prompt-only path). - const { command, args } = harnessInvocation(opts.harness, opts.profile, opts.taskPrompt) + const { command, args } = harnessInvocation(opts.harness, opts.profile, opts.taskPrompt, { + // This helper created the candidate worktree above; autonomous Claude + // edits are permitted only inside that isolated checkout. + dangerouslySkipPermissions: opts.harness === 'claude', + }) const harnessResult: LocalHarnessResult = await runHarness({ harness: opts.harness, cwd: worktree.path, diff --git a/tests/agentic-generator.test.ts b/tests/agentic-generator.test.ts index 5b07771c..a707a9ed 100644 --- a/tests/agentic-generator.test.ts +++ b/tests/agentic-generator.test.ts @@ -86,12 +86,23 @@ describe('agenticGenerator — runs a harness in the worktree', () => { it('returns applied when the harness changes the worktree', async () => { // The harness "edits" by writing into its cwd (the worktree). We stub the // subprocess (the only process boundary) but use a REAL git dirty check. - const runHarness = vi.fn(async ({ cwd, taskPrompt }: { cwd: string; taskPrompt: string }) => { - expect(taskPrompt).toContain('x should be 2') - expect(taskPrompt).toContain('set x to 2') - writeFileSync(join(cwd, 'app.ts'), 'export const x = 2\n') - return HARNESS_OK - }) + const runHarness = vi.fn( + async ({ + cwd, + taskPrompt, + dangerouslySkipPermissions, + }: { + cwd: string + taskPrompt: string + dangerouslySkipPermissions?: boolean + }) => { + expect(taskPrompt).toContain('x should be 2') + expect(taskPrompt).toContain('set x to 2') + expect(dangerouslySkipPermissions).toBe(true) + writeFileSync(join(cwd, 'app.ts'), 'export const x = 2\n') + return HARNESS_OK + }, + ) const gen = agenticGenerator({ runHarness: runHarness as never }) const wt = await gitWorktreeAdapter({ repoRoot }).create({ baseRef: 'main', label: 'cand' }) diff --git a/tests/mcp/local-harness.test.ts b/tests/mcp/local-harness.test.ts index 691eb32c..c87ae7d5 100644 --- a/tests/mcp/local-harness.test.ts +++ b/tests/mcp/local-harness.test.ts @@ -127,13 +127,27 @@ describe('runLocalHarness', () => { } const calls = spawnSpy.mock.calls expect(calls[0][0]).toBe('claude') - expect(calls[0][1]).toEqual(['--headless', '-p', 'go']) + expect(calls[0][1]).toEqual(['-p', 'go']) expect(calls[1][0]).toBe('codex') expect(calls[1][1]).toEqual(['run', 'go']) expect(calls[2][0]).toBe('opencode') expect(calls[2][1]).toEqual(['run', 'go']) }) + it('adds Claude permission bypass only when the caller explicitly opts in', async () => { + const spawnSpy = vi.fn((_cmd: string, _args: ReadonlyArray) => + makeFakeChild({ exitCode: 0 }), + ) + await runLocalHarness({ + harness: 'claude', + cwd: '/tmp/isolated-worktree', + taskPrompt: 'go', + dangerouslySkipPermissions: true, + spawn: spawnSpy, + }) + expect(spawnSpy.mock.calls[0][1]).toEqual(['-p', 'go', '--dangerously-skip-permissions']) + }) + it('honors a pre-built invocation override (profile-aware args) without rebuilding from the prompt', async () => { const spawnSpy = vi.fn((_cmd: string, _args: ReadonlyArray) => makeFakeChild({ exitCode: 0 }), @@ -141,13 +155,13 @@ describe('runLocalHarness', () => { await runLocalHarness({ harness: 'claude', cwd: '/tmp/wt', - // The prompt-only fallback path would emit ['--headless','-p','go'] — the override wins. + // The prompt-only fallback path would emit ['-p','go'] — the override wins exactly. taskPrompt: 'go', - invocation: { command: 'claude', args: ['--headless', '-p', 'sys\n\ngo', '-m', 'deepseek'] }, + invocation: { command: 'claude', args: ['-p', 'sys\n\ngo', '-m', 'deepseek'] }, spawn: spawnSpy, }) expect(spawnSpy.mock.calls[0][0]).toBe('claude') - expect(spawnSpy.mock.calls[0][1]).toEqual(['--headless', '-p', 'sys\n\ngo', '-m', 'deepseek']) + expect(spawnSpy.mock.calls[0][1]).toEqual(['-p', 'sys\n\ngo', '-m', 'deepseek']) }) }) @@ -183,19 +197,24 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { it('threads BOTH systemPrompt and model together', () => { const inv = harnessInvocation('claude', profileWith('SYS', 'kimi-k2.7'), 'task') expect(inv.command).toBe('claude') - expect(inv.args).toEqual(['--headless', '-p', 'SYS\n\ntask', '-m', 'kimi-k2.7']) + expect(inv.args).toEqual(['-p', 'SYS\n\ntask', '-m', 'kimi-k2.7']) }) it('an empty/absent profile yields exactly the legacy prompt-only shape (byte-identical)', () => { - expect(harnessInvocation('claude', { name: 'x' }, 'go').args).toEqual([ - '--headless', - '-p', - 'go', - ]) + expect(harnessInvocation('claude', { name: 'x' }, 'go').args).toEqual(['-p', 'go']) expect(harnessInvocation('codex', { name: 'x' }, 'go').args).toEqual(['run', 'go']) expect(harnessInvocation('opencode', { name: 'x' }, 'go').args).toEqual(['run', 'go']) }) + it('adds Claude permission bypass only when an isolated worktree explicitly opts in', () => { + expect( + harnessInvocation('claude', { name: 'x' }, 'go', { + dangerouslySkipPermissions: true, + }).args, + ).toEqual(['-p', 'go', '--dangerously-skip-permissions']) + expect(harnessInvocation('claude', { name: 'x' }, 'go').args).toEqual(['-p', 'go']) + }) + it('throws on an unknown harness', () => { expect(() => // @ts-expect-error testing runtime validation diff --git a/tests/runtime/worktree-cli-executor.test.ts b/tests/runtime/worktree-cli-executor.test.ts index 481bee77..a53ec11d 100644 --- a/tests/runtime/worktree-cli-executor.test.ts +++ b/tests/runtime/worktree-cli-executor.test.ts @@ -171,6 +171,7 @@ describe('createWorktreeCliExecutor', () => { ) // ... and the authored model reaches the harness `-m` selector. const args = seen?.invocation?.args ?? [] + expect(args).toContain('--dangerously-skip-permissions') const mIdx = args.indexOf('-m') expect(mIdx).toBeGreaterThanOrEqual(0) expect(args[mIdx + 1]).toBe('deepseek/deepseek-v4-flash')