@bobbyg603/mog 0.1.3 → 1.0.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
@@ -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.1.3",
3
+ "version": "1.0.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",
@@ -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
@@ -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,19 +1,19 @@
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
 
10
9
  const SANDBOX_NAME = "mog";
10
+ const TEMPLATE_TAG = "mog-template:latest";
11
11
 
12
12
  async function init() {
13
13
  log.info("Initializing mog sandbox...");
14
14
 
15
15
  const reposDir = getReposDir();
16
- const exists = await sandboxExists(SANDBOX_NAME);
16
+ const exists = sandboxExists(SANDBOX_NAME);
17
17
 
18
18
  if (exists) {
19
19
  log.warn(`Sandbox '${SANDBOX_NAME}' already exists.`);
@@ -21,10 +21,10 @@ async function init() {
21
21
  } else {
22
22
  log.info(`Creating persistent sandbox '${SANDBOX_NAME}'...`);
23
23
  log.info(`Workspace: ${reposDir}`);
24
- const createResult = spawnSync("docker", ["sandbox", "create", "--name", SANDBOX_NAME, "claude", reposDir], {
25
- stdio: "inherit",
24
+ const createResult = Bun.spawnSync(["docker", "sandbox", "create", "--name", SANDBOX_NAME, "claude", reposDir], {
25
+ stdio: ["inherit", "inherit", "inherit"],
26
26
  });
27
- const createExit = createResult.status;
27
+ const createExit = createResult.exitCode;
28
28
  if (createExit !== 0) {
29
29
  log.die("Failed to create sandbox.");
30
30
  }
@@ -35,14 +35,25 @@ async function init() {
35
35
  console.log();
36
36
  }
37
37
 
38
- const runResult = spawnSync("docker", ["sandbox", "run", SANDBOX_NAME], {
39
- stdio: "inherit",
38
+ const runResult = Bun.spawnSync(["docker", "sandbox", "run", SANDBOX_NAME], {
39
+ stdio: ["inherit", "inherit", "inherit"],
40
40
  });
41
- const exitCode = runResult.status;
41
+ const exitCode = runResult.exitCode;
42
42
  if (exitCode !== 0) {
43
43
  log.die("Sandbox failed to run. Try 'docker sandbox ls' to check its status.");
44
44
  }
45
45
 
46
+ // Save sandbox as template so it can be restored after Docker restarts
47
+ log.info("Saving sandbox snapshot (preserves auth across Docker restarts)...");
48
+ const saveResult = Bun.spawnSync(["docker", "sandbox", "save", SANDBOX_NAME, TEMPLATE_TAG], {
49
+ stdio: ["inherit", "inherit", "inherit"],
50
+ });
51
+ if (saveResult.exitCode !== 0) {
52
+ log.warn("Failed to save sandbox snapshot. Auth may not persist across Docker restarts.");
53
+ } else {
54
+ log.ok("Snapshot saved.");
55
+ }
56
+
46
57
  log.ok("mog is ready. Run: mog <owner/repo> <issue_number>");
47
58
  }
48
59
 
@@ -57,10 +68,15 @@ async function main() {
57
68
  }
58
69
  }
59
70
 
60
- // Check docker sandbox is available
71
+ const reposDir = getReposDir();
72
+
73
+ // Check docker sandbox is available (may fail if sandbox state is stale after Docker restart)
61
74
  const sandboxCheck = Bun.spawnSync(["docker", "sandbox", "ls"]);
62
75
  if (sandboxCheck.exitCode !== 0) {
63
- log.die("Docker sandbox not available. Make sure Docker Desktop is running and up to date.");
76
+ const recovered = tryRecoverSandbox(reposDir);
77
+ if (!recovered) {
78
+ log.die("Docker sandbox not available. Make sure Docker Desktop is running and up to date.");
79
+ }
64
80
  }
65
81
 
66
82
  if (args[0] === "init") {
@@ -81,27 +97,39 @@ async function main() {
81
97
 
82
98
  const repo = args[0];
83
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
+
84
105
  const [owner, repoName] = repo.split("/");
85
106
 
86
107
  if (!owner || !repoName) {
87
108
  log.die("Invalid repo format. Use: owner/repo");
88
109
  }
89
110
 
90
- // Verify sandbox exists
91
- if (!(await sandboxExists(SANDBOX_NAME))) {
92
- log.die(`Sandbox '${SANDBOX_NAME}' not found. Run 'mog init' first.`);
111
+ // Verify sandbox exists, try to restore from template if missing
112
+ if (!sandboxExists(SANDBOX_NAME)) {
113
+ if (!templateExists()) {
114
+ log.die(`Sandbox '${SANDBOX_NAME}' not found. Run 'mog init' first.`);
115
+ }
116
+ log.info("Sandbox missing — restoring from saved snapshot...");
117
+ const restored = restoreSandboxFromTemplate(SANDBOX_NAME, reposDir);
118
+ if (!restored) {
119
+ log.die("Failed to restore sandbox from snapshot. Run 'mog init' to recreate.");
120
+ }
121
+ log.ok("Sandbox restored from snapshot (auth preserved).");
93
122
  }
94
123
 
95
124
  // Fetch issue
96
- const issue = await fetchIssue(repo, issueNum);
125
+ const issue = fetchIssue(repo, issueNum);
97
126
  log.ok(`Issue: ${issue.title}`);
98
127
 
99
128
  // Ensure repo & worktree
100
- const reposDir = getReposDir();
101
- const { defaultBranch } = await ensureRepo(repo, owner, repoName, reposDir);
129
+ const { defaultBranch } = ensureRepo(repo, owner, repoName, reposDir);
102
130
  log.info(`Default branch: ${defaultBranch}`);
103
131
 
104
- const { worktreeDir, branchName } = await createWorktree(
132
+ const { worktreeDir, branchName } = createWorktree(
105
133
  reposDir, owner, repoName, defaultBranch, issueNum, issue.title
106
134
  );
107
135
 
@@ -117,25 +145,56 @@ async function main() {
117
145
  await runClaude(SANDBOX_NAME, worktreeDir, prompt);
118
146
 
119
147
  // Push and create PR
120
- await pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
148
+ pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
121
149
  }
122
150
 
123
151
  function getReposDir(): string {
124
152
  return process.env.MOG_REPOS_DIR || `${process.env.HOME}/mog-repos`;
125
153
  }
126
154
 
127
- async function sandboxExists(name: string): Promise<boolean> {
155
+ function sandboxExists(name: string): boolean {
128
156
  const result = Bun.spawnSync(["docker", "sandbox", "ls"]);
129
- const output = result.stdout.toString();
130
- return output.includes(name);
157
+ return result.stdout.toString().split("\n").some(line => line.split(/\s+/)[0] === name);
158
+ }
159
+
160
+ function templateExists(): boolean {
161
+ const result = Bun.spawnSync(["docker", "image", "inspect", TEMPLATE_TAG]);
162
+ return result.exitCode === 0;
163
+ }
164
+
165
+ function restoreSandboxFromTemplate(name: string, reposDir: string): boolean {
166
+ const create = Bun.spawnSync(["docker", "sandbox", "create", "--template", TEMPLATE_TAG, "--name", name, "claude", reposDir], {
167
+ stdio: ["inherit", "inherit", "inherit"],
168
+ });
169
+ return create.exitCode === 0;
131
170
  }
132
171
 
133
- async function run(cmd: string[]): Promise<string> {
134
- const proc = Bun.spawnSync(cmd);
135
- if (proc.exitCode !== 0) {
136
- throw new Error(`Command failed: ${cmd.join(" ")}\n${proc.stderr.toString()}`);
172
+ function tryRecoverSandbox(reposDir: string): boolean {
173
+ log.warn("Docker sandbox state is stale — attempting recovery...");
174
+
175
+ // Clean up stale sandbox
176
+ Bun.spawnSync(["docker", "sandbox", "rm", SANDBOX_NAME], { stdio: ["ignore", "ignore", "ignore"] });
177
+
178
+ // Check if docker sandbox ls works now
179
+ const check = Bun.spawnSync(["docker", "sandbox", "ls"]);
180
+ if (check.exitCode !== 0) {
181
+ // docker sandbox itself is broken, not just stale state
182
+ return false;
183
+ }
184
+
185
+ // If we have a saved template, restore from it
186
+ if (templateExists()) {
187
+ log.info("Restoring sandbox from saved snapshot...");
188
+ const restored = restoreSandboxFromTemplate(SANDBOX_NAME, reposDir);
189
+ if (restored) {
190
+ log.ok("Sandbox restored from snapshot (auth preserved).");
191
+ return true;
192
+ }
137
193
  }
138
- return proc.stdout.toString().trim();
194
+
195
+ // Recovered docker sandbox command but no template — user needs to mog init
196
+ log.warn("No saved snapshot found. Run 'mog init' to set up the sandbox.");
197
+ return true;
139
198
  }
140
199
 
141
200
  function buildPrompt(repo: string, issueNum: string, issue: { title: string; body: string; labels: string }): string {
package/src/sandbox.ts CHANGED
@@ -15,28 +15,46 @@ interface StreamEvent {
15
15
  is_error?: boolean;
16
16
  }
17
17
 
18
- export async function ensureSandbox(name: string, reposDir: string): Promise<void> {
19
- const ls = Bun.spawnSync(["docker", "sandbox", "ls"]);
20
- if (ls.stdout.toString().includes(name)) {
21
- return;
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]);
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]);
22
33
  }
23
34
 
24
- log.info(`Creating persistent sandbox '${name}'...`);
25
- const create = Bun.spawnSync(["docker", "sandbox", "create", "--name", name, "claude", reposDir]);
26
- if (create.exitCode !== 0) {
27
- 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.");
28
37
  }
29
- log.ok("Sandbox created.");
30
38
  }
31
39
 
32
- 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> {
33
51
  const proc = Bun.spawn([
34
52
  "docker", "sandbox", "exec",
35
53
  "-w", worktreeDir,
36
54
  sandboxName,
37
55
  "claude", "--dangerously-skip-permissions",
38
56
  "--verbose", "--output-format", "stream-json",
39
- "-p", prompt,
57
+ ...claudeArgs,
40
58
  ], {
41
59
  stdout: "pipe",
42
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 };