diff --git a/runner/package.json b/runner/package.json index f04fcbb..864baec 100644 --- a/runner/package.json +++ b/runner/package.json @@ -17,10 +17,12 @@ "prepare": "tsc", "typecheck": "tsc --noEmit", "test": "vitest run", - "fake": "tsx scripts/fake.ts" + "fake": "tsx scripts/fake.ts", + "try": "tsx scripts/try.ts" }, "dependencies": { - "@codehands/contracts": "file:../../codehands-contracts" + "@codehands/contracts": "file:../../codehands-contracts", + "execa": "^10.2.0" }, "devDependencies": { "@types/node": "^26.6.5", diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index c33b537..cae035b 100644 --- a/runner/pnpm-lock.yaml +++ b/runner/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@codehands/contracts': specifier: file:../../codehands-contracts version: file:../../codehands-contracts + execa: + specifier: ^10.2.0 + version: 10.2.0 devDependencies: '@types/node': specifier: ^26.6.5 @@ -321,6 +324,13 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -522,6 +532,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + execa@10.2.0: + resolution: {integrity: sha512-j1pFOVxaybMVCTNHSBuISL2KQcDS3KIE1wBmUp4n2X+1FgaQI2ZDJVy9TYxDyMCzYojRIMh9O8X/UoWZtxTJdw==} + engines: {node: '>=22'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -535,11 +549,35 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -557,6 +595,18 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -575,6 +625,10 @@ packages: resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} + engines: {node: '>=18'} + rollup@4.64.10: resolution: {integrity: sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -583,6 +637,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -593,6 +651,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -631,6 +693,10 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -704,11 +770,20 @@ packages: jsdom: optional: true + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + snapshots: '@codehands/contracts@file:../../codehands-contracts': {} @@ -871,6 +946,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.64.10': optional: true + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1041,15 +1120,47 @@ snapshots: dependencies: '@types/estree': 1.0.9 + execa@10.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.1 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.2.0 + expect-type@1.4.0: {} fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + fsevents@2.3.3: optional: true + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + human-signals@8.0.1: {} + + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + js-tokens@9.0.1: {} loupe@3.2.1: {} @@ -1062,6 +1173,15 @@ snapshots: nanoid@3.3.20: {} + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + parse-ms@4.0.0: {} + + path-key@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -1076,6 +1196,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + pretty-ms@9.3.1: + dependencies: + parse-ms: 4.0.0 + rollup@4.64.10: dependencies: '@types/estree': 1.0.9 @@ -1110,12 +1234,16 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.2: {} stackback@0.0.2: {} std-env@3.10.0: {} + strip-final-newline@4.0.0: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -1166,6 +1294,8 @@ snapshots: undici-types@8.9.0: {} + unicorn-magic@0.3.0: {} + vite-node@3.2.4(@types/node@26.6.5)(tsx@4.23.15): dependencies: cac: 6.7.14 @@ -1241,7 +1371,11 @@ snapshots: - tsx - yaml + which-command@0.1.0: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + yoctocolors@2.2.0: {} diff --git a/runner/scripts/try.ts b/runner/scripts/try.ts new file mode 100644 index 0000000..3ac1ce1 --- /dev/null +++ b/runner/scripts/try.ts @@ -0,0 +1,43 @@ +// pnpm try [repoPath] [prompt] [--approve | --reject]: one real run through the runner, one event per line. +// Without a flag it asks approve / reject / keep at the end (or keeps, when not in a terminal). +// Costs real money (capped by budgetUsd below). Run scripts/reset-fixture.sh afterwards. +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { createRunner } from "../src/index.js"; + +const flags = process.argv.slice(2).filter((a) => a.startsWith("--")); +const [repoPath = join(homedir(), "codehands-fixtures", "date-bug"), prompt = "Fix the failing test. Run npm test to check."] = + process.argv.slice(2).filter((a) => !a.startsWith("--")); + +const runner = createRunner(); +const sessionId = randomUUID(); +let finished = false; +for await (const e of runner.start({ sessionId, repoPath, prompt, agent: "claude-code", model: "sonnet", budgetUsd: 0.5 })) { + const p = e.payload; + const detail = p.reason ?? p.command ?? p.path ?? (typeof p.text === "string" ? p.text.slice(0, 80) : "") ?? ""; + const extra = e.type === "test.result" ? ` passed=${p.passed}` : e.type === "task.finished" ? ` $${p.total_cost_usd}` : ""; + console.log(`${String(e.seq).padStart(3)} ${e.type.padEnd(17)} ${String(detail).replace(/\s+/g, " ")}${extra}`); + if (e.type === "task.finished") finished = true; +} +if (!finished) process.exit(1); + +console.log(`\n${(await runner.diff(sessionId)).stat}\n`); + +let choice = flags.includes("--approve") ? "a" : flags.includes("--reject") ? "r" : "k"; +if (!flags.length && process.stdin.isTTY) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + choice = (await rl.question("approve, reject or keep? [a/r/k] ")).trim().toLowerCase()[0] ?? "k"; + rl.close(); +} + +if (choice === "a") { + const { branch, commit } = await runner.approve(sessionId, prompt); + console.log(`approved: ${branch} @ ${commit.slice(0, 7)} (git log ${branch} --oneline -1)`); +} else if (choice === "r") { + await runner.reject(sessionId); + console.log("rejected: worktree and branch deleted"); +} else { + console.log("kept: the worktree is still there, see `git worktree list` in the repo"); +} diff --git a/runner/src/agents/claude-code.ts b/runner/src/agents/claude-code.ts index 7d6cc85..d0c4c45 100644 --- a/runner/src/agents/claude-code.ts +++ b/runner/src/agents/claude-code.ts @@ -3,7 +3,9 @@ import type { EventSequence } from "../events.js"; // Week 1 guide, section 4. Flags checked against Claude Code 2.1.280. -const ALLOWED_TOOLS = "Read,Edit,Write,Glob,Grep,Bash(npm test:*)"; +// Claude picks PowerShell on Windows, so both shells get the same one command. +// --strict-mcp-config keeps the user's own connectors (Gmail, GitHub …) out of agent runs. +const ALLOWED_TOOLS = "Read,Edit,Write,Glob,Grep,Bash(npm test:*),PowerShell(npm test:*)"; export function claudeArgs(o: StartOptions, notes?: string): string[] { const args = [ @@ -12,6 +14,7 @@ export function claudeArgs(o: StartOptions, notes?: string): string[] { "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", ALLOWED_TOOLS, + "--strict-mcp-config", "--max-budget-usd", String(o.budgetUsd ?? 0.5), ]; if (o.model) args.push("--model", o.model); @@ -26,20 +29,121 @@ export function claudeResumeArgs(sessionId: string, prompt: string, budgetUsd = "--output-format", "stream-json", "--verbose", "--permission-mode", "acceptEdits", "--allowedTools", ALLOWED_TOOLS, + "--strict-mcp-config", "--max-budget-usd", String(budgetUsd), ]; } -// TODO(Tue): read every line of test/fixtures/spike-1.jsonl first, then map: -// system/init -> task.started -// assistant text -> message -// assistant tool_use Read -> file.read -// assistant tool_use Edit/Write -> file.changed -// assistant tool_use Bash -> command.started -// user tool_result -> command.finished (+ test.result for the test command) -// result -> task.finished with total_cost_usd and usage exactly as given +// Mapping, checked against test/fixtures/spike-1.jsonl (recorded Oct 22): +// system/init -> task.started +// system/permission_denied -> message with denied: true (no contract event for it yet) +// assistant text -> message +// assistant tool_use Read/Glob/Grep -> file.read +// assistant tool_use Edit/Write -> file.changed +// assistant tool_use Bash/PowerShell -> command.started (Claude uses PowerShell on Windows) +// user tool_result for a command -> command.finished (+ test.result for a test command) +// result -> task.finished, or task.failed when it is an error. +// total_cost_usd and usage exactly as given +// Skipped: rate_limit_event (can come before init), thinking, other system lines. // A line that is not JSON becomes a `message` with the raw text. Always keep `raw`. // One line can hold several content blocks, so this returns a list. -export function parseClaudeLine(_line: string, _seq: EventSequence): RunnerEvent[] { - throw new Error("not implemented: parseClaudeLine (Tue)"); + +const READ_TOOLS = new Set(["Read", "Glob", "Grep"]); +const CHANGE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]); +const COMMAND_TOOLS = new Set(["Bash", "PowerShell"]); +const TEST_COMMAND = /\b(npm|pnpm|yarn)( run)? test\b|\bnode --test\b|\b(vitest|jest|pytest)\b/; +// Exit codes can hide behind a pipe (`npm test | tail`), so the output is checked too. +const TEST_FAILED = /^# fail [1-9]|\b[1-9]\d* (failed|failing)\b|^FAIL\b/m; + +// Results can arrive out of order, so a tool_result is matched to its tool_use by id. +export interface ClaudeParseState { + tools: Map; + denied: Set; // tool_use ids that were blocked and never ran +} + +export function newClaudeParseState(): ClaudeParseState { + return { tools: new Map(), denied: new Set() }; +} + +type Json = Record; + +export function parseClaudeLine(line: string, seq: EventSequence, state = newClaudeParseState()): RunnerEvent[] { + if (!line.trim()) return []; + let o: Json; + try { + o = JSON.parse(line); + } catch { + return [seq.next("message", { text: line }, line)]; + } + + if (o.type === "system" && o.subtype === "init") { + return [seq.next("task.started", { + agent: "claude-code", model: o.model, cwd: o.cwd, agentSessionId: o.session_id, permissionMode: o.permissionMode, + }, o)]; + } + + if (o.type === "system" && o.subtype === "permission_denied") { + if (o.tool_use_id) state.denied.add(o.tool_use_id); + return [seq.next("message", { text: `Blocked: ${o.message}`, denied: true, tool: o.tool_name }, o)]; + } + + if (o.type === "assistant") { + return blocks(o).flatMap((b): RunnerEvent[] => { + if (b.type === "text" && b.text) return [seq.next("message", { text: b.text }, o)]; + if (b.type !== "tool_use") return []; + const input: Json = b.input ?? {}; + state.tools.set(b.id, { name: b.name, command: input.command }); + if (READ_TOOLS.has(b.name)) { + return [seq.next("file.read", { path: input.file_path ?? input.path ?? ".", pattern: input.pattern, tool: b.name }, o)]; + } + if (CHANGE_TOOLS.has(b.name)) { + return [seq.next("file.changed", { path: input.file_path ?? input.notebook_path, tool: b.name }, o)]; + } + if (COMMAND_TOOLS.has(b.name)) { + return [seq.next("command.started", { command: input.command, tool: b.name }, o)]; + } + return [seq.next("message", { text: `Used ${b.name}`, tool: b.name }, o)]; + }); + } + + if (o.type === "user") { + return blocks(o).flatMap((b): RunnerEvent[] => { + if (b.type !== "tool_result") return []; + const tool = state.tools.get(b.tool_use_id); + if (!tool || !COMMAND_TOOLS.has(tool.name)) return []; + const output = resultText(b.content); + const isError = b.is_error === true; + const denied = state.denied.has(b.tool_use_id); + const events = [seq.next("command.finished", { command: tool.command, isError, denied, output }, o)]; + if (!denied && tool.command && TEST_COMMAND.test(tool.command)) { + events.push(seq.next("test.result", { command: tool.command, passed: !isError && !TEST_FAILED.test(output) }, o)); + } + return events; + }); + } + + if (o.type === "result") { + const failed = o.is_error === true || o.subtype !== "success"; + return [seq.next(failed ? "task.failed" : "task.finished", { + subtype: o.subtype, + total_cost_usd: o.total_cost_usd, + duration_ms: o.duration_ms, + num_turns: o.num_turns, + usage: o.usage, + result: o.result, + agentSessionId: o.session_id, + }, o)]; + } + + return []; +} + +function blocks(o: Json): Json[] { + return Array.isArray(o.message?.content) ? o.message.content : []; +} + +function resultText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n"); + return ""; } diff --git a/runner/src/git/diff.ts b/runner/src/git/diff.ts index eef2517..da6a720 100644 --- a/runner/src/git/diff.ts +++ b/runner/src/git/diff.ts @@ -1,12 +1,22 @@ // Week 1 guide, Wednesday. Untracked files are invisible to plain `git diff`, // so stage everything first. -// TODO(Wed): `git add -A`, then `git diff --cached --binary ` and `--stat`. -export async function diffSince(_worktree: string, _base: string): Promise<{ patch: string; stat: string }> { - throw new Error("not implemented: diffSince (Wed)"); +import { execa } from "execa"; +import { git } from "./worktree.js"; + +export async function diffSince(worktree: string, base: string): Promise<{ patch: string; stat: string }> { + await git(worktree, ["add", "-A"]); + // Not trimmed: a patch needs its final newline to apply. + const { stdout: patch } = await execa("git", ["diff", "--cached", "--binary", base], { cwd: worktree, stdin: "ignore", stripFinalNewline: false }); + const stat = await git(worktree, ["diff", "--cached", "--stat", base]); + return { patch, stat }; } -// TODO(Wed): `git commit -m ` in the worktree, return the new commit hash. -export async function commitAll(_worktree: string, _message: string): Promise { - throw new Error("not implemented: commitAll (Wed)"); +// Commits everything the agent changed on the worktree's branch and returns the new commit hash. +export async function commitAll(worktree: string, message: string): Promise { + await git(worktree, ["add", "-A"]); + const staged = await git(worktree, ["diff", "--cached", "--name-only"]); + if (!staged) throw new Error("nothing to approve: the agent made no changes"); + await git(worktree, ["commit", "-q", "-m", message]); + return git(worktree, ["rev-parse", "HEAD"]); } diff --git a/runner/src/git/worktree.ts b/runner/src/git/worktree.ts index 328fe06..3690a98 100644 --- a/runner/src/git/worktree.ts +++ b/runner/src/git/worktree.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; +import { execa } from "execa"; // Week 1 guide, sections 6 and 7 (git worktree traps). @@ -19,26 +20,38 @@ export function branchName(prompt: string, sessionId: string): string { } // Outside the repo, so worktrees never nest. Keep it short for Windows path limits. -export function worktreePath(repoKey: string, sessionId: string): string { - return join(homedir(), ".codehands", "worktrees", repoKey, sessionId.slice(0, 8)); +export function worktreePath(repoKey: string, sessionId: string, root = join(homedir(), ".codehands", "worktrees")): string { + return join(root, repoKey, sessionId.slice(0, 8)); } -// TODO(Tue): `git rev-parse HEAD` in repoPath. Throw a clear error when the repo has no commits. -export async function baseCommit(_repoPath: string): Promise { - throw new Error("not implemented: baseCommit (Tue)"); +// Runs git in a folder and returns trimmed stdout. Throws with git's own message on failure. +export async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execa("git", args, { cwd, stdin: "ignore" }); + return stdout.trim(); } -// TODO(Tue): `git worktree add -b `. Always a new branch. -export async function addWorktree(_repoPath: string, _branch: string, _path: string, _base: string): Promise { - throw new Error("not implemented: addWorktree (Tue)"); +// A repo with no commits can't make worktrees, so say that plainly. +export async function baseCommit(repoPath: string): Promise { + try { + return await git(repoPath, ["rev-parse", "--verify", "HEAD"]); + } catch { + throw new Error(`${repoPath} is not a git repo or has no commits yet`); + } } -// TODO(Wed): `git worktree remove --force `, then `git branch -D ` when rejecting. -export async function removeWorktree(_repoPath: string, _path: string, _deleteBranch?: string): Promise { - throw new Error("not implemented: removeWorktree (Wed)"); +// Always a new branch: one branch can only be checked out in one worktree. +export async function addWorktree(repoPath: string, branch: string, path: string, base: string): Promise { + await git(repoPath, ["worktree", "add", "-b", branch, path, base]); } -// TODO(Tue): `git worktree prune`, run once when the runner starts. -export async function pruneWorktrees(_repoPath: string): Promise { - throw new Error("not implemented: pruneWorktrees (Tue)"); +// Approve keeps the branch, reject passes it to be deleted too. +// If the folder was already deleted by hand, prune clears git's stale entry instead. +export async function removeWorktree(repoPath: string, path: string, deleteBranch?: string): Promise { + await git(repoPath, ["worktree", "remove", "--force", path]).catch(() => pruneWorktrees(repoPath)); + if (deleteBranch) await git(repoPath, ["branch", "-D", deleteBranch]); +} + +// Clears entries for worktree folders that were deleted by hand. +export async function pruneWorktrees(repoPath: string): Promise { + await git(repoPath, ["worktree", "prune"]); } diff --git a/runner/src/index.ts b/runner/src/index.ts index 0facbc8..29db983 100644 --- a/runner/src/index.ts +++ b/runner/src/index.ts @@ -1,4 +1,4 @@ -export { createRunner } from "./runner.js"; +export { createRunner, type RunnerOptions } from "./runner.js"; export { createFakeRunner, type FakeRunnerOptions } from "./fake-runner.js"; export { EventSequence } from "./events.js"; export { SessionStore, type SessionRecord } from "./session-store.js"; diff --git a/runner/src/memory/notes.ts b/runner/src/memory/notes.ts index 2089280..dd646c8 100644 --- a/runner/src/memory/notes.ts +++ b/runner/src/memory/notes.ts @@ -2,9 +2,14 @@ // ~/.codehands/memory//notes.md, repoKey = first 12 chars of sha1(origin url or absolute path). // Never write CLAUDE.md or AGENTS.md into the worktree, they would show up in the diff. -// TODO(Fri): sha1 of `git remote get-url origin`, falling back to the absolute path. -export async function repoKey(_repoPath: string): Promise { - throw new Error("not implemented: repoKey (Fri)"); +import { createHash } from "node:crypto"; +import { resolve } from "node:path"; +import { git } from "../git/worktree.js"; + +// Also names the repo's worktree folder, so start() needs it before Friday. +export async function repoKey(repoPath: string): Promise { + const origin = await git(repoPath, ["remote", "get-url", "origin"]).catch(() => resolve(repoPath)); + return createHash("sha1").update(origin).digest("hex").slice(0, 12); } // TODO(Fri): read notes.md, empty string if missing. diff --git a/runner/src/runner.ts b/runner/src/runner.ts index 15d92e0..025082c 100644 --- a/runner/src/runner.ts +++ b/runner/src/runner.ts @@ -1,19 +1,117 @@ -import type { Runner } from "@codehands/contracts"; +import { createInterface } from "node:readline"; +import type { Runner, RunnerEvent, StartOptions } from "@codehands/contracts"; +import { execa } from "execa"; +import { claudeArgs, newClaudeParseState, parseClaudeLine } from "./agents/claude-code.js"; +import { EventSequence } from "./events.js"; +import { commitAll, diffSince } from "./git/diff.js"; +import { addWorktree, baseCommit, branchName, pruneWorktrees, removeWorktree, worktreePath } from "./git/worktree.js"; +import { repoKey } from "./memory/notes.js"; import { SessionStore } from "./session-store.js"; // The real runner. Diagram 1c in the week 1 guide is the map for start(). -export function createRunner(): Runner { +export interface RunnerOptions { + worktreeRoot?: string; // default ~/.codehands/worktrees + // Which program to run for a task. Tests swap in a script that replays a recording. + agentCommand?: (o: StartOptions) => { file: string; args: string[] }; +} + +const claudeCommand = (o: StartOptions) => ({ file: "claude", args: claudeArgs(o) }); + +export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: RunnerOptions = {}): Runner { const store = new SessionStore(); - void store; + + async function* start(o: StartOptions): AsyncIterable { + const record = store.create(o); + const seq = new EventSequence(o.sessionId); + const emit = (event: RunnerEvent) => { + record.lastSeq = seq.last; + return event; + }; + const fail = (reason: string, extra: Record = {}) => { + store.transition(o.sessionId, "failed"); + return emit(seq.next("task.failed", { reason, ...extra })); + }; + + if (o.agent !== "claude-code") { + yield fail(`agent ${o.agent} is not supported yet`); + return; + } + + // 1. A private copy of the repo on a new branch. + try { + record.baseCommit = await baseCommit(o.repoPath); + record.branch = branchName(o.prompt, o.sessionId); + record.worktreePath = worktreePath(await repoKey(o.repoPath), o.sessionId, worktreeRoot); + await pruneWorktrees(o.repoPath); + await addWorktree(o.repoPath, record.branch, record.worktreePath, record.baseCommit); + } catch (err) { + yield fail(`could not make the worktree: ${(err as Error).message}`); + return; + } + store.transition(o.sessionId, "running"); + + // 2. Run the agent inside the copy. reject: false, so a crash or a missing binary is a result, not a throw. + const { file, args } = agentCommand(o); + const child = execa(file, args, { + cwd: record.worktreePath, + stdin: "ignore", + reject: false, + buffer: { stdout: false, stderr: true }, + windowsHide: true, + }); + record.pid = child.pid; + + // 3. Every complete stdout line becomes events. readline never hands over half a line. + // The final event waits until the process is gone, so it can carry the diff. + const parseState = newClaudeParseState(); + let finished: RunnerEvent | undefined; + for await (const line of createInterface({ input: child.stdout!, crlfDelay: Infinity })) { + for (const event of parseClaudeLine(line, seq, parseState)) { + if (event.type === "task.finished" || event.type === "task.failed") finished = event; + else yield emit(event); + } + } + + // 4. The process is done. No result line means it crashed or never started. + const result = await child; + record.pid = undefined; + if (!finished) { + yield fail("the agent exited without a result", { + exitCode: result.exitCode, + error: result.failed ? result.shortMessage : undefined, + stderr: String(result.stderr ?? "").slice(-2000), + }); + return; + } + if (finished.type === "task.failed") { + store.transition(o.sessionId, "failed"); + yield emit(finished); + return; + } + + // 5. Store the change and put its summary on task.finished, so the phone shows it right away. + try { + record.diff = await diffSince(record.worktreePath, record.baseCommit); + finished.payload.diffStat = record.diff.stat; + } catch (err) { + finished.payload.diffError = (err as Error).message; + } + store.transition(o.sessionId, "awaiting_approval"); + yield emit(finished); + } + + // Everything below needs the worktree that start() made. + function worktreeOf(sessionId: string) { + const record = store.get(sessionId); + if (!record.worktreePath || !record.baseCommit || !record.branch) { + throw new Error(`session ${sessionId} has no worktree`); + } + return { record, worktreePath: record.worktreePath, baseCommit: record.baseCommit, branch: record.branch }; + } return { - // TODO(Tue): baseCommit -> addWorktree -> spawn claude with execa (cwd = worktree) - // -> readline over stdout -> parseClaudeLine -> yield. No result line -> task.failed. - // Handle both `exit` and `error` on the child. - start() { - throw new Error("not implemented: start (Tue)"); - }, + start, // TODO(Wed): stopProcessTree(pid), transition to stopped, yield task.stopped. Keep the worktree. async stop() { throw new Error("not implemented: stop (Wed)"); @@ -22,17 +120,34 @@ export function createRunner(): Runner { resume() { throw new Error("not implemented: resume (Thu)"); }, - // TODO(Wed): diffSince(worktree, baseCommit), store it on the session. - async diff() { - throw new Error("not implemented: diff (Wed)"); + // The change since the base commit, new files included. Fresh each call while the worktree exists. + async diff(sessionId) { + const { record, worktreePath, baseCommit } = worktreeOf(sessionId); + if (record.status === "approved" || record.status === "rejected") { + if (!record.diff) throw new Error(`session ${sessionId} has no stored diff`); + return record.diff; + } + record.diff = await diffSince(worktreePath, baseCommit); + return record.diff; }, - // TODO(Wed): commitAll on the branch, removeWorktree, keep the branch. - async approve() { - throw new Error("not implemented: approve (Wed)"); + + // Commit on the task's branch, then remove the worktree. The branch stays for the person to merge. + async approve(sessionId, message) { + const { record, worktreePath, baseCommit, branch } = worktreeOf(sessionId); + store.check(sessionId, "approved"); + record.diff = await diffSince(worktreePath, baseCommit); + const commit = await commitAll(worktreePath, message.trim() || `codehands: ${record.options.prompt}`); + await removeWorktree(record.options.repoPath, worktreePath); + store.transition(sessionId, "approved"); + return { branch, commit }; }, - // TODO(Wed): removeWorktree and delete the branch. - async reject() { - throw new Error("not implemented: reject (Wed)"); + + // Throw the change away: worktree and branch both go. + async reject(sessionId) { + const { record, worktreePath, branch } = worktreeOf(sessionId); + store.check(sessionId, "rejected"); + await removeWorktree(record.options.repoPath, worktreePath, branch); + store.transition(sessionId, "rejected"); }, }; } diff --git a/runner/src/session-store.ts b/runner/src/session-store.ts index e28c831..4a2041f 100644 --- a/runner/src/session-store.ts +++ b/runner/src/session-store.ts @@ -10,6 +10,7 @@ export interface SessionRecord { baseCommit?: string; pid?: number; lastSeq: number; + diff?: { patch: string; stat: string }; } export class SessionStore { @@ -30,11 +31,17 @@ export class SessionStore { // Refuses any move not in SESSION_TRANSITIONS, e.g. approving a running session. transition(sessionId: string, to: SessionStatus): SessionRecord { + const record = this.check(sessionId, to); + record.status = to; + return record; + } + + // The same check without moving, for work that must happen before the move (commit, then approved). + check(sessionId: string, to: SessionStatus): SessionRecord { const record = this.get(sessionId); if (!canTransition(record.status, to)) { throw new Error(`illegal move for ${sessionId}: ${record.status} -> ${to}`); } - record.status = to; return record; } } diff --git a/runner/test/claude-parser.test.ts b/runner/test/claude-parser.test.ts index 105ea39..69433f5 100644 --- a/runner/test/claude-parser.test.ts +++ b/runner/test/claude-parser.test.ts @@ -1,10 +1,86 @@ -import { describe, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { newClaudeParseState, parseClaudeLine } from "../src/agents/claude-code.js"; +import { EventSequence } from "../src/events.js"; + +// Replays test/fixtures/spike-1.jsonl (a real Claude Code 2.1.280 run, Oct 22) with no API cost. +const lines = readFileSync(new URL("./fixtures/spike-1.jsonl", import.meta.url), "utf8").split("\n"); + +function replay(input: string[]) { + const seq = new EventSequence("s1"); + const state = newClaudeParseState(); + return input.flatMap((line) => parseClaudeLine(line, seq, state)); +} -// Tuesday: replay test/fixtures/spike-1.jsonl through parseClaudeLine with no API cost. describe("parseClaudeLine", () => { - it.todo("maps system/init to task.started"); - it.todo("maps assistant text, Read, Edit/Write and Bash blocks"); - it.todo("maps the result line to task.finished with total_cost_usd and usage untouched"); - it.todo("turns a malformed line into a message with the raw text"); - it.todo("keeps raw on every event"); + const events = replay(lines); + + it("replays the whole spike in order", () => { + expect(events.map((e) => e.type)).toEqual([ + "task.started", + "command.started", "message", "command.finished", // PowerShell, blocked + "command.started", "file.read", "message", "command.finished", // PowerShell blocked, Glob + "file.read", "file.read", "file.read", + "file.changed", + "command.started", "command.finished", "test.result", + "message", + "task.finished", + ]); + expect(events.map((e) => e.seq)).toEqual(events.map((_, i) => i + 1)); + }); + + it("maps system/init to task.started, even when a rate_limit_event comes first", () => { + expect(JSON.parse(lines[0]!).type).toBe("rate_limit_event"); + expect(events[0]!.type).toBe("task.started"); + expect(events[0]!.payload).toMatchObject({ agent: "claude-code", permissionMode: "acceptEdits" }); + }); + + it("maps assistant text, Read, Edit/Write and Bash/PowerShell blocks", () => { + expect(events.filter((e) => e.type === "file.read" && e.payload.tool === "Read").map((e) => e.payload.path)) + .toEqual([expect.stringMatching(/date\.js$/), expect.stringMatching(/date\.test\.js$/), expect.stringMatching(/package\.json$/)]); + expect(events.find((e) => e.type === "file.changed")!.payload).toMatchObject({ tool: "Edit", path: expect.stringMatching(/src[\\/]date\.js$/) }); + expect(events.filter((e) => e.type === "command.started").map((e) => e.payload.tool)).toEqual(["PowerShell", "PowerShell", "Bash"]); + expect(events.at(-2)!.payload.text).toMatch(/^I fixed the failing test/); + }); + + it("marks blocked commands and gives no test result for a test that never ran", () => { + const finished = events.filter((e) => e.type === "command.finished"); + expect(finished.map((e) => e.payload.denied)).toEqual([true, true, false]); + expect(events.filter((e) => e.type === "message" && e.payload.denied)).toHaveLength(2); + expect(events.filter((e) => e.type === "test.result").map((e) => e.payload.passed)).toEqual([true]); + }); + + it("fails a test command whose output shows failures even when the exit code hides it", () => { + const use = { type: "assistant", message: { content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "npm test 2>&1 | tail -5" } }] } }; + const result = { type: "user", message: { content: [{ type: "tool_result", tool_use_id: "t1", is_error: false, content: "# pass 0\n# fail 1" }] } }; + const out = replay([JSON.stringify(use), JSON.stringify(result)]); + expect(out.find((e) => e.type === "test.result")!.payload.passed).toBe(false); + }); + + it("maps the result line to task.finished with total_cost_usd and usage untouched", () => { + const raw = JSON.parse(lines.filter(Boolean).at(-1)!); + const last = events.at(-1)!; + expect(last.type).toBe("task.finished"); + expect(last.payload.total_cost_usd).toBe(raw.total_cost_usd); + expect(last.payload.usage).toEqual(raw.usage); + }); + + it("maps an error result to task.failed", () => { + const [e] = replay([JSON.stringify({ type: "result", subtype: "error_max_budget_usd", is_error: true, total_cost_usd: 0.5 })]); + expect(e!.type).toBe("task.failed"); + expect(e!.payload.total_cost_usd).toBe(0.5); + }); + + it("turns a malformed or half-finished line into a message with the raw text, and skips blank lines", () => { + const half = lines[1]!.slice(0, 40); + const out = replay(["not json", half, "", " "]); + expect(out.map((e) => [e.type, e.payload.text, e.raw])).toEqual([ + ["message", "not json", "not json"], + ["message", half, half], + ]); + }); + + it("keeps raw on every event", () => { + expect(events.every((e) => e.raw !== undefined)).toBe(true); + }); }); diff --git a/runner/test/fixtures/spike-1.jsonl b/runner/test/fixtures/spike-1.jsonl new file mode 100644 index 0000000..11c0af1 --- /dev/null +++ b/runner/test/fixtures/spike-1.jsonl @@ -0,0 +1,25 @@ +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1792660800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.01,"resetsAt":1792660800},"seven_day":{"utilization":0.06,"resetsAt":1793102400}}},"uuid":"948a64ea-6c7f-4568-abd3-49a273f9763b","session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c"} +{"type":"system","subtype":"init","cwd":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1","session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","tools":["Task","Artifact","ArtifactComments","ArtifactData","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","Glob","Grep","ListAgents","ListMcpResourcesTool","Monitor","NotebookEdit","PowerShell","PushNotification","Read","ReadMcpResourceDirTool","ReadMcpResourceTool","RemoteTrigger","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskStop","ToolSearch","WebFetch","WebSearch","Workflow","Write","mcp__claude_ai_Claude_Docs__batch","mcp__claude_ai_Claude_Docs__create","mcp__claude_ai_Claude_Docs__delete","mcp__claude_ai_Claude_Docs__export","mcp__claude_ai_Claude_Docs__guide","mcp__claude_ai_Claude_Docs__query","mcp__claude_ai_Claude_Docs__read","mcp__claude_ai_Claude_Docs__update","mcp__claude_ai_Fathom__find_person","mcp__claude_ai_Fathom__find_relevant_meetings","mcp__claude_ai_Fathom__get_identity","mcp__claude_ai_Fathom__get_meeting_summary","mcp__claude_ai_Fathom__get_meeting_transcript","mcp__claude_ai_Fathom__get_recording_by_call_id","mcp__claude_ai_Fathom__get_recording_by_url","mcp__claude_ai_Fathom__get_relevant_meetings_result","mcp__claude_ai_Fathom__list_meetings","mcp__claude_ai_Fathom__list_teams","mcp__claude_ai_Fathom__scan_meeting_summaries","mcp__claude_ai_Fathom__search_meetings"],"mcp_servers":[{"name":"plugin:sales:slack","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:hubspot","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:salesforce","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:close","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:monday","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:clay","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:zoominfo","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:notion","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:atlassian","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:fireflies","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:apollo","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:outreach","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:google calendar","status":"failed","source":"plugin"},{"name":"plugin:sales:gmail","status":"failed","source":"plugin"},{"name":"plugin:sales:microsoft-365","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:similarweb","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:google drive","status":"failed","source":"plugin"},{"name":"plugin:sales:gong","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:zoom","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:otter-ai","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:calendly","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:lusha","status":"needs-auth","source":"plugin"},{"name":"plugin:sales:crunchbase","status":"needs-auth","source":"plugin"},{"name":"plugin:engineering:linear","status":"needs-auth","source":"plugin"},{"name":"plugin:engineering:asana","status":"needs-auth","source":"plugin"},{"name":"plugin:engineering:github","status":"needs-auth","source":"plugin"},{"name":"plugin:engineering:pagerduty","status":"needs-auth","source":"plugin"},{"name":"plugin:engineering:datadog","status":"needs-auth","source":"plugin"},{"name":"plugin:engineering:google calendar","status":"failed","source":"plugin"},{"name":"plugin:engineering:gmail","status":"failed","source":"plugin"},{"name":"plugin:productivity:clickup","status":"needs-auth","source":"plugin"},{"name":"plugin:productivity:google calendar","status":"failed","source":"plugin"},{"name":"plugin:productivity:gmail","status":"failed","source":"plugin"},{"name":"plugin:marketing:canva","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:figma","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:amplitude","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:amplitude-eu","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:ahrefs","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:similarweb","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:klaviyo","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:supermetrics","status":"needs-auth","source":"plugin"},{"name":"plugin:marketing:google calendar","status":"failed","source":"plugin"},{"name":"plugin:marketing:gmail","status":"failed","source":"plugin"},{"name":"claude.ai Claude Docs","status":"connected","source":"claudeai"},{"name":"claude.ai Upwork","status":"pending","source":"claudeai"},{"name":"claude.ai syspos","status":"pending","source":"claudeai"},{"name":"claude.ai syspos (2)","status":"needs-auth","source":"claudeai"},{"name":"claude.ai Syspos Owner Agent","status":"needs-auth","source":"claudeai"},{"name":"claude.ai higgsfield","status":"pending","source":"claudeai"},{"name":"claude.ai Fathom","status":"connected","source":"claudeai"},{"name":"claude.ai Gmail","status":"needs-auth","source":"claudeai"},{"name":"claude.ai Google Calendar","status":"needs-auth","source":"claudeai"},{"name":"claude.ai Google Drive","status":"needs-auth","source":"claudeai"}],"model":"claude-opus-5-5[1m]","permissionMode":"acceptEdits","slash_commands":["color","frontend-design","impeccable","n8n-code-javascript","n8n-code-python","n8n-expression-syntax","n8n-mcp-tools-expert","n8n-node-configuration","n8n-validation-expert","n8n-workflow-patterns","deep-research","sales:account-context","sales:account-plan","sales:account-research","sales:account-tiering","sales:call-prep","sales:call-summary","sales:close-plan","sales:competitive-intelligence","sales:create-an-asset","sales:crm-hygiene-check","sales:customer-health","sales:customer-voice","sales:daily-briefing","sales:deal-advance-gap","sales:deal-review","sales:deal-signals","sales:deal-slip-scenario","sales:draft-outreach","sales:end-of-day","sales:expansion-whitespace","sales:forecast","sales:handle-objection","sales:inbox-sweep","sales:lead-triage","sales:log-activity","sales:pipeline-review","sales:renewal-radar","sales:rep-context","sales:route-lead","sales:schedule-meeting","sales:setup","sales:stakeholder-map","sales:team-pipeline","sales:update-opportunity","sales:weekly-wrap","sales:win-loss-review","engineering:architecture","engineering:code-review","engineering:debug","engineering:deploy-checklist","engineering:documentation","engineering:incident-response","engineering:standup","engineering:system-design","engineering:tech-debt","engineering:testing-strategy","productivity:start","productivity:update","marketing:brand-review","marketing:campaign-plan","marketing:competitive-brief","marketing:draft-content","marketing:email-sequence","marketing:performance-report","marketing:seo-audit","cowork-plugin-management:cowork-plugin-customizer","cowork-plugin-management:create-cowork-plugin","design","slides","design-sync","dataviz","artifact-diagramming","artifact-capabilities","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","advisor","agents","auto-mode-setup","autocompact","clear","compact","config","output-style","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-plugins","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design-consent","design-revoke","list-agents","team-onboarding","anthropic-skills:brand-guidelines","anthropic-skills:built-in-browser","anthropic-skills:canvas-design","anthropic-skills:chrome-browser","anthropic-skills:computer-use","anthropic-skills:deep-research","anthropic-skills:docs","anthropic-skills:docx","anthropic-skills:frontend-design","anthropic-skills:google-workspace","anthropic-skills:impeccable","anthropic-skills:import-memory","anthropic-skills:morning","anthropic-skills:pdf","anthropic-skills:pptx","anthropic-skills:skill-creator","anthropic-skills:ui-ux-pro-max","anthropic-skills:web-design-guidelines","anthropic-skills:xlsx"],"terminal_slash_commands":["doctor","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.280","output_style":"default","agents":["claude","claude-code-guide","Explore","general-purpose","Plan","statusline-setup"],"skills":["color","frontend-design","impeccable","n8n-code-javascript","n8n-code-python","n8n-expression-syntax","n8n-mcp-tools-expert","n8n-node-configuration","n8n-validation-expert","n8n-workflow-patterns","deep-research","sales:account-context","sales:account-plan","sales:account-research","sales:account-tiering","sales:call-prep","sales:call-summary","sales:close-plan","sales:competitive-intelligence","sales:create-an-asset","sales:crm-hygiene-check","sales:customer-health","sales:customer-voice","sales:daily-briefing","sales:deal-advance-gap","sales:deal-review","sales:deal-signals","sales:deal-slip-scenario","sales:draft-outreach","sales:end-of-day","sales:expansion-whitespace","sales:forecast","sales:handle-objection","sales:inbox-sweep","sales:lead-triage","sales:log-activity","sales:pipeline-review","sales:renewal-radar","sales:rep-context","sales:route-lead","sales:schedule-meeting","sales:setup","sales:stakeholder-map","sales:team-pipeline","sales:update-opportunity","sales:weekly-wrap","sales:win-loss-review","engineering:architecture","engineering:code-review","engineering:debug","engineering:deploy-checklist","engineering:documentation","engineering:incident-response","engineering:standup","engineering:system-design","engineering:tech-debt","engineering:testing-strategy","productivity:start","productivity:update","marketing:brand-review","marketing:campaign-plan","marketing:competitive-brief","marketing:draft-content","marketing:email-sequence","marketing:performance-report","marketing:seo-audit","cowork-plugin-management:cowork-plugin-customizer","cowork-plugin-management:create-cowork-plugin","design","slides","design-sync","dataviz","artifact-diagramming","artifact-capabilities","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","anthropic-skills:brand-guidelines","anthropic-skills:built-in-browser","anthropic-skills:canvas-design","anthropic-skills:chrome-browser","anthropic-skills:computer-use","anthropic-skills:deep-research","anthropic-skills:docs","anthropic-skills:docx","anthropic-skills:frontend-design","anthropic-skills:google-workspace","anthropic-skills:impeccable","anthropic-skills:import-memory","anthropic-skills:morning","anthropic-skills:pdf","anthropic-skills:pptx","anthropic-skills:skill-creator","anthropic-skills:ui-ux-pro-max","anthropic-skills:web-design-guidelines","anthropic-skills:xlsx"],"plugins":[{"name":"sales","path":"C:\\Users\\hp\\.claude\\plugins\\synced\\90e47895-5d2c-4d7e-88b0-37d9eb3590cf_306557d1-f899-455c-9a85-8a6af08f5bd8\\sales~g2","source":"sales@synced","version":"2.1.0"},{"name":"engineering","path":"C:\\Users\\hp\\.claude\\plugins\\synced\\90e47895-5d2c-4d7e-88b0-37d9eb3590cf_306557d1-f899-455c-9a85-8a6af08f5bd8\\engineering","source":"engineering@synced","version":"1.2.0"},{"name":"productivity","path":"C:\\Users\\hp\\.claude\\plugins\\synced\\90e47895-5d2c-4d7e-88b0-37d9eb3590cf_306557d1-f899-455c-9a85-8a6af08f5bd8\\productivity~g2","source":"productivity@synced","version":"1.3.1"},{"name":"marketing","path":"C:\\Users\\hp\\.claude\\plugins\\synced\\90e47895-5d2c-4d7e-88b0-37d9eb3590cf_306557d1-f899-455c-9a85-8a6af08f5bd8\\marketing","source":"marketing@synced","version":"1.2.0"},{"name":"cowork-plugin-management","path":"C:\\Users\\hp\\.claude\\plugins\\synced\\90e47895-5d2c-4d7e-88b0-37d9eb3590cf_306557d1-f899-455c-9a85-8a6af08f5bd8\\cowork-plugin-management","source":"cowork-plugin-management@synced","version":"0.2.2"},{"name":"agents-md","path":"builtin","source":"agents-md@builtin"},{"name":"telemetry","path":"builtin","source":"telemetry@builtin"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1","mcp_read_resource_v1","mcp_tool_ui_meta_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"a42bf3d4-cd7d-41c2-93a0-0c1a4b710b21","memory_paths":{"auto":"C:\\Users\\hp\\.claude\\projects\\C--Users-hp-codehands-fixtures-date-bug\\memory\\"},"scratchpad_path":"C:\\Users\\hp\\AppData\\Local\\Temp\\claude\\C--Users-hp-codehands-fixtures-wt-spike-1\\f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c\\scratchpad","messaging_socket_path":"\\\\.\\pipe\\LOCAL\\cc-msg-a6474cc9cbfba60d464aae0a534527ef","fast_mode_state":"off","fast_mode_disabled_reason":"extra_usage_disabled","powershell_path":"C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe","startup_timing":{"entrypoint":"claude-vscode","warm_spare_claimed":false,"plugin_full_loads":0,"user_context_builds":2,"sdk_mcp_dial_runs":0,"sdk_mcp_servers_dialed":0,"phases":{"node_boot_ms":138,"settings_load_ms":0,"pre_action_ms":134,"permission_context_ms":146,"setup_import_ms":1,"setup_uds_messaging_ms":99,"setup_hooks_snapshot_ms":3,"setup_file_watcher_ms":0,"setup_release_notes_ms":24,"setup_ms":120,"skills_load_ms":505,"tools_commands_load_ms":361,"mcp_connect_ms":31,"hooks_init_ms":94,"input_ready_ms":1892,"first_message_read_ms":1920,"sdk_mcp_update_ms":6,"first_message_handle_ms":6,"first_message_handle_cpu_ms":0,"first_message_handle_loop_lag_ms":0,"mcp_prewait_ms":1942,"qe_system_prompt_ms":28,"prompt_submit_hooks_ms":1,"qe_plugin_skills_load_ms":2,"init_emit_ms":2},"time_origin_ms":1792652060020.3066,"phase_start_ms":{"node_boot_ms":0,"settings_load_ms":140,"pre_action_ms":143,"permission_context_ms":290,"setup_import_ms":491,"setup_uds_messaging_ms":505,"setup_hooks_snapshot_ms":607,"setup_file_watcher_ms":610,"setup_release_notes_ms":613,"setup_ms":516,"skills_load_ms":511,"tools_commands_load_ms":680,"mcp_connect_ms":1153,"hooks_init_ms":1144,"input_ready_ms":0,"first_message_read_ms":0,"sdk_mcp_update_ms":1920,"first_message_handle_ms":1920,"mcp_prewait_ms":1943,"qe_system_prompt_ms":3900,"prompt_submit_hooks_ms":3956,"qe_plugin_skills_load_ms":3970,"init_emit_ms":3979}}} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqTbrDXVKQFf5m7gda2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JN1rLzKghksfPVhobaQNYs","name":"PowerShell","input":{"command":"git ls-files; npm test","description":"List files and run tests"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":17838,"cache_read_input_tokens":26494,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":17838},"output_tokens":24,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"4d06e16c-a64f-49a6-8708-ac7ad5fc650a","timestamp":"2026-10-22T06:54:27.107Z","request_id":"req_011CgGqTbPRxyJkdXEzSoQ2h","wire_tool_inputs":{"toolu_01JN1rLzKghksfPVhobaQNYs":{"command":"git ls-files; npm test","description":"List files and run tests"}}} +{"type":"system","subtype":"permission_denied","tool_name":"PowerShell","tool_use_id":"toolu_01JN1rLzKghksfPVhobaQNYs","decision_reason_type":"subcommandResults","message":"This PowerShell command contains multiple operations. The following part requires approval: git ls-files; npm test","uuid":"9191fd03-0e34-47d0-a3f4-f65fdeea1ed9","session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"This PowerShell command contains multiple operations. The following part requires approval: git ls-files; npm test","is_error":true,"tool_use_id":"toolu_01JN1rLzKghksfPVhobaQNYs"}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"4a0b3568-f481-4d53-b469-f593f166e2e3","timestamp":"2026-10-22T06:54:28.022Z","tool_use_result":"Error: This PowerShell command contains multiple operations. The following part requires approval: git ls-files; npm test","tool_result_meta":[{"id":"toolu_01JN1rLzKghksfPVhobaQNYs","non_execution_kind":"user-rejected"}]} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqTtrQcFcR6FpcdWZAf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016CiNPoKo5eBkdN3SwEMTjF","name":"PowerShell","input":{"command":"npm test","description":"Run tests"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6924,"cache_read_input_tokens":44332,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6924},"output_tokens":24,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"ef6c0161-b730-4618-844b-ccf623c6e33e","timestamp":"2026-10-22T06:54:31.162Z","request_id":"req_011CgGqTtcWj3PQEZwJAf9r7","wire_tool_inputs":{"toolu_016CiNPoKo5eBkdN3SwEMTjF":{"command":"npm test","description":"Run tests"}}} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqTtrQcFcR6FpcdWZAf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01StrJfJaVw8b6j4xchGi7gv","name":"Glob","input":{"pattern":"**/*.{js,ts,mjs,json}"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6924,"cache_read_input_tokens":44332,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":6924},"output_tokens":24,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"05671b39-dbbb-458a-b89f-dc88f5d86931","timestamp":"2026-10-22T06:54:31.390Z","request_id":"req_011CgGqTtcWj3PQEZwJAf9r7","wire_tool_inputs":{"toolu_01StrJfJaVw8b6j4xchGi7gv":{"pattern":"**/*.{js,ts,mjs,json}"}}} +{"type":"system","subtype":"permission_denied","tool_name":"PowerShell","tool_use_id":"toolu_016CiNPoKo5eBkdN3SwEMTjF","decision_reason_type":"subcommandResults","message":"This PowerShell command contains multiple operations. The following part requires approval: npm test","uuid":"549060f3-7287-4e27-879c-c44468c785eb","session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"This PowerShell command contains multiple operations. The following part requires approval: npm test","is_error":true,"tool_use_id":"toolu_016CiNPoKo5eBkdN3SwEMTjF"}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"052bdce9-38ab-4862-a65f-7343cd500c1a","timestamp":"2026-10-22T06:54:31.698Z","tool_use_result":"Error: This PowerShell command contains multiple operations. The following part requires approval: npm test","tool_result_meta":[{"id":"toolu_016CiNPoKo5eBkdN3SwEMTjF","non_execution_kind":"user-rejected"}]} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01StrJfJaVw8b6j4xchGi7gv","type":"tool_result","content":"package.json\nsrc\\date.js\ntest\\date.test.js"}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"ee465fbe-5cd1-47fb-bf38-939c9b2dd50a","timestamp":"2026-10-22T06:54:31.771Z","tool_use_result":{"filenames":["package.json","src\\date.js","test\\date.test.js"],"durationMs":49,"numFiles":3,"truncated":false,"totalMatches":3,"countIsComplete":true}} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqUAz218FbJohLT68ak","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012aMjkxz1N9vC6V7YUcngNw","name":"Read","input":{"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\src\\date.js"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":292,"cache_read_input_tokens":51256,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":292},"output_tokens":16,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"5a86c100-058a-415e-b48d-25b3b9e6cd40","timestamp":"2026-10-22T06:54:37.056Z","request_id":"req_011CgGqUAeRraprRvZv2tcqH","wire_tool_inputs":{"toolu_012aMjkxz1N9vC6V7YUcngNw":{"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\src\\date.js"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012aMjkxz1N9vC6V7YUcngNw","type":"tool_result","content":"1\t// Returns the day of the month for an ISO date string, e.g. \"2026-10-01T00:00:00Z\" -> 1.\n2\texport function dayOfMonth(iso) {\n3\t const d = new Date(iso);\n4\t return d.getDate();\n5\t}\n6\t"}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"5126d08f-f6e9-45da-a227-69b88abad1d6","timestamp":"2026-10-22T06:54:37.096Z","tool_use_result":{"type":"text","file":{"filePath":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\src\\date.js","content":"// Returns the day of the month for an ISO date string, e.g. \"2026-10-01T00:00:00Z\" -> 1.\nexport function dayOfMonth(iso) {\n const d = new Date(iso);\n return d.getDate();\n}\n","numLines":6,"startLine":1,"totalLines":6}}} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqUAz218FbJohLT68ak","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QrU1PhDp4VX8214VUjbYtw","name":"Read","input":{"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\test\\date.test.js"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":292,"cache_read_input_tokens":51256,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":292},"output_tokens":16,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"365fa759-ab81-4803-98ce-7a50e8a5c2f0","timestamp":"2026-10-22T06:54:37.603Z","request_id":"req_011CgGqUAeRraprRvZv2tcqH","wire_tool_inputs":{"toolu_01QrU1PhDp4VX8214VUjbYtw":{"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\test\\date.test.js"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QrU1PhDp4VX8214VUjbYtw","type":"tool_result","content":"1\timport { test } from \"node:test\";\n2\timport assert from \"node:assert/strict\";\n3\timport { dayOfMonth } from \"../src/date.js\";\n4\t\n5\ttest(\"day of month is read in UTC\", () => {\n6\t assert.equal(dayOfMonth(\"2026-10-01T00:00:00Z\"), 1);\n7\t assert.equal(dayOfMonth(\"2026-10-31T23:30:00Z\"), 31);\n8\t});\n9\t"}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"f67492b6-e330-4d3e-805c-e9b4a7094683","timestamp":"2026-10-22T06:54:37.626Z","tool_use_result":{"type":"text","file":{"filePath":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\test\\date.test.js","content":"import { test } from \"node:test\";\nimport assert from \"node:assert/strict\";\nimport { dayOfMonth } from \"../src/date.js\";\n\ntest(\"day of month is read in UTC\", () => {\n assert.equal(dayOfMonth(\"2026-10-01T00:00:00Z\"), 1);\n assert.equal(dayOfMonth(\"2026-10-31T23:30:00Z\"), 31);\n});\n","numLines":9,"startLine":1,"totalLines":9}}} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqUAz218FbJohLT68ak","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LgZGNhUTyT5sBZutwyJZVw","name":"Read","input":{"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\package.json"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":292,"cache_read_input_tokens":51256,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":292},"output_tokens":16,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"decae91d-5ece-4e20-849d-17e3225b5f9d","timestamp":"2026-10-22T06:54:37.844Z","request_id":"req_011CgGqUAeRraprRvZv2tcqH","wire_tool_inputs":{"toolu_01LgZGNhUTyT5sBZutwyJZVw":{"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\package.json"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LgZGNhUTyT5sBZutwyJZVw","type":"tool_result","content":"1\t{\n2\t \"name\": \"date-bug\",\n3\t \"private\": true,\n4\t \"type\": \"module\",\n5\t \"scripts\": { \"test\": \"node --test\" }\n6\t}\n7\t"}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"60b1f53f-f763-43c5-9729-d7883a539007","timestamp":"2026-10-22T06:54:37.871Z","tool_use_result":{"type":"text","file":{"filePath":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\package.json","content":"{\n \"name\": \"date-bug\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": { \"test\": \"node --test\" }\n}\n","numLines":7,"startLine":1,"totalLines":7}}} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqUd5ATcpCLNsWauzTT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JJxHaq72zkLcWAaPBBJrU6","name":"Edit","input":{"replace_all":false,"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\src\\date.js","old_string":"getDate()","new_string":"getUTCDate()"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":664,"cache_read_input_tokens":51548,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":664},"output_tokens":16,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"1a804b81-89ce-4245-a62f-96fc97b72f5e","timestamp":"2026-10-22T06:54:43.926Z","request_id":"req_011CgGqUcjKsrnEBVGQSgz8K","wire_tool_inputs":{"toolu_01JJxHaq72zkLcWAaPBBJrU6":{"file_path":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\src\\date.js","old_string":"getDate()","new_string":"getUTCDate()"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JJxHaq72zkLcWAaPBBJrU6","type":"tool_result","content":"The file C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\src\\date.js has been updated successfully. (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"bb3f23f3-9070-4268-bfe6-659396c01018","timestamp":"2026-10-22T06:54:43.957Z","tool_use_result":{"filePath":"C:\\Users\\hp\\codehands-fixtures\\wt-spike-1\\src\\date.js","oldString":"getDate()","newString":"getUTCDate()","originalFile":"// Returns the day of the month for an ISO date string, e.g. \"2026-10-01T00:00:00Z\" -> 1.\nexport function dayOfMonth(iso) {\n const d = new Date(iso);\n return d.getDate();\n}\n","structuredPatch":[{"oldStart":1,"oldLines":5,"newStart":1,"newLines":5,"lines":[" // Returns the day of the month for an ISO date string, e.g. \"2026-10-01T00:00:00Z\" -> 1."," export function dayOfMonth(iso) {"," const d = new Date(iso);","- return d.getDate();","+ return d.getUTCDate();"," }"]}],"userModified":false,"replaceAll":false}} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqUd5ATcpCLNsWauzTT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UN5mTQiENyubG3rPzjka8L","name":"Bash","input":{"command":"npm test 2>&1 | tail -15","description":"Run tests"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":664,"cache_read_input_tokens":51548,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":664},"output_tokens":16,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"82c55c1c-fffa-40a0-85b0-2ceb590e8e3e","timestamp":"2026-10-22T06:54:44.166Z","request_id":"req_011CgGqUcjKsrnEBVGQSgz8K","wire_tool_inputs":{"toolu_01UN5mTQiENyubG3rPzjka8L":{"command":"npm test 2>&1 | tail -15","description":"Run tests"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UN5mTQiENyubG3rPzjka8L","type":"tool_result","content":"# Subtest: day of month is read in UTC\nok 1 - day of month is read in UTC\n ---\n duration_ms: 0.8378\n type: 'test'\n ...\n1..1\n# tests 1\n# suites 0\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 99.2895","is_error":false}]},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"26c56491-d8cf-4774-9ae9-35d0e49f4251","timestamp":"2026-10-22T06:54:46.425Z","tool_use_result":{"stdout":"# Subtest: day of month is read in UTC\nok 1 - day of month is read in UTC\n ---\n duration_ms: 0.8378\n type: 'test'\n ...\n1..1\n# tests 1\n# suites 0\n# pass 1\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n# duration_ms 99.2895","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":50,"estimated_tokens_delta":50,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"03916cf8-399c-46fe-b1be-b0916b02248d"} +{"type":"system","subtype":"thinking_tokens","estimated_tokens":267,"estimated_tokens_delta":217,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"83541522-ea2e-43b6-bb68-17ca3be09de7"} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqVFRC4RMWpcVWkw1fr","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAQSowgKEAgTGAI4AUIIdGhpbmtpbmcSDDnoP14Yxx44K8zGzxoMIsOg45xj4a5Tg8O1IjCCIsVL3HKrOfgLJJMtiWtAAGmT6xY43JoE0L6KeWDaN8qTWEVJGwuBv9ZnrFEA7owqwAd+kVkvAmu6xtaCRC6FIQHAyU3CU3gEO9HWMjEudpFqA9Pwo89fnf9A6xth+Izkm2hItOx2Jr5bgX7czwI3dKz5KWoAESvGNTlREDuMtBCvMP1XRD29QcxlM7uad7tRqc+Ufe24eXv2DsvdEG0ogYExSfqurwbFBsNKOe4F0HHGl0ymJDym/p9IpY2V9uTYyYh+zySjSlEiitfMfFV7VokwilNpkK/xKoFNsR3bP/97JG5kYfF/sP5XxKOBwEs5Ln7gRGCrcGl1NWAwOLDcnVA8emt67F4b2M5wNwVRTaF+PC7Y+RcCX0ZXaiCmIe626Eyq9URaTGsfLzfA6sfFLN/F4q+1dz6TyqdGs2MFtN9Y8I+WH6PcpXBmWHJMtHG7msRk/q6IDwiY3dDn9szaoocjzBVMWiDsuDLrY/RAWqjqXqdyGyXmzGMPHvmr69mIcwYyop1LgDivZCiphMqq2C885nweQmo2bDOHSIrzlAURbuCIHYCjTGKIgPodhEKhQzP8eJJ2SA24GFcEiOlut2JMl1bwpb/StHk8YGsOAd+Nl2AyL9GaPtJbfh7Tc5RjZwnzTpb6lEzTe84X/eqdWXXyp1jtqfj5nJyDwwaYMypA5SEa32yACRmRMhWnFE9l+5SXZrGAnc01qXQT6uHTq8qIcL0cslD9D4tE22Kz3EnNKiW+NUYBRX2b8xitZM8dwmHs091GsCTT+4z/+hrFbEo51LQsFRkXItnklJajqO7xiDJKU9lkJyivJx5EPe6JJLlz9hjGgxm/B9v0XCVLDD6bTuOTpgBxquvCK1TGG3OU4kFjb3Lw2bc38LbeUxR+iHdgbBoFckcnIJf0pGam9mZc96nz4IV4zkorSBl1Wl18UGggLsAIa6EETzXtzXLZO8t8g8C5r5M14RE41X07wDAc9f12loj0dC6I7L3n8DjiHWnl2GK2wvFl4CIE1qYLezpDNLfvcoZqg2R/s2nn/PMb+zaGzlBsfuXW3Zp9mdsr5HLDRf/HdhjpTsKoveb+vj02XwKkbY6QsXoQkFmEIts1xKHBQSNpvSCsXOaiiFRf/Rhu9khn0PGVN2kuk3Kfuie+OBpBF6p05DC0m4ovc1aA88y5YwtuZ5ZzuvRCzIR7e02C8SnGOgovIX1ByTzdOG41zU3Xp0GPL7uYVd3aRs/HyNLzn4QUElJgv+atrjNEE6Td8UhBIspgAyq8puGgxXdGpFRGrif8mr6yoMF1fPiXL7AMeOmeAjgRkGUGXuvK7tC/YVagD16c81EBHbUrDHwYAQ=="}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":503,"cache_read_input_tokens":52212,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":503},"output_tokens":3,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"ab62764d-2609-43be-aaa8-e2fa671d10d0","timestamp":"2026-10-22T06:54:52.339Z","request_id":"req_011CgGqVFA4wA4farGxwryPz"} +{"type":"assistant","message":{"model":"claude-opus-5-5","id":"msg_011CgGqVFRC4RMWpcVWkw1fr","type":"message","role":"assistant","content":[{"type":"text","text":"I fixed the failing test, and `npm test` now passes (1 test, 0 failures).\n\n**The bug:** `dayOfMonth` in `src/date.js` used `getDate()`, which gives the day in the machine's local time zone. The test expects the day in UTC (it's named \"day of month is read in UTC\"). So on a machine whose time zone isn't UTC, a time like `2026-10-31T23:30:00Z` can fall on a different local day, and the function returned the wrong number.\n\n**The fix:** I changed `getDate()` to `getUTCDate()` (`src/date.js:4`). The result no longer depends on the machine's time zone.\n\nI haven't committed the change.\n\nSeparately, several connected services (Gmail, Google Calendar, GitHub and others) need to be authorized before their tools work. You can do that in your claude.ai connector settings, or with `/mcp` in an interactive session. None of them were needed for this fix."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":503,"cache_read_input_tokens":52212,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":503},"output_tokens":3,"service_tier":"standard","inference_geo":"global","speed":"standard"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","uuid":"ce07bb35-1f1d-48ef-852a-7cbf8a6ff638","timestamp":"2026-10-22T06:54:52.353Z","request_id":"req_011CgGqVFA4wA4farGxwryPz"} +{"duration_api_ms":25716,"stop_reason":"end_turn","session_id":"f9e34e0f-c8ea-41ae-9b37-f8ddb1f8725c","total_cost_usd":0.2777744,"usage":{"input_tokens":10,"cache_creation_input_tokens":26221,"cache_read_input_tokens":225842,"output_tokens":1092,"output_tokens_details":{"thinking_tokens":124},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":26221,"ephemeral_5m_input_tokens":0},"inference_geo":"global","iterations":[{"input_tokens":2,"output_tokens":430,"cache_read_input_tokens":52212,"cache_creation_input_tokens":503,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":503},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":903,"outputTokens":11,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0009580000000000001,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":0,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"},"claude-opus-5-5[1m]":{"inputTokens":10,"outputTokens":1092,"cacheReadInputTokens":225842,"cacheCreationInputTokens":26221,"webSearchRequests":0,"costUSD":0.2768164,"contextWindow":1000000,"maxOutputTokens":128000,"thinkingTokens":124,"canonicalModel":"claude-opus-5-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[{"tool_name":"PowerShell","tool_use_id":"toolu_01JN1rLzKghksfPVhobaQNYs","tool_input":{"command":"git ls-files; npm test","description":"List files and run tests"}},{"tool_name":"PowerShell","tool_use_id":"toolu_016CiNPoKo5eBkdN3SwEMTjF","tool_input":{"command":"npm test","description":"Run tests"}}],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"extra_usage_disabled","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":9,"subtype":"success","api_error_status":null,"result":"I fixed the failing test, and `npm test` now passes (1 test, 0 failures).\n\n**The bug:** `dayOfMonth` in `src/date.js` used `getDate()`, which gives the day in the machine's local time zone. The test expects the day in UTC (it's named \"day of month is read in UTC\"). So on a machine whose time zone isn't UTC, a time like `2026-10-31T23:30:00Z` can fall on a different local day, and the function returned the wrong number.\n\n**The fix:** I changed `getDate()` to `getUTCDate()` (`src/date.js:4`). The result no longer depends on the machine's time zone.\n\nI haven't committed the change.\n\nSeparately, several connected services (Gmail, Google Calendar, GitHub and others) need to be authorized before their tools work. You can do that in your claude.ai connector settings, or with `/mcp` in an interactive session. None of them were needed for this fix.","ttft_ms":3189,"type":"result","duration_ms":28496,"uuid":"ccb7d6c2-0185-418c-a008-efdcad533b50","ttft_stream_ms":2111,"time_to_request_ms":147,"first_content_frame_ms":2112,"queued_turn_count":0,"result_index":0} diff --git a/runner/test/helpers/replay-agent.mjs b/runner/test/helpers/replay-agent.mjs new file mode 100644 index 0000000..cf86c56 --- /dev/null +++ b/runner/test/helpers/replay-agent.mjs @@ -0,0 +1,12 @@ +// Stand-in for `claude`: prints a recorded JSONL file line by line, then exits with the given code. +// node replay-agent.mjs [exitCode] [edit] +// With "edit" it also changes a.txt and adds new.txt in its working folder, like a real fix would. +import { readFileSync, writeFileSync } from "node:fs"; + +const [file, exitCode = "0", mode] = process.argv.slice(2); +if (mode === "edit") { + writeFileSync("a.txt", "hello\nworld\n"); + writeFileSync("new.txt", "a new file\n"); +} +if (file) process.stdout.write(readFileSync(file, "utf8")); +process.exitCode = Number(exitCode); diff --git a/runner/test/runner.test.ts b/runner/test/runner.test.ts new file mode 100644 index 0000000..a41189a --- /dev/null +++ b/runner/test/runner.test.ts @@ -0,0 +1,158 @@ +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { RunnerEvent, StartOptions } from "@codehands/contracts"; +import { afterAll, describe, expect, it } from "vitest"; +import { createRunner, type RunnerOptions } from "../src/index.js"; + +// start() against a throwaway git repo. The agent is a script that replays spike-1.jsonl, so no API cost. +const replay = fileURLToPath(new URL("./helpers/replay-agent.mjs", import.meta.url)); +const spike = fileURLToPath(new URL("./fixtures/spike-1.jsonl", import.meta.url)); +const tmp = mkdtempSync(join(tmpdir(), "codehands-runner-")); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const git = (cwd: string, ...args: string[]) => + execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "-c", "core.autocrlf=false", ...args], { cwd, encoding: "utf8" }).trim(); + +function makeRepo(withCommit = true): string { + const repo = mkdtempSync(join(tmp, "repo-")); + git(repo, "init", "-q", "-b", "main"); + if (withCommit) { + writeFileSync(join(repo, "a.txt"), "hello\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-q", "-m", "first"); + } + return repo; +} + +function options(repoPath: string, extra: Partial = {}): StartOptions { + return { sessionId: randomUUID(), repoPath, prompt: "Fix the failing test", agent: "claude-code", ...extra }; +} + +async function collect(o: StartOptions, runnerOptions: RunnerOptions = {}, runner = newRunner(runnerOptions)): Promise { + const events: RunnerEvent[] = []; + for await (const e of runner.start(o)) events.push(e); + return events; +} + +const newRunner = (runnerOptions: RunnerOptions = {}) => createRunner({ worktreeRoot: join(tmp, "worktrees"), ...runnerOptions }); + +const replayAgent = (file: string, exitCode = 0, mode = ""): RunnerOptions["agentCommand"] => + () => ({ file: process.execPath, args: [replay, file, String(exitCode), mode] }); + +describe("runner.start", () => { + it("makes a worktree on a new branch, runs the agent there and streams its events", async () => { + const repo = makeRepo(); + const o = options(repo); + const events = await collect(o, { agentCommand: replayAgent(spike) }); + + expect(events[0]!.type).toBe("task.started"); + expect(events.at(-1)!.type).toBe("task.finished"); + expect(events).toHaveLength(17); + expect(events.map((e) => e.seq)).toEqual(events.map((_, i) => i + 1)); + expect(events.every((e) => e.sessionId === o.sessionId)).toBe(true); + + const branch = `codehands/fix-the-failing-test-${o.sessionId.slice(0, 8)}`; + expect(git(repo, "branch", "--list", branch)).toContain(branch); + const worktree = join(tmp, "worktrees"); + expect(git(repo, "worktree", "list")).toContain(o.sessionId.slice(0, 8)); + expect(existsSync(worktree)).toBe(true); + expect(git(repo, "status", "--porcelain")).toBe(""); // the real repo is untouched + }); + + it("fails clearly when the repo has no commits", async () => { + const events = await collect(options(makeRepo(false)), { agentCommand: replayAgent(spike) }); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("task.failed"); + expect(events[0]!.payload.reason).toMatch(/no commits/); + }); + + it("fails when the agent exits without a result line", async () => { + const empty = join(tmp, "empty.jsonl"); + writeFileSync(empty, ""); + const events = await collect(options(makeRepo()), { agentCommand: replayAgent(empty, 1) }); + expect(events.map((e) => e.type)).toEqual(["task.failed"]); + expect(events[0]!.payload).toMatchObject({ reason: "the agent exited without a result", exitCode: 1 }); + }); + + it("fails instead of crashing when the agent binary is missing", async () => { + const events = await collect(options(makeRepo()), { + agentCommand: () => ({ file: "codehands-no-such-binary", args: [] }), + }); + expect(events.map((e) => e.type)).toEqual(["task.failed"]); + expect(String(events[0]!.payload.error)).toMatch(/ENOENT|not found|codehands-no-such-binary/i); + }); + + it("refuses agents that are not supported yet", async () => { + const events = await collect(options(makeRepo(), { agent: "codex" })); + expect(events.map((e) => [e.type, e.payload.reason])).toEqual([["task.failed", "agent codex is not supported yet"]]); + }); +}); + +describe("runner diff, approve and reject", () => { + // A finished run whose agent changed a.txt and added new.txt. + async function finishedRun() { + const repo = makeRepo(); + const base = git(repo, "rev-parse", "HEAD"); + const o = options(repo); + const runner = newRunner({ agentCommand: replayAgent(spike, 0, "edit") }); + const events = await collect(o, {}, runner); + const worktree = git(repo, "worktree", "list", "--porcelain").split("\n") + .filter((l) => l.startsWith("worktree ")).map((l) => l.slice(9)).find((p) => p.includes(o.sessionId.slice(0, 8)))!; + return { repo, base, o, runner, events, worktree, branch: `codehands/fix-the-failing-test-${o.sessionId.slice(0, 8)}` }; + } + + it("puts the diff summary on task.finished and returns the full patch, new files included", async () => { + const { runner, o, events } = await finishedRun(); + expect(events.at(-1)!.type).toBe("task.finished"); + expect(events.at(-1)!.payload.diffStat).toMatch(/2 files changed/); + const { patch, stat } = await runner.diff(o.sessionId); + expect(patch).toContain("+world"); + expect(patch).toContain("new.txt"); + expect(patch.endsWith("\n")).toBe(true); + expect(stat).toMatch(/a\.txt/); + }); + + it("approve commits on the task branch, removes the worktree and keeps the branch", async () => { + const { repo, base, runner, o, worktree, branch } = await finishedRun(); + const result = await runner.approve(o.sessionId, "Fix the failing test"); + + expect(result.branch).toBe(branch); + expect(git(repo, "rev-parse", branch)).toBe(result.commit); + expect(git(repo, "rev-parse", `${branch}~1`)).toBe(base); + expect(git(repo, "log", "-1", "--format=%s", branch)).toBe("Fix the failing test"); + expect(git(repo, "show", "--name-only", "--format=", branch).split("\n").sort()).toEqual(["a.txt", "new.txt"]); + expect(existsSync(worktree)).toBe(false); + expect(git(repo, "rev-parse", "HEAD")).toBe(base); // main didn't move + expect(git(repo, "status", "--porcelain")).toBe(""); + await expect(runner.approve(o.sessionId, "again")).rejects.toThrow(/illegal move/); + }); + + it("reject removes the worktree and deletes the branch", async () => { + const { repo, runner, o, worktree, branch } = await finishedRun(); + await runner.reject(o.sessionId); + expect(existsSync(worktree)).toBe(false); + expect(git(repo, "branch", "--list", branch)).toBe(""); + await expect(runner.approve(o.sessionId, "too late")).rejects.toThrow(/illegal move/); + }); + + it("refuses to approve when the agent changed nothing, and reject still works after", async () => { + const repo = makeRepo(); + const o = options(repo); + const runner = newRunner({ agentCommand: replayAgent(spike) }); + await collect(o, {}, runner); + await expect(runner.approve(o.sessionId, "msg")).rejects.toThrow(/nothing to approve/); + await expect(runner.reject(o.sessionId)).resolves.toBeUndefined(); + }); + + it("refuses approve and reject on a session that failed before it had a worktree", async () => { + const o = options(makeRepo(false)); + const runner = newRunner({ agentCommand: replayAgent(spike) }); + await collect(o, {}, runner); + await expect(runner.approve(o.sessionId, "msg")).rejects.toThrow(/no worktree/); + await expect(runner.reject(o.sessionId)).rejects.toThrow(/no worktree/); + }); +});