@bobbyg603/mog 1.5.2 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobbyg603/mog",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "One command to go from GitHub issue to pull request, powered by Claude Code in a Docker sandbox",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { cleanIssueTitle, getConventionalPrefix } from "./github";
3
+ import type { Issue } from "./github";
4
+
5
+ function makeIssue(labels: string): Issue {
6
+ return { title: "", body: "", labels, comments: "" };
7
+ }
8
+
9
+ describe("cleanIssueTitle", () => {
10
+ test("strips conventional commit prefix", () => {
11
+ expect(cleanIssueTitle("fix: BlueSky Integration")).toBe("BlueSky Integration");
12
+ expect(cleanIssueTitle("feat: Add new feature")).toBe("Add new feature");
13
+ expect(cleanIssueTitle("chore: Update deps")).toBe("Update deps");
14
+ expect(cleanIssueTitle("refactor: Clean up code")).toBe("Clean up code");
15
+ });
16
+
17
+ test("strips trailing issue references", () => {
18
+ expect(cleanIssueTitle("BlueSky Integration [#154]")).toBe("BlueSky Integration");
19
+ expect(cleanIssueTitle("Some feature [#42]")).toBe("Some feature");
20
+ });
21
+
22
+ test("strips both prefix and issue reference", () => {
23
+ expect(cleanIssueTitle("fix: BlueSky Integration [#154]")).toBe("BlueSky Integration");
24
+ });
25
+
26
+ test("strips multiple issue references", () => {
27
+ expect(cleanIssueTitle("fix: Something [#1] [#2]")).toBe("Something");
28
+ expect(cleanIssueTitle("BlueSky [#154] Integration [#184]")).toBe("BlueSky Integration");
29
+ });
30
+
31
+ test("leaves clean titles unchanged", () => {
32
+ expect(cleanIssueTitle("BlueSky Integration")).toBe("BlueSky Integration");
33
+ expect(cleanIssueTitle("Add support for webhooks")).toBe("Add support for webhooks");
34
+ });
35
+
36
+ test("is case-insensitive for prefix", () => {
37
+ expect(cleanIssueTitle("Fix: BlueSky Integration")).toBe("BlueSky Integration");
38
+ expect(cleanIssueTitle("FIX: BlueSky Integration")).toBe("BlueSky Integration");
39
+ });
40
+ });
41
+
42
+ describe("getConventionalPrefix", () => {
43
+ test("returns feat for enhancement label", () => {
44
+ expect(getConventionalPrefix(makeIssue("enhancement"))).toBe("feat");
45
+ });
46
+
47
+ test("returns feat for feature label", () => {
48
+ expect(getConventionalPrefix(makeIssue("feature"))).toBe("feat");
49
+ });
50
+
51
+ test("returns fix for bug label", () => {
52
+ expect(getConventionalPrefix(makeIssue("bug"))).toBe("fix");
53
+ });
54
+
55
+ test("returns fix for no labels", () => {
56
+ expect(getConventionalPrefix(makeIssue("none"))).toBe("fix");
57
+ });
58
+
59
+ test("does not false-positive on substring matches", () => {
60
+ expect(getConventionalPrefix(makeIssue("no-enhancement"))).toBe("fix");
61
+ expect(getConventionalPrefix(makeIssue("feature-request"))).toBe("fix");
62
+ });
63
+
64
+ test("handles multiple labels", () => {
65
+ expect(getConventionalPrefix(makeIssue("bug, enhancement"))).toBe("feat");
66
+ expect(getConventionalPrefix(makeIssue("priority, bug"))).toBe("fix");
67
+ });
68
+ });
package/src/github.ts CHANGED
@@ -127,6 +127,29 @@ export function fetchPRFeedback(repo: string, branchName: string): PRFeedback |
127
127
  return { prNumber, prUrl, reviews };
128
128
  }
129
129
 
130
+ export function closePR(repo: string, prNumber: number): void {
131
+ const result = Bun.spawnSync([
132
+ "gh", "pr", "close", String(prNumber),
133
+ "--repo", repo,
134
+ "--delete-branch",
135
+ ]);
136
+ if (result.exitCode !== 0) {
137
+ log.warn(`Failed to close PR #${prNumber}.`);
138
+ }
139
+ }
140
+
141
+ export function cleanIssueTitle(title: string): string {
142
+ return title
143
+ .replace(/^(feat|fix|chore|docs|refactor|test|ci|build|perf|style):\s*/i, "")
144
+ .replace(/\s*\[#\d+\]/g, "")
145
+ .trim();
146
+ }
147
+
148
+ export function getConventionalPrefix(issue: Issue): string {
149
+ const labels = issue.labels.split(", ").map(l => l.trim());
150
+ return labels.includes("enhancement") || labels.includes("feature") ? "feat" : "fix";
151
+ }
152
+
130
153
  export function pushAndCreatePR(
131
154
  repo: string,
132
155
  worktreeDir: string,
@@ -151,6 +174,8 @@ export function pushAndCreatePR(
151
174
  return;
152
175
  }
153
176
 
177
+ const prefix = getConventionalPrefix(issue);
178
+
154
179
  // Stage any unstaged changes Claude might have left
155
180
  if (hasUncommitted) {
156
181
  log.info("Staging uncommitted changes...");
@@ -158,22 +183,21 @@ export function pushAndCreatePR(
158
183
  if (addResult.exitCode !== 0) {
159
184
  log.die("Failed to stage changes.");
160
185
  }
161
- const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
162
- const commitResult = Bun.spawnSync(["git", "commit", "-m", `${prefix}: address issue #${issueNum} - ${issue.title}`], { cwd: worktreeDir });
186
+ const commitResult = Bun.spawnSync(["git", "commit", "-m", `${prefix}: address issue #${issueNum} - ${cleanIssueTitle(issue.title)}`], { cwd: worktreeDir });
163
187
  if (commitResult.exitCode !== 0) {
164
188
  log.warn("Commit failed — changes may already be committed.");
165
189
  }
166
190
  }
167
191
 
168
- // Squash all commits into one
169
- const commitCount = Bun.spawnSync(["git", "rev-list", "--count", `${defaultBranch}..HEAD`], { cwd: worktreeDir });
192
+ // Squash all commits into one (use origin ref — local branch may be stale)
193
+ const mergeBase = `origin/${defaultBranch}`;
194
+ const commitCount = Bun.spawnSync(["git", "rev-list", "--count", `${mergeBase}..HEAD`], { cwd: worktreeDir });
170
195
  const count = parseInt(commitCount.stdout.toString().trim(), 10) || 0;
171
196
  if (count > 1) {
172
197
  log.info(`Squashing ${count} commits into one...`);
173
- const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
174
- const squash = Bun.spawnSync(["git", "reset", "--soft", defaultBranch], { cwd: worktreeDir });
198
+ const squash = Bun.spawnSync(["git", "reset", "--soft", mergeBase], { cwd: worktreeDir });
175
199
  if (squash.exitCode === 0) {
176
- const msg = `${prefix}: ${issue.title.toLowerCase()} (#${issueNum})`;
200
+ const msg = `${prefix}: ${cleanIssueTitle(issue.title).toLowerCase()} (#${issueNum})`;
177
201
  Bun.spawnSync(["git", "commit", "-m", msg], { cwd: worktreeDir });
178
202
  log.ok("Commits squashed.");
179
203
  } else {
@@ -181,9 +205,11 @@ export function pushAndCreatePR(
181
205
  }
182
206
  }
183
207
 
184
- // Push (force-with-lease when updating an existing PR)
208
+ // Push force-with-lease if the remote branch already exists
185
209
  log.info(`Pushing branch '${branchName}' to origin...`);
186
- const pushArgs = existingPR
210
+ const remoteRef = Bun.spawnSync(["git", "ls-remote", "--heads", "origin", branchName], { cwd: worktreeDir });
211
+ const remoteBranchExists = remoteRef.stdout.toString().trim().length > 0;
212
+ const pushArgs = remoteBranchExists
187
213
  ? ["git", "push", "--force-with-lease", "-u", "origin", branchName]
188
214
  : ["git", "push", "-u", "origin", branchName];
189
215
  const push = Bun.spawnSync(pushArgs, { cwd: worktreeDir });
@@ -192,8 +218,22 @@ export function pushAndCreatePR(
192
218
  }
193
219
  log.ok("Branch pushed.");
194
220
 
221
+ const prTitle = `${prefix}: ${cleanIssueTitle(issue.title)} [#${issueNum}]`;
222
+ const prBody = buildPRBody(issueNum, summary);
223
+
195
224
  if (existingPR) {
196
- // Update existing PR
225
+ // Update existing PR title and description
226
+ const edit = Bun.spawnSync([
227
+ "gh", "pr", "edit", String(existingPR.prNumber),
228
+ "--repo", repo,
229
+ "--title", prTitle,
230
+ "--body", prBody,
231
+ ], { cwd: worktreeDir });
232
+
233
+ if (edit.exitCode !== 0) {
234
+ log.warn("Failed to update PR title/description.");
235
+ }
236
+
197
237
  log.ok("Existing PR updated!");
198
238
  console.log(`\x1b[0;32m${existingPR.prUrl}\x1b[0m`);
199
239
  console.log();
@@ -206,26 +246,6 @@ export function pushAndCreatePR(
206
246
  // Create PR
207
247
  log.info("Opening pull request...");
208
248
 
209
- const summarySection = summary
210
- ? `### What was done\n\n${summary}\n\n`
211
- : "";
212
-
213
- const prBody = `## Summary
214
-
215
- Closes #${issueNum}
216
-
217
- ${summarySection}This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
218
-
219
- ### Issue: ${issue.title}
220
-
221
- ${issue.body}
222
-
223
- ---
224
- *Please review the changes carefully before merging.*`;
225
-
226
- const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
227
- const prTitle = `${prefix}: ${issue.title} [#${issueNum}]`;
228
-
229
249
  const pr = Bun.spawnSync([
230
250
  "gh", "pr", "create",
231
251
  "--repo", repo,
@@ -248,3 +268,19 @@ ${issue.body}
248
268
  log.info(`Worktree: ${worktreeDir}`);
249
269
  log.info(`To clean up the worktree later: git worktree remove ${worktreeDir}`);
250
270
  }
271
+
272
+ function buildPRBody(issueNum: string, summary?: string): string {
273
+ const summarySection = summary
274
+ ? `### What was done\n\n${summary}\n\n`
275
+ : "";
276
+
277
+ return `## Summary
278
+
279
+ Closes #${issueNum}
280
+
281
+ ${summarySection}---
282
+
283
+ This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
284
+
285
+ *Please review the changes carefully before merging.*`;
286
+ }
package/src/index.ts CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  import fs from "fs";
4
4
  import path from "path";
5
- import { fetchIssue, listIssues, fetchPRFeedback } from "./github";
5
+ import { fetchIssue, listIssues, fetchPRFeedback, closePR, cleanIssueTitle, pushAndCreatePR } from "./github";
6
6
  import { detectRepo, ensureRepo, createWorktree } from "./worktree";
7
7
  import { runClaude } from "./sandbox";
8
- import { pushAndCreatePR } from "./github";
9
8
  import type { PRFeedback } from "./github";
10
9
  import { log } from "./log";
11
10
 
@@ -217,19 +216,21 @@ async function main() {
217
216
  log.info(`Default branch: ${defaultBranch}`);
218
217
 
219
218
  const { worktreeDir, branchName, reused } = createWorktree(
220
- reposDir, owner, repoName, defaultBranch, issueNum, issue.title
219
+ reposDir, owner, repoName, defaultBranch, issueNum, cleanIssueTitle(issue.title), fresh
221
220
  );
222
221
 
223
- // Check for existing PR (unless --fresh)
222
+ // Check for existing PR
224
223
  let existingPR: PRFeedback | undefined;
225
- let isRetry = reused;
226
- if (!fresh) {
227
- const pr = fetchPRFeedback(repo, branchName);
228
- if (pr) {
229
- existingPR = pr;
230
- isRetry = true;
231
- log.ok(`Found existing PR #${pr.prNumber} — will include review feedback and update it.`);
232
- }
224
+ let isRetry = fresh ? false : reused;
225
+ const pr = fetchPRFeedback(repo, branchName);
226
+ if (pr && fresh) {
227
+ log.info(`--fresh: closing existing PR #${pr.prNumber} and deleting remote branch...`);
228
+ closePR(repo, pr.prNumber);
229
+ log.ok(`PR #${pr.prNumber} closed. A new PR will be created.`);
230
+ } else if (pr) {
231
+ existingPR = pr;
232
+ isRetry = true;
233
+ log.ok(`Found existing PR #${pr.prNumber} — will include review feedback and update it.`);
233
234
  }
234
235
 
235
236
  // Copy included files into worktree
@@ -243,11 +244,12 @@ async function main() {
243
244
  }
244
245
 
245
246
  // Build prompts
246
- const prFeedback = existingPR?.reviews || "";
247
+ const prFeedback = fresh ? "" : (existingPR?.reviews || "");
247
248
  const planningPrompt = buildPlanningPrompt(repo, issueNum, issue, prFeedback, isRetry);
248
249
  const buildingPromptFn = (remaining: string[], plan: string) =>
249
250
  buildBuildingPrompt(repo, issueNum, issue, remaining, plan);
250
- const reviewPrompt = buildReviewPrompt(repo, issueNum, issue);
251
+ const reviewPrompt = buildReviewPrompt(repo, issueNum, issue, defaultBranch);
252
+ const summaryPrompt = buildSummaryPrompt(repo, issueNum, issue, defaultBranch);
251
253
 
252
254
  // Run Claude in sandbox
253
255
  log.info("Launching Claude Code in sandbox...");
@@ -255,7 +257,7 @@ async function main() {
255
257
  log.info(`Worktree: ${worktreeDir}`);
256
258
  console.log();
257
259
 
258
- const summary = await runClaude(SANDBOX_NAME, worktreeDir, planningPrompt, buildingPromptFn, reviewPrompt);
260
+ const summary = await runClaude(SANDBOX_NAME, worktreeDir, defaultBranch, planningPrompt, buildingPromptFn, reviewPrompt, summaryPrompt);
259
261
 
260
262
  // Remove included files so they don't end up in the PR
261
263
  for (const filePath of copiedFiles) {
@@ -439,10 +441,46 @@ Rules:
439
441
  5. Do NOT work on any other tasks after committing.`;
440
442
  }
441
443
 
444
+ function buildSummaryPrompt(
445
+ repo: string,
446
+ issueNum: string,
447
+ issue: { title: string; body: string; labels: string; comments: string },
448
+ defaultBranch: string,
449
+ ): string {
450
+ return `You are summarizing the changes made for GitHub issue #${issueNum} in the repository ${repo}.
451
+
452
+ ## Issue: ${issue.title}
453
+
454
+ ### Description
455
+ ${issue.body}
456
+
457
+ ## Instructions
458
+
459
+ Run \`git diff ${defaultBranch}...HEAD\` to see all changes made on this branch.
460
+
461
+ Write a concise summary of **all changes** made to resolve the issue and save it to a file called \`SUMMARY.md\` in the root of the repository. Do NOT commit this file.
462
+
463
+ CRITICAL FORMATTING RULES — the file contents will be inserted directly into a PR body as-is:
464
+ 1. Do NOT make any code changes or commits.
465
+ 2. The file must start IMMEDIATELY with the first bullet point. No intro text like "Here's what was done" or "Let me summarize". No lead-in sentences whatsoever.
466
+ 3. Use a flat list of markdown bullet points (\`- \`). No headings, no sub-sections, no numbered lists.
467
+ 4. Each bullet should describe a concrete change: what was added, modified, or fixed and where.
468
+ 5. Keep it short — aim for 3-8 bullets. A reviewer should be able to scan it in 10 seconds.
469
+ 6. Do NOT list things that were reviewed but not changed. Only describe what actually changed.
470
+ 7. Do NOT repeat the issue title or description — the PR already includes those.
471
+ 8. End with the last bullet point. No closing remarks, no sign-off, no "Let me know" or "Done." after the bullets.
472
+
473
+ Example of correct SUMMARY.md contents:
474
+ - Added \`FooService\` class in \`src/services/foo.ts\` with retry logic and error handling
475
+ - Updated \`src/routes/api.ts\` to wire up the new \`/foo\` endpoint
476
+ - Added unit tests for \`FooService\` in \`src/services/__tests__/foo.test.ts\``;
477
+ }
478
+
442
479
  function buildReviewPrompt(
443
480
  repo: string,
444
481
  issueNum: string,
445
482
  issue: { title: string; body: string; labels: string; comments: string },
483
+ defaultBranch: string,
446
484
  ): string {
447
485
  return `You are reviewing changes made for GitHub issue #${issueNum} in the repository ${repo}.
448
486
 
@@ -452,7 +490,7 @@ ${formatIssueContext(issueNum, issue)}
452
490
 
453
491
  All implementation tasks are complete. Your job is to **review the entire branch** for quality and completeness.
454
492
 
455
- Run \`git diff main...HEAD\` (or the equivalent for the default branch) to see all changes made.
493
+ Run \`git diff ${defaultBranch}...HEAD\` to see all changes made.
456
494
 
457
495
  Check for:
458
496
  1. **Missed locations**: Search the codebase for similar patterns, logic, or code that handles the same concern as the changes. If the fix or feature was applied in one place but a similar pattern exists elsewhere, apply it there too.
@@ -0,0 +1,77 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { readPlanFile, getUncheckedItems, isPlanComplete } from "./sandbox";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import os from "os";
6
+
7
+ describe("getUncheckedItems", () => {
8
+ test("returns unchecked items from plan content", () => {
9
+ const plan = `# Plan
10
+ - [ ] First task
11
+ - [x] Second task (done)
12
+ - [ ] Third task`;
13
+ expect(getUncheckedItems(plan)).toEqual([
14
+ "- [ ] First task",
15
+ "- [ ] Third task",
16
+ ]);
17
+ });
18
+
19
+ test("returns empty array when all items are checked", () => {
20
+ const plan = `# Plan
21
+ - [x] First task
22
+ - [x] Second task`;
23
+ expect(getUncheckedItems(plan)).toEqual([]);
24
+ });
25
+
26
+ test("returns empty array for content with no checklist items", () => {
27
+ expect(getUncheckedItems("Just some text")).toEqual([]);
28
+ });
29
+
30
+ test("returns empty array for empty string", () => {
31
+ expect(getUncheckedItems("")).toEqual([]);
32
+ });
33
+ });
34
+
35
+ describe("isPlanComplete", () => {
36
+ test("returns true when all items are checked", () => {
37
+ const plan = `# Plan
38
+ - [x] First task
39
+ - [x] Second task`;
40
+ expect(isPlanComplete(plan)).toBe(true);
41
+ });
42
+
43
+ test("returns false when unchecked items remain", () => {
44
+ const plan = `# Plan
45
+ - [x] First task
46
+ - [ ] Second task`;
47
+ expect(isPlanComplete(plan)).toBe(false);
48
+ });
49
+
50
+ test("returns false when there are no checklist items at all", () => {
51
+ expect(isPlanComplete("Just some text")).toBe(false);
52
+ });
53
+
54
+ test("returns false for empty string", () => {
55
+ expect(isPlanComplete("")).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("readPlanFile", () => {
60
+ test("returns file content when plan file exists", () => {
61
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mog-test-"));
62
+ const planContent = "# Plan\n- [ ] Task one\n";
63
+ fs.writeFileSync(path.join(tmpDir, "IMPLEMENTATION_PLAN.md"), planContent);
64
+
65
+ expect(readPlanFile(tmpDir)).toBe(planContent);
66
+
67
+ fs.rmSync(tmpDir, { recursive: true });
68
+ });
69
+
70
+ test("returns null when plan file does not exist", () => {
71
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mog-test-"));
72
+
73
+ expect(readPlanFile(tmpDir)).toBeNull();
74
+
75
+ fs.rmSync(tmpDir, { recursive: true });
76
+ });
77
+ });
package/src/sandbox.ts CHANGED
@@ -22,6 +22,7 @@ const MAX_ITERATIONS = parseInt(
22
22
  );
23
23
  const MAX_STALLS = 2;
24
24
  const PLAN_FILENAME = "IMPLEMENTATION_PLAN.md";
25
+ const SUMMARY_FILENAME = "SUMMARY.md";
25
26
 
26
27
  export function readPlanFile(worktreeDir: string): string | null {
27
28
  const planPath = `${worktreeDir}/${PLAN_FILENAME}`;
@@ -43,14 +44,24 @@ export function isPlanComplete(planContent: string): boolean {
43
44
  return unchecked.length === 0 && (checked?.length ?? 0) > 0;
44
45
  }
45
46
 
46
- function getCommitCount(sandboxName: string, worktreeDir: string): number {
47
+ function getCommitCount(sandboxName: string, worktreeDir: string, defaultBranch: string): number {
47
48
  const result = Bun.spawnSync([
48
49
  "docker", "sandbox", "exec",
49
50
  "-w", worktreeDir,
50
51
  sandboxName,
51
- "git", "rev-list", "HEAD", "--not", "--remotes", "--count",
52
+ "git", "rev-list", "--count", `origin/${defaultBranch}..HEAD`,
52
53
  ]);
53
- if (result.exitCode !== 0) return 0;
54
+ if (result.exitCode !== 0) {
55
+ // Fallback: count commits not on any remote (works even if origin ref is missing)
56
+ const fallback = Bun.spawnSync([
57
+ "docker", "sandbox", "exec",
58
+ "-w", worktreeDir,
59
+ sandboxName,
60
+ "git", "rev-list", "HEAD", "--not", "--remotes", "--count",
61
+ ]);
62
+ if (fallback.exitCode !== 0) return 0;
63
+ return parseInt(fallback.stdout.toString().trim(), 10) || 0;
64
+ }
54
65
  return parseInt(result.stdout.toString().trim(), 10) || 0;
55
66
  }
56
67
 
@@ -71,17 +82,54 @@ function cleanupPlanFile(sandboxName: string, worktreeDir: string): void {
71
82
  ]);
72
83
  }
73
84
 
85
+ function readSummaryFile(worktreeDir: string): string | null {
86
+ const summaryPath = `${worktreeDir}/${SUMMARY_FILENAME}`;
87
+ try {
88
+ const content = fs.readFileSync(summaryPath, "utf-8").trim();
89
+ // Clean up the file so it doesn't end up in commits
90
+ fs.unlinkSync(summaryPath);
91
+ return content || null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ async function runSummaryPhase(
98
+ sandboxName: string,
99
+ worktreeDir: string,
100
+ defaultBranch: string,
101
+ summaryPrompt?: string,
102
+ ): Promise<string> {
103
+ if (!summaryPrompt || getCommitCount(sandboxName, worktreeDir, defaultBranch) <= 0) {
104
+ return "";
105
+ }
106
+
107
+ log.info("Summarizing changes...");
108
+ await execClaude(sandboxName, worktreeDir, ["-p", summaryPrompt]);
109
+
110
+ const summary = readSummaryFile(worktreeDir);
111
+ if (summary) {
112
+ log.ok("Summary generated.");
113
+ return summary;
114
+ }
115
+
116
+ log.warn("Claude did not write SUMMARY.md — PR will not have a summary section.");
117
+ return "";
118
+ }
119
+
74
120
  export async function runClaude(
75
121
  sandboxName: string,
76
122
  worktreeDir: string,
123
+ defaultBranch: string,
77
124
  planningPrompt: string,
78
125
  buildingPromptFn: (remainingItems: string[], planContent: string) => string,
79
126
  reviewPrompt?: string,
127
+ summaryPrompt?: string,
80
128
  ): Promise<string> {
81
129
  let lastResult = "";
82
130
 
83
- // Phase 1 — Planning
84
- log.info("Phase 1: Creating implementation plan...");
131
+ // Planning
132
+ log.info("Planning implementation...");
85
133
  await execClaude(sandboxName, worktreeDir, ["-p", planningPrompt]);
86
134
 
87
135
  const planContent = readPlanFile(worktreeDir);
@@ -95,7 +143,7 @@ export async function runClaude(
95
143
  if (fallbackResult) lastResult = fallbackResult;
96
144
 
97
145
  for (let i = 0; i < MAX_ITERATIONS; i++) {
98
- if (getCommitCount(sandboxName, worktreeDir) > 0) return lastResult;
146
+ if (getCommitCount(sandboxName, worktreeDir, defaultBranch) > 0) break;
99
147
  log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_ITERATIONS + 1})...`);
100
148
  const contResult = await execClaude(sandboxName, worktreeDir, [
101
149
  "--continue", "-p",
@@ -104,15 +152,17 @@ export async function runClaude(
104
152
  if (contResult) lastResult = contResult;
105
153
  }
106
154
 
107
- if (getCommitCount(sandboxName, worktreeDir) === 0) {
155
+ if (getCommitCount(sandboxName, worktreeDir, defaultBranch) === 0) {
108
156
  log.warn("Claude did not produce any commits after all attempts.");
109
157
  }
110
- return lastResult;
158
+
159
+ const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
160
+ return summary || lastResult;
111
161
  }
112
162
 
113
163
  log.ok(`Implementation plan created with ${unchecked.length} task(s).`);
114
164
 
115
- // Phase 2 — Building loop
165
+ // Building loop
116
166
  let stallCount = 0;
117
167
 
118
168
  for (let i = 0; i < MAX_ITERATIONS; i++) {
@@ -128,7 +178,7 @@ export async function runClaude(
128
178
  break;
129
179
  }
130
180
 
131
- const commitsBefore = getCommitCount(sandboxName, worktreeDir);
181
+ const commitsBefore = getCommitCount(sandboxName, worktreeDir, defaultBranch);
132
182
  const uncheckedBefore = remaining.length;
133
183
 
134
184
  log.info(`Iteration ${i + 1}/${MAX_ITERATIONS}: ${remaining[0]!.replace("- [ ] ", "")}`);
@@ -139,7 +189,7 @@ export async function runClaude(
139
189
 
140
190
  const planAfter = readPlanFile(worktreeDir);
141
191
  const uncheckedAfter = planAfter ? getUncheckedItems(planAfter).length : 0;
142
- const commitsAfter = getCommitCount(sandboxName, worktreeDir);
192
+ const commitsAfter = getCommitCount(sandboxName, worktreeDir, defaultBranch);
143
193
 
144
194
  if (uncheckedAfter >= uncheckedBefore && commitsAfter <= commitsBefore) {
145
195
  stallCount++;
@@ -153,14 +203,14 @@ export async function runClaude(
153
203
  }
154
204
  }
155
205
 
156
- // Phase 3 — Review
206
+ // Review
157
207
  if (reviewPrompt) {
158
- log.info("Phase 3: Reviewing changes for quality and completeness...");
208
+ log.info("Reviewing changes for quality and completeness...");
159
209
  const reviewResult = await execClaude(sandboxName, worktreeDir, ["-p", reviewPrompt]);
160
210
  if (reviewResult) lastResult = reviewResult;
161
211
  }
162
212
 
163
- // Phase 4 — Cleanup
213
+ // Cleanup
164
214
  cleanupPlanFile(sandboxName, worktreeDir);
165
215
 
166
216
  const finalPlan = readPlanFile(worktreeDir);
@@ -173,7 +223,9 @@ export async function runClaude(
173
223
  log.ok("Plan file cleaned up.");
174
224
  }
175
225
 
176
- return lastResult;
226
+ // Summary
227
+ const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
228
+ return summary || lastResult;
177
229
  }
178
230
 
179
231
  async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<string> {
package/src/worktree.ts CHANGED
@@ -72,7 +72,8 @@ export function createWorktree(
72
72
  repoName: string,
73
73
  defaultBranch: string,
74
74
  issueNum: string,
75
- issueTitle: string
75
+ issueTitle: string,
76
+ fresh?: boolean,
76
77
  ): { worktreeDir: string; branchName: string; reused: boolean } {
77
78
  const safeTitle = issueTitle
78
79
  .toLowerCase()
@@ -85,6 +86,12 @@ export function createWorktree(
85
86
  const repoDir = `${reposDir}/${owner}/${repoName}`;
86
87
  const worktreeDir = `${reposDir}/${owner}/${repoName}-worktrees/${branchName}`;
87
88
 
89
+ if (fs.existsSync(worktreeDir) && fresh) {
90
+ log.info(`--fresh: removing existing worktree at ${worktreeDir}...`);
91
+ Bun.spawnSync(["git", "worktree", "remove", "--force", worktreeDir], { cwd: repoDir });
92
+ Bun.spawnSync(["git", "branch", "-D", branchName], { cwd: repoDir });
93
+ }
94
+
88
95
  if (fs.existsSync(worktreeDir)) {
89
96
  log.warn(`Worktree already exists at ${worktreeDir}, reusing.`);
90
97
  return { worktreeDir, branchName, reused: true };