@towles/tool 0.0.66 → 0.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towles/tool",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
4
4
  "description": "One off quality of life scripts that I use on a daily basis.",
5
5
  "homepage": "https://github.com/ChrisTowles/towles-tool#readme",
6
6
  "bugs": {
@@ -1,7 +1,8 @@
1
- import { rmSync } from "node:fs";
1
+ import { rmSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
3
4
 
4
- import { Flags } from "@oclif/core";
5
+ import { Args, Flags } from "@oclif/core";
5
6
  import consola from "consola";
6
7
 
7
8
  import { BaseCommand } from "../base.js";
@@ -14,6 +15,7 @@ import {
14
15
  initConfig,
15
16
  log,
16
17
  logBanner,
18
+ runClaude,
17
19
  runPipeline,
18
20
  sleep,
19
21
  } from "../../lib/auto-claude/index.js";
@@ -24,7 +26,18 @@ export default class AutoClaude extends BaseCommand {
24
26
 
25
27
  static override description = "Automated issue-to-PR pipeline using Claude Code";
26
28
 
29
+ static override args = {
30
+ prompt: Args.string({
31
+ description: "Run a single prompt (skips issue pipeline)",
32
+ required: false,
33
+ }),
34
+ };
35
+
27
36
  static override examples = [
37
+ {
38
+ description: "Run a single prompt",
39
+ command: '<%= config.bin %> auto-claude "Fix the login bug in auth.ts"',
40
+ },
28
41
  {
29
42
  description: "Process a specific issue",
30
43
  command: "<%= config.bin %> auto-claude --issue 42",
@@ -49,6 +62,10 @@ export default class AutoClaude extends BaseCommand {
49
62
 
50
63
  static override flags = {
51
64
  ...BaseCommand.baseFlags,
65
+ "max-turns": Flags.integer({
66
+ description: "Maximum conversation turns for prompt mode (default: 10)",
67
+ default: 10,
68
+ }),
52
69
  issue: Flags.integer({
53
70
  char: "i",
54
71
  description: "Process a specific issue number",
@@ -88,8 +105,29 @@ export default class AutoClaude extends BaseCommand {
88
105
  };
89
106
 
90
107
  async run(): Promise<void> {
91
- const { flags } = await this.parse(AutoClaude);
108
+ const { args, flags } = await this.parse(AutoClaude);
109
+
110
+ // Prompt mode: run a single prompt with structured output, skip issue pipeline
111
+ if (args.prompt) {
112
+ await initConfig({ model: flags.model });
113
+
114
+ const promptDir = join(tmpdir(), "tt-auto-claude");
115
+ mkdirSync(promptDir, { recursive: true });
116
+ const promptFile = join(promptDir, `prompt-${Date.now()}.md`);
117
+ writeFileSync(promptFile, args.prompt);
118
+
119
+ const result = await runClaude({
120
+ promptFile,
121
+ maxTurns: flags["max-turns"],
122
+ });
123
+
124
+ if (result.is_error) {
125
+ this.error("Claude reported an error", { exit: 1 });
126
+ }
127
+ return;
128
+ }
92
129
 
130
+ // Issue pipeline mode
93
131
  const cfg = await initConfig({
94
132
  triggerLabel: flags.label,
95
133
  mainBranch: flags["main-branch"],
@@ -3,23 +3,20 @@ import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
 
5
5
  import consola from "consola";
6
- import { x } from "tinyexec";
7
6
  import { describe, it, expect, vi, beforeEach } from "vitest";
8
7
 
8
+ import type { ExecSafeFn } from "../../lib/auto-claude/labels.js";
9
9
  import { LABELS } from "../../lib/auto-claude/labels.js";
10
10
  import { retryIssues } from "./retry.js";
11
11
 
12
12
  // Suppress consola output during tests
13
13
  consola.level = -999;
14
14
 
15
- // Mock tinyexec so setLabel/removeLabel (which use execSafe -> x) work
16
- vi.mock("tinyexec", () => ({
17
- x: vi.fn().mockResolvedValue({ stdout: "", exitCode: 0, stderr: "" }),
18
- }));
15
+ const mockExecSafe: ExecSafeFn = vi.fn().mockResolvedValue({ stdout: "", ok: true });
19
16
 
20
17
  function getGhEditCalls() {
21
18
  return vi
22
- .mocked(x)
19
+ .mocked(mockExecSafe)
23
20
  .mock.calls.filter(
24
21
  ([cmd, args]) => cmd === "gh" && args?.[0] === "issue" && args?.[1] === "edit",
25
22
  )
@@ -41,7 +38,7 @@ describe("retryIssues", () => {
41
38
  },
42
39
  ];
43
40
 
44
- const count = await retryIssues("owner/repo", "auto-claude", issues, false);
41
+ const count = await retryIssues("owner/repo", "auto-claude", issues, false, mockExecSafe);
45
42
 
46
43
  expect(count).toBe(1);
47
44
  const editCalls = getGhEditCalls();
@@ -70,7 +67,7 @@ describe("retryIssues", () => {
70
67
  },
71
68
  ];
72
69
 
73
- const count = await retryIssues("owner/repo", "auto-claude", issues, false);
70
+ const count = await retryIssues("owner/repo", "auto-claude", issues, false, mockExecSafe);
74
71
 
75
72
  expect(count).toBe(2);
76
73
  const editCalls = getGhEditCalls();
@@ -79,7 +76,7 @@ describe("retryIssues", () => {
79
76
  });
80
77
 
81
78
  it("returns 0 when given empty selection", async () => {
82
- const count = await retryIssues("owner/repo", "auto-claude", [], false);
79
+ const count = await retryIssues("owner/repo", "auto-claude", [], false, mockExecSafe);
83
80
  expect(count).toBe(0);
84
81
  expect(getGhEditCalls().length).toBe(0);
85
82
  });
@@ -103,7 +100,7 @@ describe("retryIssues", () => {
103
100
  },
104
101
  ];
105
102
 
106
- await retryIssues("owner/repo", "auto-claude", issues, true);
103
+ await retryIssues("owner/repo", "auto-claude", issues, true, mockExecSafe);
107
104
  expect(existsSync(issueDir)).toBe(false);
108
105
  } finally {
109
106
  process.chdir(originalCwd);
@@ -129,7 +126,7 @@ describe("retryIssues", () => {
129
126
  },
130
127
  ];
131
128
 
132
- await retryIssues("owner/repo", "auto-claude", issues, false);
129
+ await retryIssues("owner/repo", "auto-claude", issues, false, mockExecSafe);
133
130
  expect(existsSync(issueDir)).toBe(true);
134
131
  } finally {
135
132
  process.chdir(originalCwd);
@@ -9,6 +9,7 @@ import prompts from "prompts";
9
9
  import { BaseCommand } from "../base.js";
10
10
  import { initConfig } from "../../lib/auto-claude/index.js";
11
11
  import { LABELS, removeLabel, setLabel } from "../../lib/auto-claude/labels.js";
12
+ import type { ExecSafeFn } from "../../lib/auto-claude/labels.js";
12
13
  import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
13
14
  import type { Issue } from "../../utils/git/gh-cli-wrapper.js";
14
15
 
@@ -21,14 +22,15 @@ export async function retryIssues(
21
22
  triggerLabel: string,
22
23
  selected: Issue[],
23
24
  clean: boolean,
25
+ exec?: ExecSafeFn,
24
26
  ): Promise<number> {
25
27
  for (const issue of selected) {
26
28
  consola.info(`Retrying issue #${issue.number}: ${issue.title}`);
27
29
 
28
- await removeLabel(repo, issue.number, LABELS.failed);
30
+ await removeLabel(repo, issue.number, LABELS.failed, exec);
29
31
  consola.success(` Removed '${LABELS.failed}' label`);
30
32
 
31
- await setLabel(repo, issue.number, triggerLabel);
33
+ await setLabel(repo, issue.number, triggerLabel, exec);
32
34
  consola.success(` Added '${triggerLabel}' label`);
33
35
 
34
36
  if (clean) {
@@ -5,7 +5,8 @@ import pc from "picocolors";
5
5
 
6
6
  import { getConfig } from "./config.js";
7
7
  import { sleep } from "./shell.js";
8
- import { spawnClaude } from "./spawn-claude.js";
8
+ import { spawnClaude as defaultSpawnClaude } from "./spawn-claude.js";
9
+ import type { SpawnClaudeFn } from "./spawn-claude.js";
9
10
 
10
11
  // ── Claude CLI ──
11
12
 
@@ -16,14 +17,26 @@ export interface ClaudeResult {
16
17
  num_turns: number;
17
18
  }
18
19
 
20
+ export interface ClaudeLogger {
21
+ info: (...args: unknown[]) => void;
22
+ warn: (...args: unknown[]) => void;
23
+ error: (...args: unknown[]) => void;
24
+ success: (...args: unknown[]) => void;
25
+ log: (...args: unknown[]) => void;
26
+ }
27
+
19
28
  const PROCESS_RETRIES = 3;
20
29
  const PROCESS_RETRY_DELAY_MS = 5_000;
21
30
 
22
31
  export async function runClaude(opts: {
23
32
  promptFile: string;
24
33
  maxTurns?: number;
34
+ spawnFn?: SpawnClaudeFn;
35
+ logger?: ClaudeLogger;
25
36
  }): Promise<ClaudeResult> {
26
37
  const cfg = getConfig();
38
+ const spawnFn = opts.spawnFn ?? defaultSpawnClaude;
39
+ const log = opts.logger ?? consola;
27
40
  const args = [
28
41
  "-p",
29
42
  "--output-format",
@@ -37,23 +50,21 @@ export async function runClaude(opts: {
37
50
  `@${opts.promptFile}`,
38
51
  ];
39
52
 
40
- consola.info(
41
- `${pc.dim("▶")} Calling Claude${opts.maxTurns ? ` (max ${opts.maxTurns} turns)` : ""}…`,
42
- );
53
+ log.info(`${pc.dim("▶")} Calling Claude${opts.maxTurns ? ` (max ${opts.maxTurns} turns)` : ""}…`);
43
54
 
44
55
  let lastError: Error | undefined;
45
56
  for (let attempt = 1; attempt <= PROCESS_RETRIES; attempt++) {
46
57
  try {
47
- const result = await runClaudeStreaming(args);
48
- consola.success(`Done — ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}`);
58
+ const result = await runClaudeStreaming(args, spawnFn, log);
59
+ log.success(`Done — ${result.num_turns} turns, $${result.total_cost_usd.toFixed(4)}`);
49
60
  if (result.result) {
50
- consola.log(result.result);
61
+ log.log(result.result);
51
62
  }
52
63
  return result;
53
64
  } catch (err) {
54
65
  lastError = err instanceof Error ? err : new Error(String(err));
55
66
  if (attempt < PROCESS_RETRIES) {
56
- consola.warn(
67
+ log.warn(
57
68
  `Claude process failed (attempt ${attempt}/${PROCESS_RETRIES}), retrying in ${PROCESS_RETRY_DELAY_MS / 1000}s…`,
58
69
  );
59
70
  await sleep(PROCESS_RETRY_DELAY_MS);
@@ -63,9 +74,13 @@ export async function runClaude(opts: {
63
74
  throw lastError ?? new Error("runClaude failed after all retries");
64
75
  }
65
76
 
66
- function runClaudeStreaming(args: string[]): Promise<ClaudeResult> {
77
+ function runClaudeStreaming(
78
+ args: string[],
79
+ spawnFn: SpawnClaudeFn,
80
+ log: ClaudeLogger,
81
+ ): Promise<ClaudeResult> {
67
82
  return new Promise((resolve, reject) => {
68
- const proc = spawnClaude(args);
83
+ const proc = spawnFn(args);
69
84
  let capturedResult: ClaudeResult | null = null;
70
85
  let turnCount = 0;
71
86
 
@@ -80,7 +95,7 @@ function runClaudeStreaming(args: string[]): Promise<ClaudeResult> {
80
95
  if (!line.trim()) return;
81
96
  try {
82
97
  const event = JSON.parse(line) as Record<string, unknown>;
83
- handleStreamEvent(event, (turns) => {
98
+ handleStreamEvent(event, log, (turns) => {
84
99
  turnCount = turns;
85
100
  });
86
101
 
@@ -146,14 +161,18 @@ function toolDetail(block: Record<string, unknown>): string {
146
161
  return "";
147
162
  }
148
163
 
149
- function logToolUse(block: Record<string, unknown>): void {
164
+ function logToolUse(block: Record<string, unknown>, log: ClaudeLogger): void {
150
165
  const name = block.name;
151
166
  if (typeof name === "string") {
152
- consola.info(` ${pc.dim("\u21B3")} ${name}${toolDetail(block)}`);
167
+ log.info(` ${pc.dim("\u21B3")} ${name}${toolDetail(block)}`);
153
168
  }
154
169
  }
155
170
 
156
- function handleStreamEvent(event: Record<string, unknown>, onTurn: (count: number) => void): void {
171
+ function handleStreamEvent(
172
+ event: Record<string, unknown>,
173
+ log: ClaudeLogger,
174
+ onTurn: (count: number) => void,
175
+ ): void {
157
176
  // Only handle stream_event — assistant turn events duplicate the same tools
158
177
  if (event.type === "stream_event" && typeof event.event === "object" && event.event !== null) {
159
178
  const inner = event.event as Record<string, unknown>;
@@ -165,13 +184,13 @@ function handleStreamEvent(event: Record<string, unknown>, onTurn: (count: numbe
165
184
  ) {
166
185
  const block = inner.content_block as Record<string, unknown>;
167
186
  if (block.type === "tool_use") {
168
- logToolUse(block);
187
+ logToolUse(block, log);
169
188
  } else if (block.type === "thinking") {
170
189
  const thinkingText =
171
190
  typeof block.thinking === "string" && block.thinking.length > 0
172
191
  ? pc.dim(` ${truncate(block.thinking.split("\n")[0].trim(), 60)}`)
173
192
  : "";
174
- consola.info(` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${thinkingText}`);
193
+ log.info(` ${pc.dim("\u21B3")} ${pc.italic("thinking")}${thinkingText}`);
175
194
  }
176
195
  }
177
196
  }
@@ -1,4 +1,5 @@
1
1
  export { type AutoClaudeConfig, AutoClaudeConfigSchema, getConfig, initConfig } from "./config.js";
2
+ export { type ClaudeResult, runClaude } from "./claude-cli.js";
2
3
  export { STEP_NAMES, runPipeline } from "./pipeline.js";
3
4
  export type { StepName } from "./prompt-templates/index.js";
4
5
  export { git } from "../../utils/git/exec.js";
@@ -1,13 +1,9 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { execSafe } from "../../utils/git/exec.js";
3
+ import type { ExecSafeFn } from "./labels";
4
4
  import { ensureLabelsExist, LABELS, removeLabel, setLabel } from "./labels";
5
5
 
6
- vi.mock("../../utils/git/exec.js", () => ({
7
- execSafe: vi.fn().mockResolvedValue({ stdout: "", ok: true }),
8
- }));
9
-
10
- const mockedExecSafe = vi.mocked(execSafe);
6
+ const mockExecSafe: ExecSafeFn = vi.fn().mockResolvedValue({ stdout: "", ok: true });
11
7
 
12
8
  describe("LABELS", () => {
13
9
  it("has expected label values", () => {
@@ -28,11 +24,11 @@ describe("ensureLabelsExist", () => {
28
24
  });
29
25
 
30
26
  it("creates all labels with --force", async () => {
31
- await ensureLabelsExist("owner/repo");
27
+ await ensureLabelsExist("owner/repo", mockExecSafe);
32
28
 
33
- expect(mockedExecSafe).toHaveBeenCalledTimes(4);
29
+ expect(mockExecSafe).toHaveBeenCalledTimes(4);
34
30
  for (const label of Object.values(LABELS)) {
35
- expect(mockedExecSafe).toHaveBeenCalledWith("gh", [
31
+ expect(mockExecSafe).toHaveBeenCalledWith("gh", [
36
32
  "label",
37
33
  "create",
38
34
  label,
@@ -50,9 +46,9 @@ describe("setLabel", () => {
50
46
  });
51
47
 
52
48
  it("calls gh issue edit with --add-label", async () => {
53
- await setLabel("owner/repo", 42, "auto-claude-in-progress");
49
+ await setLabel("owner/repo", 42, "auto-claude-in-progress", mockExecSafe);
54
50
 
55
- expect(mockedExecSafe).toHaveBeenCalledWith("gh", [
51
+ expect(mockExecSafe).toHaveBeenCalledWith("gh", [
56
52
  "issue",
57
53
  "edit",
58
54
  "42",
@@ -70,9 +66,9 @@ describe("removeLabel", () => {
70
66
  });
71
67
 
72
68
  it("calls gh issue edit with --remove-label", async () => {
73
- await removeLabel("owner/repo", 42, "auto-claude-failed");
69
+ await removeLabel("owner/repo", 42, "auto-claude-failed", mockExecSafe);
74
70
 
75
- expect(mockedExecSafe).toHaveBeenCalledWith("gh", [
71
+ expect(mockExecSafe).toHaveBeenCalledWith("gh", [
76
72
  "issue",
77
73
  "edit",
78
74
  "42",
@@ -1,4 +1,4 @@
1
- import { execSafe } from "../../utils/git/exec.js";
1
+ import { execSafe as defaultExecSafe } from "../../utils/git/exec.js";
2
2
 
3
3
  // ── Label helpers ──
4
4
 
@@ -9,34 +9,33 @@ export const LABELS = {
9
9
  success: "auto-claude-success",
10
10
  } as const;
11
11
 
12
- export async function ensureLabelsExist(repo: string): Promise<void> {
12
+ export type ExecSafeFn = (cmd: string, args: string[]) => Promise<{ stdout: string; ok: boolean }>;
13
+
14
+ export async function ensureLabelsExist(
15
+ repo: string,
16
+ exec: ExecSafeFn = defaultExecSafe,
17
+ ): Promise<void> {
13
18
  await Promise.all(
14
19
  Object.values(LABELS).map((label) =>
15
- execSafe("gh", ["label", "create", label, "--repo", repo, "--force"]),
20
+ exec("gh", ["label", "create", label, "--repo", repo, "--force"]),
16
21
  ),
17
22
  );
18
23
  }
19
24
 
20
- export async function setLabel(repo: string, issueNumber: number, label: string): Promise<void> {
21
- await execSafe("gh", [
22
- "issue",
23
- "edit",
24
- String(issueNumber),
25
- "--repo",
26
- repo,
27
- "--add-label",
28
- label,
29
- ]);
25
+ export async function setLabel(
26
+ repo: string,
27
+ issueNumber: number,
28
+ label: string,
29
+ exec: ExecSafeFn = defaultExecSafe,
30
+ ): Promise<void> {
31
+ await exec("gh", ["issue", "edit", String(issueNumber), "--repo", repo, "--add-label", label]);
30
32
  }
31
33
 
32
- export async function removeLabel(repo: string, issueNumber: number, label: string): Promise<void> {
33
- await execSafe("gh", [
34
- "issue",
35
- "edit",
36
- String(issueNumber),
37
- "--repo",
38
- repo,
39
- "--remove-label",
40
- label,
41
- ]);
34
+ export async function removeLabel(
35
+ repo: string,
36
+ issueNumber: number,
37
+ label: string,
38
+ exec: ExecSafeFn = defaultExecSafe,
39
+ ): Promise<void> {
40
+ await exec("gh", ["issue", "edit", String(issueNumber), "--repo", repo, "--remove-label", label]);
42
41
  }
@@ -9,50 +9,26 @@ 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";
20
+ import type { ExecSafeFn } from "./labels";
19
21
 
20
22
  consola.level = -999;
21
23
 
22
- let mockClaudeImpl: MockClaudeImpl = null;
23
- vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
24
-
25
- // Track gh calls for label assertions
26
- let ghCalls: string[][] = [];
27
-
28
- vi.mock("tinyexec", async (importOriginal) => {
29
- const original = await importOriginal<typeof import("tinyexec")>();
30
- return {
31
- ...original,
32
- x: vi.fn(
33
- async (
34
- cmd: string,
35
- args: string[],
36
- opts?: Record<string, unknown>,
37
- ): Promise<{ stdout: string; exitCode: number }> => {
38
- if (cmd === "gh") {
39
- ghCalls.push(args);
40
- // Return empty success for label/issue/pr commands
41
- return { stdout: "[]", exitCode: 0 };
42
- }
43
- return original.x(cmd, args, opts as never) as unknown as Promise<{
44
- stdout: string;
45
- exitCode: number;
46
- }>;
47
- },
48
- ),
49
- };
50
- });
51
-
52
24
  describe("runPipeline", () => {
53
25
  let originalCwd: string;
54
26
  let repo: TestRepo;
55
27
  let ctx: IssueContext;
28
+ let mockClaudeImpl: MockClaudeImpl;
29
+ let ghCalls: string[][];
30
+ let mockSpawnFn: SpawnClaudeFn;
31
+ let mockExec: ExecSafeFn;
56
32
 
57
33
  beforeEach(async () => {
58
34
  originalCwd = process.cwd();
@@ -66,6 +42,24 @@ describe("runPipeline", () => {
66
42
  ctx = buildTestContext(repo.dir);
67
43
  mockClaudeImpl = null;
68
44
  ghCalls = [];
45
+
46
+ mockSpawnFn = vi.fn((args: string[]) => {
47
+ if (mockClaudeImpl) {
48
+ const { stdout, exitCode } = mockClaudeImpl(args);
49
+ return createMockClaudeProcess(stdout, exitCode);
50
+ }
51
+ throw new Error("Unexpected spawnClaude call -- set mockClaudeImpl");
52
+ }) as SpawnClaudeFn;
53
+
54
+ mockExec = vi.fn(async (cmd: string, args: string[]) => {
55
+ if (cmd === "gh") {
56
+ ghCalls.push(args);
57
+ return { stdout: "[]", ok: true };
58
+ }
59
+ // Pass through non-gh commands to real exec
60
+ const { execSafe } = await import("../../utils/git/exec");
61
+ return execSafe(cmd, args);
62
+ }) as ExecSafeFn;
69
63
  });
70
64
 
71
65
  afterEach(() => {
@@ -78,7 +72,7 @@ describe("runPipeline", () => {
78
72
 
79
73
  mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
80
74
 
81
- await runPipeline(ctx);
75
+ await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
82
76
 
83
77
  const ramblingsPath = join(ctx.issueDir, ARTIFACTS.initialRamblings);
84
78
  expect(existsSync(ramblingsPath)).toBe(true);
@@ -97,7 +91,7 @@ describe("runPipeline", () => {
97
91
 
98
92
  mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
99
93
 
100
- await runPipeline(ctx);
94
+ await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
101
95
 
102
96
  const content = readFileSync(ramblingsPath, "utf-8");
103
97
  expect(content).toBe("# Existing ramblings");
@@ -116,7 +110,7 @@ describe("runPipeline", () => {
116
110
  return { stdout: successClaudeJson(), exitCode: 0 };
117
111
  };
118
112
 
119
- await runPipeline(ctx, "plan");
113
+ await runPipeline(ctx, "plan", { spawnFn: mockSpawnFn, exec: mockExec });
120
114
 
121
115
  expect(claudeCallCount).toBe(1);
122
116
  });
@@ -126,7 +120,7 @@ describe("runPipeline", () => {
126
120
 
127
121
  mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
128
122
 
129
- await runPipeline(ctx);
123
+ await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
130
124
 
131
125
  const currentBranch = execSync("git branch --show-current", {
132
126
  cwd: repo.dir,
@@ -160,7 +154,7 @@ describe("runPipeline", () => {
160
154
  return { stdout: successClaudeJson(), exitCode: 0 };
161
155
  };
162
156
 
163
- await runPipeline(ctx);
157
+ await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
164
158
 
165
159
  expect(claudeCallCount).toBe(4);
166
160
 
@@ -209,7 +203,7 @@ describe("runPipeline", () => {
209
203
  return { stdout: successClaudeJson(), exitCode: 0 };
210
204
  };
211
205
 
212
- await runPipeline(ctx);
206
+ await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
213
207
 
214
208
  // 1 plan + 3 steps * 2 attempts = 7
215
209
  expect(claudeCallCount).toBe(7);
@@ -245,7 +239,7 @@ describe("runPipeline", () => {
245
239
  return { stdout: successClaudeJson(), exitCode: 0 };
246
240
  };
247
241
 
248
- await runPipeline(ctx);
242
+ await runPipeline(ctx, undefined, { spawnFn: mockSpawnFn, exec: mockExec });
249
243
 
250
244
  // 1 plan + 3 steps * 3 attempts (maxReviewRetries=2 → 3 total) = 10
251
245
  expect(claudeCallCount).toBe(10);
@@ -280,7 +274,7 @@ describe("runPipeline", () => {
280
274
  return { stdout: successClaudeJson(), exitCode: 0 };
281
275
  };
282
276
 
283
- await runPipeline(ctx, "implement");
277
+ await runPipeline(ctx, "implement", { spawnFn: mockSpawnFn, exec: mockExec });
284
278
 
285
279
  expect(claudeCallCount).toBe(2);
286
280
  });