@bobbyg603/mog 1.0.2 → 1.2.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
@@ -1,4 +1,4 @@
1
- <img width="300" alt="claude moggin" src="https://github.com/user-attachments/assets/089db43c-7381-4e62-87bc-af2e7cd0129f" />
1
+ <img height="320" alt="claude moggin issues" src="https://github.com/user-attachments/assets/72b347d2-b128-47b5-8dcd-e15248350fe0" />
2
2
 
3
3
 
4
4
  # mog — Sandboxed Claude Issue Mogging
@@ -91,7 +91,8 @@ mog sparx-tech/hub-firmware 45
91
91
  | Environment Variable | Default | Description |
92
92
  |---|---|---|
93
93
  | `MOG_REPOS_DIR` | `~/mog-repos` | Where repos are cloned and worktrees created (also the sandbox workspace) |
94
- | `MOG_MAX_CONTINUATIONS` | `5` | Max times Claude is re-prompted if it stops without committing |
94
+ | `MOG_MAX_ITERATIONS` | `10` | Max build loop iterations per issue |
95
+ | `MOG_MAX_CONTINUATIONS` | — | Legacy alias for `MOG_MAX_ITERATIONS` |
95
96
 
96
97
  ## Worktree management
97
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobbyg603/mog",
3
- "version": "1.0.2",
3
+ "version": "1.2.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",
package/src/github.ts CHANGED
@@ -34,7 +34,8 @@ export function pushAndCreatePR(
34
34
  branchName: string,
35
35
  defaultBranch: string,
36
36
  issueNum: string,
37
- issue: Issue
37
+ issue: Issue,
38
+ summary?: string
38
39
  ): void {
39
40
  // Check for unpushed commits or uncommitted changes
40
41
  const unpushed = Bun.spawnSync(["git", "log", `origin/${defaultBranch}..HEAD`, "--oneline"], { cwd: worktreeDir });
@@ -75,11 +76,15 @@ export function pushAndCreatePR(
75
76
  // Create PR
76
77
  log.info("Opening pull request...");
77
78
 
79
+ const summarySection = summary
80
+ ? `### What was done\n\n${summary}\n\n`
81
+ : "";
82
+
78
83
  const prBody = `## Summary
79
84
 
80
85
  Closes #${issueNum}
81
86
 
82
- This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
87
+ ${summarySection}This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
83
88
 
84
89
  ### Issue: ${issue.title}
85
90
 
package/src/index.ts CHANGED
@@ -95,14 +95,16 @@ async function main() {
95
95
  return;
96
96
  }
97
97
 
98
- const repo = args[0];
99
- const issueNum = args[1];
98
+ const repo = args[0] as string;
99
+ const issueNum = args[1] as string;
100
100
 
101
- if (!/^\d+$/.test(issueNum)) {
101
+ if (!repo || !issueNum || !/^\d+$/.test(issueNum)) {
102
102
  log.die(`Invalid issue number: '${issueNum}'. Must be a positive integer.`);
103
103
  }
104
104
 
105
- const [owner, repoName] = repo.split("/");
105
+ const parts = repo.split("/");
106
+ const owner = parts[0] as string;
107
+ const repoName = parts[1] as string;
106
108
 
107
109
  if (!owner || !repoName) {
108
110
  log.die("Invalid repo format. Use: owner/repo");
@@ -133,8 +135,10 @@ async function main() {
133
135
  reposDir, owner, repoName, defaultBranch, issueNum, issue.title
134
136
  );
135
137
 
136
- // Build prompt
137
- const prompt = buildPrompt(repo, issueNum, issue);
138
+ // Build prompts
139
+ const planningPrompt = buildPlanningPrompt(repo, issueNum, issue);
140
+ const buildingPromptFn = (remaining: string[], plan: string) =>
141
+ buildBuildingPrompt(repo, issueNum, issue, remaining, plan);
138
142
 
139
143
  // Run Claude in sandbox
140
144
  log.info("Launching Claude Code in sandbox...");
@@ -142,10 +146,10 @@ async function main() {
142
146
  log.info(`Worktree: ${worktreeDir}`);
143
147
  console.log();
144
148
 
145
- await runClaude(SANDBOX_NAME, worktreeDir, prompt);
149
+ const summary = await runClaude(SANDBOX_NAME, worktreeDir, planningPrompt, buildingPromptFn);
146
150
 
147
151
  // Push and create PR
148
- pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
152
+ pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue, summary);
149
153
  }
150
154
 
151
155
  function getReposDir(): string {
@@ -197,7 +201,7 @@ function tryRecoverSandbox(reposDir: string): boolean {
197
201
  return true;
198
202
  }
199
203
 
200
- function buildPrompt(repo: string, issueNum: string, issue: { title: string; body: string; labels: string }): string {
204
+ function buildPlanningPrompt(repo: string, issueNum: string, issue: { title: string; body: string; labels: string }): string {
201
205
  return `You are working on GitHub issue #${issueNum} for the repository ${repo}.
202
206
 
203
207
  ## Issue: ${issue.title}
@@ -208,6 +212,52 @@ ${issue.body}
208
212
  ### Labels
209
213
  ${issue.labels}
210
214
 
215
+ ## Instructions
216
+
217
+ Your job in this step is to **plan only** — do NOT implement anything and do NOT commit.
218
+
219
+ 1. Read and understand the codebase structure thoroughly.
220
+ 2. Analyze the issue and break it down into small, atomic implementation tasks.
221
+ 3. Create a file called \`IMPLEMENTATION_PLAN.md\` in the root of the repository with a checklist of tasks.
222
+
223
+ The plan should:
224
+ - Have 3-8 tasks (fewer for simple issues, more for complex ones)
225
+ - Order tasks by dependency (implement foundations first)
226
+ - Each task should be a single, atomic unit of work that results in one commit
227
+ - Use markdown checklist format: \`- [ ] Task description\`
228
+
229
+ Example format:
230
+ \`\`\`markdown
231
+ # Implementation Plan for #${issueNum}
232
+
233
+ - [ ] Add the FooBar interface to src/types.ts
234
+ - [ ] Implement the FooBar service in src/services/foobar.ts
235
+ - [ ] Update the main handler to use FooBar service
236
+ - [ ] Add unit tests for FooBar service
237
+ \`\`\`
238
+
239
+ Do NOT implement any code changes. Do NOT make any commits. Only create the plan file.`;
240
+ }
241
+
242
+ function buildBuildingPrompt(
243
+ repo: string,
244
+ issueNum: string,
245
+ issue: { title: string; body: string; labels: string },
246
+ remainingItems: string[],
247
+ planContent: string,
248
+ ): string {
249
+ // Fallback: no plan — use original single-shot prompt
250
+ if (remainingItems.length === 0 && !planContent) {
251
+ return `You are working on GitHub issue #${issueNum} for the repository ${repo}.
252
+
253
+ ## Issue: ${issue.title}
254
+
255
+ ### Description
256
+ ${issue.body}
257
+
258
+ ### Labels
259
+ ${issue.labels}
260
+
211
261
  ## Instructions
212
262
  1. Read and understand the codebase structure first.
213
263
  2. Implement the changes described in the issue above.
@@ -218,6 +268,35 @@ ${issue.labels}
218
268
 
219
269
  When you are done, make a single commit (or a small, logical set of commits) with
220
270
  a message like: "fix: <short description> (#${issueNum})"`;
271
+ }
272
+
273
+ const currentTask = remainingItems[0]?.replace("- [ ] ", "") || "Complete remaining work";
274
+
275
+ return `You are working on GitHub issue #${issueNum} for the repository ${repo}.
276
+
277
+ ## Issue: ${issue.title}
278
+
279
+ ### Description
280
+ ${issue.body}
281
+
282
+ ### Labels
283
+ ${issue.labels}
284
+
285
+ ## Current Implementation Plan
286
+
287
+ ${planContent}
288
+
289
+ ## Instructions
290
+
291
+ Implement ONLY the following task:
292
+ **${currentTask}**
293
+
294
+ Rules:
295
+ 1. Implement ONLY this one task — do not work on other unchecked items.
296
+ 2. Write clean code that follows the existing project conventions.
297
+ 3. Update \`IMPLEMENTATION_PLAN.md\` to check off the completed item (change \`- [ ]\` to \`- [x]\`).
298
+ 4. Commit ALL changes (including the updated plan file) with a message like: "feat: ${currentTask.toLowerCase()} (#${issueNum})"
299
+ 5. Do NOT work on any other tasks after committing.`;
221
300
  }
222
301
 
223
302
  main().catch((err) => {
package/src/sandbox.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from "fs";
1
2
  import { log } from "./log";
2
3
 
3
4
  interface StreamEvent {
@@ -15,39 +16,159 @@ interface StreamEvent {
15
16
  is_error?: boolean;
16
17
  }
17
18
 
18
- const MAX_CONTINUATIONS = parseInt(process.env.MOG_MAX_CONTINUATIONS || "5", 10);
19
- const CONTINUE_PROMPT = `You stopped before finishing. The task is not done yet — there are no commits.
20
- Continue where you left off. Do NOT re-plan. Execute the implementation now and commit when done.`;
19
+ const MAX_ITERATIONS = parseInt(
20
+ process.env.MOG_MAX_ITERATIONS || process.env.MOG_MAX_CONTINUATIONS || "30",
21
+ 10,
22
+ );
23
+ const MAX_STALLS = 2;
24
+ const PLAN_FILENAME = "IMPLEMENTATION_PLAN.md";
21
25
 
22
- export async function runClaude(sandboxName: string, worktreeDir: string, prompt: string): Promise<void> {
23
- // Initial run
24
- await execClaude(sandboxName, worktreeDir, ["-p", prompt]);
25
-
26
- // Continue loop: if no commits were made, nudge Claude to keep going
27
- for (let i = 0; i < MAX_CONTINUATIONS; i++) {
28
- if (hasNewCommits(sandboxName, worktreeDir)) {
29
- return;
30
- }
31
- log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_CONTINUATIONS + 1})...`);
32
- await execClaude(sandboxName, worktreeDir, ["--continue", "-p", CONTINUE_PROMPT]);
26
+ export function readPlanFile(worktreeDir: string): string | null {
27
+ const planPath = `${worktreeDir}/${PLAN_FILENAME}`;
28
+ try {
29
+ return fs.readFileSync(planPath, "utf-8");
30
+ } catch {
31
+ return null;
33
32
  }
33
+ }
34
34
 
35
- if (!hasNewCommits(sandboxName, worktreeDir)) {
36
- log.warn("Claude did not produce any commits after all attempts.");
37
- }
35
+ export function getUncheckedItems(planContent: string): string[] {
36
+ const matches = planContent.match(/^- \[ \] .+$/gm);
37
+ return matches || [];
38
+ }
39
+
40
+ export function isPlanComplete(planContent: string): boolean {
41
+ const unchecked = getUncheckedItems(planContent);
42
+ const checked = planContent.match(/^- \[x\] .+$/gim);
43
+ return unchecked.length === 0 && (checked?.length ?? 0) > 0;
38
44
  }
39
45
 
40
- function hasNewCommits(sandboxName: string, worktreeDir: string): boolean {
46
+ function getCommitCount(sandboxName: string, worktreeDir: string): number {
41
47
  const result = Bun.spawnSync([
42
48
  "docker", "sandbox", "exec",
43
49
  "-w", worktreeDir,
44
50
  sandboxName,
45
- "git", "log", "--oneline", "HEAD", "--not", "--remotes", "-1",
51
+ "git", "rev-list", "HEAD", "--not", "--remotes", "--count",
52
+ ]);
53
+ if (result.exitCode !== 0) return 0;
54
+ return parseInt(result.stdout.toString().trim(), 10) || 0;
55
+ }
56
+
57
+ function cleanupPlanFile(sandboxName: string, worktreeDir: string): void {
58
+ const rmResult = Bun.spawnSync([
59
+ "docker", "sandbox", "exec",
60
+ "-w", worktreeDir,
61
+ sandboxName,
62
+ "git", "rm", "-f", PLAN_FILENAME,
63
+ ]);
64
+ if (rmResult.exitCode !== 0) return;
65
+
66
+ Bun.spawnSync([
67
+ "docker", "sandbox", "exec",
68
+ "-w", worktreeDir,
69
+ sandboxName,
70
+ "git", "commit", "-m", "chore: remove implementation plan",
46
71
  ]);
47
- return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
48
72
  }
49
73
 
50
- async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<void> {
74
+ export async function runClaude(
75
+ sandboxName: string,
76
+ worktreeDir: string,
77
+ planningPrompt: string,
78
+ buildingPromptFn: (remainingItems: string[], planContent: string) => string,
79
+ ): Promise<string> {
80
+ let lastResult = "";
81
+
82
+ // Phase 1 — Planning
83
+ log.info("Phase 1: Creating implementation plan...");
84
+ await execClaude(sandboxName, worktreeDir, ["-p", planningPrompt]);
85
+
86
+ const planContent = readPlanFile(worktreeDir);
87
+ const unchecked = planContent ? getUncheckedItems(planContent) : [];
88
+
89
+ // Fallback: no plan file or no checklist items — single-shot mode
90
+ if (!planContent || unchecked.length === 0) {
91
+ log.warn("No implementation plan created — falling back to single-shot mode.");
92
+ const fallbackPrompt = buildingPromptFn([], "");
93
+ const fallbackResult = await execClaude(sandboxName, worktreeDir, ["-p", fallbackPrompt]);
94
+ if (fallbackResult) lastResult = fallbackResult;
95
+
96
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
97
+ if (getCommitCount(sandboxName, worktreeDir) > 0) return lastResult;
98
+ log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_ITERATIONS + 1})...`);
99
+ const contResult = await execClaude(sandboxName, worktreeDir, [
100
+ "--continue", "-p",
101
+ "You stopped before finishing. The task is not done yet — there are no commits. Continue where you left off. Do NOT re-plan. Execute the implementation now and commit when done.",
102
+ ]);
103
+ if (contResult) lastResult = contResult;
104
+ }
105
+
106
+ if (getCommitCount(sandboxName, worktreeDir) === 0) {
107
+ log.warn("Claude did not produce any commits after all attempts.");
108
+ }
109
+ return lastResult;
110
+ }
111
+
112
+ log.ok(`Implementation plan created with ${unchecked.length} task(s).`);
113
+
114
+ // Phase 2 — Building loop
115
+ let stallCount = 0;
116
+
117
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
118
+ const currentPlan = readPlanFile(worktreeDir);
119
+ if (!currentPlan) {
120
+ log.warn("Plan file disappeared — stopping build loop.");
121
+ break;
122
+ }
123
+
124
+ const remaining = getUncheckedItems(currentPlan);
125
+ if (remaining.length === 0) {
126
+ log.ok("All plan items completed.");
127
+ break;
128
+ }
129
+
130
+ const commitsBefore = getCommitCount(sandboxName, worktreeDir);
131
+ const uncheckedBefore = remaining.length;
132
+
133
+ log.info(`Iteration ${i + 1}/${MAX_ITERATIONS}: ${remaining[0]!.replace("- [ ] ", "")}`);
134
+ log.info(`${remaining.length} task(s) remaining.`);
135
+
136
+ const buildResult = await execClaude(sandboxName, worktreeDir, ["-p", buildingPromptFn(remaining, currentPlan)]);
137
+ if (buildResult) lastResult = buildResult;
138
+
139
+ const planAfter = readPlanFile(worktreeDir);
140
+ const uncheckedAfter = planAfter ? getUncheckedItems(planAfter).length : 0;
141
+ const commitsAfter = getCommitCount(sandboxName, worktreeDir);
142
+
143
+ if (uncheckedAfter >= uncheckedBefore && commitsAfter <= commitsBefore) {
144
+ stallCount++;
145
+ log.warn(`No progress detected (stall ${stallCount}/${MAX_STALLS}).`);
146
+ if (stallCount >= MAX_STALLS) {
147
+ log.warn("Claude appears stuck — stopping build loop.");
148
+ break;
149
+ }
150
+ } else {
151
+ stallCount = 0;
152
+ }
153
+ }
154
+
155
+ // Phase 3 — Cleanup
156
+ cleanupPlanFile(sandboxName, worktreeDir);
157
+
158
+ const finalPlan = readPlanFile(worktreeDir);
159
+ if (finalPlan) {
160
+ const finalRemaining = getUncheckedItems(finalPlan);
161
+ if (finalRemaining.length > 0) {
162
+ log.warn(`${finalRemaining.length} task(s) were not completed.`);
163
+ }
164
+ } else {
165
+ log.ok("Plan file cleaned up.");
166
+ }
167
+
168
+ return lastResult;
169
+ }
170
+
171
+ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<string> {
51
172
  const proc = Bun.spawn([
52
173
  "docker", "sandbox", "exec",
53
174
  "-w", worktreeDir,
@@ -64,6 +185,7 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
64
185
  const reader = proc.stdout.getReader();
65
186
  const decoder = new TextDecoder();
66
187
  let buffer = "";
188
+ let resultText = "";
67
189
 
68
190
  while (true) {
69
191
  const { done, value } = await reader.read();
@@ -80,7 +202,8 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
80
202
 
81
203
  try {
82
204
  const event: StreamEvent = JSON.parse(line);
83
- printEvent(event);
205
+ const r = printEvent(event);
206
+ if (r) resultText = r;
84
207
  } catch {
85
208
  // Skip malformed JSON lines
86
209
  }
@@ -91,7 +214,8 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
91
214
  if (buffer.trim()) {
92
215
  try {
93
216
  const event: StreamEvent = JSON.parse(buffer);
94
- printEvent(event);
217
+ const r = printEvent(event);
218
+ if (r) resultText = r;
95
219
  } catch {
96
220
  // Skip
97
221
  }
@@ -106,9 +230,11 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
106
230
  }
107
231
  log.warn(`Claude Code exited with code ${exitCode}.`);
108
232
  }
233
+
234
+ return resultText;
109
235
  }
110
236
 
111
- function printEvent(event: StreamEvent): void {
237
+ function printEvent(event: StreamEvent): string | null {
112
238
  if (event.type === "assistant" && event.message?.content) {
113
239
  for (const block of event.message.content) {
114
240
  if (block.type === "text" && block.text) {
@@ -123,8 +249,10 @@ function printEvent(event: StreamEvent): void {
123
249
  log.err(event.result || "Unknown error");
124
250
  } else if (event.result) {
125
251
  log.done(event.result.slice(0, 200));
252
+ return event.result;
126
253
  }
127
254
  }
255
+ return null;
128
256
  }
129
257
 
130
258
  function getToolDetail(name: string, input?: Record<string, unknown>): string {
package/src/worktree.ts CHANGED
@@ -36,11 +36,9 @@ export function ensureRepo(
36
36
 
37
37
  const defaultBranch = branchProc.stdout.toString().trim();
38
38
 
39
- // Update the main repo to latest before creating worktrees
40
- log.info(`Updating ${repoDir} (${defaultBranch})...`);
39
+ // Fetch latest remote refs (no checkout/pull avoids conflicts with existing worktrees)
40
+ log.info(`Fetching latest from origin (${defaultBranch})...`);
41
41
  Bun.spawnSync(["git", "fetch", "origin", defaultBranch], { cwd: repoDir });
42
- Bun.spawnSync(["git", "checkout", defaultBranch], { cwd: repoDir, stdout: "ignore", stderr: "ignore" });
43
- Bun.spawnSync(["git", "pull", "origin", defaultBranch], { cwd: repoDir });
44
42
 
45
43
  return { defaultBranch };
46
44
  }
@@ -81,14 +79,17 @@ export function createWorktree(
81
79
  );
82
80
 
83
81
  if (result.exitCode !== 0) {
84
- // Try using existing branch
85
- const fallback = Bun.spawnSync(
86
- ["git", "worktree", "add", worktreeDir, branchName],
82
+ // Branch likely exists from a previous run — delete it and retry from origin
83
+ log.info(`Branch '${branchName}' already exists, recreating from origin/${defaultBranch}...`);
84
+ Bun.spawnSync(["git", "branch", "-D", branchName], { cwd: repoDir });
85
+
86
+ const retry = Bun.spawnSync(
87
+ ["git", "worktree", "add", "-b", branchName, worktreeDir, `origin/${defaultBranch}`],
87
88
  { cwd: repoDir }
88
89
  );
89
90
 
90
- if (fallback.exitCode !== 0) {
91
- log.die(`Failed to create worktree. Branch '${branchName}' may already exist.`);
91
+ if (retry.exitCode !== 0) {
92
+ log.die(`Failed to create worktree. Branch '${branchName}' may be in use by another worktree.`);
92
93
  }
93
94
  }
94
95