@towles/tool 0.0.66 → 0.0.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,14 @@ import consola from "consola";
2
2
  import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import { initConfig } from "./config";
5
- import { createSpawnClaudeMock, createTestRepo } from "./test-helpers";
6
- import type { MockClaudeImpl, TestRepo } from "./test-helpers";
5
+ import { createMockClaudeProcess, createTestRepo } from "./test-helpers";
6
+ import type { TestRepo } from "./test-helpers";
7
+ import type { SpawnClaudeFn } from "./spawn-claude";
8
+ import type { ClaudeLogger } from "./claude-cli";
7
9
 
8
10
  consola.level = -999;
9
11
 
10
- let mockSpawnImpl: MockClaudeImpl = null;
11
- vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockSpawnImpl));
12
-
13
- describe("runClaude (mocked spawn-claude)", () => {
12
+ describe("runClaude (injected spawnFn)", () => {
14
13
  let originalCwd: string;
15
14
  let repo: TestRepo;
16
15
 
@@ -26,19 +25,20 @@ describe("runClaude (mocked spawn-claude)", () => {
26
25
  });
27
26
 
28
27
  beforeEach(() => {
29
- mockSpawnImpl = null;
30
28
  vi.clearAllMocks();
31
29
  });
32
30
 
33
31
  it("parses stream-json result event", async () => {
34
- mockSpawnImpl = () => ({
35
- stdout: JSON.stringify({
36
- result: "All done",
37
- is_error: false,
38
- total_cost_usd: 0.05,
39
- num_turns: 3,
40
- }),
41
- exitCode: 0,
32
+ const mockSpawnClaude: SpawnClaudeFn = vi.fn((args: string[]) => {
33
+ return createMockClaudeProcess(
34
+ JSON.stringify({
35
+ result: "All done",
36
+ is_error: false,
37
+ total_cost_usd: 0.05,
38
+ num_turns: 3,
39
+ }),
40
+ 0,
41
+ );
42
42
  });
43
43
 
44
44
  await initConfig({ repo: "test/repo", mainBranch: "main" });
@@ -48,14 +48,14 @@ describe("runClaude (mocked spawn-claude)", () => {
48
48
  const result = await runClaude({
49
49
  promptFile: "test-prompt.md",
50
50
  maxTurns: 10,
51
+ spawnFn: mockSpawnClaude,
51
52
  });
52
53
 
53
54
  expect(result.result).toBe("All done");
54
55
  expect(result.is_error).toBe(false);
55
56
  expect(result.num_turns).toBe(3);
56
57
 
57
- const { spawnClaude } = await import("./spawn-claude");
58
- expect(spawnClaude).toHaveBeenCalledWith(
58
+ expect(mockSpawnClaude).toHaveBeenCalledWith(
59
59
  expect.arrayContaining([
60
60
  "-p",
61
61
  "--output-format",
@@ -71,7 +71,13 @@ describe("runClaude (mocked spawn-claude)", () => {
71
71
  });
72
72
 
73
73
  it("logs tool names from stream_event and shows thinking indicator", async () => {
74
- const infoSpy = vi.spyOn(consola, "info");
74
+ const mockLogger: ClaudeLogger = {
75
+ info: vi.fn(),
76
+ warn: vi.fn(),
77
+ error: vi.fn(),
78
+ success: vi.fn(),
79
+ log: vi.fn(),
80
+ };
75
81
 
76
82
  const thinkingEvent = {
77
83
  type: "stream_event",
@@ -94,30 +100,35 @@ describe("runClaude (mocked spawn-claude)", () => {
94
100
  num_turns: 1,
95
101
  };
96
102
 
97
- mockSpawnImpl = () => ({
98
- stdout: [thinkingEvent, toolEvent, resultEvent].map((e) => JSON.stringify(e)).join("\n"),
99
- exitCode: 0,
103
+ const mockSpawnClaude: SpawnClaudeFn = vi.fn(() => {
104
+ return createMockClaudeProcess(
105
+ [thinkingEvent, toolEvent, resultEvent].map((e) => JSON.stringify(e)).join("\n"),
106
+ 0,
107
+ );
100
108
  });
101
109
 
102
110
  await initConfig({ repo: "test/repo", mainBranch: "main" });
103
111
 
104
112
  const { runClaude } = await import("./claude-cli");
105
- const result = await runClaude({ promptFile: "test.md" });
113
+ const result = await runClaude({
114
+ promptFile: "test.md",
115
+ spawnFn: mockSpawnClaude,
116
+ logger: mockLogger,
117
+ });
106
118
 
107
119
  expect(result.result).toBe("Done");
108
120
  expect(result.num_turns).toBe(1);
109
121
 
110
- const infoCalls = infoSpy.mock.calls.map((c) => String(c[0]));
122
+ const infoCalls = (mockLogger.info as ReturnType<typeof vi.fn>).mock.calls.map((c) =>
123
+ String(c[0]),
124
+ );
111
125
  expect(infoCalls.some((msg) => msg.includes("thinking"))).toBe(true);
112
126
  expect(infoCalls.some((msg) => msg.includes("Edit"))).toBe(true);
113
-
114
- infoSpy.mockRestore();
115
127
  });
116
128
 
117
129
  it("returns fallback when no result event in stream", async () => {
118
- mockSpawnImpl = () => ({
119
- stdout: '{"type":"system","message":"starting"}',
120
- exitCode: 0,
130
+ const mockSpawnClaude: SpawnClaudeFn = vi.fn(() => {
131
+ return createMockClaudeProcess('{"type":"system","message":"starting"}', 0);
121
132
  });
122
133
 
123
134
  await initConfig({ repo: "test/repo", mainBranch: "main" });
@@ -126,6 +137,7 @@ describe("runClaude (mocked spawn-claude)", () => {
126
137
 
127
138
  const result = await runClaude({
128
139
  promptFile: "test.md",
140
+ spawnFn: mockSpawnClaude,
129
141
  });
130
142
 
131
143
  expect(result.result).toBe("");
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import type { ChildProcess } from "node:child_process";
3
3
 
4
+ export type SpawnClaudeFn = (args: string[]) => ChildProcess;
5
+
4
6
  /**
5
7
  * Spawns the Claude CLI as a child process.
6
8
  * Extracted into its own module so tests can mock it cleanly
@@ -9,11 +9,12 @@ import { getConfig } from "../config.js";
9
9
  import { ARTIFACTS } from "../prompt-templates/index.js";
10
10
  import { log } from "../utils.js";
11
11
  import type { IssueContext } from "../utils.js";
12
+ import type { ExecSafeFn } from "../labels.js";
12
13
 
13
- export async function createPr(ctx: IssueContext): Promise<string> {
14
+ export async function createPr(ctx: IssueContext, exec?: ExecSafeFn): Promise<string> {
14
15
  const cfg = getConfig();
15
16
 
16
- const existingUrl = await getExistingPrUrl(ctx.branch);
17
+ const existingUrl = await getExistingPrUrl(ctx.branch, exec);
17
18
  if (existingUrl) {
18
19
  log(`PR already exists: ${existingUrl}`);
19
20
  return existingUrl;
@@ -48,18 +49,21 @@ export async function createPr(ctx: IssueContext): Promise<string> {
48
49
  "Generated by auto-claude pipeline",
49
50
  ].join("\n");
50
51
 
51
- const prUrl = await ghRaw([
52
- "pr",
53
- "create",
54
- "--repo",
55
- cfg.repo,
56
- "--head",
57
- ctx.branch,
58
- "--title",
59
- ctx.title,
60
- "--body",
61
- body,
62
- ]);
52
+ const prUrl = await ghRaw(
53
+ [
54
+ "pr",
55
+ "create",
56
+ "--repo",
57
+ cfg.repo,
58
+ "--head",
59
+ ctx.branch,
60
+ "--title",
61
+ ctx.title,
62
+ "--body",
63
+ body,
64
+ ],
65
+ exec,
66
+ );
63
67
 
64
68
  if (!prUrl) {
65
69
  throw new Error("Failed to create PR — gh returned empty output");
@@ -69,7 +73,7 @@ export async function createPr(ctx: IssueContext): Promise<string> {
69
73
  log(`PR created: ${prUrl}`);
70
74
 
71
75
  try {
72
- await attachArtifacts(ctx, prUrl);
76
+ await attachArtifacts(ctx, prUrl, exec);
73
77
  } catch (e) {
74
78
  consola.warn(`Artifact upload failed (non-blocking): ${e}`);
75
79
  }
@@ -77,7 +81,7 @@ export async function createPr(ctx: IssueContext): Promise<string> {
77
81
  return prUrl;
78
82
  }
79
83
 
80
- async function attachArtifacts(ctx: IssueContext, prUrl: string): Promise<void> {
84
+ async function attachArtifacts(ctx: IssueContext, prUrl: string, exec?: ExecSafeFn): Promise<void> {
81
85
  const archivePath = join(ctx.issueDir, "artifacts.tar.gz");
82
86
  const tag = `ac-issue-${ctx.number}`;
83
87
  const cfg = getConfig();
@@ -94,31 +98,27 @@ async function attachArtifacts(ctx: IssueContext, prUrl: string): Promise<void>
94
98
  ]);
95
99
 
96
100
  log("Uploading artifacts to GitHub release…");
97
- await ghRaw(["release", "delete", tag, "--yes", "--repo", cfg.repo]);
98
-
99
- await ghRaw([
100
- "release",
101
- "create",
102
- tag,
103
- "--prerelease",
104
- "--title",
105
- `Artifacts: #${ctx.number}`,
106
- "--repo",
107
- cfg.repo,
108
- archivePath,
109
- ]);
110
-
111
- const assetUrl = await ghRaw([
112
- "release",
113
- "view",
114
- tag,
115
- "--json",
116
- "assets",
117
- "--jq",
118
- ".assets[0].url",
119
- "--repo",
120
- cfg.repo,
121
- ]);
101
+ await ghRaw(["release", "delete", tag, "--yes", "--repo", cfg.repo], exec);
102
+
103
+ await ghRaw(
104
+ [
105
+ "release",
106
+ "create",
107
+ tag,
108
+ "--prerelease",
109
+ "--title",
110
+ `Artifacts: #${ctx.number}`,
111
+ "--repo",
112
+ cfg.repo,
113
+ archivePath,
114
+ ],
115
+ exec,
116
+ );
117
+
118
+ const assetUrl = await ghRaw(
119
+ ["release", "view", tag, "--json", "assets", "--jq", ".assets[0].url", "--repo", cfg.repo],
120
+ exec,
121
+ );
122
122
 
123
123
  if (!assetUrl) {
124
124
  consola.warn("Could not get artifact download URL — skipping PR comment");
@@ -133,22 +133,25 @@ async function attachArtifacts(ctx: IssueContext, prUrl: string): Promise<void>
133
133
  "Contains: plan.md, completed-summary.md, simplify-summary.md, review.md",
134
134
  ].join("\n");
135
135
 
136
- await ghRaw(["pr", "comment", prUrl, "--body", comment]);
136
+ await ghRaw(["pr", "comment", prUrl, "--body", comment], exec);
137
137
  }
138
138
 
139
- async function getExistingPrUrl(branch: string): Promise<string | null> {
140
- const out = await ghRaw([
141
- "pr",
142
- "list",
143
- "--repo",
144
- getConfig().repo,
145
- "--head",
146
- branch,
147
- "--state",
148
- "open",
149
- "--json",
150
- "url",
151
- ]);
139
+ async function getExistingPrUrl(branch: string, exec?: ExecSafeFn): Promise<string | null> {
140
+ const out = await ghRaw(
141
+ [
142
+ "pr",
143
+ "list",
144
+ "--repo",
145
+ getConfig().repo,
146
+ "--head",
147
+ branch,
148
+ "--state",
149
+ "open",
150
+ "--json",
151
+ "url",
152
+ ],
153
+ exec,
154
+ );
152
155
  try {
153
156
  const prs = JSON.parse(out) as Array<{ url: string }>;
154
157
  return prs.length > 0 ? prs[0].url : null;
@@ -10,8 +10,9 @@ import { runClaude } from "../claude-cli.js";
10
10
  import { resolveTemplate } from "../templates.js";
11
11
  import { buildTokens, log, logStep } from "../utils.js";
12
12
  import type { IssueContext } from "../utils.js";
13
+ import type { SpawnClaudeFn } from "../spawn-claude.js";
13
14
 
14
- export async function stepImplement(ctx: IssueContext): Promise<boolean> {
15
+ export async function stepImplement(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
15
16
  const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
16
17
  const maxIterations = getConfig().maxImplementIterations;
17
18
 
@@ -36,6 +37,7 @@ export async function stepImplement(ctx: IssueContext): Promise<boolean> {
36
37
  const result = await runClaude({
37
38
  promptFile,
38
39
  maxTurns: getConfig().maxTurns,
40
+ spawnFn,
39
41
  });
40
42
 
41
43
  if (result.is_error) {
@@ -3,8 +3,9 @@ import { join } from "node:path";
3
3
  import { ARTIFACTS, STEP_LABELS, TEMPLATES } from "../prompt-templates/index.js";
4
4
  import { ensureBranch, runStepWithArtifact } from "../utils.js";
5
5
  import type { IssueContext } from "../utils.js";
6
+ import type { SpawnClaudeFn } from "../spawn-claude.js";
6
7
 
7
- export async function stepPlan(ctx: IssueContext): Promise<boolean> {
8
+ export async function stepPlan(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
8
9
  await ensureBranch(ctx.branch);
9
10
 
10
11
  return runStepWithArtifact({
@@ -12,23 +13,26 @@ export async function stepPlan(ctx: IssueContext): Promise<boolean> {
12
13
  ctx,
13
14
  artifactPath: join(ctx.issueDir, ARTIFACTS.plan),
14
15
  templateName: TEMPLATES.plan,
16
+ spawnFn,
15
17
  });
16
18
  }
17
19
 
18
- export async function stepSimplify(ctx: IssueContext): Promise<boolean> {
20
+ export async function stepSimplify(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
19
21
  return runStepWithArtifact({
20
22
  stepName: STEP_LABELS.simplify,
21
23
  ctx,
22
24
  artifactPath: join(ctx.issueDir, ARTIFACTS.simplifySummary),
23
25
  templateName: TEMPLATES.simplify,
26
+ spawnFn,
24
27
  });
25
28
  }
26
29
 
27
- export async function stepReview(ctx: IssueContext): Promise<boolean> {
30
+ export async function stepReview(ctx: IssueContext, spawnFn?: SpawnClaudeFn): Promise<boolean> {
28
31
  return runStepWithArtifact({
29
32
  stepName: STEP_LABELS.review,
30
33
  ctx,
31
34
  artifactPath: join(ctx.issueDir, ARTIFACTS.review),
32
35
  templateName: TEMPLATES.review,
36
+ spawnFn,
33
37
  });
34
38
  }
@@ -9,29 +9,44 @@ import { initConfig } from "../config";
9
9
  import { ARTIFACTS } from "../prompt-templates/index";
10
10
  import {
11
11
  buildTestContext,
12
- createSpawnClaudeMock,
12
+ createMockClaudeProcess,
13
13
  createTestRepoWithRemote,
14
14
  errorClaudeJson,
15
15
  successClaudeJson,
16
16
  } from "../test-helpers";
17
17
  import type { MockClaudeImpl, TestRepo } from "../test-helpers";
18
18
  import type { IssueContext } from "../utils";
19
+ import type { SpawnClaudeFn } from "../spawn-claude";
19
20
 
20
21
  consola.level = -999;
21
22
 
22
- let mockClaudeImpl: MockClaudeImpl = null;
23
- vi.mock("../spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
24
-
25
23
  // ── Shared setup/teardown for all step tests ──
26
24
 
27
- function setupStepTest(): { originalCwd: string; repo: TestRepo; ctx: IssueContext } {
25
+ function setupStepTest(): {
26
+ originalCwd: string;
27
+ repo: TestRepo;
28
+ ctx: IssueContext;
29
+ spawnFn: SpawnClaudeFn;
30
+ mockClaudeImpl: { current: MockClaudeImpl };
31
+ } {
28
32
  const originalCwd = process.cwd();
29
33
  const repo = createTestRepoWithRemote();
30
34
  process.chdir(repo.dir);
31
35
  const ctx = buildTestContext(repo.dir);
32
36
  mkdirSync(ctx.issueDir, { recursive: true });
33
- mockClaudeImpl = null;
34
- return { originalCwd, repo, ctx };
37
+
38
+ const mockClaudeImpl = { current: null as MockClaudeImpl };
39
+
40
+ const spawnFn = vi.fn((args: string[]) => {
41
+ const impl = mockClaudeImpl.current;
42
+ if (impl) {
43
+ const { stdout, exitCode } = impl(args);
44
+ return createMockClaudeProcess(stdout, exitCode);
45
+ }
46
+ throw new Error("Unexpected spawnClaude call -- set mockClaudeImpl.current");
47
+ }) as SpawnClaudeFn;
48
+
49
+ return { originalCwd, repo, ctx, spawnFn, mockClaudeImpl };
35
50
  }
36
51
 
37
52
  function teardownStepTest(originalCwd: string, repo: TestRepo): void {
@@ -45,9 +60,11 @@ describe("runStepWithArtifact", () => {
45
60
  let originalCwd: string;
46
61
  let repo: TestRepo;
47
62
  let ctx: IssueContext;
63
+ let spawnFn: SpawnClaudeFn;
64
+ let mockClaudeImpl: { current: MockClaudeImpl };
48
65
 
49
66
  beforeEach(async () => {
50
- ({ originalCwd, repo, ctx } = setupStepTest());
67
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
51
68
  await initConfig({ repo: "test/repo", mainBranch: "main" });
52
69
  });
53
70
 
@@ -63,13 +80,14 @@ describe("runStepWithArtifact", () => {
63
80
  ctx,
64
81
  artifactPath,
65
82
  templateName: "01_plan.prompt.md",
83
+ spawnFn,
66
84
  });
67
85
 
68
86
  expect(result).toBe(true);
69
87
  });
70
88
 
71
89
  it("returns false when Claude returns is_error", async () => {
72
- mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
90
+ mockClaudeImpl.current = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
73
91
 
74
92
  const { runStepWithArtifact } = await import("../utils");
75
93
  const artifactPath = join(ctx.issueDir, "missing-artifact.md");
@@ -79,13 +97,14 @@ describe("runStepWithArtifact", () => {
79
97
  ctx,
80
98
  artifactPath,
81
99
  templateName: "01_plan.prompt.md",
100
+ spawnFn,
82
101
  });
83
102
 
84
103
  expect(result).toBe(false);
85
104
  });
86
105
 
87
106
  it("returns false when artifact not produced after Claude run", async () => {
88
- mockClaudeImpl = () => ({ stdout: successClaudeJson(), exitCode: 0 });
107
+ mockClaudeImpl.current = () => ({ stdout: successClaudeJson(), exitCode: 0 });
89
108
 
90
109
  const { runStepWithArtifact } = await import("../utils");
91
110
  const artifactPath = join(ctx.issueDir, "never-created.md");
@@ -95,6 +114,7 @@ describe("runStepWithArtifact", () => {
95
114
  ctx,
96
115
  artifactPath,
97
116
  templateName: "01_plan.prompt.md",
117
+ spawnFn,
98
118
  });
99
119
 
100
120
  expect(result).toBe(false);
@@ -104,7 +124,7 @@ describe("runStepWithArtifact", () => {
104
124
  const { runStepWithArtifact } = await import("../utils");
105
125
  const artifactPath = join(ctx.issueDir, "plan.md");
106
126
 
107
- mockClaudeImpl = () => {
127
+ mockClaudeImpl.current = () => {
108
128
  writeFileSync(artifactPath, "# Plan\n\nDetailed plan content here.");
109
129
  return { stdout: successClaudeJson(), exitCode: 0 };
110
130
  };
@@ -114,6 +134,7 @@ describe("runStepWithArtifact", () => {
114
134
  ctx,
115
135
  artifactPath,
116
136
  templateName: "01_plan.prompt.md",
137
+ spawnFn,
117
138
  });
118
139
 
119
140
  expect(result).toBe(true);
@@ -126,9 +147,11 @@ describe("stepPlan", () => {
126
147
  let originalCwd: string;
127
148
  let repo: TestRepo;
128
149
  let ctx: IssueContext;
150
+ let spawnFn: SpawnClaudeFn;
151
+ let mockClaudeImpl: { current: MockClaudeImpl };
129
152
 
130
153
  beforeEach(async () => {
131
- ({ originalCwd, repo, ctx } = setupStepTest());
154
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
132
155
  await initConfig({ repo: "test/repo", mainBranch: "main" });
133
156
  });
134
157
 
@@ -139,7 +162,7 @@ describe("stepPlan", () => {
139
162
 
140
163
  writeFileSync(join(ctx.issueDir, ARTIFACTS.plan), "# Existing plan");
141
164
 
142
- const result = await stepPlan(ctx);
165
+ const result = await stepPlan(ctx, spawnFn);
143
166
  expect(result).toBe(true);
144
167
  });
145
168
 
@@ -147,12 +170,12 @@ describe("stepPlan", () => {
147
170
  const { stepPlan } = await import("./simple-steps");
148
171
  const planPath = join(ctx.issueDir, ARTIFACTS.plan);
149
172
 
150
- mockClaudeImpl = () => {
173
+ mockClaudeImpl.current = () => {
151
174
  writeFileSync(planPath, "# Plan\n\nDetailed plan.");
152
175
  return { stdout: successClaudeJson(), exitCode: 0 };
153
176
  };
154
177
 
155
- const result = await stepPlan(ctx);
178
+ const result = await stepPlan(ctx, spawnFn);
156
179
  expect(result).toBe(true);
157
180
 
158
181
  // Verify we ended up on the branch (ensureBranch was called)
@@ -165,9 +188,9 @@ describe("stepPlan", () => {
165
188
  it("returns false when Claude fails", async () => {
166
189
  const { stepPlan } = await import("./simple-steps");
167
190
 
168
- mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
191
+ mockClaudeImpl.current = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
169
192
 
170
- const result = await stepPlan(ctx);
193
+ const result = await stepPlan(ctx, spawnFn);
171
194
  expect(result).toBe(false);
172
195
  });
173
196
  });
@@ -178,9 +201,11 @@ describe("stepSimplify", () => {
178
201
  let originalCwd: string;
179
202
  let repo: TestRepo;
180
203
  let ctx: IssueContext;
204
+ let spawnFn: SpawnClaudeFn;
205
+ let mockClaudeImpl: { current: MockClaudeImpl };
181
206
 
182
207
  beforeEach(async () => {
183
- ({ originalCwd, repo, ctx } = setupStepTest());
208
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
184
209
  await initConfig({ repo: "test/repo", mainBranch: "main" });
185
210
 
186
211
  // stepSimplify doesn't switch branches, but we need to be on one
@@ -194,7 +219,7 @@ describe("stepSimplify", () => {
194
219
 
195
220
  writeFileSync(join(ctx.issueDir, ARTIFACTS.simplifySummary), "# Simplified");
196
221
 
197
- const result = await stepSimplify(ctx);
222
+ const result = await stepSimplify(ctx, spawnFn);
198
223
  expect(result).toBe(true);
199
224
  });
200
225
 
@@ -202,21 +227,21 @@ describe("stepSimplify", () => {
202
227
  const { stepSimplify } = await import("./simple-steps");
203
228
  const artifactPath = join(ctx.issueDir, ARTIFACTS.simplifySummary);
204
229
 
205
- mockClaudeImpl = () => {
230
+ mockClaudeImpl.current = () => {
206
231
  writeFileSync(artifactPath, "# Simplify Summary\n\nCode simplified.");
207
232
  return { stdout: successClaudeJson(), exitCode: 0 };
208
233
  };
209
234
 
210
- const result = await stepSimplify(ctx);
235
+ const result = await stepSimplify(ctx, spawnFn);
211
236
  expect(result).toBe(true);
212
237
  });
213
238
 
214
239
  it("returns false when Claude fails", async () => {
215
240
  const { stepSimplify } = await import("./simple-steps");
216
241
 
217
- mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
242
+ mockClaudeImpl.current = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
218
243
 
219
- const result = await stepSimplify(ctx);
244
+ const result = await stepSimplify(ctx, spawnFn);
220
245
  expect(result).toBe(false);
221
246
  });
222
247
  });
@@ -227,9 +252,11 @@ describe("stepImplement", () => {
227
252
  let originalCwd: string;
228
253
  let repo: TestRepo;
229
254
  let ctx: IssueContext;
255
+ let spawnFn: SpawnClaudeFn;
256
+ let mockClaudeImpl: { current: MockClaudeImpl };
230
257
 
231
258
  beforeEach(async () => {
232
- ({ originalCwd, repo, ctx } = setupStepTest());
259
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
233
260
  await initConfig({
234
261
  repo: "test/repo",
235
262
  mainBranch: "main",
@@ -247,7 +274,7 @@ describe("stepImplement", () => {
247
274
 
248
275
  writeFileSync(join(ctx.issueDir, ARTIFACTS.completedSummary), "# Done");
249
276
 
250
- const result = await stepImplement(ctx);
277
+ const result = await stepImplement(ctx, spawnFn);
251
278
  expect(result).toBe(true);
252
279
  });
253
280
 
@@ -255,12 +282,12 @@ describe("stepImplement", () => {
255
282
  const { stepImplement } = await import("./implement");
256
283
 
257
284
  let callCount = 0;
258
- mockClaudeImpl = () => {
285
+ mockClaudeImpl.current = () => {
259
286
  callCount++;
260
287
  return { stdout: successClaudeJson(), exitCode: 0 };
261
288
  };
262
289
 
263
- const result = await stepImplement(ctx);
290
+ const result = await stepImplement(ctx, spawnFn);
264
291
  expect(result).toBe(false);
265
292
  expect(callCount).toBe(3);
266
293
  });
@@ -272,12 +299,12 @@ describe("stepImplement", () => {
272
299
  writeFileSync(join(ctx.issueDir, ARTIFACTS.review), "# Review\n\nFix the tests.");
273
300
 
274
301
  const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
275
- mockClaudeImpl = () => {
302
+ mockClaudeImpl.current = () => {
276
303
  writeFileSync(completedPath, "# Done");
277
304
  return { stdout: successClaudeJson(), exitCode: 0 };
278
305
  };
279
306
 
280
- const result = await stepImplement(ctx);
307
+ const result = await stepImplement(ctx, spawnFn);
281
308
  expect(result).toBe(true);
282
309
  });
283
310
 
@@ -287,7 +314,7 @@ describe("stepImplement", () => {
287
314
  let callCount = 0;
288
315
  const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
289
316
 
290
- mockClaudeImpl = () => {
317
+ mockClaudeImpl.current = () => {
291
318
  callCount++;
292
319
  if (callCount === 2) {
293
320
  writeFileSync(completedPath, "# Implementation Complete\n\nAll tasks done.");
@@ -295,7 +322,7 @@ describe("stepImplement", () => {
295
322
  return { stdout: successClaudeJson(), exitCode: 0 };
296
323
  };
297
324
 
298
- const result = await stepImplement(ctx);
325
+ const result = await stepImplement(ctx, spawnFn);
299
326
  expect(result).toBe(true);
300
327
  expect(callCount).toBe(2);
301
328
  });