@bobbyg603/mog 1.5.2 → 1.6.0

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/README.md CHANGED
@@ -74,6 +74,33 @@ mog 123 --include .env --include serviceAccountKey.json
74
74
  mog list
75
75
  mog list --verbose
76
76
  mog owner/repo list --verbose
77
+
78
+ # Check version
79
+ mog --version
80
+ mog -v
81
+ ```
82
+
83
+ ### Git identity
84
+
85
+ `mog` automatically configures the git identity inside the sandbox so commits are attributed correctly. Identity is resolved via a 3-tier priority chain:
86
+
87
+ 1. **Per-repo mog config** (`~/.mog/repos/<owner>/<repo>/config.json`)
88
+ 2. **Host git config** (auto-detected at runtime from your local `git config`)
89
+ 3. **Global mog config** (`~/.mog/config.json`)
90
+
91
+ Most users need zero configuration — `mog` reads your host git identity automatically. Use `mog config` to override when needed:
92
+
93
+ ```bash
94
+ # View current per-repo config (auto-detected from git remote)
95
+ mog config
96
+
97
+ # Set per-repo git identity
98
+ mog config user.name "Your Name"
99
+ mog config user.email "you@example.com"
100
+
101
+ # Set global fallback identity
102
+ mog config --global user.name "Your Name"
103
+ mog config --global user.email "you@example.com"
77
104
  ```
78
105
 
79
106
  ### Re-mogging
@@ -122,12 +149,32 @@ mog 123 --fresh
122
149
 
123
150
  ## Configuration
124
151
 
152
+ ### Environment variables
153
+
125
154
  | Environment Variable | Default | Description |
126
155
  |---|---|---|
127
156
  | `MOG_REPOS_DIR` | `~/mog-repos` | Where repos are cloned and worktrees created (also the sandbox workspace) |
128
157
  | `MOG_MAX_ITERATIONS` | `30` | Max build loop iterations per issue |
129
158
  | `MOG_MAX_CONTINUATIONS` | — | Legacy alias for `MOG_MAX_ITERATIONS` |
130
159
 
160
+ ### Config files
161
+
162
+ `mog config` manages git identity settings stored in `~/.mog/`:
163
+
164
+ ```
165
+ ~/.mog/
166
+ config.json ← global config
167
+ repos/
168
+ owner/repo/config.json ← per-repo config
169
+ ```
170
+
171
+ | Config Key | Description |
172
+ |---|---|
173
+ | `user.name` | Git author name for commits inside the sandbox |
174
+ | `user.email` | Git author email for commits inside the sandbox |
175
+
176
+ See [Git identity](#git-identity) for details on how these are resolved.
177
+
131
178
  ## Worktree management
132
179
 
133
180
  `mog` uses bare clones and git worktrees so you can run multiple issues concurrently without conflicts:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobbyg603/mog",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
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,147 @@
1
+ import { describe, test, expect, beforeEach, afterAll } from "bun:test";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { loadConfig, saveConfig, loadRepoConfig, saveRepoConfig, getGitIdentity } from "./config";
6
+
7
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mog-config-test-"));
8
+ const originalHome = process.env.HOME;
9
+
10
+ beforeEach(() => {
11
+ process.env.HOME = tmpDir;
12
+ const mogDir = path.join(tmpDir, ".mog");
13
+ if (fs.existsSync(mogDir)) {
14
+ fs.rmSync(mogDir, { recursive: true });
15
+ }
16
+ });
17
+
18
+ afterAll(() => {
19
+ process.env.HOME = originalHome;
20
+ fs.rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("loadConfig", () => {
24
+ test("returns empty object when no config file exists", () => {
25
+ expect(loadConfig()).toEqual({});
26
+ });
27
+
28
+ test("returns parsed config from file", () => {
29
+ const mogDir = path.join(tmpDir, ".mog");
30
+ fs.mkdirSync(mogDir, { recursive: true });
31
+ fs.writeFileSync(
32
+ path.join(mogDir, "config.json"),
33
+ JSON.stringify({ "user.name": "Alice", "user.email": "alice@example.com" })
34
+ );
35
+
36
+ expect(loadConfig()).toEqual({
37
+ "user.name": "Alice",
38
+ "user.email": "alice@example.com",
39
+ });
40
+ });
41
+
42
+ test("returns empty object for invalid JSON", () => {
43
+ const mogDir = path.join(tmpDir, ".mog");
44
+ fs.mkdirSync(mogDir, { recursive: true });
45
+ fs.writeFileSync(path.join(mogDir, "config.json"), "not json");
46
+
47
+ expect(loadConfig()).toEqual({});
48
+ });
49
+ });
50
+
51
+ describe("saveConfig", () => {
52
+ test("creates .mog directory and writes config", () => {
53
+ saveConfig({ "user.name": "Bob", "user.email": "bob@example.com" });
54
+
55
+ const configPath = path.join(tmpDir, ".mog", "config.json");
56
+ expect(fs.existsSync(configPath)).toBe(true);
57
+
58
+ const content = JSON.parse(fs.readFileSync(configPath, "utf-8"));
59
+ expect(content).toEqual({ "user.name": "Bob", "user.email": "bob@example.com" });
60
+ });
61
+
62
+ test("overwrites existing config", () => {
63
+ saveConfig({ "user.name": "First" });
64
+ saveConfig({ "user.name": "Second", "user.email": "second@example.com" });
65
+
66
+ const configPath = path.join(tmpDir, ".mog", "config.json");
67
+ const content = JSON.parse(fs.readFileSync(configPath, "utf-8"));
68
+ expect(content).toEqual({ "user.name": "Second", "user.email": "second@example.com" });
69
+ });
70
+
71
+ test("saves empty config", () => {
72
+ saveConfig({});
73
+
74
+ const configPath = path.join(tmpDir, ".mog", "config.json");
75
+ const content = JSON.parse(fs.readFileSync(configPath, "utf-8"));
76
+ expect(content).toEqual({});
77
+ });
78
+ });
79
+
80
+ describe("loadRepoConfig", () => {
81
+ test("returns empty object when no repo config file exists", () => {
82
+ expect(loadRepoConfig("owner/repo")).toEqual({});
83
+ });
84
+
85
+ test("returns parsed repo config from file", () => {
86
+ saveRepoConfig("owner/repo", { "user.name": "Alice", "user.email": "alice@work.com" });
87
+ expect(loadRepoConfig("owner/repo")).toEqual({
88
+ "user.name": "Alice",
89
+ "user.email": "alice@work.com",
90
+ });
91
+ });
92
+
93
+ test("keeps repo configs separate", () => {
94
+ saveRepoConfig("owner/repo-a", { "user.name": "Alice", "user.email": "alice@work.com" });
95
+ saveRepoConfig("owner/repo-b", { "user.name": "Bob", "user.email": "bob@personal.com" });
96
+
97
+ expect(loadRepoConfig("owner/repo-a")).toEqual({ "user.name": "Alice", "user.email": "alice@work.com" });
98
+ expect(loadRepoConfig("owner/repo-b")).toEqual({ "user.name": "Bob", "user.email": "bob@personal.com" });
99
+ });
100
+ });
101
+
102
+ describe("getGitIdentity", () => {
103
+ test("falls back to global config when no repo specified", () => {
104
+ saveConfig({ "user.name": "Global", "user.email": "global@example.com" });
105
+ // Note: this test also picks up host git config (tier 2), which runs before global.
106
+ // Since we can't easily mock host git, we just verify it returns something non-null.
107
+ const identity = getGitIdentity();
108
+ expect(identity).not.toBeNull();
109
+ });
110
+
111
+ test("per-repo config takes priority over global config", () => {
112
+ saveConfig({ "user.name": "Global", "user.email": "global@example.com" });
113
+ saveRepoConfig("owner/repo", { "user.name": "RepoUser", "user.email": "repo@work.com" });
114
+
115
+ expect(getGitIdentity("owner/repo")).toEqual({
116
+ name: "RepoUser",
117
+ email: "repo@work.com",
118
+ });
119
+ });
120
+
121
+ test("falls back through chain when per-repo config is incomplete", () => {
122
+ saveRepoConfig("owner/repo", { "user.name": "RepoUser" }); // missing email
123
+ saveConfig({ "user.name": "Global", "user.email": "global@example.com" });
124
+
125
+ // Should skip per-repo (incomplete), pick up host git or global
126
+ const identity = getGitIdentity("owner/repo");
127
+ expect(identity).not.toBeNull();
128
+ // Should NOT be the incomplete repo config
129
+ expect(identity!.email).not.toBe("");
130
+ });
131
+
132
+ test("returns null when no config exists anywhere and host git is empty", () => {
133
+ // With a fake HOME, host git config may still return the real system config.
134
+ // This test just verifies it doesn't crash with no mog configs at all.
135
+ const identity = getGitIdentity("nonexistent/repo");
136
+ // Can't assert null since host git may be configured, but it shouldn't throw
137
+ expect(identity === null || (identity.name && identity.email)).toBeTruthy();
138
+ });
139
+
140
+ test("returns null for global config with empty strings", () => {
141
+ saveConfig({ "user.name": "", "user.email": "" });
142
+ // With empty mog config and potentially no host git, may return null
143
+ const identity = getGitIdentity();
144
+ // If host git is configured it'll return that, otherwise null — both are valid
145
+ expect(identity === null || (identity.name && identity.email)).toBeTruthy();
146
+ });
147
+ });
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export interface MogConfig {
5
+ "user.name"?: string;
6
+ "user.email"?: string;
7
+ }
8
+
9
+ export interface GitIdentity {
10
+ name: string;
11
+ email: string;
12
+ }
13
+
14
+ function getConfigPath(): string {
15
+ return path.join(process.env.HOME || "~", ".mog", "config.json");
16
+ }
17
+
18
+ function getRepoConfigPath(repo: string): string {
19
+ return path.join(process.env.HOME || "~", ".mog", "repos", repo, "config.json");
20
+ }
21
+
22
+ export function loadConfig(): MogConfig {
23
+ const configPath = getConfigPath();
24
+ try {
25
+ const raw = fs.readFileSync(configPath, "utf-8");
26
+ return JSON.parse(raw) as MogConfig;
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ export function saveConfig(config: MogConfig): void {
33
+ const configPath = getConfigPath();
34
+ const dir = path.dirname(configPath);
35
+ fs.mkdirSync(dir, { recursive: true });
36
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
37
+ }
38
+
39
+ export function loadRepoConfig(repo: string): MogConfig {
40
+ const configPath = getRepoConfigPath(repo);
41
+ try {
42
+ const raw = fs.readFileSync(configPath, "utf-8");
43
+ return JSON.parse(raw) as MogConfig;
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+
49
+ export function saveRepoConfig(repo: string, config: MogConfig): void {
50
+ const configPath = getRepoConfigPath(repo);
51
+ const dir = path.dirname(configPath);
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
54
+ }
55
+
56
+ export function detectHostGitIdentity(): GitIdentity | null {
57
+ const nameResult = Bun.spawnSync(["git", "config", "user.name"]);
58
+ const emailResult = Bun.spawnSync(["git", "config", "user.email"]);
59
+
60
+ const name = nameResult.exitCode === 0 ? nameResult.stdout.toString().trim() : "";
61
+ const email = emailResult.exitCode === 0 ? emailResult.stdout.toString().trim() : "";
62
+
63
+ if (name && email) {
64
+ return { name, email };
65
+ }
66
+
67
+ return null;
68
+ }
69
+
70
+ function identityFromConfig(config: MogConfig): GitIdentity | null {
71
+ const name = config["user.name"];
72
+ const email = config["user.email"];
73
+ if (name && email) {
74
+ return { name, email };
75
+ }
76
+ return null;
77
+ }
78
+
79
+ export function getGitIdentity(repo?: string): GitIdentity | null {
80
+ // 1. Per-repo mog config (explicit override)
81
+ if (repo) {
82
+ const repoIdentity = identityFromConfig(loadRepoConfig(repo));
83
+ if (repoIdentity) return repoIdentity;
84
+ }
85
+
86
+ // 2. Host git config (auto-detected from cwd)
87
+ const hostIdentity = detectHostGitIdentity();
88
+ if (hostIdentity) return hostIdentity;
89
+
90
+ // 3. Global mog config (fallback)
91
+ return identityFromConfig(loadConfig());
92
+ }
@@ -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
@@ -1,3 +1,4 @@
1
+ import { getGitIdentity } from "./config";
1
2
  import { log } from "./log";
2
3
 
3
4
  export interface Issue {
@@ -127,6 +128,37 @@ export function fetchPRFeedback(repo: string, branchName: string): PRFeedback |
127
128
  return { prNumber, prUrl, reviews };
128
129
  }
129
130
 
131
+ export function closePR(repo: string, prNumber: number): void {
132
+ const result = Bun.spawnSync([
133
+ "gh", "pr", "close", String(prNumber),
134
+ "--repo", repo,
135
+ "--delete-branch",
136
+ ]);
137
+ if (result.exitCode !== 0) {
138
+ log.warn(`Failed to close PR #${prNumber}.`);
139
+ }
140
+ }
141
+
142
+ export function cleanIssueTitle(title: string): string {
143
+ return title
144
+ .replace(/^(feat|fix|chore|docs|refactor|test|ci|build|perf|style):\s*/i, "")
145
+ .replace(/\s*\[#\d+\]/g, "")
146
+ .trim();
147
+ }
148
+
149
+ export function getConventionalPrefix(issue: Issue): string {
150
+ const labels = issue.labels.split(", ").map(l => l.trim());
151
+ return labels.includes("enhancement") || labels.includes("feature") ? "feat" : "fix";
152
+ }
153
+
154
+ function gitWithIdentity(repo: string, ...args: string[]): string[] {
155
+ const identity = getGitIdentity(repo);
156
+ if (identity) {
157
+ return ["git", `-c`, `user.name=${identity.name}`, `-c`, `user.email=${identity.email}`, ...args];
158
+ }
159
+ return ["git", ...args];
160
+ }
161
+
130
162
  export function pushAndCreatePR(
131
163
  repo: string,
132
164
  worktreeDir: string,
@@ -151,6 +183,8 @@ export function pushAndCreatePR(
151
183
  return;
152
184
  }
153
185
 
186
+ const prefix = getConventionalPrefix(issue);
187
+
154
188
  // Stage any unstaged changes Claude might have left
155
189
  if (hasUncommitted) {
156
190
  log.info("Staging uncommitted changes...");
@@ -158,32 +192,33 @@ export function pushAndCreatePR(
158
192
  if (addResult.exitCode !== 0) {
159
193
  log.die("Failed to stage changes.");
160
194
  }
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 });
195
+ const commitResult = Bun.spawnSync(gitWithIdentity(repo, "commit", "-m", `${prefix}: address issue #${issueNum} - ${cleanIssueTitle(issue.title)}`), { cwd: worktreeDir });
163
196
  if (commitResult.exitCode !== 0) {
164
197
  log.warn("Commit failed — changes may already be committed.");
165
198
  }
166
199
  }
167
200
 
168
- // Squash all commits into one
169
- const commitCount = Bun.spawnSync(["git", "rev-list", "--count", `${defaultBranch}..HEAD`], { cwd: worktreeDir });
201
+ // Squash all commits into one (use origin ref — local branch may be stale)
202
+ const mergeBase = `origin/${defaultBranch}`;
203
+ const commitCount = Bun.spawnSync(["git", "rev-list", "--count", `${mergeBase}..HEAD`], { cwd: worktreeDir });
170
204
  const count = parseInt(commitCount.stdout.toString().trim(), 10) || 0;
171
205
  if (count > 1) {
172
206
  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 });
207
+ const squash = Bun.spawnSync(["git", "reset", "--soft", mergeBase], { cwd: worktreeDir });
175
208
  if (squash.exitCode === 0) {
176
- const msg = `${prefix}: ${issue.title.toLowerCase()} (#${issueNum})`;
177
- Bun.spawnSync(["git", "commit", "-m", msg], { cwd: worktreeDir });
209
+ const msg = `${prefix}: ${cleanIssueTitle(issue.title).toLowerCase()} (#${issueNum})`;
210
+ Bun.spawnSync(gitWithIdentity(repo, "commit", "-m", msg), { cwd: worktreeDir });
178
211
  log.ok("Commits squashed.");
179
212
  } else {
180
213
  log.warn("Failed to squash — pushing individual commits instead.");
181
214
  }
182
215
  }
183
216
 
184
- // Push (force-with-lease when updating an existing PR)
217
+ // Push force-with-lease if the remote branch already exists
185
218
  log.info(`Pushing branch '${branchName}' to origin...`);
186
- const pushArgs = existingPR
219
+ const remoteRef = Bun.spawnSync(["git", "ls-remote", "--heads", "origin", branchName], { cwd: worktreeDir });
220
+ const remoteBranchExists = remoteRef.stdout.toString().trim().length > 0;
221
+ const pushArgs = remoteBranchExists
187
222
  ? ["git", "push", "--force-with-lease", "-u", "origin", branchName]
188
223
  : ["git", "push", "-u", "origin", branchName];
189
224
  const push = Bun.spawnSync(pushArgs, { cwd: worktreeDir });
@@ -192,8 +227,22 @@ export function pushAndCreatePR(
192
227
  }
193
228
  log.ok("Branch pushed.");
194
229
 
230
+ const prTitle = `${prefix}: ${cleanIssueTitle(issue.title)} [#${issueNum}]`;
231
+ const prBody = buildPRBody(issueNum, summary);
232
+
195
233
  if (existingPR) {
196
- // Update existing PR
234
+ // Update existing PR title and description
235
+ const edit = Bun.spawnSync([
236
+ "gh", "pr", "edit", String(existingPR.prNumber),
237
+ "--repo", repo,
238
+ "--title", prTitle,
239
+ "--body", prBody,
240
+ ], { cwd: worktreeDir });
241
+
242
+ if (edit.exitCode !== 0) {
243
+ log.warn("Failed to update PR title/description.");
244
+ }
245
+
197
246
  log.ok("Existing PR updated!");
198
247
  console.log(`\x1b[0;32m${existingPR.prUrl}\x1b[0m`);
199
248
  console.log();
@@ -206,26 +255,6 @@ export function pushAndCreatePR(
206
255
  // Create PR
207
256
  log.info("Opening pull request...");
208
257
 
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
258
  const pr = Bun.spawnSync([
230
259
  "gh", "pr", "create",
231
260
  "--repo", repo,
@@ -248,3 +277,19 @@ ${issue.body}
248
277
  log.info(`Worktree: ${worktreeDir}`);
249
278
  log.info(`To clean up the worktree later: git worktree remove ${worktreeDir}`);
250
279
  }
280
+
281
+ function buildPRBody(issueNum: string, summary?: string): string {
282
+ const summarySection = summary
283
+ ? `### What was done\n\n${summary}\n\n`
284
+ : "";
285
+
286
+ return `## Summary
287
+
288
+ Closes #${issueNum}
289
+
290
+ ${summarySection}---
291
+
292
+ This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
293
+
294
+ *Please review the changes carefully before merging.*`;
295
+ }
package/src/index.ts CHANGED
@@ -2,10 +2,11 @@
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
- import { runClaude } from "./sandbox";
8
- import { pushAndCreatePR } from "./github";
7
+ import { runClaude, applySandboxGitConfig } from "./sandbox";
8
+ import { loadConfig, saveConfig, loadRepoConfig, saveRepoConfig } from "./config";
9
+ import type { MogConfig } from "./config";
9
10
  import type { PRFeedback } from "./github";
10
11
  import { log } from "./log";
11
12
 
@@ -60,9 +61,38 @@ async function init() {
60
61
  log.ok("mog is ready. Run: mog <issue_number> (from a git repo) or mog <owner/repo> <issue_number>");
61
62
  }
62
63
 
64
+ function printUsage(): void {
65
+ console.log("Usage:");
66
+ console.log(" mog init — one-time setup (create sandbox & login)");
67
+ console.log(" mog <issue_num> — auto-detect repo from git remote");
68
+ console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
69
+ console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
70
+ console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
71
+ console.log();
72
+ console.log("Options:");
73
+ console.log(" --include <file> — copy a file into the worktree (repeatable)");
74
+ console.log(" --fresh — ignore existing PR, start a brand new one");
75
+ console.log(" --version, -v — print the current version");
76
+ console.log();
77
+ console.log("Example:");
78
+ console.log(" mog init");
79
+ console.log(" mog 123");
80
+ console.log(" mog 123 --include .env");
81
+ console.log(" mog workingdevshero/automate-it 123");
82
+ console.log(" mog list");
83
+ console.log(" mog list --verbose");
84
+ }
85
+
63
86
  async function main() {
64
87
  const args = process.argv.slice(2);
65
88
 
89
+ // Handle --version / -v before anything else (no dependencies required)
90
+ if (args.includes("--version") || args.includes("-v")) {
91
+ const pkg = await Bun.file(path.join(import.meta.dir, "..", "package.json")).json();
92
+ console.log(pkg.version);
93
+ return;
94
+ }
95
+
66
96
  // Validate dependencies
67
97
  for (const cmd of ["gh", "git", "docker"]) {
68
98
  const which = Bun.spawnSync(["which", cmd]);
@@ -87,6 +117,65 @@ async function main() {
87
117
  return;
88
118
  }
89
119
 
120
+ // mog config [--global] [key] [value]
121
+ if (args[0] === "config") {
122
+ const configArgs = args.slice(1);
123
+ const isGlobal = configArgs.includes("--global");
124
+ const filtered = configArgs.filter(a => a !== "--global");
125
+ const key = filtered[0];
126
+ const value = filtered[1];
127
+ const validKeys: (keyof MogConfig)[] = ["user.name", "user.email"];
128
+
129
+ // Determine which config to use: --global or per-repo (auto-detected)
130
+ let repo: string | null = null;
131
+ if (!isGlobal) {
132
+ repo = detectRepo();
133
+ }
134
+
135
+ const load = () => repo ? loadRepoConfig(repo) : loadConfig();
136
+ const save = (c: MogConfig) => repo ? saveRepoConfig(repo, c) : saveConfig(c);
137
+ const scope = repo ? `repo (${repo})` : "global";
138
+
139
+ if (!key) {
140
+ // Show all config values
141
+ const config = load();
142
+ if (Object.keys(config).length === 0) {
143
+ log.info(`No ${scope} config values set.`);
144
+ } else {
145
+ log.info(`${scope} config:`);
146
+ for (const [k, v] of Object.entries(config)) {
147
+ console.log(`${k}=${v}`);
148
+ }
149
+ }
150
+ return;
151
+ }
152
+
153
+ if (!validKeys.includes(key as keyof MogConfig)) {
154
+ log.die(`Unknown config key: '${key}'. Valid keys: ${validKeys.join(", ")}`);
155
+ }
156
+
157
+ const configKey = key as keyof MogConfig;
158
+
159
+ if (value === undefined) {
160
+ // Read a single value
161
+ const config = load();
162
+ const val = config[configKey];
163
+ if (val !== undefined) {
164
+ console.log(val);
165
+ } else {
166
+ log.die(`Config key '${key}' is not set (${scope}).`);
167
+ }
168
+ return;
169
+ }
170
+
171
+ // Set a value
172
+ const config = load();
173
+ config[configKey] = value;
174
+ save(config);
175
+ log.ok(`${key}=${value} (${scope})`);
176
+ return;
177
+ }
178
+
90
179
  // mog list [--verbose] or mog <owner/repo> list [--verbose]
91
180
  if (args[0] === "list" || args[1] === "list") {
92
181
  let repo: string;
@@ -107,24 +196,7 @@ async function main() {
107
196
  }
108
197
 
109
198
  if (args.length < 1) {
110
- console.log("Usage:");
111
- console.log(" mog init — one-time setup (create sandbox & login)");
112
- console.log(" mog <issue_num> — auto-detect repo from git remote");
113
- console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
114
- console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
115
- console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
116
- console.log();
117
- console.log("Options:");
118
- console.log(" --include <file> — copy a file into the worktree (repeatable)");
119
- console.log(" --fresh — ignore existing PR, start a brand new one");
120
- console.log();
121
- console.log("Example:");
122
- console.log(" mog init");
123
- console.log(" mog 123");
124
- console.log(" mog 123 --include .env");
125
- console.log(" mog workingdevshero/automate-it 123");
126
- console.log(" mog list");
127
- console.log(" mog list --verbose");
199
+ printUsage();
128
200
  return;
129
201
  }
130
202
 
@@ -166,24 +238,7 @@ async function main() {
166
238
  log.die(`Invalid issue number: '${issueNum}'. Must be a positive integer.`);
167
239
  }
168
240
  } else {
169
- console.log("Usage:");
170
- console.log(" mog init — one-time setup (create sandbox & login)");
171
- console.log(" mog <issue_num> — auto-detect repo from git remote");
172
- console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
173
- console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
174
- console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
175
- console.log();
176
- console.log("Options:");
177
- console.log(" --include <file> — copy a file into the worktree (repeatable)");
178
- console.log(" --fresh — ignore existing PR, start a brand new one");
179
- console.log();
180
- console.log("Example:");
181
- console.log(" mog init");
182
- console.log(" mog 123");
183
- console.log(" mog 123 --include .env");
184
- console.log(" mog workingdevshero/automate-it 123");
185
- console.log(" mog list");
186
- console.log(" mog list --verbose");
241
+ printUsage();
187
242
  return;
188
243
  }
189
244
 
@@ -217,19 +272,21 @@ async function main() {
217
272
  log.info(`Default branch: ${defaultBranch}`);
218
273
 
219
274
  const { worktreeDir, branchName, reused } = createWorktree(
220
- reposDir, owner, repoName, defaultBranch, issueNum, issue.title
275
+ reposDir, owner, repoName, defaultBranch, issueNum, cleanIssueTitle(issue.title), fresh
221
276
  );
222
277
 
223
- // Check for existing PR (unless --fresh)
278
+ // Check for existing PR
224
279
  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
- }
280
+ let isRetry = fresh ? false : reused;
281
+ const pr = fetchPRFeedback(repo, branchName);
282
+ if (pr && fresh) {
283
+ log.info(`--fresh: closing existing PR #${pr.prNumber} and deleting remote branch...`);
284
+ closePR(repo, pr.prNumber);
285
+ log.ok(`PR #${pr.prNumber} closed. A new PR will be created.`);
286
+ } else if (pr) {
287
+ existingPR = pr;
288
+ isRetry = true;
289
+ log.ok(`Found existing PR #${pr.prNumber} — will include review feedback and update it.`);
233
290
  }
234
291
 
235
292
  // Copy included files into worktree
@@ -243,11 +300,15 @@ async function main() {
243
300
  }
244
301
 
245
302
  // Build prompts
246
- const prFeedback = existingPR?.reviews || "";
303
+ const prFeedback = fresh ? "" : (existingPR?.reviews || "");
247
304
  const planningPrompt = buildPlanningPrompt(repo, issueNum, issue, prFeedback, isRetry);
248
305
  const buildingPromptFn = (remaining: string[], plan: string) =>
249
306
  buildBuildingPrompt(repo, issueNum, issue, remaining, plan);
250
- const reviewPrompt = buildReviewPrompt(repo, issueNum, issue);
307
+ const reviewPrompt = buildReviewPrompt(repo, issueNum, issue, defaultBranch);
308
+ const summaryPrompt = buildSummaryPrompt(repo, issueNum, issue, defaultBranch);
309
+
310
+ // Apply git identity inside sandbox before running Claude
311
+ applySandboxGitConfig(SANDBOX_NAME, repo);
251
312
 
252
313
  // Run Claude in sandbox
253
314
  log.info("Launching Claude Code in sandbox...");
@@ -255,7 +316,7 @@ async function main() {
255
316
  log.info(`Worktree: ${worktreeDir}`);
256
317
  console.log();
257
318
 
258
- const summary = await runClaude(SANDBOX_NAME, worktreeDir, planningPrompt, buildingPromptFn, reviewPrompt);
319
+ const summary = await runClaude(SANDBOX_NAME, worktreeDir, defaultBranch, planningPrompt, buildingPromptFn, reviewPrompt, summaryPrompt);
259
320
 
260
321
  // Remove included files so they don't end up in the PR
261
322
  for (const filePath of copiedFiles) {
@@ -272,6 +333,35 @@ async function main() {
272
333
  pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue, summary, existingPR);
273
334
  }
274
335
 
336
+ function printUsage(): void {
337
+ console.log("Usage:");
338
+ console.log(" mog init — one-time setup (create sandbox & login)");
339
+ console.log(" mog config [key] [value] — get/set per-repo config (auto-detected)");
340
+ console.log(" mog config --global [key] [value] — get/set global config");
341
+ console.log(" mog <issue_num> — auto-detect repo from git remote");
342
+ console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
343
+ console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
344
+ console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
345
+ console.log();
346
+ console.log("Options:");
347
+ console.log(" --include <file> — copy a file into the worktree (repeatable)");
348
+ console.log(" --fresh — ignore existing PR, start a brand new one");
349
+ console.log();
350
+ console.log("Config keys: user.name, user.email");
351
+ console.log(" Git identity is auto-detected from your repo's git config.");
352
+ console.log(" Use 'mog config' to override per-repo, or --global for all repos.");
353
+ console.log();
354
+ console.log("Example:");
355
+ console.log(" mog init");
356
+ console.log(" mog config user.name \"Your Name\"");
357
+ console.log(" mog config --global user.email \"you@example.com\"");
358
+ console.log(" mog 123");
359
+ console.log(" mog 123 --include .env");
360
+ console.log(" mog workingdevshero/automate-it 123");
361
+ console.log(" mog list");
362
+ console.log(" mog list --verbose");
363
+ }
364
+
275
365
  function getReposDir(): string {
276
366
  return process.env.MOG_REPOS_DIR || `${process.env.HOME}/mog-repos`;
277
367
  }
@@ -439,10 +529,46 @@ Rules:
439
529
  5. Do NOT work on any other tasks after committing.`;
440
530
  }
441
531
 
532
+ function buildSummaryPrompt(
533
+ repo: string,
534
+ issueNum: string,
535
+ issue: { title: string; body: string; labels: string; comments: string },
536
+ defaultBranch: string,
537
+ ): string {
538
+ return `You are summarizing the changes made for GitHub issue #${issueNum} in the repository ${repo}.
539
+
540
+ ## Issue: ${issue.title}
541
+
542
+ ### Description
543
+ ${issue.body}
544
+
545
+ ## Instructions
546
+
547
+ Run \`git diff ${defaultBranch}...HEAD\` to see all changes made on this branch.
548
+
549
+ 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.
550
+
551
+ CRITICAL FORMATTING RULES — the file contents will be inserted directly into a PR body as-is:
552
+ 1. Do NOT make any code changes or commits.
553
+ 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.
554
+ 3. Use a flat list of markdown bullet points (\`- \`). No headings, no sub-sections, no numbered lists.
555
+ 4. Each bullet should describe a concrete change: what was added, modified, or fixed and where.
556
+ 5. Keep it short — aim for 3-8 bullets. A reviewer should be able to scan it in 10 seconds.
557
+ 6. Do NOT list things that were reviewed but not changed. Only describe what actually changed.
558
+ 7. Do NOT repeat the issue title or description — the PR already includes those.
559
+ 8. End with the last bullet point. No closing remarks, no sign-off, no "Let me know" or "Done." after the bullets.
560
+
561
+ Example of correct SUMMARY.md contents:
562
+ - Added \`FooService\` class in \`src/services/foo.ts\` with retry logic and error handling
563
+ - Updated \`src/routes/api.ts\` to wire up the new \`/foo\` endpoint
564
+ - Added unit tests for \`FooService\` in \`src/services/__tests__/foo.test.ts\``;
565
+ }
566
+
442
567
  function buildReviewPrompt(
443
568
  repo: string,
444
569
  issueNum: string,
445
570
  issue: { title: string; body: string; labels: string; comments: string },
571
+ defaultBranch: string,
446
572
  ): string {
447
573
  return `You are reviewing changes made for GitHub issue #${issueNum} in the repository ${repo}.
448
574
 
@@ -452,7 +578,7 @@ ${formatIssueContext(issueNum, issue)}
452
578
 
453
579
  All implementation tasks are complete. Your job is to **review the entire branch** for quality and completeness.
454
580
 
455
- Run \`git diff main...HEAD\` (or the equivalent for the default branch) to see all changes made.
581
+ Run \`git diff ${defaultBranch}...HEAD\` to see all changes made.
456
582
 
457
583
  Check for:
458
584
  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
@@ -1,5 +1,6 @@
1
1
  import fs from "fs";
2
2
  import { log } from "./log";
3
+ import { getGitIdentity } from "./config";
3
4
 
4
5
  interface StreamEvent {
5
6
  type: string;
@@ -22,6 +23,26 @@ const MAX_ITERATIONS = parseInt(
22
23
  );
23
24
  const MAX_STALLS = 2;
24
25
  const PLAN_FILENAME = "IMPLEMENTATION_PLAN.md";
26
+ const SUMMARY_FILENAME = "SUMMARY.md";
27
+
28
+ export function applySandboxGitConfig(sandboxName: string, repo?: string): void {
29
+ const identity = getGitIdentity(repo);
30
+ if (!identity) return;
31
+
32
+ Bun.spawnSync([
33
+ "docker", "sandbox", "exec",
34
+ sandboxName,
35
+ "git", "config", "--global", "user.name", identity.name,
36
+ ]);
37
+
38
+ Bun.spawnSync([
39
+ "docker", "sandbox", "exec",
40
+ sandboxName,
41
+ "git", "config", "--global", "user.email", identity.email,
42
+ ]);
43
+
44
+ log.ok(`Sandbox git identity: ${identity.name} <${identity.email}>`);
45
+ }
25
46
 
26
47
  export function readPlanFile(worktreeDir: string): string | null {
27
48
  const planPath = `${worktreeDir}/${PLAN_FILENAME}`;
@@ -43,14 +64,24 @@ export function isPlanComplete(planContent: string): boolean {
43
64
  return unchecked.length === 0 && (checked?.length ?? 0) > 0;
44
65
  }
45
66
 
46
- function getCommitCount(sandboxName: string, worktreeDir: string): number {
67
+ function getCommitCount(sandboxName: string, worktreeDir: string, defaultBranch: string): number {
47
68
  const result = Bun.spawnSync([
48
69
  "docker", "sandbox", "exec",
49
70
  "-w", worktreeDir,
50
71
  sandboxName,
51
- "git", "rev-list", "HEAD", "--not", "--remotes", "--count",
72
+ "git", "rev-list", "--count", `origin/${defaultBranch}..HEAD`,
52
73
  ]);
53
- if (result.exitCode !== 0) return 0;
74
+ if (result.exitCode !== 0) {
75
+ // Fallback: count commits not on any remote (works even if origin ref is missing)
76
+ const fallback = Bun.spawnSync([
77
+ "docker", "sandbox", "exec",
78
+ "-w", worktreeDir,
79
+ sandboxName,
80
+ "git", "rev-list", "HEAD", "--not", "--remotes", "--count",
81
+ ]);
82
+ if (fallback.exitCode !== 0) return 0;
83
+ return parseInt(fallback.stdout.toString().trim(), 10) || 0;
84
+ }
54
85
  return parseInt(result.stdout.toString().trim(), 10) || 0;
55
86
  }
56
87
 
@@ -71,17 +102,54 @@ function cleanupPlanFile(sandboxName: string, worktreeDir: string): void {
71
102
  ]);
72
103
  }
73
104
 
105
+ function readSummaryFile(worktreeDir: string): string | null {
106
+ const summaryPath = `${worktreeDir}/${SUMMARY_FILENAME}`;
107
+ try {
108
+ const content = fs.readFileSync(summaryPath, "utf-8").trim();
109
+ // Clean up the file so it doesn't end up in commits
110
+ fs.unlinkSync(summaryPath);
111
+ return content || null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ async function runSummaryPhase(
118
+ sandboxName: string,
119
+ worktreeDir: string,
120
+ defaultBranch: string,
121
+ summaryPrompt?: string,
122
+ ): Promise<string> {
123
+ if (!summaryPrompt || getCommitCount(sandboxName, worktreeDir, defaultBranch) <= 0) {
124
+ return "";
125
+ }
126
+
127
+ log.info("Summarizing changes...");
128
+ await execClaude(sandboxName, worktreeDir, ["-p", summaryPrompt]);
129
+
130
+ const summary = readSummaryFile(worktreeDir);
131
+ if (summary) {
132
+ log.ok("Summary generated.");
133
+ return summary;
134
+ }
135
+
136
+ log.warn("Claude did not write SUMMARY.md — PR will not have a summary section.");
137
+ return "";
138
+ }
139
+
74
140
  export async function runClaude(
75
141
  sandboxName: string,
76
142
  worktreeDir: string,
143
+ defaultBranch: string,
77
144
  planningPrompt: string,
78
145
  buildingPromptFn: (remainingItems: string[], planContent: string) => string,
79
146
  reviewPrompt?: string,
147
+ summaryPrompt?: string,
80
148
  ): Promise<string> {
81
149
  let lastResult = "";
82
150
 
83
- // Phase 1 — Planning
84
- log.info("Phase 1: Creating implementation plan...");
151
+ // Planning
152
+ log.info("Planning implementation...");
85
153
  await execClaude(sandboxName, worktreeDir, ["-p", planningPrompt]);
86
154
 
87
155
  const planContent = readPlanFile(worktreeDir);
@@ -95,7 +163,7 @@ export async function runClaude(
95
163
  if (fallbackResult) lastResult = fallbackResult;
96
164
 
97
165
  for (let i = 0; i < MAX_ITERATIONS; i++) {
98
- if (getCommitCount(sandboxName, worktreeDir) > 0) return lastResult;
166
+ if (getCommitCount(sandboxName, worktreeDir, defaultBranch) > 0) break;
99
167
  log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_ITERATIONS + 1})...`);
100
168
  const contResult = await execClaude(sandboxName, worktreeDir, [
101
169
  "--continue", "-p",
@@ -104,15 +172,17 @@ export async function runClaude(
104
172
  if (contResult) lastResult = contResult;
105
173
  }
106
174
 
107
- if (getCommitCount(sandboxName, worktreeDir) === 0) {
175
+ if (getCommitCount(sandboxName, worktreeDir, defaultBranch) === 0) {
108
176
  log.warn("Claude did not produce any commits after all attempts.");
109
177
  }
110
- return lastResult;
178
+
179
+ const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
180
+ return summary || lastResult;
111
181
  }
112
182
 
113
183
  log.ok(`Implementation plan created with ${unchecked.length} task(s).`);
114
184
 
115
- // Phase 2 — Building loop
185
+ // Building loop
116
186
  let stallCount = 0;
117
187
 
118
188
  for (let i = 0; i < MAX_ITERATIONS; i++) {
@@ -128,7 +198,7 @@ export async function runClaude(
128
198
  break;
129
199
  }
130
200
 
131
- const commitsBefore = getCommitCount(sandboxName, worktreeDir);
201
+ const commitsBefore = getCommitCount(sandboxName, worktreeDir, defaultBranch);
132
202
  const uncheckedBefore = remaining.length;
133
203
 
134
204
  log.info(`Iteration ${i + 1}/${MAX_ITERATIONS}: ${remaining[0]!.replace("- [ ] ", "")}`);
@@ -139,7 +209,7 @@ export async function runClaude(
139
209
 
140
210
  const planAfter = readPlanFile(worktreeDir);
141
211
  const uncheckedAfter = planAfter ? getUncheckedItems(planAfter).length : 0;
142
- const commitsAfter = getCommitCount(sandboxName, worktreeDir);
212
+ const commitsAfter = getCommitCount(sandboxName, worktreeDir, defaultBranch);
143
213
 
144
214
  if (uncheckedAfter >= uncheckedBefore && commitsAfter <= commitsBefore) {
145
215
  stallCount++;
@@ -153,14 +223,14 @@ export async function runClaude(
153
223
  }
154
224
  }
155
225
 
156
- // Phase 3 — Review
226
+ // Review
157
227
  if (reviewPrompt) {
158
- log.info("Phase 3: Reviewing changes for quality and completeness...");
228
+ log.info("Reviewing changes for quality and completeness...");
159
229
  const reviewResult = await execClaude(sandboxName, worktreeDir, ["-p", reviewPrompt]);
160
230
  if (reviewResult) lastResult = reviewResult;
161
231
  }
162
232
 
163
- // Phase 4 — Cleanup
233
+ // Cleanup
164
234
  cleanupPlanFile(sandboxName, worktreeDir);
165
235
 
166
236
  const finalPlan = readPlanFile(worktreeDir);
@@ -173,7 +243,9 @@ export async function runClaude(
173
243
  log.ok("Plan file cleaned up.");
174
244
  }
175
245
 
176
- return lastResult;
246
+ // Summary
247
+ const summary = await runSummaryPhase(sandboxName, worktreeDir, defaultBranch, summaryPrompt);
248
+ return summary || lastResult;
177
249
  }
178
250
 
179
251
  async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<string> {
@@ -0,0 +1,30 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import path from "path";
3
+
4
+ const entrypoint = path.join(import.meta.dir, "index.ts");
5
+ const bunPath = Bun.which("bun") ?? process.execPath;
6
+ const packageJson = require("../package.json");
7
+
8
+ describe("--version flag", () => {
9
+ test("--version prints the version from package.json", async () => {
10
+ const proc = Bun.spawn([bunPath, "run", entrypoint, "--version"], {
11
+ stdout: "pipe",
12
+ stderr: "pipe",
13
+ });
14
+ const stdout = await new Response(proc.stdout).text();
15
+ await proc.exited;
16
+ expect(proc.exitCode).toBe(0);
17
+ expect(stdout.trim()).toBe(packageJson.version);
18
+ });
19
+
20
+ test("-v prints the version from package.json", async () => {
21
+ const proc = Bun.spawn([bunPath, "run", entrypoint, "-v"], {
22
+ stdout: "pipe",
23
+ stderr: "pipe",
24
+ });
25
+ const stdout = await new Response(proc.stdout).text();
26
+ await proc.exited;
27
+ expect(proc.exitCode).toBe(0);
28
+ expect(stdout.trim()).toBe(packageJson.version);
29
+ });
30
+ });
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 };