@towles/tool 0.0.54 → 0.0.56

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 (43) hide show
  1. package/README.md +52 -12
  2. package/package.json +4 -4
  3. package/src/commands/auto-claude.ts +220 -0
  4. package/src/commands/doctor.ts +2 -34
  5. package/src/config/settings.ts +0 -10
  6. package/src/lib/auto-claude/config.test.ts +53 -0
  7. package/src/lib/auto-claude/config.ts +68 -0
  8. package/src/lib/auto-claude/index.ts +15 -0
  9. package/src/lib/auto-claude/pipeline.test.ts +14 -0
  10. package/src/lib/auto-claude/pipeline.ts +67 -0
  11. package/src/lib/auto-claude/prompt-templates/01-prompt-research.md +28 -0
  12. package/src/lib/auto-claude/prompt-templates/02-prompt-plan.md +28 -0
  13. package/src/lib/auto-claude/prompt-templates/03-prompt-plan-annotations.md +21 -0
  14. package/src/lib/auto-claude/prompt-templates/04-prompt-plan-implementation.md +33 -0
  15. package/src/lib/auto-claude/prompt-templates/05-prompt-implement.md +31 -0
  16. package/src/lib/auto-claude/prompt-templates/06-prompt-review.md +30 -0
  17. package/src/lib/auto-claude/prompt-templates/07-prompt-refresh.md +39 -0
  18. package/src/lib/auto-claude/prompt-templates/index.test.ts +146 -0
  19. package/src/lib/auto-claude/prompt-templates/index.ts +45 -0
  20. package/src/lib/auto-claude/steps/create-pr.ts +95 -0
  21. package/src/lib/auto-claude/steps/fetch-issues.ts +64 -0
  22. package/src/lib/auto-claude/steps/implement.ts +63 -0
  23. package/src/lib/auto-claude/steps/plan-annotations.ts +54 -0
  24. package/src/lib/auto-claude/steps/plan-implementation.ts +14 -0
  25. package/src/lib/auto-claude/steps/plan.ts +14 -0
  26. package/src/lib/auto-claude/steps/refresh.ts +114 -0
  27. package/src/lib/auto-claude/steps/remove-label.ts +22 -0
  28. package/src/lib/auto-claude/steps/research.ts +21 -0
  29. package/src/lib/auto-claude/steps/review.ts +14 -0
  30. package/src/lib/auto-claude/utils.test.ts +136 -0
  31. package/src/lib/auto-claude/utils.ts +347 -0
  32. package/src/commands/ralph/plan/add.ts +0 -69
  33. package/src/commands/ralph/plan/done.ts +0 -82
  34. package/src/commands/ralph/plan/list.test.ts +0 -48
  35. package/src/commands/ralph/plan/list.ts +0 -100
  36. package/src/commands/ralph/plan/remove.ts +0 -71
  37. package/src/commands/ralph/run.test.ts +0 -607
  38. package/src/commands/ralph/run.ts +0 -362
  39. package/src/commands/ralph/show.ts +0 -88
  40. package/src/lib/ralph/execution.ts +0 -292
  41. package/src/lib/ralph/formatter.ts +0 -240
  42. package/src/lib/ralph/index.ts +0 -4
  43. package/src/lib/ralph/state.ts +0 -201
@@ -0,0 +1,14 @@
1
+ import { join } from "node:path";
2
+
3
+ import { ARTIFACTS, STEP_LABELS, TEMPLATES } from "../prompt-templates/index.js";
4
+ import { runStepWithArtifact } from "../utils.js";
5
+ import type { IssueContext } from "../utils.js";
6
+
7
+ export async function stepPlan(ctx: IssueContext): Promise<boolean> {
8
+ return runStepWithArtifact({
9
+ stepName: STEP_LABELS.plan,
10
+ ctx,
11
+ artifactPath: join(ctx.issueDir, ARTIFACTS.plan),
12
+ templateName: TEMPLATES.plan,
13
+ });
14
+ }
@@ -0,0 +1,114 @@
1
+ import { rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import consola from "consola";
5
+
6
+ import { getConfig } from "../config.js";
7
+ import { ARTIFACTS, STEP_LABELS, TEMPLATES } from "../prompt-templates/index.js";
8
+ import {
9
+ buildTokens,
10
+ commitArtifacts,
11
+ execSafe,
12
+ fileExists,
13
+ git,
14
+ log,
15
+ logStep,
16
+ resolveTemplate,
17
+ runClaude,
18
+ } from "../utils.js";
19
+ import type { IssueContext } from "../utils.js";
20
+
21
+ export async function stepRefresh(ctx: IssueContext): Promise<boolean> {
22
+ logStep(STEP_LABELS.refresh, ctx);
23
+ const { mainBranch, remote } = getConfig();
24
+
25
+ const branchList = await git(["branch", "--list", ctx.branch]);
26
+ if (!branchList.includes(ctx.branch.split("/").pop()!)) {
27
+ try {
28
+ await git(["fetch", remote, ctx.branch]);
29
+ await git(["checkout", ctx.branch]);
30
+ } catch {
31
+ log(`Branch ${ctx.branch} does not exist locally or remotely.`);
32
+ return false;
33
+ }
34
+ }
35
+
36
+ await git(["checkout", mainBranch]);
37
+ await git(["pull", remote, mainBranch]);
38
+ await git(["checkout", ctx.branch]);
39
+
40
+ if (await isBranchUpToDate(mainBranch)) {
41
+ log(`Branch is already up-to-date with ${mainBranch}.`);
42
+ } else {
43
+ await rebaseOrMerge(mainBranch);
44
+ }
45
+
46
+ const tokens = buildTokens(ctx);
47
+ const promptFile = resolveTemplate(TEMPLATES.refresh, tokens, ctx.issueDir);
48
+ const result = await runClaude({
49
+ promptFile,
50
+ permissionMode: "acceptEdits",
51
+ maxTurns: getConfig().maxTurns,
52
+ });
53
+
54
+ if (result.is_error) {
55
+ consola.error(`Refresh step failed: ${result.result}`);
56
+ await git(["checkout", mainBranch]).catch(() => {});
57
+ return false;
58
+ }
59
+
60
+ await commitArtifacts(ctx, `chore(auto-claude): refresh for ${ctx.repo}#${ctx.number}`);
61
+ await invalidateStaleArtifacts(ctx);
62
+
63
+ await git(["push", "--force-with-lease", "-u", remote, ctx.branch]);
64
+ await git(["checkout", mainBranch]).catch(() => {});
65
+
66
+ return true;
67
+ }
68
+
69
+ async function isBranchUpToDate(mainBranch: string): Promise<boolean> {
70
+ try {
71
+ await git(["merge-base", "--is-ancestor", mainBranch, "HEAD"]);
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ async function rebaseOrMerge(mainBranch: string): Promise<void> {
79
+ try {
80
+ await git(["rebase", mainBranch]);
81
+ } catch {
82
+ await git(["rebase", "--abort"]).catch(() => {});
83
+ try {
84
+ await git(["merge", mainBranch, "--no-edit"]);
85
+ } catch {
86
+ const conflicts = await execSafe("git", ["diff", "--name-only", "--diff-filter=U"]);
87
+ if (conflicts.stdout.length > 0) {
88
+ await git(["merge", "--abort"]);
89
+ throw new Error(`Merge conflicts detected in: ${conflicts.stdout}`);
90
+ }
91
+ await git(["add", "."]);
92
+ await git(["commit", "--no-edit"]);
93
+ }
94
+ }
95
+ }
96
+
97
+ async function invalidateStaleArtifacts(ctx: IssueContext): Promise<void> {
98
+ const paths = [
99
+ join(ctx.issueDir, ARTIFACTS.review),
100
+ join(ctx.issueDir, ARTIFACTS.completedSummary),
101
+ ];
102
+
103
+ const removed = paths.filter((p) => {
104
+ if (fileExists(p)) {
105
+ rmSync(p);
106
+ return true;
107
+ }
108
+ return false;
109
+ });
110
+
111
+ if (removed.length > 0) {
112
+ await commitArtifacts(ctx, "chore(auto-claude): invalidate stale artifacts after refresh");
113
+ }
114
+ }
@@ -0,0 +1,22 @@
1
+ import { getConfig } from "../config.js";
2
+ import { STEP_LABELS } from "../prompt-templates/index.js";
3
+ import { ghRaw, log, logStep } from "../utils.js";
4
+ import type { IssueContext } from "../utils.js";
5
+
6
+ export async function stepRemoveLabel(ctx: IssueContext): Promise<boolean> {
7
+ logStep(STEP_LABELS.removeLabel, ctx);
8
+
9
+ const cfg = getConfig();
10
+ await ghRaw([
11
+ "issue",
12
+ "edit",
13
+ String(ctx.number),
14
+ "--repo",
15
+ ctx.repo,
16
+ "--remove-label",
17
+ cfg.triggerLabel,
18
+ ]);
19
+
20
+ log(`Removed "${cfg.triggerLabel}" label from ${ctx.repo}#${ctx.number}`);
21
+ return true;
22
+ }
@@ -0,0 +1,21 @@
1
+ import { join } from "node:path";
2
+
3
+ import { ARTIFACTS, STEP_LABELS, TEMPLATES } from "../prompt-templates/index.js";
4
+ import { ensureBranch, fileExists, readFile, runStepWithArtifact } from "../utils.js";
5
+ import type { IssueContext } from "../utils.js";
6
+
7
+ function isValidResearch(path: string): boolean {
8
+ return fileExists(path) && readFile(path).length > 200;
9
+ }
10
+
11
+ export async function stepResearch(ctx: IssueContext): Promise<boolean> {
12
+ await ensureBranch(ctx.branch);
13
+
14
+ return runStepWithArtifact({
15
+ stepName: STEP_LABELS.research,
16
+ ctx,
17
+ artifactPath: join(ctx.issueDir, ARTIFACTS.research),
18
+ templateName: TEMPLATES.research,
19
+ artifactValidator: isValidResearch,
20
+ });
21
+ }
@@ -0,0 +1,14 @@
1
+ import { join } from "node:path";
2
+
3
+ import { ARTIFACTS, STEP_LABELS, TEMPLATES } from "../prompt-templates/index.js";
4
+ import { runStepWithArtifact } from "../utils.js";
5
+ import type { IssueContext } from "../utils.js";
6
+
7
+ export async function stepReview(ctx: IssueContext): Promise<boolean> {
8
+ return runStepWithArtifact({
9
+ stepName: STEP_LABELS.review,
10
+ ctx,
11
+ artifactPath: join(ctx.issueDir, ARTIFACTS.review),
12
+ templateName: TEMPLATES.review,
13
+ });
14
+ }
@@ -0,0 +1,136 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { initConfig } from "./config";
8
+ import {
9
+ buildContextFromArtifacts,
10
+ buildIssueContext,
11
+ buildTokens,
12
+ resolveTemplate,
13
+ } from "./utils";
14
+
15
+ // Initialize config once for tests that need getConfig()
16
+ beforeAll(async () => {
17
+ await initConfig({ repo: "test/repo", mainBranch: "main" });
18
+ });
19
+
20
+ describe("buildIssueContext", () => {
21
+ it("should build context with correct fields", () => {
22
+ const ctx = buildIssueContext(
23
+ { number: 42, title: "Fix the bug", body: "Something is broken" },
24
+ "owner/repo",
25
+ "src/",
26
+ );
27
+
28
+ expect(ctx.number).toBe(42);
29
+ expect(ctx.title).toBe("Fix the bug");
30
+ expect(ctx.body).toBe("Something is broken");
31
+ expect(ctx.repo).toBe("owner/repo");
32
+ expect(ctx.scopePath).toBe("src/");
33
+ expect(ctx.issueDirRel).toBe(".auto-claude/issue-42");
34
+ expect(ctx.issueDir).toContain(".auto-claude/issue-42");
35
+ expect(ctx.branch).toBe("auto-claude/issue-42");
36
+ });
37
+
38
+ it("should derive branch name from issue number", () => {
39
+ const ctx = buildIssueContext({ number: 7, title: "t", body: "" }, "r", ".");
40
+ expect(ctx.branch).toBe("auto-claude/issue-7");
41
+ });
42
+ });
43
+
44
+ describe("buildTokens", () => {
45
+ it("should produce expected token keys", () => {
46
+ const ctx = buildIssueContext({ number: 1, title: "t", body: "" }, "test/repo", "lib/");
47
+ const tokens = buildTokens(ctx);
48
+
49
+ expect(tokens.SCOPE_PATH).toBe("lib/");
50
+ expect(tokens.ISSUE_DIR).toBe(".auto-claude/issue-1");
51
+ expect(tokens.MAIN_BRANCH).toBe("main");
52
+ });
53
+ });
54
+
55
+ describe("resolveTemplate", () => {
56
+ let tmpDir: string;
57
+
58
+ beforeEach(() => {
59
+ tmpDir = mkdtempSync(join(tmpdir(), "auto-claude-test-"));
60
+ });
61
+
62
+ afterAll(() => {
63
+ if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
64
+ });
65
+
66
+ it("should replace token placeholders and write resolved file", () => {
67
+ // Use an actual template from the templates dir
68
+ const tokens = { SCOPE_PATH: "src/", ISSUE_DIR: ".auto-claude/issue-99", MAIN_BRANCH: "main" };
69
+ const issueDir = join(tmpDir, "issue-99");
70
+ mkdirSync(issueDir, { recursive: true });
71
+
72
+ const result = resolveTemplate("01-prompt-research.md", tokens, issueDir);
73
+
74
+ // Should return a relative path
75
+ expect(result).toContain("issue-99");
76
+ expect(result).toContain("01-prompt-research.md");
77
+
78
+ // Resolved file should exist and have tokens replaced
79
+ const content = readFileSync(join(issueDir, "01-prompt-research.md"), "utf-8");
80
+ expect(content).toContain("src/");
81
+ expect(content).toContain(".auto-claude/issue-99");
82
+ expect(content).not.toContain("{{SCOPE_PATH}}");
83
+ expect(content).not.toContain("{{ISSUE_DIR}}");
84
+ });
85
+ });
86
+
87
+ describe("buildContextFromArtifacts", () => {
88
+ let tmpDir: string;
89
+ let originalCwd: string;
90
+
91
+ beforeAll(() => {
92
+ originalCwd = process.cwd();
93
+ tmpDir = mkdtempSync(join(tmpdir(), "auto-claude-artifacts-"));
94
+ process.chdir(tmpDir);
95
+ });
96
+
97
+ afterAll(() => {
98
+ process.chdir(originalCwd);
99
+ rmSync(tmpDir, { recursive: true, force: true });
100
+ });
101
+
102
+ it("should throw when no artifacts exist", () => {
103
+ expect(() => buildContextFromArtifacts(999)).toThrow("No artifacts found");
104
+ });
105
+
106
+ it("should parse title and body from initial-ramblings.md", async () => {
107
+ // Re-init config in the temp dir context
108
+ await initConfig({ repo: "test/repo", mainBranch: "main" });
109
+
110
+ const issueDir = join(tmpDir, ".auto-claude/issue-77");
111
+ mkdirSync(issueDir, { recursive: true });
112
+ writeFileSync(
113
+ join(issueDir, "initial-ramblings.md"),
114
+ "# My Great Feature\n\n> test/repo#77\n\nThis is the body of the issue.\nWith multiple lines.",
115
+ );
116
+
117
+ const ctx = buildContextFromArtifacts(77);
118
+
119
+ expect(ctx.number).toBe(77);
120
+ expect(ctx.title).toBe("My Great Feature");
121
+ expect(ctx.body).toContain("This is the body of the issue.");
122
+ expect(ctx.repo).toBe("test/repo");
123
+ expect(ctx.branch).toBe("auto-claude/issue-77");
124
+ });
125
+
126
+ it("should fallback title when heading is missing", async () => {
127
+ await initConfig({ repo: "test/repo", mainBranch: "main" });
128
+
129
+ const issueDir = join(tmpDir, ".auto-claude/issue-88");
130
+ mkdirSync(issueDir, { recursive: true });
131
+ writeFileSync(join(issueDir, "initial-ramblings.md"), "No heading here\n\njust text");
132
+
133
+ const ctx = buildContextFromArtifacts(88);
134
+ expect(ctx.title).toBe("Issue #88");
135
+ });
136
+ });
@@ -0,0 +1,347 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import consola from "consola";
6
+ import pc from "picocolors";
7
+ import { x } from "tinyexec";
8
+
9
+ import { getConfig } from "./config.js";
10
+ import { ARTIFACTS } from "./prompt-templates/index.js";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ export const TEMPLATES_DIR = join(__dirname, "prompt-templates");
14
+
15
+ // ── Shell helpers ──
16
+
17
+ async function exec(cmd: string, args: string[]): Promise<string> {
18
+ const result = await x(cmd, args, { nodeOptions: { cwd: process.cwd() }, throwOnError: true });
19
+ return result.stdout.trim();
20
+ }
21
+
22
+ export async function execSafe(
23
+ cmd: string,
24
+ args: string[],
25
+ ): Promise<{ stdout: string; ok: boolean }> {
26
+ const result = await x(cmd, args, { nodeOptions: { cwd: process.cwd() }, throwOnError: false });
27
+ return { stdout: (result.stdout ?? "").trim(), ok: result.exitCode === 0 };
28
+ }
29
+
30
+ export async function gh<T = unknown>(args: string[]): Promise<T> {
31
+ const out = await exec("gh", args);
32
+ return JSON.parse(out) as T;
33
+ }
34
+
35
+ export async function ghRaw(args: string[]): Promise<string> {
36
+ const result = await execSafe("gh", args);
37
+ return result.stdout;
38
+ }
39
+
40
+ export async function git(args: string[]): Promise<string> {
41
+ return exec("git", args);
42
+ }
43
+
44
+ export function sleep(ms: number): Promise<void> {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
47
+
48
+ // ── Claude CLI ──
49
+
50
+ export interface ClaudeResult {
51
+ result: string;
52
+ is_error: boolean;
53
+ total_cost_usd: number;
54
+ num_turns: number;
55
+ }
56
+
57
+ export async function runClaude(opts: {
58
+ promptFile: string;
59
+ permissionMode: "plan" | "acceptEdits";
60
+ maxTurns?: number;
61
+ retry?: boolean;
62
+ }): Promise<ClaudeResult> {
63
+ const args = [
64
+ "-p",
65
+ "--output-format",
66
+ "json",
67
+ "--permission-mode",
68
+ opts.permissionMode,
69
+ ...(opts.maxTurns ? ["--max-turns", String(opts.maxTurns)] : []),
70
+ `@${opts.promptFile}`,
71
+ ];
72
+
73
+ const cfg = getConfig();
74
+ let retryDelay = cfg.retryDelayMs;
75
+ let retries = 0;
76
+
77
+ while (true) {
78
+ 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 };
98
+ }
99
+ } catch (e) {
100
+ const shouldRetry = opts.retry ?? cfg.loopRetryEnabled ?? false;
101
+ if (!shouldRetry) throw e;
102
+
103
+ retries++;
104
+ if (retries >= cfg.maxRetries) {
105
+ throw new Error(`Claude failed after ${cfg.maxRetries} retries: ${e}`);
106
+ }
107
+
108
+ consola.warn(`Claude process error (attempt ${retries}/${cfg.maxRetries}): ${e}`);
109
+ consola.info(`Retrying in ${retryDelay / 1000}s...`);
110
+ await sleep(retryDelay);
111
+ retryDelay = Math.min(retryDelay * 2, cfg.maxRetryDelayMs);
112
+ }
113
+ }
114
+ }
115
+
116
+ // ── Template resolution ──
117
+
118
+ export interface TokenValues {
119
+ SCOPE_PATH: string;
120
+ ISSUE_DIR: string;
121
+ MAIN_BRANCH: string;
122
+ }
123
+
124
+ export function resolveTemplate(
125
+ templateName: string,
126
+ tokens: TokenValues,
127
+ issueDir: string,
128
+ ): string {
129
+ const templatePath = join(TEMPLATES_DIR, templateName);
130
+ let template = readFileSync(templatePath, "utf-8");
131
+
132
+ for (const [key, value] of Object.entries(tokens)) {
133
+ template = template.replaceAll(`{{${key}}}`, value);
134
+ }
135
+
136
+ const resolvedPath = join(issueDir, templateName);
137
+ ensureDir(dirname(resolvedPath));
138
+ writeFileSync(resolvedPath, template, "utf-8");
139
+
140
+ return relative(process.cwd(), resolvedPath);
141
+ }
142
+
143
+ // ── File helpers ──
144
+
145
+ export function ensureDir(dir: string): void {
146
+ mkdirSync(dir, { recursive: true });
147
+ }
148
+
149
+ export function fileExists(path: string): boolean {
150
+ return existsSync(path);
151
+ }
152
+
153
+ export function readFile(path: string): string {
154
+ return readFileSync(path, "utf-8");
155
+ }
156
+
157
+ export function writeFile(path: string, content: string): void {
158
+ ensureDir(dirname(path));
159
+ writeFileSync(path, content, "utf-8");
160
+ }
161
+
162
+ // ── Git helpers ──
163
+
164
+ export async function commitArtifacts(ctx: IssueContext, message: string): Promise<void> {
165
+ await git(["add", ctx.issueDirRel]);
166
+ const staged = await execSafe("git", ["diff", "--cached", "--name-only"]);
167
+ if (staged.ok && staged.stdout.length > 0) {
168
+ await git(["commit", "-m", message]);
169
+ }
170
+ }
171
+
172
+ // ── Issue context ──
173
+
174
+ export interface IssueContext {
175
+ number: number;
176
+ title: string;
177
+ body: string;
178
+ repo: string;
179
+ scopePath: string;
180
+ issueDir: string;
181
+ issueDirRel: string;
182
+ branch: string;
183
+ }
184
+
185
+ export function buildIssueContext(
186
+ issue: { number: number; title: string; body: string },
187
+ repo: string,
188
+ scopePath: string,
189
+ ): IssueContext {
190
+ const issueDirRel = `.auto-claude/issue-${issue.number}`;
191
+ return {
192
+ number: issue.number,
193
+ title: issue.title,
194
+ body: issue.body,
195
+ repo,
196
+ scopePath,
197
+ issueDir: join(process.cwd(), issueDirRel),
198
+ issueDirRel,
199
+ branch: `auto-claude/issue-${issue.number}`,
200
+ };
201
+ }
202
+
203
+ export function buildTokens(ctx: IssueContext): TokenValues {
204
+ return {
205
+ SCOPE_PATH: ctx.scopePath,
206
+ ISSUE_DIR: ctx.issueDirRel,
207
+ MAIN_BRANCH: getConfig().mainBranch,
208
+ };
209
+ }
210
+
211
+ export function buildContextFromArtifacts(issueNumber: number): IssueContext {
212
+ const cfg = getConfig();
213
+ const issueDirRel = `.auto-claude/issue-${issueNumber}`;
214
+ const ramblingsPath = join(process.cwd(), issueDirRel, ARTIFACTS.initialRamblings);
215
+
216
+ if (!fileExists(ramblingsPath)) {
217
+ throw new Error(`No artifacts found at ${issueDirRel}. Run the pipeline first.`);
218
+ }
219
+
220
+ const content = readFile(ramblingsPath);
221
+ const titleMatch = content.match(/^# (.+)$/m);
222
+ const title = titleMatch?.[1] ?? `Issue #${issueNumber}`;
223
+
224
+ // Body starts after the second blank line (past the title and metadata lines)
225
+ const secondBlankIdx = findNthBlankLine(content.split("\n"), 2);
226
+ const body = content
227
+ .split("\n")
228
+ .slice(secondBlankIdx + 1)
229
+ .join("\n")
230
+ .trim();
231
+
232
+ return buildIssueContext({ number: issueNumber, title, body }, cfg.repo, cfg.scopePath);
233
+ }
234
+
235
+ function findNthBlankLine(lines: string[], n: number): number {
236
+ let count = 0;
237
+ for (let i = 0; i < lines.length; i++) {
238
+ if (lines[i].trim() === "") {
239
+ count++;
240
+ if (count === n) return i;
241
+ }
242
+ }
243
+ return 0;
244
+ }
245
+
246
+ export function log(msg: string): void {
247
+ consola.info(`[auto-claude] ${msg}`);
248
+ }
249
+
250
+ export function logBanner(label: string, width = 60): void {
251
+ const inner = ` ${label} `;
252
+ const totalDashes = Math.max(0, width - inner.length - 2);
253
+ const left = Math.ceil(totalDashes / 2);
254
+ const right = Math.floor(totalDashes / 2);
255
+ const dashes = pc.cyan;
256
+ consola.log(
257
+ `${dashes("#" + "-".repeat(left))}${pc.bold(inner)}${dashes("-".repeat(right) + "#")}`,
258
+ );
259
+ }
260
+
261
+ export function logStep(step: string, ctx: IssueContext, skipped = false): void {
262
+ const tag = skipped ? pc.yellow("SKIP") : pc.green("RUN");
263
+ logBanner(`[${tag}] ${step}`);
264
+ consola.log(pc.dim(`${ctx.repo}#${ctx.number} — ${ctx.title}`));
265
+ }
266
+
267
+ // ── Git branch helpers ──
268
+
269
+ export async function ensureBranch(branch: string): Promise<void> {
270
+ const { mainBranch, remote } = getConfig();
271
+
272
+ try {
273
+ const branches = await git(["branch", "--list", branch]);
274
+ if (branches.includes(branch)) {
275
+ await git(["checkout", branch]);
276
+ return;
277
+ }
278
+ } catch {
279
+ /* ignore */
280
+ }
281
+
282
+ try {
283
+ await git(["fetch", remote, branch]);
284
+ await git(["checkout", branch]);
285
+ return;
286
+ } catch {
287
+ /* doesn't exist remotely */
288
+ }
289
+
290
+ await git(["checkout", mainBranch]);
291
+ await git(["pull", remote, mainBranch]);
292
+ await git(["checkout", "-b", branch]);
293
+ }
294
+
295
+ // ── Common step runner ──
296
+
297
+ export interface StepRunnerOptions {
298
+ stepName: string;
299
+ ctx: IssueContext;
300
+ artifactPath: string;
301
+ templateName: string;
302
+ artifactValidator?: (path: string) => boolean;
303
+ }
304
+
305
+ /**
306
+ * Runs a standard pipeline step: check artifact (skip if exists), run Claude
307
+ * with a template, validate the artifact was produced, and commit.
308
+ *
309
+ * Used by plan, plan-implementation, review, and similar steps that follow
310
+ * the same pattern.
311
+ */
312
+ export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<boolean> {
313
+ const { stepName, ctx, artifactPath, templateName, artifactValidator } = opts;
314
+
315
+ const isValid = artifactValidator ?? fileExists;
316
+ if (isValid(artifactPath)) {
317
+ logStep(stepName, ctx, true);
318
+ return true;
319
+ }
320
+
321
+ logStep(stepName, ctx);
322
+
323
+ const tokens = buildTokens(ctx);
324
+ const promptFile = resolveTemplate(templateName, tokens, ctx.issueDir);
325
+
326
+ const result = await runClaude({
327
+ promptFile,
328
+ permissionMode: "acceptEdits",
329
+ maxTurns: getConfig().maxTurns,
330
+ });
331
+
332
+ if (result.is_error) {
333
+ consola.error(`${stepName} step failed: ${result.result}`);
334
+ return false;
335
+ }
336
+
337
+ if (!isValid(artifactPath)) {
338
+ consola.error(`${stepName} step did not produce expected artifact`);
339
+ return false;
340
+ }
341
+
342
+ await commitArtifacts(
343
+ ctx,
344
+ `chore(auto-claude): ${stepName.toLowerCase()} for ${ctx.repo}#${ctx.number}`,
345
+ );
346
+ return true;
347
+ }