@towles/tool 0.0.59 → 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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/commands/auto-claude.ts +5 -1
  3. package/src/commands/gh/branch.test.ts +107 -108
  4. package/src/commands/gh/branch.ts +42 -38
  5. package/src/lib/auto-claude/pipeline-execution.test.ts +191 -0
  6. package/src/lib/auto-claude/prompt-templates/01_research.prompt.md +21 -0
  7. package/src/lib/auto-claude/prompt-templates/02_plan.prompt.md +27 -0
  8. package/src/lib/auto-claude/prompt-templates/03_plan-annotations.prompt.md +15 -0
  9. package/src/lib/auto-claude/prompt-templates/04_plan-implementation.prompt.md +35 -0
  10. package/src/lib/auto-claude/prompt-templates/05_implement.prompt.md +36 -0
  11. package/src/lib/auto-claude/prompt-templates/06_review.prompt.md +32 -0
  12. package/src/lib/auto-claude/prompt-templates/07_refresh.prompt.md +30 -0
  13. package/src/lib/auto-claude/prompt-templates/CLAUDE.md +12 -0
  14. package/src/lib/auto-claude/prompt-templates/index.test.ts +2 -2
  15. package/src/lib/auto-claude/prompt-templates/index.ts +7 -7
  16. package/src/lib/auto-claude/run-claude.test.ts +155 -0
  17. package/src/lib/auto-claude/spawn-claude.ts +14 -0
  18. package/src/lib/auto-claude/steps/steps.test.ts +304 -0
  19. package/src/lib/auto-claude/test-helpers.ts +139 -0
  20. package/src/lib/auto-claude/utils-execution.test.ts +152 -0
  21. package/src/lib/auto-claude/utils.test.ts +7 -7
  22. package/src/lib/auto-claude/utils.ts +99 -21
  23. package/src/utils/git/branch-name.test.ts +83 -0
  24. package/src/utils/git/branch-name.ts +10 -0
  25. package/src/lib/auto-claude/prompt-templates/01-prompt-research.md +0 -28
  26. package/src/lib/auto-claude/prompt-templates/02-prompt-plan.md +0 -28
  27. package/src/lib/auto-claude/prompt-templates/03-prompt-plan-annotations.md +0 -21
  28. package/src/lib/auto-claude/prompt-templates/04-prompt-plan-implementation.md +0 -33
  29. package/src/lib/auto-claude/prompt-templates/05-prompt-implement.md +0 -31
  30. package/src/lib/auto-claude/prompt-templates/06-prompt-review.md +0 -30
  31. package/src/lib/auto-claude/prompt-templates/07-prompt-refresh.md +0 -39
  32. package/src/utils/git/git-wrapper.test.ts +0 -26
  33. package/src/utils/git/git-wrapper.ts +0 -15
@@ -0,0 +1,139 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { execSync } from "node:child_process";
3
+ import { EventEmitter } from "node:events";
4
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { PassThrough } from "node:stream";
8
+
9
+ import { vi } from "vitest";
10
+
11
+ import type { ClaudeResult, IssueContext } from "./utils";
12
+
13
+ export interface TestRepo {
14
+ dir: string;
15
+ cleanup: () => void;
16
+ }
17
+
18
+ /**
19
+ * Creates a local git repo with a single empty commit on `main`.
20
+ */
21
+ export function createTestRepo(prefix = "ac-test-"): TestRepo {
22
+ const dir = mkdtempSync(join(tmpdir(), prefix));
23
+ execSync("git init", { cwd: dir, stdio: "ignore" });
24
+ execSync("git config user.email 'test@test.com'", { cwd: dir, stdio: "ignore" });
25
+ execSync("git config user.name 'Test'", { cwd: dir, stdio: "ignore" });
26
+ execSync("git commit --allow-empty -m 'init'", { cwd: dir, stdio: "ignore" });
27
+ execSync("git branch -M main", { cwd: dir, stdio: "ignore" });
28
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
29
+ }
30
+
31
+ /**
32
+ * Creates a local git repo backed by a bare clone acting as `origin`.
33
+ */
34
+ export function createTestRepoWithRemote(prefix = "ac-test-remote-"): TestRepo {
35
+ const base = mkdtempSync(join(tmpdir(), prefix));
36
+ const workDir = join(base, "work");
37
+ const bareDir = join(base, "bare.git");
38
+
39
+ mkdirSync(workDir);
40
+ execSync("git init", { cwd: workDir, stdio: "ignore" });
41
+ execSync("git config user.email 'test@test.com'", { cwd: workDir, stdio: "ignore" });
42
+ execSync("git config user.name 'Test'", { cwd: workDir, stdio: "ignore" });
43
+ execSync("git commit --allow-empty -m 'init'", { cwd: workDir, stdio: "ignore" });
44
+ execSync("git branch -M main", { cwd: workDir, stdio: "ignore" });
45
+ execSync(`git clone --bare "${workDir}" "${bareDir}"`, { stdio: "ignore" });
46
+ execSync(`git remote add origin "${bareDir}"`, { cwd: workDir, stdio: "ignore" });
47
+ execSync("git push -u origin main", { cwd: workDir, stdio: "ignore" });
48
+
49
+ return { dir: workDir, cleanup: () => rmSync(base, { recursive: true, force: true }) };
50
+ }
51
+
52
+ /**
53
+ * Builds a minimal IssueContext for testing.
54
+ */
55
+ export function buildTestContext(dir: string, issueNumber = 1): IssueContext {
56
+ const issueDirRel = `.auto-claude/issue-${issueNumber}`;
57
+ return {
58
+ number: issueNumber,
59
+ title: "Test Issue",
60
+ body: "Test body",
61
+ repo: "test/repo",
62
+ scopePath: ".",
63
+ issueDir: join(dir, issueDirRel),
64
+ issueDirRel,
65
+ branch: `feature/${issueNumber}-test-issue`,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Returns a stream-json result line for a successful ClaudeResult.
71
+ */
72
+ export function successClaudeJson(result = "done"): string {
73
+ return JSON.stringify({
74
+ result,
75
+ is_error: false,
76
+ total_cost_usd: 0.01,
77
+ num_turns: 2,
78
+ } satisfies ClaudeResult);
79
+ }
80
+
81
+ /**
82
+ * Returns a stream-json result line for a failed ClaudeResult.
83
+ */
84
+ export function errorClaudeJson(result = "failed"): string {
85
+ return JSON.stringify({
86
+ result,
87
+ is_error: true,
88
+ total_cost_usd: 0,
89
+ num_turns: 0,
90
+ } satisfies ClaudeResult);
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
+ }
@@ -0,0 +1,152 @@
1
+ import { execSync } from "node:child_process";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import consola from "consola";
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
7
+
8
+ import { initConfig } from "./config";
9
+ import { createTestRepo, createTestRepoWithRemote } from "./test-helpers";
10
+ import type { TestRepo } from "./test-helpers";
11
+
12
+ consola.level = -999;
13
+
14
+ // ── Shell helpers: execSafe, git ──
15
+
16
+ describe("shell helpers (real execution)", () => {
17
+ let originalCwd: string;
18
+ let repo: TestRepo;
19
+
20
+ beforeAll(async () => {
21
+ originalCwd = process.cwd();
22
+ repo = createTestRepo();
23
+ process.chdir(repo.dir);
24
+ await initConfig({ repo: "test/repo", mainBranch: "main" });
25
+ });
26
+
27
+ afterAll(() => {
28
+ process.chdir(originalCwd);
29
+ repo.cleanup();
30
+ });
31
+
32
+ it("execSafe returns ok:true for a successful command", async () => {
33
+ const { execSafe } = await import("./utils");
34
+ const result = await execSafe("echo", ["hello"]);
35
+ expect(result.ok).toBe(true);
36
+ expect(result.stdout).toBe("hello");
37
+ });
38
+
39
+ it("execSafe returns ok:false for a failing command", async () => {
40
+ const { execSafe } = await import("./utils");
41
+ const result = await execSafe("git", ["checkout", "nonexistent-branch-xyz"]);
42
+ expect(result.ok).toBe(false);
43
+ });
44
+
45
+ it("git() runs real git commands", async () => {
46
+ const { git } = await import("./utils");
47
+ const status = await git(["status", "--porcelain"]);
48
+ expect(typeof status).toBe("string");
49
+ });
50
+ });
51
+
52
+ // ── ensureBranch: real git with remote ──
53
+
54
+ describe("ensureBranch (real git)", () => {
55
+ let originalCwd: string;
56
+ let repo: TestRepo;
57
+
58
+ beforeEach(async () => {
59
+ originalCwd = process.cwd();
60
+ repo = createTestRepoWithRemote();
61
+ process.chdir(repo.dir);
62
+ await initConfig({ repo: "test/repo", mainBranch: "main", remote: "origin" });
63
+ });
64
+
65
+ afterEach(() => {
66
+ process.chdir(originalCwd);
67
+ repo.cleanup();
68
+ });
69
+
70
+ it("creates a new branch from main when branch doesn't exist", async () => {
71
+ const { ensureBranch, git } = await import("./utils");
72
+
73
+ await ensureBranch("feature/42-new-branch");
74
+
75
+ const currentBranch = await git(["branch", "--show-current"]);
76
+ expect(currentBranch).toBe("feature/42-new-branch");
77
+ });
78
+
79
+ it("checks out an existing local branch", async () => {
80
+ const { ensureBranch, git } = await import("./utils");
81
+
82
+ execSync("git checkout -b feature/existing-branch", { cwd: repo.dir, stdio: "ignore" });
83
+ execSync("git checkout main", { cwd: repo.dir, stdio: "ignore" });
84
+
85
+ await ensureBranch("feature/existing-branch");
86
+
87
+ const currentBranch = await git(["branch", "--show-current"]);
88
+ expect(currentBranch).toBe("feature/existing-branch");
89
+ });
90
+
91
+ it("can checkout after branch creation", async () => {
92
+ const { ensureBranch, git } = await import("./utils");
93
+
94
+ await ensureBranch("feature/99-test-checkout");
95
+ const branch1 = await git(["branch", "--show-current"]);
96
+ expect(branch1).toBe("feature/99-test-checkout");
97
+
98
+ execSync("git checkout main", { cwd: repo.dir, stdio: "ignore" });
99
+ await ensureBranch("feature/99-test-checkout");
100
+ const branch2 = await git(["branch", "--show-current"]);
101
+ expect(branch2).toBe("feature/99-test-checkout");
102
+ });
103
+ });
104
+
105
+ // ── commitArtifacts: real git ──
106
+
107
+ describe("commitArtifacts (real git)", () => {
108
+ let originalCwd: string;
109
+ let repo: TestRepo;
110
+
111
+ beforeEach(async () => {
112
+ originalCwd = process.cwd();
113
+ repo = createTestRepo();
114
+ process.chdir(repo.dir);
115
+ await initConfig({ repo: "test/repo", mainBranch: "main" });
116
+ });
117
+
118
+ afterEach(() => {
119
+ process.chdir(originalCwd);
120
+ repo.cleanup();
121
+ });
122
+
123
+ it("commits staged files in the issue directory", async () => {
124
+ const { commitArtifacts, buildIssueContext } = await import("./utils");
125
+
126
+ const ctx = buildIssueContext({ number: 1, title: "Test", body: "body" }, "test/repo", ".");
127
+
128
+ const issueDir = join(repo.dir, ctx.issueDirRel);
129
+ mkdirSync(issueDir, { recursive: true });
130
+ writeFileSync(join(issueDir, "test.md"), "# Test artifact");
131
+
132
+ await commitArtifacts(ctx, "test commit");
133
+
134
+ const log = execSync("git log --oneline", { cwd: repo.dir, encoding: "utf-8" });
135
+ expect(log).toContain("test commit");
136
+ });
137
+
138
+ it("does not commit when no changes are staged", async () => {
139
+ const { commitArtifacts, buildIssueContext } = await import("./utils");
140
+
141
+ const ctx = buildIssueContext({ number: 2, title: "Test", body: "body" }, "test/repo", ".");
142
+
143
+ const issueDir = join(repo.dir, ctx.issueDirRel);
144
+ mkdirSync(issueDir, { recursive: true });
145
+
146
+ const logBefore = execSync("git log --oneline", { cwd: repo.dir, encoding: "utf-8" });
147
+ await commitArtifacts(ctx, "should not appear");
148
+ const logAfter = execSync("git log --oneline", { cwd: repo.dir, encoding: "utf-8" });
149
+
150
+ expect(logAfter).toBe(logBefore);
151
+ });
152
+ });
@@ -32,12 +32,12 @@ describe("buildIssueContext", () => {
32
32
  expect(ctx.scopePath).toBe("src/");
33
33
  expect(ctx.issueDirRel).toBe(".auto-claude/issue-42");
34
34
  expect(ctx.issueDir).toContain(".auto-claude/issue-42");
35
- expect(ctx.branch).toBe("auto-claude/issue-42");
35
+ expect(ctx.branch).toBe("feature/42-fix-the-bug");
36
36
  });
37
37
 
38
- it("should derive branch name from issue number", () => {
38
+ it("should derive branch name from issue title", () => {
39
39
  const ctx = buildIssueContext({ number: 7, title: "t", body: "" }, "r", ".");
40
- expect(ctx.branch).toBe("auto-claude/issue-7");
40
+ expect(ctx.branch).toBe("feature/7-t");
41
41
  });
42
42
  });
43
43
 
@@ -69,14 +69,14 @@ describe("resolveTemplate", () => {
69
69
  const issueDir = join(tmpDir, "issue-99");
70
70
  mkdirSync(issueDir, { recursive: true });
71
71
 
72
- const result = resolveTemplate("01-prompt-research.md", tokens, issueDir);
72
+ const result = resolveTemplate("01_research.prompt.md", tokens, issueDir);
73
73
 
74
74
  // Should return a relative path
75
75
  expect(result).toContain("issue-99");
76
- expect(result).toContain("01-prompt-research.md");
76
+ expect(result).toContain("01_research.prompt.md");
77
77
 
78
78
  // Resolved file should exist and have tokens replaced
79
- const content = readFileSync(join(issueDir, "01-prompt-research.md"), "utf-8");
79
+ const content = readFileSync(join(issueDir, "01_research.prompt.md"), "utf-8");
80
80
  expect(content).toContain("src/");
81
81
  expect(content).toContain(".auto-claude/issue-99");
82
82
  expect(content).not.toContain("{{SCOPE_PATH}}");
@@ -120,7 +120,7 @@ describe("buildContextFromArtifacts", () => {
120
120
  expect(ctx.title).toBe("My Great Feature");
121
121
  expect(ctx.body).toContain("This is the body of the issue.");
122
122
  expect(ctx.repo).toBe("test/repo");
123
- expect(ctx.branch).toBe("auto-claude/issue-77");
123
+ expect(ctx.branch).toBe("feature/77-my-great-feature");
124
124
  });
125
125
 
126
126
  it("should fallback title when heading is missing", async () => {
@@ -1,13 +1,16 @@
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";
6
7
  import pc from "picocolors";
7
8
  import { x } from "tinyexec";
8
9
 
10
+ import { createBranchNameFromIssue } from "../../utils/git/branch-name.js";
9
11
  import { getConfig } from "./config.js";
10
12
  import { ARTIFACTS } from "./prompt-templates/index.js";
13
+ import { spawnClaude } from "./spawn-claude.js";
11
14
 
12
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
16
  export const TEMPLATES_DIR = join(__dirname, "prompt-templates");
@@ -63,7 +66,8 @@ export async function runClaude(opts: {
63
66
  const args = [
64
67
  "-p",
65
68
  "--output-format",
66
- "json",
69
+ "stream-json",
70
+ "--verbose",
67
71
  "--permission-mode",
68
72
  opts.permissionMode,
69
73
  ...(opts.maxTurns ? ["--max-turns", String(opts.maxTurns)] : []),
@@ -76,26 +80,12 @@ export async function runClaude(opts: {
76
80
 
77
81
  while (true) {
78
82
  try {
79
- const proc = await x("claude", args, {
80
- nodeOptions: { cwd: process.cwd(), stdio: ["ignore", "pipe", "inherit"] },
81
- throwOnError: true,
82
- });
83
- const stdout = proc.stdout;
84
-
85
- try {
86
- const parsed = JSON.parse(stdout) as ClaudeResult;
87
- consola.success(`Done — ${parsed.num_turns} turns`);
88
- if (parsed.result) {
89
- consola.log(parsed.result);
90
- }
91
- return parsed;
92
- } catch {
93
- consola.warn("Done — failed to parse Claude output");
94
- if (stdout.trim()) {
95
- consola.log(stdout.trim());
96
- }
97
- 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);
98
87
  }
88
+ return result;
99
89
  } catch (e) {
100
90
  const shouldRetry = opts.retry ?? cfg.loopRetryEnabled ?? false;
101
91
  if (!shouldRetry) throw e;
@@ -113,6 +103,94 @@ export async function runClaude(opts: {
113
103
  }
114
104
  }
115
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
+
116
194
  // ── Template resolution ──
117
195
 
118
196
  export interface TokenValues {
@@ -196,7 +274,7 @@ export function buildIssueContext(
196
274
  scopePath,
197
275
  issueDir: join(process.cwd(), issueDirRel),
198
276
  issueDirRel,
199
- branch: `auto-claude/issue-${issue.number}`,
277
+ branch: createBranchNameFromIssue({ number: issue.number, title: issue.title }),
200
278
  };
201
279
  }
202
280
 
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createBranchNameFromIssue } from "./branch-name";
3
+
4
+ describe("createBranchNameFromIssue", () => {
5
+ it("creates branch name from issue with basic title", () => {
6
+ const branchName = createBranchNameFromIssue({
7
+ number: 4,
8
+ title: "Long Issue Title - with a lot of words and stuff ",
9
+ });
10
+ expect(branchName).toBe("feature/4-long-issue-title-with-a-lot-of-words-and-stuff");
11
+ });
12
+
13
+ it("handles special characters in title", () => {
14
+ const branchName = createBranchNameFromIssue({
15
+ number: 123,
16
+ title: "Fix bug: @user reported $100 issue!",
17
+ });
18
+ expect(branchName).toBe("feature/123-fix-bug-user-reported-100-issue");
19
+ });
20
+
21
+ it("handles title with only numbers", () => {
22
+ const branchName = createBranchNameFromIssue({ number: 42, title: "123 456" });
23
+ expect(branchName).toBe("feature/42-123-456");
24
+ });
25
+
26
+ it("trims trailing dashes", () => {
27
+ const branchName = createBranchNameFromIssue({ number: 7, title: "Update docs ---" });
28
+ expect(branchName).toBe("feature/7-update-docs");
29
+ });
30
+
31
+ it("handles unicode characters", () => {
32
+ const branchName = createBranchNameFromIssue({ number: 99, title: "Fix für Übersetzung" });
33
+ expect(branchName).toBe("feature/99-fix-f-r-bersetzung");
34
+ });
35
+
36
+ it("handles empty-ish title", () => {
37
+ const branchName = createBranchNameFromIssue({ number: 1, title: " " });
38
+ expect(branchName).toBe("feature/1-");
39
+ });
40
+
41
+ it("handles title with underscores", () => {
42
+ const branchName = createBranchNameFromIssue({ number: 50, title: "snake_case_title" });
43
+ expect(branchName).toBe("feature/50-snake_case_title");
44
+ });
45
+
46
+ it("handles very long titles", () => {
47
+ const branchName = createBranchNameFromIssue({
48
+ number: 200,
49
+ title: "This is a very long issue title that goes on and on with many words",
50
+ });
51
+ expect(branchName).toBe(
52
+ "feature/200-this-is-a-very-long-issue-title-that-goes-on-and-on-with-many-words",
53
+ );
54
+ });
55
+
56
+ it("collapses multiple consecutive dashes", () => {
57
+ const branchName = createBranchNameFromIssue({
58
+ number: 15,
59
+ title: "Fix multiple spaces",
60
+ });
61
+ expect(branchName).toBe("feature/15-fix-multiple-spaces");
62
+ });
63
+
64
+ it("handles title with brackets and parentheses", () => {
65
+ const branchName = createBranchNameFromIssue({
66
+ number: 33,
67
+ title: "[Bug] Fix (critical) issue",
68
+ });
69
+ expect(branchName).toBe("feature/33--bug-fix-critical-issue");
70
+ });
71
+
72
+ it("produces same result whether called with minimal or extra fields", () => {
73
+ const minimal = createBranchNameFromIssue({ number: 90, title: "Add e2e tests" });
74
+ const withExtras = createBranchNameFromIssue({
75
+ number: 90,
76
+ title: "Add e2e tests",
77
+ state: "open",
78
+ labels: [],
79
+ } as { number: number; title: string });
80
+ expect(minimal).toBe(withExtras);
81
+ expect(minimal).toBe("feature/90-add-e2e-tests");
82
+ });
83
+ });
@@ -0,0 +1,10 @@
1
+ export function createBranchNameFromIssue(issue: { number: number; title: string }): string {
2
+ let slug = issue.title.toLowerCase();
3
+ slug = slug.trim();
4
+ slug = slug.replaceAll(" ", "-");
5
+ slug = slug.replace(/[^0-9a-zA-Z_-]/g, "-");
6
+ slug = slug.replace(/-+/g, "-");
7
+ slug = slug.replace(/-+$/, "");
8
+
9
+ return `feature/${issue.number}-${slug}`;
10
+ }
@@ -1,28 +0,0 @@
1
- You are a senior developer researching a codebase to prepare for implementing a GitHub issue.
2
-
3
- ## Your task
4
-
5
- Read the issue description in @{{ISSUE_DIR}}/initial-ramblings.md and then **research the codebase in depth** to understand what would be involved in implementing it.
6
-
7
- **CRITICAL RULES:**
8
-
9
- - Do **NOT** implement the issue. Do not create, modify, or delete any project source files.
10
- - Your **ONLY** deliverable is writing the file @{{ISSUE_DIR}}/research.md.
11
- - If the issue seems trivial, research it anyway — document the relevant files, patterns, and context.
12
-
13
- ## Where to look
14
-
15
- The code for this project lives primarily at `{{SCOPE_PATH}}/`. Start your investigation there but explore any related files across the monorepo.
16
-
17
- Read every relevant file in full. Understand how the system works deeply — its architecture, data flow, and all its specificities. Do not skim. Do not stop researching until you have a thorough understanding of every part of the codebase that this issue touches.
18
-
19
- ## What to write in @{{ISSUE_DIR}}/research.md
20
-
21
- 1. **Relevant files** — every file that would need to be read or modified, with brief descriptions of what each does
22
- 2. **Existing patterns** — how similar features are currently implemented in this codebase (naming conventions, folder structure, component patterns, API patterns)
23
- 3. **Dependencies** — libraries, utilities, shared code, and services that are relevant
24
- 4. **Potential impact areas** — what else might break or need updating (tests, types, imports, configs)
25
- 5. **Edge cases and constraints** — anything tricky that the implementation should watch out for
26
- 6. **Reference implementations** — if there's a similar feature already built, document it as a reference
27
-
28
- Be thorough. Keep researching until you have complete understanding — missing information here means a worse plan later.
@@ -1,28 +0,0 @@
1
- You are a senior developer planning the implementation for a GitHub issue.
2
-
3
- Read the issue in @{{ISSUE_DIR}}/initial-ramblings.md and the research in @{{ISSUE_DIR}}/research.md.
4
-
5
- **CRITICAL RULES:**
6
-
7
- - Do **NOT** implement the issue. Do not create, modify, or delete any project source files.
8
- - Your **ONLY** deliverable is writing the file @{{ISSUE_DIR}}/plan.md.
9
- - Read the actual source files before suggesting changes. Base the plan on what the code actually does, not assumptions.
10
-
11
- The code for this project lives primarily at `{{SCOPE_PATH}}/`.
12
-
13
- Write @{{ISSUE_DIR}}/plan.md containing:
14
-
15
- 1. **Summary** — what we're building and why (1-2 paragraphs)
16
- 2. **Approach** — the high-level technical approach chosen
17
- 3. **Architectural decisions** — any significant choices made and why (e.g., which component pattern, state management approach, API structure)
18
- 4. **Key code snippets** — include concrete code examples showing the important parts of the implementation (function signatures, component structure, schema changes, etc.)
19
- 5. **Scope boundaries** — what is explicitly out of scope to keep the change focused
20
- 6. **Risks** — anything that could go wrong or needs special attention during implementation
21
- 7. **Alternative approaches** — a brief section listing other valid ways to solve this problem. For each alternative, include: the approach name, a one-liner on how it works, and why the chosen approach was preferred. Consider industry best practices, common patterns, and obvious alternatives. This section is for PR reviewers only — it will NOT be used in the implementation plan.
22
-
23
- **Design principles to follow:**
24
-
25
- - Fixing a known issue later instead of now is not simplicity — if the plan touches an area with a known bug, address it.
26
- - Adding a second primitive for something we already have a primitive for is not simplicity — reuse existing abstractions.
27
-
28
- Keep it concise and focused on decisions, not on repeating the research.
@@ -1,21 +0,0 @@
1
- You are a senior developer revising a plan based on reviewer feedback.
2
-
3
- Read the current plan in @{{ISSUE_DIR}}/plan.md and the reviewer's annotations in @{{ISSUE_DIR}}/plan-annotations.md.
4
-
5
- The original issue is described in @{{ISSUE_DIR}}/initial-ramblings.md and the research is in @{{ISSUE_DIR}}/research.md.
6
-
7
- **CRITICAL RULES:**
8
-
9
- - Do **NOT** implement the issue. Do not create, modify, or delete any project source files.
10
- - Your **ONLY** deliverable is updating @{{ISSUE_DIR}}/plan.md to address the annotations.
11
-
12
- The code for this project lives primarily at `{{SCOPE_PATH}}/`.
13
-
14
- ## Instructions
15
-
16
- - Read every annotation carefully.
17
- - Address all the notes — update the plan to incorporate the feedback.
18
- - If an annotation asks a question, answer it in the plan.
19
- - If an annotation suggests a different approach, evaluate it and update the plan accordingly.
20
- - If an annotation points out something missing, add it.
21
- - Keep the same structure and formatting of the plan, just improve its content.