@bobbyg603/mog 0.2.0 → 1.0.1

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
@@ -91,6 +91,7 @@ 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
95
 
95
96
  ## Worktree management
96
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobbyg603/mog",
3
- "version": "0.2.0",
3
+ "version": "1.0.1",
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",
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://github.com/workingdevshero/mog.git"
15
+ "url": "https://github.com/bobbyg603/mog.git"
16
16
  },
17
17
  "keywords": [
18
18
  "claude",
@@ -22,6 +22,9 @@
22
22
  "docker",
23
23
  "sandbox"
24
24
  ],
25
+ "scripts": {
26
+ "dev:reset": "docker sandbox rm mog && docker rmi mog-template:latest 2>/dev/null; echo 'Sandbox removed. Run mog init to recreate.'"
27
+ },
25
28
  "license": "MIT",
26
29
  "devDependencies": {
27
30
  "@types/bun": "latest"
package/src/github.ts CHANGED
@@ -6,7 +6,7 @@ export interface Issue {
6
6
  labels: string;
7
7
  }
8
8
 
9
- export async function fetchIssue(repo: string, issueNum: string): Promise<Issue> {
9
+ export function fetchIssue(repo: string, issueNum: string): Issue {
10
10
  log.info(`Fetching issue #${issueNum} from ${repo}...`);
11
11
 
12
12
  const proc = Bun.spawnSync([
@@ -28,14 +28,14 @@ export async function fetchIssue(repo: string, issueNum: string): Promise<Issue>
28
28
  };
29
29
  }
30
30
 
31
- export async function pushAndCreatePR(
31
+ export function pushAndCreatePR(
32
32
  repo: string,
33
33
  worktreeDir: string,
34
34
  branchName: string,
35
35
  defaultBranch: string,
36
36
  issueNum: string,
37
37
  issue: Issue
38
- ) {
38
+ ): void {
39
39
  // Check for unpushed commits or uncommitted changes
40
40
  const unpushed = Bun.spawnSync(["git", "log", `origin/${defaultBranch}..HEAD`, "--oneline"], { cwd: worktreeDir });
41
41
  const diffCheck = Bun.spawnSync(["git", "diff", "--quiet"], { cwd: worktreeDir });
@@ -53,8 +53,15 @@ export async function pushAndCreatePR(
53
53
  // Stage any unstaged changes Claude might have left
54
54
  if (hasUncommitted) {
55
55
  log.info("Staging uncommitted changes...");
56
- Bun.spawnSync(["git", "add", "-A"], { cwd: worktreeDir });
57
- Bun.spawnSync(["git", "commit", "-m", `fix: address issue #${issueNum} - ${issue.title}`], { cwd: worktreeDir });
56
+ const addResult = Bun.spawnSync(["git", "add", "-A"], { cwd: worktreeDir });
57
+ if (addResult.exitCode !== 0) {
58
+ log.die("Failed to stage changes.");
59
+ }
60
+ const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
61
+ const commitResult = Bun.spawnSync(["git", "commit", "-m", `${prefix}: address issue #${issueNum} - ${issue.title}`], { cwd: worktreeDir });
62
+ if (commitResult.exitCode !== 0) {
63
+ log.warn("Commit failed — changes may already be committed.");
64
+ }
58
65
  }
59
66
 
60
67
  // Push
@@ -72,7 +79,7 @@ export async function pushAndCreatePR(
72
79
 
73
80
  Closes #${issueNum}
74
81
 
75
- This PR was generated by [mog](https://github.com/workingdevshero/mog) using Claude Code in a Docker sandbox.
82
+ This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
76
83
 
77
84
  ### Issue: ${issue.title}
78
85
 
@@ -81,7 +88,8 @@ ${issue.body}
81
88
  ---
82
89
  *Please review the changes carefully before merging.*`;
83
90
 
84
- const prTitle = `fix: ${issue.title} [#${issueNum}]`;
91
+ const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
92
+ const prTitle = `${prefix}: ${issue.title} [#${issueNum}]`;
85
93
 
86
94
  const pr = Bun.spawnSync([
87
95
  "gh", "pr", "create",
package/src/index.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { spawnSync } from "child_process";
4
3
  import { fetchIssue } from "./github";
5
4
  import { ensureRepo, createWorktree } from "./worktree";
6
- import { ensureSandbox, runClaude } from "./sandbox";
5
+ import { runClaude } from "./sandbox";
7
6
  import { pushAndCreatePR } from "./github";
8
7
  import { log } from "./log";
9
8
 
@@ -14,7 +13,7 @@ async function init() {
14
13
  log.info("Initializing mog sandbox...");
15
14
 
16
15
  const reposDir = getReposDir();
17
- const exists = await sandboxExists(SANDBOX_NAME);
16
+ const exists = sandboxExists(SANDBOX_NAME);
18
17
 
19
18
  if (exists) {
20
19
  log.warn(`Sandbox '${SANDBOX_NAME}' already exists.`);
@@ -22,10 +21,10 @@ async function init() {
22
21
  } else {
23
22
  log.info(`Creating persistent sandbox '${SANDBOX_NAME}'...`);
24
23
  log.info(`Workspace: ${reposDir}`);
25
- const createResult = spawnSync("docker", ["sandbox", "create", "--name", SANDBOX_NAME, "claude", reposDir], {
26
- stdio: "inherit",
24
+ const createResult = Bun.spawnSync(["docker", "sandbox", "create", "--name", SANDBOX_NAME, "claude", reposDir], {
25
+ stdio: ["inherit", "inherit", "inherit"],
27
26
  });
28
- const createExit = createResult.status;
27
+ const createExit = createResult.exitCode;
29
28
  if (createExit !== 0) {
30
29
  log.die("Failed to create sandbox.");
31
30
  }
@@ -36,20 +35,20 @@ async function init() {
36
35
  console.log();
37
36
  }
38
37
 
39
- const runResult = spawnSync("docker", ["sandbox", "run", SANDBOX_NAME], {
40
- stdio: "inherit",
38
+ const runResult = Bun.spawnSync(["docker", "sandbox", "run", SANDBOX_NAME], {
39
+ stdio: ["inherit", "inherit", "inherit"],
41
40
  });
42
- const exitCode = runResult.status;
41
+ const exitCode = runResult.exitCode;
43
42
  if (exitCode !== 0) {
44
43
  log.die("Sandbox failed to run. Try 'docker sandbox ls' to check its status.");
45
44
  }
46
45
 
47
46
  // Save sandbox as template so it can be restored after Docker restarts
48
47
  log.info("Saving sandbox snapshot (preserves auth across Docker restarts)...");
49
- const saveResult = spawnSync("docker", ["sandbox", "save", SANDBOX_NAME, TEMPLATE_TAG], {
50
- stdio: "inherit",
48
+ const saveResult = Bun.spawnSync(["docker", "sandbox", "save", SANDBOX_NAME, TEMPLATE_TAG], {
49
+ stdio: ["inherit", "inherit", "inherit"],
51
50
  });
52
- if (saveResult.status !== 0) {
51
+ if (saveResult.exitCode !== 0) {
53
52
  log.warn("Failed to save sandbox snapshot. Auth may not persist across Docker restarts.");
54
53
  } else {
55
54
  log.ok("Snapshot saved.");
@@ -69,10 +68,12 @@ async function main() {
69
68
  }
70
69
  }
71
70
 
71
+ const reposDir = getReposDir();
72
+
72
73
  // Check docker sandbox is available (may fail if sandbox state is stale after Docker restart)
73
74
  const sandboxCheck = Bun.spawnSync(["docker", "sandbox", "ls"]);
74
75
  if (sandboxCheck.exitCode !== 0) {
75
- const recovered = tryRecoverSandbox(getReposDir());
76
+ const recovered = tryRecoverSandbox(reposDir);
76
77
  if (!recovered) {
77
78
  log.die("Docker sandbox not available. Make sure Docker Desktop is running and up to date.");
78
79
  }
@@ -96,6 +97,11 @@ async function main() {
96
97
 
97
98
  const repo = args[0];
98
99
  const issueNum = args[1];
100
+
101
+ if (!/^\d+$/.test(issueNum)) {
102
+ log.die(`Invalid issue number: '${issueNum}'. Must be a positive integer.`);
103
+ }
104
+
99
105
  const [owner, repoName] = repo.split("/");
100
106
 
101
107
  if (!owner || !repoName) {
@@ -103,12 +109,11 @@ async function main() {
103
109
  }
104
110
 
105
111
  // Verify sandbox exists, try to restore from template if missing
106
- if (!(await sandboxExists(SANDBOX_NAME))) {
112
+ if (!sandboxExists(SANDBOX_NAME)) {
107
113
  if (!templateExists()) {
108
114
  log.die(`Sandbox '${SANDBOX_NAME}' not found. Run 'mog init' first.`);
109
115
  }
110
116
  log.info("Sandbox missing — restoring from saved snapshot...");
111
- const reposDir = getReposDir();
112
117
  const restored = restoreSandboxFromTemplate(SANDBOX_NAME, reposDir);
113
118
  if (!restored) {
114
119
  log.die("Failed to restore sandbox from snapshot. Run 'mog init' to recreate.");
@@ -117,15 +122,14 @@ async function main() {
117
122
  }
118
123
 
119
124
  // Fetch issue
120
- const issue = await fetchIssue(repo, issueNum);
125
+ const issue = fetchIssue(repo, issueNum);
121
126
  log.ok(`Issue: ${issue.title}`);
122
127
 
123
128
  // Ensure repo & worktree
124
- const reposDir = getReposDir();
125
- const { defaultBranch } = await ensureRepo(repo, owner, repoName, reposDir);
129
+ const { defaultBranch } = ensureRepo(repo, owner, repoName, reposDir);
126
130
  log.info(`Default branch: ${defaultBranch}`);
127
131
 
128
- const { worktreeDir, branchName } = await createWorktree(
132
+ const { worktreeDir, branchName } = createWorktree(
129
133
  reposDir, owner, repoName, defaultBranch, issueNum, issue.title
130
134
  );
131
135
 
@@ -141,17 +145,16 @@ async function main() {
141
145
  await runClaude(SANDBOX_NAME, worktreeDir, prompt);
142
146
 
143
147
  // Push and create PR
144
- await pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
148
+ pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
145
149
  }
146
150
 
147
151
  function getReposDir(): string {
148
152
  return process.env.MOG_REPOS_DIR || `${process.env.HOME}/mog-repos`;
149
153
  }
150
154
 
151
- async function sandboxExists(name: string): Promise<boolean> {
155
+ function sandboxExists(name: string): boolean {
152
156
  const result = Bun.spawnSync(["docker", "sandbox", "ls"]);
153
- const output = result.stdout.toString();
154
- return output.includes(name);
157
+ return result.stdout.toString().split("\n").some(line => line.split(/\s+/)[0] === name);
155
158
  }
156
159
 
157
160
  function templateExists(): boolean {
@@ -160,17 +163,17 @@ function templateExists(): boolean {
160
163
  }
161
164
 
162
165
  function restoreSandboxFromTemplate(name: string, reposDir: string): boolean {
163
- const create = spawnSync("docker", ["sandbox", "create", "--template", TEMPLATE_TAG, "--name", name, "claude", reposDir], {
164
- stdio: "inherit",
166
+ const create = Bun.spawnSync(["docker", "sandbox", "create", "--template", TEMPLATE_TAG, "--name", name, "claude", reposDir], {
167
+ stdio: ["inherit", "inherit", "inherit"],
165
168
  });
166
- return create.status === 0;
169
+ return create.exitCode === 0;
167
170
  }
168
171
 
169
172
  function tryRecoverSandbox(reposDir: string): boolean {
170
173
  log.warn("Docker sandbox state is stale — attempting recovery...");
171
174
 
172
175
  // Clean up stale sandbox
173
- spawnSync("docker", ["sandbox", "rm", SANDBOX_NAME], { stdio: "ignore" });
176
+ Bun.spawnSync(["docker", "sandbox", "rm", SANDBOX_NAME], { stdio: ["ignore", "ignore", "ignore"] });
174
177
 
175
178
  // Check if docker sandbox ls works now
176
179
  const check = Bun.spawnSync(["docker", "sandbox", "ls"]);
@@ -194,14 +197,6 @@ function tryRecoverSandbox(reposDir: string): boolean {
194
197
  return true;
195
198
  }
196
199
 
197
- async function run(cmd: string[]): Promise<string> {
198
- const proc = Bun.spawnSync(cmd);
199
- if (proc.exitCode !== 0) {
200
- throw new Error(`Command failed: ${cmd.join(" ")}\n${proc.stderr.toString()}`);
201
- }
202
- return proc.stdout.toString().trim();
203
- }
204
-
205
200
  function buildPrompt(repo: string, issueNum: string, issue: { title: string; body: string; labels: string }): string {
206
201
  return `You are working on GitHub issue #${issueNum} for the repository ${repo}.
207
202
 
package/src/sandbox.ts CHANGED
@@ -15,39 +15,46 @@ interface StreamEvent {
15
15
  is_error?: boolean;
16
16
  }
17
17
 
18
- export async function ensureSandbox(name: string, reposDir: string, templateTag?: string): Promise<void> {
19
- const ls = Bun.spawnSync(["docker", "sandbox", "ls"]);
20
- if (ls.stdout.toString().includes(name)) {
21
- return;
22
- }
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.`;
21
+
22
+ export async function runClaude(sandboxName: string, worktreeDir: string, prompt: string): Promise<void> {
23
+ // Initial run
24
+ await execClaude(sandboxName, worktreeDir, ["-p", prompt]);
23
25
 
24
- // Try to restore from template if available
25
- const createArgs = ["sandbox", "create"];
26
- if (templateTag) {
27
- const inspect = Bun.spawnSync(["docker", "image", "inspect", templateTag]);
28
- if (inspect.exitCode === 0) {
29
- log.info(`Restoring sandbox '${name}' from saved snapshot...`);
30
- createArgs.push("--template", templateTag);
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;
31
30
  }
31
+ log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_CONTINUATIONS + 1})...`);
32
+ await execClaude(sandboxName, worktreeDir, ["--continue", "-p", CONTINUE_PROMPT]);
32
33
  }
33
- createArgs.push("--name", name, "claude", reposDir);
34
34
 
35
- log.info(`Creating persistent sandbox '${name}'...`);
36
- const create = Bun.spawnSync(["docker", ...createArgs]);
37
- if (create.exitCode !== 0) {
38
- log.die(`Failed to create sandbox: ${create.stderr.toString()}`);
35
+ if (!hasNewCommits(sandboxName, worktreeDir)) {
36
+ log.warn("Claude did not produce any commits after all attempts.");
39
37
  }
40
- log.ok("Sandbox created.");
41
38
  }
42
39
 
43
- export async function runClaude(sandboxName: string, worktreeDir: string, prompt: string): Promise<void> {
40
+ function hasNewCommits(sandboxName: string, worktreeDir: string): boolean {
41
+ const result = Bun.spawnSync([
42
+ "docker", "sandbox", "exec",
43
+ "-w", worktreeDir,
44
+ sandboxName,
45
+ "git", "log", "--oneline", "HEAD", "--not", "--remotes", "-1",
46
+ ]);
47
+ return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
48
+ }
49
+
50
+ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<void> {
44
51
  const proc = Bun.spawn([
45
52
  "docker", "sandbox", "exec",
46
53
  "-w", worktreeDir,
47
54
  sandboxName,
48
55
  "claude", "--dangerously-skip-permissions",
49
56
  "--verbose", "--output-format", "stream-json",
50
- "-p", prompt,
57
+ ...claudeArgs,
51
58
  ], {
52
59
  stdout: "pipe",
53
60
  stderr: "pipe",
package/src/worktree.ts CHANGED
@@ -1,13 +1,13 @@
1
+ import fs from "fs";
1
2
  import { log } from "./log";
2
3
 
3
- export async function ensureRepo(
4
+ export function ensureRepo(
4
5
  repo: string,
5
6
  owner: string,
6
7
  repoName: string,
7
8
  reposDir: string
8
- ): Promise<{ defaultBranch: string }> {
9
+ ): { defaultBranch: string } {
9
10
  const repoDir = `${reposDir}/${owner}/${repoName}`;
10
- const fs = await import("fs");
11
11
 
12
12
  if (!fs.existsSync(repoDir)) {
13
13
  log.info(`Cloning ${repo} into ${repoDir}...`);
@@ -37,14 +37,14 @@ export async function ensureRepo(
37
37
  return { defaultBranch: branchProc.stdout.toString().trim() };
38
38
  }
39
39
 
40
- export async function createWorktree(
40
+ export function createWorktree(
41
41
  reposDir: string,
42
42
  owner: string,
43
43
  repoName: string,
44
44
  defaultBranch: string,
45
45
  issueNum: string,
46
46
  issueTitle: string
47
- ): Promise<{ worktreeDir: string; branchName: string }> {
47
+ ): { worktreeDir: string; branchName: string } {
48
48
  const safeTitle = issueTitle
49
49
  .toLowerCase()
50
50
  .replace(/[^a-z0-9]/g, "-")
@@ -56,8 +56,6 @@ export async function createWorktree(
56
56
  const repoDir = `${reposDir}/${owner}/${repoName}`;
57
57
  const worktreeDir = `${reposDir}/${owner}/${repoName}-worktrees/${branchName}`;
58
58
 
59
- const fs = await import("fs");
60
-
61
59
  if (fs.existsSync(worktreeDir)) {
62
60
  log.warn(`Worktree already exists at ${worktreeDir}, reusing.`);
63
61
  return { worktreeDir, branchName };