@towles/tool 0.0.60 → 0.0.61
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 +1 -1
- package/src/commands/auto-claude.ts +5 -1
- package/src/commands/gh/branch.ts +3 -2
- package/src/lib/auto-claude/pipeline-execution.test.ts +11 -15
- package/src/lib/auto-claude/run-claude.test.ts +24 -29
- package/src/lib/auto-claude/spawn-claude.ts +14 -0
- package/src/lib/auto-claude/steps/steps.test.ts +12 -38
- package/src/lib/auto-claude/test-helpers.ts +55 -2
- package/src/lib/auto-claude/utils.ts +97 -20
- package/src/utils/git/git-wrapper.test.ts +0 -26
- package/src/utils/git/git-wrapper.ts +0 -15
package/package.json
CHANGED
|
@@ -202,7 +202,11 @@ async function syncWithRemote(): Promise<void> {
|
|
|
202
202
|
}
|
|
203
203
|
const status = await git(["status", "--porcelain"]);
|
|
204
204
|
if (status.length > 0) {
|
|
205
|
-
|
|
205
|
+
const files = status.trim().split("\n");
|
|
206
|
+
consola.warn(`Working tree has ${files.length} uncommitted change(s):`);
|
|
207
|
+
for (const file of files) {
|
|
208
|
+
consola.warn(` ${file.trim()}`);
|
|
209
|
+
}
|
|
206
210
|
}
|
|
207
211
|
await git(["pull", cfg.remote, cfg.mainBranch]);
|
|
208
212
|
}
|
|
@@ -5,10 +5,11 @@ import { colors } from "consola/utils";
|
|
|
5
5
|
import { Fzf } from "fzf";
|
|
6
6
|
import consola from "consola";
|
|
7
7
|
|
|
8
|
+
import { exec } from "tinyexec";
|
|
9
|
+
|
|
8
10
|
import { BaseCommand } from "../base.js";
|
|
9
11
|
import type { Issue } from "../../utils/git/gh-cli-wrapper.js";
|
|
10
12
|
import { getIssues, isGithubCliInstalled } from "../../utils/git/gh-cli-wrapper.js";
|
|
11
|
-
import { createBranch } from "../../utils/git/git-wrapper.js";
|
|
12
13
|
import { createBranchNameFromIssue } from "../../utils/git/branch-name.js";
|
|
13
14
|
import { getTerminalColumns, limitText, printWithHexColor } from "../../utils/render.js";
|
|
14
15
|
|
|
@@ -130,7 +131,7 @@ export default class GhBranch extends BaseCommand {
|
|
|
130
131
|
);
|
|
131
132
|
|
|
132
133
|
const branchName = createBranchNameFromIssue(selectedIssue);
|
|
133
|
-
|
|
134
|
+
await exec("git", ["checkout", "-b", branchName]);
|
|
134
135
|
} catch {
|
|
135
136
|
this.exit(1);
|
|
136
137
|
}
|
|
@@ -9,19 +9,21 @@ import { initConfig } from "./config";
|
|
|
9
9
|
import { ARTIFACTS } from "./prompt-templates/index";
|
|
10
10
|
import {
|
|
11
11
|
buildTestContext,
|
|
12
|
+
createSpawnClaudeMock,
|
|
12
13
|
createTestRepoWithRemote,
|
|
13
14
|
errorClaudeJson,
|
|
14
15
|
successClaudeJson,
|
|
15
16
|
} from "./test-helpers";
|
|
16
|
-
import type { TestRepo } from "./test-helpers";
|
|
17
|
+
import type { MockClaudeImpl, TestRepo } from "./test-helpers";
|
|
17
18
|
import type { IssueContext } from "./utils";
|
|
18
19
|
|
|
19
20
|
consola.level = -999;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
let mockClaudeImpl: MockClaudeImpl = null;
|
|
23
|
+
vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
|
|
24
|
+
|
|
25
|
+
// ── Mock tinyexec: intercept "gh" calls, pass through git ──
|
|
22
26
|
|
|
23
|
-
let mockClaudeImpl: ((args: string[]) => Promise<{ stdout: string; exitCode: number }>) | null =
|
|
24
|
-
null;
|
|
25
27
|
let mockGhImpl: ((args: string[]) => Promise<{ stdout: string; exitCode: number }>) | null = null;
|
|
26
28
|
|
|
27
29
|
vi.mock("tinyexec", async (importOriginal) => {
|
|
@@ -34,12 +36,6 @@ vi.mock("tinyexec", async (importOriginal) => {
|
|
|
34
36
|
args: string[],
|
|
35
37
|
opts?: Record<string, unknown>,
|
|
36
38
|
): Promise<{ stdout: string; exitCode: number }> => {
|
|
37
|
-
if (cmd === "claude" && mockClaudeImpl) {
|
|
38
|
-
return mockClaudeImpl(args);
|
|
39
|
-
}
|
|
40
|
-
if (cmd === "claude") {
|
|
41
|
-
throw new Error("Unexpected claude call -- set mockClaudeImpl");
|
|
42
|
-
}
|
|
43
39
|
if (cmd === "gh" && mockGhImpl) {
|
|
44
40
|
return mockGhImpl(args);
|
|
45
41
|
}
|
|
@@ -82,7 +78,7 @@ describe("runPipeline", () => {
|
|
|
82
78
|
it("writes initial-ramblings.md on first run", async () => {
|
|
83
79
|
const { runPipeline } = await import("./pipeline");
|
|
84
80
|
|
|
85
|
-
mockClaudeImpl =
|
|
81
|
+
mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
|
|
86
82
|
|
|
87
83
|
await runPipeline(ctx);
|
|
88
84
|
|
|
@@ -101,7 +97,7 @@ describe("runPipeline", () => {
|
|
|
101
97
|
const ramblingsPath = join(ctx.issueDir, ARTIFACTS.initialRamblings);
|
|
102
98
|
writeFileSync(ramblingsPath, "# Existing ramblings");
|
|
103
99
|
|
|
104
|
-
mockClaudeImpl =
|
|
100
|
+
mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
|
|
105
101
|
|
|
106
102
|
await runPipeline(ctx);
|
|
107
103
|
|
|
@@ -115,7 +111,7 @@ describe("runPipeline", () => {
|
|
|
115
111
|
let claudeCallCount = 0;
|
|
116
112
|
const researchPath = join(ctx.issueDir, ARTIFACTS.research);
|
|
117
113
|
|
|
118
|
-
mockClaudeImpl =
|
|
114
|
+
mockClaudeImpl = () => {
|
|
119
115
|
claudeCallCount++;
|
|
120
116
|
mkdirSync(ctx.issueDir, { recursive: true });
|
|
121
117
|
writeFileSync(researchPath, "x".repeat(250));
|
|
@@ -130,7 +126,7 @@ describe("runPipeline", () => {
|
|
|
130
126
|
it("stops and checks out main on step failure", async () => {
|
|
131
127
|
const { runPipeline } = await import("./pipeline");
|
|
132
128
|
|
|
133
|
-
mockClaudeImpl =
|
|
129
|
+
mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
|
|
134
130
|
|
|
135
131
|
await runPipeline(ctx);
|
|
136
132
|
|
|
@@ -145,7 +141,7 @@ describe("runPipeline", () => {
|
|
|
145
141
|
const { runPipeline } = await import("./pipeline");
|
|
146
142
|
|
|
147
143
|
let claudeCallCount = 0;
|
|
148
|
-
mockClaudeImpl =
|
|
144
|
+
mockClaudeImpl = () => {
|
|
149
145
|
claudeCallCount++;
|
|
150
146
|
mkdirSync(ctx.issueDir, { recursive: true });
|
|
151
147
|
|
|
@@ -2,23 +2,15 @@ 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 { createTestRepo } from "./test-helpers";
|
|
6
|
-
import type { TestRepo } from "./test-helpers";
|
|
5
|
+
import { createSpawnClaudeMock, createTestRepo } from "./test-helpers";
|
|
6
|
+
import type { MockClaudeImpl, TestRepo } from "./test-helpers";
|
|
7
7
|
|
|
8
8
|
consola.level = -999;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
let mockSpawnImpl: MockClaudeImpl = null;
|
|
11
|
+
vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockSpawnImpl));
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
vi.mock("tinyexec", () => ({
|
|
15
|
-
x: vi.fn((...args: unknown[]) => {
|
|
16
|
-
if (mockXImpl) return mockXImpl(...args);
|
|
17
|
-
throw new Error("mockXImpl not set");
|
|
18
|
-
}),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
describe("runClaude (mocked tinyexec)", () => {
|
|
13
|
+
describe("runClaude (mocked spawn-claude)", () => {
|
|
22
14
|
let originalCwd: string;
|
|
23
15
|
let repo: TestRepo;
|
|
24
16
|
|
|
@@ -34,12 +26,12 @@ describe("runClaude (mocked tinyexec)", () => {
|
|
|
34
26
|
});
|
|
35
27
|
|
|
36
28
|
beforeEach(() => {
|
|
37
|
-
|
|
29
|
+
mockSpawnImpl = null;
|
|
38
30
|
vi.clearAllMocks();
|
|
39
31
|
});
|
|
40
32
|
|
|
41
|
-
it("
|
|
42
|
-
|
|
33
|
+
it("parses stream-json result event", async () => {
|
|
34
|
+
mockSpawnImpl = () => ({
|
|
43
35
|
stdout: JSON.stringify({
|
|
44
36
|
result: "All done",
|
|
45
37
|
is_error: false,
|
|
@@ -63,24 +55,27 @@ describe("runClaude (mocked tinyexec)", () => {
|
|
|
63
55
|
expect(result.is_error).toBe(false);
|
|
64
56
|
expect(result.num_turns).toBe(3);
|
|
65
57
|
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
const { spawnClaude } = await import("./spawn-claude");
|
|
59
|
+
expect(spawnClaude).toHaveBeenCalledWith(
|
|
68
60
|
expect.arrayContaining([
|
|
69
61
|
"-p",
|
|
70
62
|
"--output-format",
|
|
71
|
-
"json",
|
|
63
|
+
"stream-json",
|
|
64
|
+
"--verbose",
|
|
72
65
|
"--permission-mode",
|
|
73
66
|
"plan",
|
|
74
67
|
"--max-turns",
|
|
75
68
|
"10",
|
|
76
69
|
"@test-prompt.md",
|
|
77
70
|
]),
|
|
78
|
-
expect.any(Object),
|
|
79
71
|
);
|
|
80
72
|
});
|
|
81
73
|
|
|
82
|
-
it("returns fallback when
|
|
83
|
-
|
|
74
|
+
it("returns fallback when no result event in stream", async () => {
|
|
75
|
+
mockSpawnImpl = () => ({
|
|
76
|
+
stdout: '{"type":"system","message":"starting"}',
|
|
77
|
+
exitCode: 0,
|
|
78
|
+
});
|
|
84
79
|
|
|
85
80
|
await initConfig({ repo: "test/repo", mainBranch: "main" });
|
|
86
81
|
|
|
@@ -91,19 +86,19 @@ describe("runClaude (mocked tinyexec)", () => {
|
|
|
91
86
|
permissionMode: "acceptEdits",
|
|
92
87
|
});
|
|
93
88
|
|
|
94
|
-
expect(result.result).toBe("
|
|
89
|
+
expect(result.result).toBe("");
|
|
95
90
|
expect(result.is_error).toBe(false);
|
|
96
91
|
expect(result.total_cost_usd).toBe(0);
|
|
97
92
|
});
|
|
98
93
|
|
|
99
94
|
it("retries on failure when retry is enabled", async () => {
|
|
100
95
|
let callCount = 0;
|
|
101
|
-
|
|
96
|
+
mockSpawnImpl = () => {
|
|
102
97
|
callCount++;
|
|
103
98
|
if (callCount < 3) {
|
|
104
|
-
|
|
99
|
+
return { stdout: "", exitCode: 1 };
|
|
105
100
|
}
|
|
106
|
-
return
|
|
101
|
+
return {
|
|
107
102
|
stdout: JSON.stringify({
|
|
108
103
|
result: "ok",
|
|
109
104
|
is_error: false,
|
|
@@ -111,8 +106,8 @@ describe("runClaude (mocked tinyexec)", () => {
|
|
|
111
106
|
num_turns: 1,
|
|
112
107
|
}),
|
|
113
108
|
exitCode: 0,
|
|
114
|
-
}
|
|
115
|
-
}
|
|
109
|
+
};
|
|
110
|
+
};
|
|
116
111
|
|
|
117
112
|
await initConfig({
|
|
118
113
|
repo: "test/repo",
|
|
@@ -136,7 +131,7 @@ describe("runClaude (mocked tinyexec)", () => {
|
|
|
136
131
|
}, 10_000);
|
|
137
132
|
|
|
138
133
|
it("throws after max retries exhausted", async () => {
|
|
139
|
-
|
|
134
|
+
mockSpawnImpl = () => ({ stdout: "", exitCode: 1 });
|
|
140
135
|
|
|
141
136
|
await initConfig({
|
|
142
137
|
repo: "test/repo",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { ChildProcess } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spawns the Claude CLI as a child process.
|
|
6
|
+
* Extracted into its own module so tests can mock it cleanly
|
|
7
|
+
* without affecting other exec/spawn usage.
|
|
8
|
+
*/
|
|
9
|
+
export function spawnClaude(args: string[]): ChildProcess {
|
|
10
|
+
return spawn("claude", args, {
|
|
11
|
+
cwd: process.cwd(),
|
|
12
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -9,44 +9,18 @@ import { initConfig } from "../config";
|
|
|
9
9
|
import { ARTIFACTS } from "../prompt-templates/index";
|
|
10
10
|
import {
|
|
11
11
|
buildTestContext,
|
|
12
|
+
createSpawnClaudeMock,
|
|
12
13
|
createTestRepoWithRemote,
|
|
13
14
|
errorClaudeJson,
|
|
14
15
|
successClaudeJson,
|
|
15
16
|
} from "../test-helpers";
|
|
16
|
-
import type { TestRepo } from "../test-helpers";
|
|
17
|
+
import type { MockClaudeImpl, TestRepo } from "../test-helpers";
|
|
17
18
|
import type { IssueContext } from "../utils";
|
|
18
19
|
|
|
19
20
|
consola.level = -999;
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
let mockClaudeImpl: ((args: string[]) => Promise<{ stdout: string; exitCode: number }>) | null =
|
|
24
|
-
null;
|
|
25
|
-
|
|
26
|
-
vi.mock("tinyexec", async (importOriginal) => {
|
|
27
|
-
const original = await importOriginal<typeof import("tinyexec")>();
|
|
28
|
-
return {
|
|
29
|
-
...original,
|
|
30
|
-
x: vi.fn(
|
|
31
|
-
async (
|
|
32
|
-
cmd: string,
|
|
33
|
-
args: string[],
|
|
34
|
-
opts?: Record<string, unknown>,
|
|
35
|
-
): Promise<{ stdout: string; exitCode: number }> => {
|
|
36
|
-
if (cmd === "claude" && mockClaudeImpl) {
|
|
37
|
-
return mockClaudeImpl(args);
|
|
38
|
-
}
|
|
39
|
-
if (cmd === "claude") {
|
|
40
|
-
throw new Error("Unexpected claude call -- set mockClaudeImpl before running");
|
|
41
|
-
}
|
|
42
|
-
return original.x(cmd, args, opts as never) as unknown as Promise<{
|
|
43
|
-
stdout: string;
|
|
44
|
-
exitCode: number;
|
|
45
|
-
}>;
|
|
46
|
-
},
|
|
47
|
-
),
|
|
48
|
-
};
|
|
49
|
-
});
|
|
22
|
+
let mockClaudeImpl: MockClaudeImpl = null;
|
|
23
|
+
vi.mock("../spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
|
|
50
24
|
|
|
51
25
|
// ── Shared setup/teardown for all step tests ──
|
|
52
26
|
|
|
@@ -95,7 +69,7 @@ describe("runStepWithArtifact", () => {
|
|
|
95
69
|
});
|
|
96
70
|
|
|
97
71
|
it("returns false when Claude returns is_error", async () => {
|
|
98
|
-
mockClaudeImpl =
|
|
72
|
+
mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
|
|
99
73
|
|
|
100
74
|
const { runStepWithArtifact } = await import("../utils");
|
|
101
75
|
const artifactPath = join(ctx.issueDir, "missing-artifact.md");
|
|
@@ -111,7 +85,7 @@ describe("runStepWithArtifact", () => {
|
|
|
111
85
|
});
|
|
112
86
|
|
|
113
87
|
it("returns false when artifact not produced after Claude run", async () => {
|
|
114
|
-
mockClaudeImpl =
|
|
88
|
+
mockClaudeImpl = () => ({ stdout: successClaudeJson(), exitCode: 0 });
|
|
115
89
|
|
|
116
90
|
const { runStepWithArtifact } = await import("../utils");
|
|
117
91
|
const artifactPath = join(ctx.issueDir, "never-created.md");
|
|
@@ -130,7 +104,7 @@ describe("runStepWithArtifact", () => {
|
|
|
130
104
|
const { runStepWithArtifact } = await import("../utils");
|
|
131
105
|
const artifactPath = join(ctx.issueDir, "plan.md");
|
|
132
106
|
|
|
133
|
-
mockClaudeImpl =
|
|
107
|
+
mockClaudeImpl = () => {
|
|
134
108
|
writeFileSync(artifactPath, "# Plan\n\nDetailed plan content here.");
|
|
135
109
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
136
110
|
};
|
|
@@ -179,7 +153,7 @@ describe("stepResearch", () => {
|
|
|
179
153
|
writeFileSync(researchPath, "short");
|
|
180
154
|
|
|
181
155
|
let claudeCalled = false;
|
|
182
|
-
mockClaudeImpl =
|
|
156
|
+
mockClaudeImpl = () => {
|
|
183
157
|
claudeCalled = true;
|
|
184
158
|
writeFileSync(researchPath, "x".repeat(250));
|
|
185
159
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
@@ -194,7 +168,7 @@ describe("stepResearch", () => {
|
|
|
194
168
|
const { stepResearch } = await import("./research");
|
|
195
169
|
|
|
196
170
|
const researchPath = join(ctx.issueDir, ARTIFACTS.research);
|
|
197
|
-
mockClaudeImpl =
|
|
171
|
+
mockClaudeImpl = () => {
|
|
198
172
|
writeFileSync(researchPath, "x".repeat(250));
|
|
199
173
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
200
174
|
};
|
|
@@ -244,7 +218,7 @@ describe("stepPlanAnnotations", () => {
|
|
|
244
218
|
const addressedPath = join(ctx.issueDir, ARTIFACTS.planAnnotationsAddressed);
|
|
245
219
|
writeFileSync(annotationsPath, "# Annotations\n\nSome feedback here.");
|
|
246
220
|
|
|
247
|
-
mockClaudeImpl =
|
|
221
|
+
mockClaudeImpl = () => ({ stdout: successClaudeJson(), exitCode: 0 });
|
|
248
222
|
|
|
249
223
|
const result = await stepPlanAnnotations(ctx);
|
|
250
224
|
|
|
@@ -288,7 +262,7 @@ describe("stepImplement", () => {
|
|
|
288
262
|
const { stepImplement } = await import("./implement");
|
|
289
263
|
|
|
290
264
|
let callCount = 0;
|
|
291
|
-
mockClaudeImpl =
|
|
265
|
+
mockClaudeImpl = () => {
|
|
292
266
|
callCount++;
|
|
293
267
|
return { stdout: successClaudeJson(), exitCode: 0 };
|
|
294
268
|
};
|
|
@@ -304,7 +278,7 @@ describe("stepImplement", () => {
|
|
|
304
278
|
let callCount = 0;
|
|
305
279
|
const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
|
|
306
280
|
|
|
307
|
-
mockClaudeImpl =
|
|
281
|
+
mockClaudeImpl = () => {
|
|
308
282
|
callCount++;
|
|
309
283
|
if (callCount === 2) {
|
|
310
284
|
writeFileSync(completedPath, "# Implementation Complete\n\nAll tasks done.");
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
1
2
|
import { execSync } from "node:child_process";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
2
4
|
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
5
|
import { tmpdir } from "node:os";
|
|
4
6
|
import { join } from "node:path";
|
|
7
|
+
import { PassThrough } from "node:stream";
|
|
8
|
+
|
|
9
|
+
import { vi } from "vitest";
|
|
5
10
|
|
|
6
11
|
import type { ClaudeResult, IssueContext } from "./utils";
|
|
7
12
|
|
|
@@ -62,7 +67,7 @@ export function buildTestContext(dir: string, issueNumber = 1): IssueContext {
|
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
/**
|
|
65
|
-
* Returns a
|
|
70
|
+
* Returns a stream-json result line for a successful ClaudeResult.
|
|
66
71
|
*/
|
|
67
72
|
export function successClaudeJson(result = "done"): string {
|
|
68
73
|
return JSON.stringify({
|
|
@@ -74,7 +79,7 @@ export function successClaudeJson(result = "done"): string {
|
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/**
|
|
77
|
-
* Returns a
|
|
82
|
+
* Returns a stream-json result line for a failed ClaudeResult.
|
|
78
83
|
*/
|
|
79
84
|
export function errorClaudeJson(result = "failed"): string {
|
|
80
85
|
return JSON.stringify({
|
|
@@ -84,3 +89,51 @@ export function errorClaudeJson(result = "failed"): string {
|
|
|
84
89
|
num_turns: 0,
|
|
85
90
|
} satisfies ClaudeResult);
|
|
86
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates a fake ChildProcess with PassThrough stdout for testing
|
|
95
|
+
* the streaming runClaude implementation.
|
|
96
|
+
*
|
|
97
|
+
* Writes `stdout` content to the stream, then emits `close` with `exitCode`.
|
|
98
|
+
*/
|
|
99
|
+
export function createMockClaudeProcess(stdout: string, exitCode = 0): ChildProcess {
|
|
100
|
+
const proc = new EventEmitter() as ChildProcess;
|
|
101
|
+
const stdoutStream = new PassThrough();
|
|
102
|
+
(proc as unknown as { stdout: PassThrough }).stdout = stdoutStream;
|
|
103
|
+
(proc as unknown as { stderr: null }).stderr = null;
|
|
104
|
+
(proc as unknown as { stdin: null }).stdin = null;
|
|
105
|
+
|
|
106
|
+
// Write and close asynchronously so listeners can attach first
|
|
107
|
+
queueMicrotask(() => {
|
|
108
|
+
if (stdout) {
|
|
109
|
+
stdoutStream.write(`${stdout}\n`);
|
|
110
|
+
}
|
|
111
|
+
stdoutStream.end();
|
|
112
|
+
proc.emit("close", exitCode);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return proc;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type MockClaudeImpl = ((args: string[]) => { stdout: string; exitCode: number }) | null;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Creates the mock object for vi.mock("./spawn-claude").
|
|
122
|
+
* Must be called inside the vi.mock factory arrow (lazy) to avoid hoisting issues.
|
|
123
|
+
*
|
|
124
|
+
* Usage:
|
|
125
|
+
* let mockClaudeImpl: MockClaudeImpl = null;
|
|
126
|
+
* vi.mock("./spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
|
|
127
|
+
*/
|
|
128
|
+
export function createSpawnClaudeMock(getImpl: () => MockClaudeImpl) {
|
|
129
|
+
return {
|
|
130
|
+
spawnClaude: vi.fn((args: string[]) => {
|
|
131
|
+
const impl = getImpl();
|
|
132
|
+
if (impl) {
|
|
133
|
+
const { stdout, exitCode } = impl(args);
|
|
134
|
+
return createMockClaudeProcess(stdout, exitCode);
|
|
135
|
+
}
|
|
136
|
+
throw new Error("Unexpected spawnClaude call -- set mockClaudeImpl");
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
5
6
|
import consola from "consola";
|
|
@@ -9,6 +10,7 @@ import { x } from "tinyexec";
|
|
|
9
10
|
import { createBranchNameFromIssue } from "../../utils/git/branch-name.js";
|
|
10
11
|
import { getConfig } from "./config.js";
|
|
11
12
|
import { ARTIFACTS } from "./prompt-templates/index.js";
|
|
13
|
+
import { spawnClaude } from "./spawn-claude.js";
|
|
12
14
|
|
|
13
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
16
|
export const TEMPLATES_DIR = join(__dirname, "prompt-templates");
|
|
@@ -64,7 +66,8 @@ export async function runClaude(opts: {
|
|
|
64
66
|
const args = [
|
|
65
67
|
"-p",
|
|
66
68
|
"--output-format",
|
|
67
|
-
"json",
|
|
69
|
+
"stream-json",
|
|
70
|
+
"--verbose",
|
|
68
71
|
"--permission-mode",
|
|
69
72
|
opts.permissionMode,
|
|
70
73
|
...(opts.maxTurns ? ["--max-turns", String(opts.maxTurns)] : []),
|
|
@@ -77,26 +80,12 @@ export async function runClaude(opts: {
|
|
|
77
80
|
|
|
78
81
|
while (true) {
|
|
79
82
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const stdout = proc.stdout;
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const parsed = JSON.parse(stdout) as ClaudeResult;
|
|
88
|
-
consola.success(`Done — ${parsed.num_turns} turns`);
|
|
89
|
-
if (parsed.result) {
|
|
90
|
-
consola.log(parsed.result);
|
|
91
|
-
}
|
|
92
|
-
return parsed;
|
|
93
|
-
} catch {
|
|
94
|
-
consola.warn("Done — failed to parse Claude output");
|
|
95
|
-
if (stdout.trim()) {
|
|
96
|
-
consola.log(stdout.trim());
|
|
97
|
-
}
|
|
98
|
-
return { result: stdout.trim(), is_error: false, total_cost_usd: 0, num_turns: 0 };
|
|
83
|
+
const result = await runClaudeStreaming(args);
|
|
84
|
+
consola.success(`Done — ${result.num_turns} turns`);
|
|
85
|
+
if (result.result) {
|
|
86
|
+
consola.log(result.result);
|
|
99
87
|
}
|
|
88
|
+
return result;
|
|
100
89
|
} catch (e) {
|
|
101
90
|
const shouldRetry = opts.retry ?? cfg.loopRetryEnabled ?? false;
|
|
102
91
|
if (!shouldRetry) throw e;
|
|
@@ -114,6 +103,94 @@ export async function runClaude(opts: {
|
|
|
114
103
|
}
|
|
115
104
|
}
|
|
116
105
|
|
|
106
|
+
function runClaudeStreaming(args: string[]): Promise<ClaudeResult> {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const proc = spawnClaude(args);
|
|
109
|
+
let capturedResult: ClaudeResult | null = null;
|
|
110
|
+
let turnCount = 0;
|
|
111
|
+
|
|
112
|
+
if (!proc.stdout) {
|
|
113
|
+
reject(new Error("Claude process has no stdout"));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const rl = createInterface({ input: proc.stdout });
|
|
118
|
+
|
|
119
|
+
rl.on("line", (line) => {
|
|
120
|
+
if (!line.trim()) return;
|
|
121
|
+
try {
|
|
122
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
123
|
+
handleStreamEvent(event, (turns) => {
|
|
124
|
+
turnCount = turns;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if ("result" in event && "is_error" in event && "num_turns" in event) {
|
|
128
|
+
capturedResult = {
|
|
129
|
+
result: String(event.result ?? ""),
|
|
130
|
+
is_error: Boolean(event.is_error),
|
|
131
|
+
total_cost_usd: Number(event.total_cost_usd ?? 0),
|
|
132
|
+
num_turns: Number(event.num_turns),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Skip non-JSON lines
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
proc.on("error", (err) => {
|
|
141
|
+
rl.close();
|
|
142
|
+
reject(err);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
proc.on("close", (code) => {
|
|
146
|
+
rl.close();
|
|
147
|
+
if (capturedResult) {
|
|
148
|
+
resolve(capturedResult);
|
|
149
|
+
} else if (code !== 0) {
|
|
150
|
+
reject(new Error(`Claude process exited with code ${code}`));
|
|
151
|
+
} else {
|
|
152
|
+
resolve({ result: "", is_error: false, total_cost_usd: 0, num_turns: turnCount });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function handleStreamEvent(event: Record<string, unknown>, onTurn: (count: number) => void): void {
|
|
159
|
+
// Tool use events: look for content blocks with tool_use type
|
|
160
|
+
if (event.type === "assistant" && Array.isArray(event.message)) {
|
|
161
|
+
for (const block of event.message) {
|
|
162
|
+
if (
|
|
163
|
+
typeof block === "object" &&
|
|
164
|
+
block !== null &&
|
|
165
|
+
"type" in block &&
|
|
166
|
+
(block as Record<string, unknown>).type === "tool_use"
|
|
167
|
+
) {
|
|
168
|
+
const name = (block as Record<string, unknown>).name;
|
|
169
|
+
if (typeof name === "string") {
|
|
170
|
+
consola.info(` ${pc.dim("\u21B3")} ${name}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Content block with tool_use (alternative format)
|
|
177
|
+
if (
|
|
178
|
+
event.type === "content_block_start" &&
|
|
179
|
+
typeof event.content_block === "object" &&
|
|
180
|
+
event.content_block !== null
|
|
181
|
+
) {
|
|
182
|
+
const block = event.content_block as Record<string, unknown>;
|
|
183
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
184
|
+
consola.info(` ${pc.dim("\u21B3")} ${block.name}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Turn count tracking
|
|
189
|
+
if (typeof event.num_turns === "number" && !("result" in event)) {
|
|
190
|
+
onTurn(event.num_turns as number);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
117
194
|
// ── Template resolution ──
|
|
118
195
|
|
|
119
196
|
export interface TokenValues {
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { isGitDirectory } from "./git-wrapper";
|
|
3
|
-
|
|
4
|
-
describe.skip("git-wrapper", () => {
|
|
5
|
-
it("isGitDirectory", async () => {
|
|
6
|
-
const result = await isGitDirectory();
|
|
7
|
-
expect(result).toBe(true);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
// it('getMergedBranches', async () => {
|
|
11
|
-
// const result = await getMergedBranches()
|
|
12
|
-
|
|
13
|
-
// if (result.length > 0) // may not have branches to cleanup so just check for no errors
|
|
14
|
-
// expect(result[0].trim()).toBe(result[0])
|
|
15
|
-
// })
|
|
16
|
-
|
|
17
|
-
// it('getLocalBranchNames', async () => {
|
|
18
|
-
// const result = await getLocalBranchNames()
|
|
19
|
-
// expect(result.includes('main')).toBeDefined()
|
|
20
|
-
// })
|
|
21
|
-
|
|
22
|
-
// it('getDefaultMainBranchName', async () => {
|
|
23
|
-
// const result = await getDefaultMainBranchName()
|
|
24
|
-
// expect(result).toBe('main')
|
|
25
|
-
// })
|
|
26
|
-
});
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { exec } from "tinyexec";
|
|
2
|
-
|
|
3
|
-
export const isGitDirectory = async (): Promise<boolean> => {
|
|
4
|
-
try {
|
|
5
|
-
const result = await exec(`git`, ["status"]);
|
|
6
|
-
return result.stdout.includes("On branch");
|
|
7
|
-
} catch (e) {
|
|
8
|
-
return false;
|
|
9
|
-
}
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export const createBranch = async ({ branchName }: { branchName: string }): Promise<string> => {
|
|
13
|
-
const result = await exec(`git`, ["checkout", "-b", branchName]);
|
|
14
|
-
return result.stdout;
|
|
15
|
-
};
|