@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 +1 -1
- package/src/commands/auto-claude/index.ts +41 -3
- package/src/commands/auto-claude/retry.test.ts +8 -11
- package/src/commands/auto-claude/retry.ts +4 -2
- package/src/lib/auto-claude/claude-cli.ts +35 -16
- package/src/lib/auto-claude/index.ts +1 -0
- package/src/lib/auto-claude/labels.test.ts +10 -14
- package/src/lib/auto-claude/labels.ts +22 -23
- package/src/lib/auto-claude/pipeline-execution.test.ts +33 -39
- package/src/lib/auto-claude/pipeline.ts +42 -20
- package/src/lib/auto-claude/run-claude.test.ts +40 -28
- package/src/lib/auto-claude/spawn-claude.ts +2 -0
- package/src/lib/auto-claude/steps/create-pr.ts +58 -55
- package/src/lib/auto-claude/steps/implement.ts +3 -1
- package/src/lib/auto-claude/steps/simple-steps.ts +7 -3
- package/src/lib/auto-claude/steps/steps.test.ts +58 -31
- package/src/lib/auto-claude/templates.test.ts +28 -29
- package/src/lib/auto-claude/templates.ts +21 -4
- package/src/lib/auto-claude/utils.ts +4 -1
- package/src/lib/graph/parser.test.ts +21 -25
- package/src/lib/graph/parser.ts +14 -5
- package/src/utils/git/gh-cli-wrapper.test.ts +16 -15
- package/src/utils/git/gh-cli-wrapper.ts +18 -5
|
@@ -9,29 +9,44 @@ import { initConfig } from "../config";
|
|
|
9
9
|
import { ARTIFACTS } from "../prompt-templates/index";
|
|
10
10
|
import {
|
|
11
11
|
buildTestContext,
|
|
12
|
-
|
|
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(): {
|
|
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
|
-
|
|
34
|
-
|
|
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
|
});
|
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
1
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
2
|
|
|
4
3
|
import { resolveTemplate } from "./templates";
|
|
5
|
-
import type { TokenValues } from "./templates";
|
|
4
|
+
import type { TemplateFsDeps, TokenValues } from "./templates";
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const mockedWriteFileSync = vi.mocked(writeFileSync);
|
|
15
|
-
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
6
|
+
function createMockFs(): TemplateFsDeps {
|
|
7
|
+
return {
|
|
8
|
+
readFileSync: vi.fn().mockReturnValue(""),
|
|
9
|
+
writeFileSync: vi.fn(),
|
|
10
|
+
mkdirSync: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
16
13
|
|
|
17
14
|
describe("resolveTemplate", () => {
|
|
15
|
+
let mockFs: TemplateFsDeps;
|
|
16
|
+
|
|
18
17
|
beforeEach(() => {
|
|
19
|
-
|
|
18
|
+
mockFs = createMockFs();
|
|
20
19
|
});
|
|
21
20
|
|
|
22
21
|
const tokens: TokenValues = {
|
|
@@ -26,13 +25,13 @@ describe("resolveTemplate", () => {
|
|
|
26
25
|
};
|
|
27
26
|
|
|
28
27
|
it("replaces all token placeholders in template", () => {
|
|
29
|
-
|
|
28
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue(
|
|
30
29
|
"Scope: {{SCOPE_PATH}}\nDir: {{ISSUE_DIR}}\nBranch: {{MAIN_BRANCH}}",
|
|
31
30
|
);
|
|
32
31
|
|
|
33
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
32
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
34
33
|
|
|
35
|
-
const writtenContent =
|
|
34
|
+
const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
|
|
36
35
|
expect(writtenContent).toContain("/home/user/project");
|
|
37
36
|
expect(writtenContent).toContain("/tmp/issues/42");
|
|
38
37
|
expect(writtenContent).toContain("main");
|
|
@@ -44,28 +43,28 @@ describe("resolveTemplate", () => {
|
|
|
44
43
|
...tokens,
|
|
45
44
|
REVIEW_FEEDBACK: "Needs more tests",
|
|
46
45
|
};
|
|
47
|
-
|
|
46
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("Feedback: {{REVIEW_FEEDBACK}}");
|
|
48
47
|
|
|
49
|
-
resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42");
|
|
48
|
+
resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42", mockFs);
|
|
50
49
|
|
|
51
|
-
const writtenContent =
|
|
50
|
+
const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
|
|
52
51
|
expect(writtenContent).toContain("Needs more tests");
|
|
53
52
|
});
|
|
54
53
|
|
|
55
54
|
it("creates output directory recursively", () => {
|
|
56
|
-
|
|
55
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("template content");
|
|
57
56
|
|
|
58
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
57
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
59
58
|
|
|
60
|
-
expect(
|
|
59
|
+
expect(mockFs.mkdirSync).toHaveBeenCalledWith("/tmp/issues/42", { recursive: true });
|
|
61
60
|
});
|
|
62
61
|
|
|
63
62
|
it("writes resolved template to issue dir", () => {
|
|
64
|
-
|
|
63
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("simple content");
|
|
65
64
|
|
|
66
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
65
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
67
66
|
|
|
68
|
-
expect(
|
|
67
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
69
68
|
"/tmp/issues/42/plan.md",
|
|
70
69
|
"simple content",
|
|
71
70
|
"utf-8",
|
|
@@ -73,19 +72,19 @@ describe("resolveTemplate", () => {
|
|
|
73
72
|
});
|
|
74
73
|
|
|
75
74
|
it("returns relative path from cwd", () => {
|
|
76
|
-
|
|
75
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("content");
|
|
77
76
|
|
|
78
|
-
const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
77
|
+
const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
79
78
|
// Should be a relative path (not starting with /)
|
|
80
79
|
expect(result).not.toMatch(/^\/tmp/);
|
|
81
80
|
});
|
|
82
81
|
|
|
83
82
|
it("handles multiple occurrences of the same token", () => {
|
|
84
|
-
|
|
83
|
+
vi.mocked(mockFs.readFileSync).mockReturnValue("{{MAIN_BRANCH}} and {{MAIN_BRANCH}} again");
|
|
85
84
|
|
|
86
|
-
resolveTemplate("plan.md", tokens, "/tmp/issues/42");
|
|
85
|
+
resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
|
|
87
86
|
|
|
88
|
-
const writtenContent =
|
|
87
|
+
const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
|
|
89
88
|
expect(writtenContent).toBe("main and main again");
|
|
90
89
|
});
|
|
91
90
|
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync as defaultMkdirSync,
|
|
3
|
+
readFileSync as defaultReadFileSync,
|
|
4
|
+
writeFileSync as defaultWriteFileSync,
|
|
5
|
+
} from "node:fs";
|
|
2
6
|
import { dirname, join, relative } from "node:path";
|
|
3
7
|
import { fileURLToPath } from "node:url";
|
|
4
8
|
|
|
@@ -14,21 +18,34 @@ export interface TokenValues {
|
|
|
14
18
|
REVIEW_FEEDBACK?: string;
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
export interface TemplateFsDeps {
|
|
22
|
+
readFileSync: typeof defaultReadFileSync;
|
|
23
|
+
writeFileSync: typeof defaultWriteFileSync;
|
|
24
|
+
mkdirSync: typeof defaultMkdirSync;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const defaultFsDeps: TemplateFsDeps = {
|
|
28
|
+
readFileSync: defaultReadFileSync,
|
|
29
|
+
writeFileSync: defaultWriteFileSync,
|
|
30
|
+
mkdirSync: defaultMkdirSync,
|
|
31
|
+
};
|
|
32
|
+
|
|
17
33
|
export function resolveTemplate(
|
|
18
34
|
templateName: string,
|
|
19
35
|
tokens: TokenValues,
|
|
20
36
|
issueDir: string,
|
|
37
|
+
fs: TemplateFsDeps = defaultFsDeps,
|
|
21
38
|
): string {
|
|
22
39
|
const templatePath = join(TEMPLATES_DIR, templateName);
|
|
23
|
-
let template = readFileSync(templatePath, "utf-8");
|
|
40
|
+
let template = fs.readFileSync(templatePath, "utf-8") as string;
|
|
24
41
|
|
|
25
42
|
for (const [key, value] of Object.entries(tokens)) {
|
|
26
43
|
template = template.replaceAll(`{{${key}}}`, value);
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
const resolvedPath = join(issueDir, templateName);
|
|
30
|
-
mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
31
|
-
writeFileSync(resolvedPath, template, "utf-8");
|
|
47
|
+
fs.mkdirSync(dirname(resolvedPath), { recursive: true });
|
|
48
|
+
fs.writeFileSync(resolvedPath, template, "utf-8");
|
|
32
49
|
|
|
33
50
|
return relative(process.cwd(), resolvedPath);
|
|
34
51
|
}
|
|
@@ -11,6 +11,7 @@ import { getConfig } from "./config.js";
|
|
|
11
11
|
import { ARTIFACTS } from "./prompt-templates/index.js";
|
|
12
12
|
import { resolveTemplate } from "./templates.js";
|
|
13
13
|
|
|
14
|
+
import type { SpawnClaudeFn } from "./spawn-claude.js";
|
|
14
15
|
import type { TokenValues } from "./templates.js";
|
|
15
16
|
|
|
16
17
|
export { ensureDir, fileExists, readFile, writeFile } from "../../utils/fs.js";
|
|
@@ -157,6 +158,7 @@ export interface StepRunnerOptions {
|
|
|
157
158
|
artifactPath: string;
|
|
158
159
|
templateName: string;
|
|
159
160
|
artifactValidator?: (path: string) => boolean;
|
|
161
|
+
spawnFn?: SpawnClaudeFn;
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
/**
|
|
@@ -167,7 +169,7 @@ export interface StepRunnerOptions {
|
|
|
167
169
|
* the same pattern.
|
|
168
170
|
*/
|
|
169
171
|
export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<boolean> {
|
|
170
|
-
const { stepName, ctx, artifactPath, templateName, artifactValidator } = opts;
|
|
172
|
+
const { stepName, ctx, artifactPath, templateName, artifactValidator, spawnFn } = opts;
|
|
171
173
|
|
|
172
174
|
const isValid = artifactValidator ?? fileExists;
|
|
173
175
|
if (isValid(artifactPath)) {
|
|
@@ -183,6 +185,7 @@ export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<bool
|
|
|
183
185
|
const result = await runClaude({
|
|
184
186
|
promptFile,
|
|
185
187
|
maxTurns: getConfig().maxTurns,
|
|
188
|
+
spawnFn,
|
|
186
189
|
});
|
|
187
190
|
|
|
188
191
|
if (result.is_error) {
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
3
2
|
|
|
3
|
+
import type { ReadFileFn } from "./parser";
|
|
4
4
|
import { calculateCutoffMs, filterByDays, parseJsonl, quickTokenCount } from "./parser";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
readFileSync: vi.fn(),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
const mockedReadFileSync = vi.mocked(fs.readFileSync);
|
|
6
|
+
const mockReadFileSync: ReadFileFn = vi.fn();
|
|
11
7
|
|
|
12
8
|
// ── Pure functions (no mocking needed) ──
|
|
13
9
|
|
|
@@ -67,38 +63,38 @@ describe("filterByDays", () => {
|
|
|
67
63
|
});
|
|
68
64
|
});
|
|
69
65
|
|
|
70
|
-
// ── parseJsonl and quickTokenCount (
|
|
66
|
+
// ── parseJsonl and quickTokenCount (use injected readFile) ──
|
|
71
67
|
|
|
72
68
|
describe("parseJsonl", () => {
|
|
73
69
|
it("parses valid JSONL lines", () => {
|
|
74
|
-
|
|
70
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
75
71
|
'{"type":"user","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}\n{"type":"assistant","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}\n',
|
|
76
72
|
);
|
|
77
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
73
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
78
74
|
expect(entries).toHaveLength(2);
|
|
79
75
|
expect(entries[0].type).toBe("user");
|
|
80
76
|
expect(entries[1].type).toBe("assistant");
|
|
81
77
|
});
|
|
82
78
|
|
|
83
79
|
it("skips empty lines", () => {
|
|
84
|
-
|
|
80
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
85
81
|
'{"type":"user","sessionId":"s1","timestamp":"t"}\n\n\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
|
|
86
82
|
);
|
|
87
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
83
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
88
84
|
expect(entries).toHaveLength(2);
|
|
89
85
|
});
|
|
90
86
|
|
|
91
87
|
it("skips invalid JSON lines", () => {
|
|
92
|
-
|
|
88
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
93
89
|
'{"type":"user","sessionId":"s1","timestamp":"t"}\nnot-json\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
|
|
94
90
|
);
|
|
95
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
91
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
96
92
|
expect(entries).toHaveLength(2);
|
|
97
93
|
});
|
|
98
94
|
|
|
99
95
|
it("returns empty array for empty file", () => {
|
|
100
|
-
|
|
101
|
-
const entries = parseJsonl("/fake/path.jsonl");
|
|
96
|
+
vi.mocked(mockReadFileSync).mockReturnValue("");
|
|
97
|
+
const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
|
|
102
98
|
expect(entries).toHaveLength(0);
|
|
103
99
|
});
|
|
104
100
|
});
|
|
@@ -113,8 +109,8 @@ describe("quickTokenCount", () => {
|
|
|
113
109
|
message: { usage: { input_tokens: 200, output_tokens: 75 } },
|
|
114
110
|
}),
|
|
115
111
|
].join("\n");
|
|
116
|
-
|
|
117
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(425);
|
|
112
|
+
vi.mocked(mockReadFileSync).mockReturnValue(lines);
|
|
113
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(425);
|
|
118
114
|
});
|
|
119
115
|
|
|
120
116
|
it("skips entries without usage", () => {
|
|
@@ -122,29 +118,29 @@ describe("quickTokenCount", () => {
|
|
|
122
118
|
JSON.stringify({ message: { content: "text" } }),
|
|
123
119
|
JSON.stringify({ message: { usage: { input_tokens: 100, output_tokens: 50 } } }),
|
|
124
120
|
].join("\n");
|
|
125
|
-
|
|
126
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(150);
|
|
121
|
+
vi.mocked(mockReadFileSync).mockReturnValue(lines);
|
|
122
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(150);
|
|
127
123
|
});
|
|
128
124
|
|
|
129
125
|
it("returns 0 for unreadable files", () => {
|
|
130
|
-
|
|
126
|
+
vi.mocked(mockReadFileSync).mockImplementation(() => {
|
|
131
127
|
throw new Error("ENOENT");
|
|
132
128
|
});
|
|
133
|
-
expect(quickTokenCount("/missing/file.jsonl")).toBe(0);
|
|
129
|
+
expect(quickTokenCount("/missing/file.jsonl", mockReadFileSync)).toBe(0);
|
|
134
130
|
});
|
|
135
131
|
|
|
136
132
|
it("handles entries with partial usage (only input_tokens)", () => {
|
|
137
133
|
const lines = JSON.stringify({
|
|
138
134
|
message: { usage: { input_tokens: 100 } },
|
|
139
135
|
});
|
|
140
|
-
|
|
141
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
|
|
136
|
+
vi.mocked(mockReadFileSync).mockReturnValue(lines);
|
|
137
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
|
|
142
138
|
});
|
|
143
139
|
|
|
144
140
|
it("skips invalid JSON lines gracefully", () => {
|
|
145
|
-
|
|
141
|
+
vi.mocked(mockReadFileSync).mockReturnValue(
|
|
146
142
|
'{"message":{"usage":{"input_tokens":50,"output_tokens":50}}}\nbadline\n',
|
|
147
143
|
);
|
|
148
|
-
expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
|
|
144
|
+
expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
|
|
149
145
|
});
|
|
150
146
|
});
|
package/src/lib/graph/parser.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { readFileSync as defaultReadFileSync } from "node:fs";
|
|
2
|
+
|
|
2
3
|
import type { JournalEntry } from "./types.js";
|
|
3
4
|
|
|
5
|
+
export type ReadFileFn = (path: string, encoding: BufferEncoding) => string;
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Calculate cutoff timestamp for days filtering.
|
|
6
9
|
* Returns 0 if days <= 0 (no filtering).
|
|
@@ -22,8 +25,11 @@ export function filterByDays<T extends { mtime: number }>(items: T[], days: numb
|
|
|
22
25
|
/**
|
|
23
26
|
* Parse JSONL file into JournalEntry array.
|
|
24
27
|
*/
|
|
25
|
-
export function parseJsonl(
|
|
26
|
-
|
|
28
|
+
export function parseJsonl(
|
|
29
|
+
filePath: string,
|
|
30
|
+
readFile: ReadFileFn = defaultReadFileSync,
|
|
31
|
+
): JournalEntry[] {
|
|
32
|
+
const content = readFile(filePath, "utf-8");
|
|
27
33
|
const entries: JournalEntry[] = [];
|
|
28
34
|
|
|
29
35
|
for (const line of content.split("\n")) {
|
|
@@ -41,9 +47,12 @@ export function parseJsonl(filePath: string): JournalEntry[] {
|
|
|
41
47
|
/**
|
|
42
48
|
* Quick token count from a JSONL file without full parsing.
|
|
43
49
|
*/
|
|
44
|
-
export function quickTokenCount(
|
|
50
|
+
export function quickTokenCount(
|
|
51
|
+
filePath: string,
|
|
52
|
+
readFile: ReadFileFn = defaultReadFileSync,
|
|
53
|
+
): number {
|
|
45
54
|
try {
|
|
46
|
-
const content =
|
|
55
|
+
const content = readFile(filePath, "utf-8");
|
|
47
56
|
let total = 0;
|
|
48
57
|
for (const line of content.split("\n")) {
|
|
49
58
|
if (!line.trim()) continue;
|