@boopkit/cli 0.4.0 → 0.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.
@@ -111,6 +111,13 @@
111
111
  "web"
112
112
  ]
113
113
  },
114
+ {
115
+ "name": "ux",
116
+ "description": "UX design judgement and the discovery/delivery workflows that operate it",
117
+ "type": "development",
118
+ "package": "@boopkit/cap-ux",
119
+ "requires": []
120
+ },
114
121
  {
115
122
  "name": "web",
116
123
  "description": "Web UI with Handlebars + Tailwind",
@@ -1,18 +1,19 @@
1
1
  {
2
- "@boopkit/cap-api": "0.4.0",
2
+ "@boopkit/cap-api": "0.4.1",
3
3
  "@boopkit/cap-background-jobs": "0.4.0",
4
4
  "@boopkit/cap-cloud-storage": "0.4.0",
5
- "@boopkit/cap-coding": "0.4.0",
6
- "@boopkit/cap-database": "0.4.0",
7
- "@boopkit/cap-email": "0.4.0",
5
+ "@boopkit/cap-coding": "0.4.1",
6
+ "@boopkit/cap-database": "0.4.3",
7
+ "@boopkit/cap-email": "0.4.1",
8
8
  "@boopkit/cap-hello": "0.4.0",
9
9
  "@boopkit/cap-noticeboard": "0.4.0",
10
- "@boopkit/cap-product-design": "0.4.0",
11
- "@boopkit/cap-project-mgmt": "0.4.0",
12
- "@boopkit/cap-services": "0.4.0",
13
- "@boopkit/cap-skills": "0.4.0",
14
- "@boopkit/cap-testing": "0.4.0",
15
- "@boopkit/cap-users": "0.4.0",
16
- "@boopkit/cap-web": "0.4.0",
10
+ "@boopkit/cap-product-design": "0.6.1",
11
+ "@boopkit/cap-project-mgmt": "0.4.1",
12
+ "@boopkit/cap-services": "0.4.2",
13
+ "@boopkit/cap-skills": "0.5.1",
14
+ "@boopkit/cap-testing": "0.5.0",
15
+ "@boopkit/cap-users": "0.4.2",
16
+ "@boopkit/cap-ux": "0.2.0",
17
+ "@boopkit/cap-web": "0.5.0",
17
18
  "@boopkit/logger": "0.4.0"
18
19
  }
package/dist/cli.js CHANGED
@@ -44,6 +44,7 @@ const add_1 = require("./commands/add");
44
44
  const adopt_1 = require("./commands/adopt");
45
45
  const update_1 = require("./commands/update");
46
46
  const loop_1 = require("./commands/loop");
47
+ const loop_merge_1 = require("./commands/loop-merge");
47
48
  const pkgJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
48
49
  const program = new commander_1.Command();
49
50
  program
@@ -101,23 +102,43 @@ program
101
102
  .action(adopt_1.runAdopt);
102
103
  program
103
104
  .command('loop')
104
- .description('Continuously process ready beads issues via Claude Code sessions')
105
- .option('--prefix <prefix>', 'Issue ID prefix to filter by')
106
- .option('--work <path>', 'Spec file for planning phase')
107
- .option('--flow <path>', 'Workflow file for planning phase')
108
- .option('--run', 'Actually execute (dry-run by default)')
109
- .option('--max <n>', 'Stop after N issues')
110
- .option('--budget <amount>', 'Budget cap per issue in USD (default: 5.00)')
111
- .option('--resume-limit <n>', 'Max resume attempts per issue (default: 3)')
112
- .option('--poll', 'Keep polling when no ready issues found')
113
- .option('--poll-interval <seconds>', 'Seconds between polls (default: 60)')
114
- .option('--model <model>', 'Claude model to use (default: claude-opus-4-6)')
115
- .option('--per-issue-branch', 'Create a separate branch per issue')
116
- .option('--no-branch', 'Skip branch creation entirely')
117
- .option('--features', 'Include feature-type issues (excluded by default)')
118
- .option('--no-skip-perms', 'Do not skip permission prompts')
119
- .option('--bare', 'Force API key auth (--bare mode); skips OAuth/keychain, uses ANTHROPIC_API_KEY strictly')
120
- .option('--json', 'Output summary as JSON')
121
- .addHelpText('after', '\nBy default runs in dry-run mode. Add --run to execute.')
122
- .action(loop_1.runLoop);
105
+ .summary('Drive Claude Code sessions to build work autonomously')
106
+ .description('Drive Claude Code sessions to build work autonomously, in one of three modes:\n' +
107
+ ' spec-driven --work <spec> --flow <flow> plan a feature tree from a spec, then build every task\n' +
108
+ ' issue-driven --prefix <issue-id> process ready beads issues in scope: ID starts with the prefix, or any parent-chain ancestor ID does\n' +
109
+ ' merge --merge [branch] integrate completed feature worktrees into main')
110
+ // — Mode selection (pick exactly one mode) —
111
+ .option('--work <path>', 'spec-driven: spec/work file to plan from (requires --flow)')
112
+ .option('--flow <path>', 'spec-driven: workflow file or skill dir that drives planning (requires --work)')
113
+ .option('--prefix <prefix>', 'issue-driven: scope of issues to process — in scope if the issue ID starts with this prefix OR any ancestor (parent chain) ID does, so issues re-parented under a matching epic/feature are included even with unrelated IDs. Pass a full issue ID; a truncated one over-matches (boop-4 also matches boop-45j)')
114
+ .option('--merge [branch]', 'merge: integrate completed feature worktrees into main (optional single branch skips readiness)')
115
+ // — Mode-specific options —
116
+ .option('--features', 'issue-driven only: also process leaf feature-type issues (excluded by default; always on in spec-driven mode). Feature roots that own child tasks are never claimed directly — they are containers, not work.')
117
+ .option('--watch', 'merge only: poll for ready branches and merge them as they land')
118
+ // — Shared execution options —
119
+ .option('--run', 'actually execute (dry-run by default)')
120
+ .option('--max <n>', 'stop after N issues')
121
+ .option('--budget <amount>', `budget cap per issue in USD (default: ${loop_1.DEFAULT_BUDGET_USD})`)
122
+ .option('--model <model>', 'Claude model to use, applied to every spawned session and nested loop (falls back to $ANTHROPIC_MODEL, then claude-opus-4-8). Sub-agents keep their own declared model unless they opt into model: inherit.')
123
+ .option('--poll', 'keep polling when no ready issues found')
124
+ .option('--poll-interval <seconds>', 'seconds between polls (default: 60)')
125
+ .option('--resume-limit <n>', 'max resume attempts per issue (default: 3)')
126
+ .option('--per-issue-branch', 'create a separate branch per issue')
127
+ .option('--no-branch', 'skip branch/worktree creation; run in place on the current branch')
128
+ .option('--no-skip-perms', 'do not skip permission prompts')
129
+ .option('--bare', 'force API key auth; skip OAuth/keychain, use ANTHROPIC_API_KEY strictly')
130
+ .option('--json', 'output summary as JSON')
131
+ .addHelpText('after', [
132
+ '',
133
+ 'Examples:',
134
+ ' boop loop --work specs/feature.spec.md --flow skills/my-flow/SKILL.md --run',
135
+ ' boop loop --prefix boop-abc --run --features',
136
+ ' boop loop --merge --run --watch',
137
+ '',
138
+ 'Notes:',
139
+ ' • Dry-run by default; add --run to execute.',
140
+ ' • --work and --flow must be used together; --features and --watch only apply to their own mode.',
141
+ ' • Merge mode rebases ready work/<id> branches onto main, runs tests, ff-merges, and cleans up worktrees.',
142
+ ].join('\n'))
143
+ .action((options) => (options.merge ? (0, loop_merge_1.runMerge)(options) : (0, loop_1.runLoop)(options)));
123
144
  program.parse(process.argv);
@@ -0,0 +1,83 @@
1
+ import { LoopOptions, WorktreeMode } from './loop';
2
+ /**
3
+ * Slice 2 — `boop loop --merge`.
4
+ *
5
+ * Sequential integration of completed feature worktrees back into main. No
6
+ * individual loop merges its own branch; this is the dedicated integrator.
7
+ *
8
+ * For each ready branch (sequential, failure-isolated):
9
+ * record orig sha → rebase work/<id> onto main → npm test → ff-merge into main
10
+ * → remove worktree + branch.
11
+ *
12
+ * Safety contract: main is NEVER left dirty. A rebase conflict aborts the rebase;
13
+ * a post-rebase test failure resets the branch to its original sha; either way we
14
+ * file a beads issue and skip to the next branch. Only `package.json` dependency
15
+ * additions are auto-resolved (the one documented known-safe pattern); every other
16
+ * conflict punts to a human via a beads issue.
17
+ */
18
+ export interface WorktreeMeta {
19
+ version: number;
20
+ mode: WorktreeMode;
21
+ scope: string;
22
+ branch: string;
23
+ worktreePath: string;
24
+ }
25
+ export interface MergeCandidate {
26
+ branch: string;
27
+ worktreePath: string;
28
+ scope: string;
29
+ mode: WorktreeMode;
30
+ ready: boolean;
31
+ }
32
+ interface ScopeIssue {
33
+ id: string;
34
+ parent?: string;
35
+ status?: string;
36
+ }
37
+ export interface MergeDeps {
38
+ projectDir?: string;
39
+ execSyncFn?: (cmd: string, opts?: {
40
+ cwd?: string;
41
+ stdio?: unknown;
42
+ }) => string;
43
+ existsSyncFn?: (p: string) => boolean;
44
+ readFileFn?: (p: string) => string;
45
+ /** Returns beads issues at the given status (`bd list --status=<s> --json`). */
46
+ bdListFn?: (status: 'open' | 'closed') => ScopeIssue[];
47
+ /** Parent-id lookup for ancestors not present in the listed set. */
48
+ getParentFn?: (id: string) => string | null;
49
+ /** File a beads issue on merge failure. Returns the created issue id (or null). */
50
+ createIssueFn?: (title: string, description: string) => string | null;
51
+ /** Best-effort sleep between watch polls. */
52
+ sleepFn?: (ms: number) => Promise<void>;
53
+ /** Dolt server-mode guard (defaults to the real `assertDoltServerMode`). */
54
+ assertServerModeFn?: (projectDir: string) => void;
55
+ }
56
+ /**
57
+ * Pick the higher of two version specifiers for the same dependency key. Ties and
58
+ * unparseable specifiers prefer `ours` (the integration/main side) for stability.
59
+ */
60
+ export declare function pickHigherVersion(ours: string, theirs: string): string;
61
+ /**
62
+ * Union-merge two package.json sides that conflicted during a rebase. Dependency
63
+ * maps are unioned (same-key version conflict → higher semver). Every other
64
+ * top-level key must already agree between the two sides; if any non-dependency
65
+ * key differs, the conflict is NOT known-safe and this throws so the caller punts
66
+ * to a human. Returns the merged JSON text (2-space indent, trailing newline).
67
+ */
68
+ export declare function mergePackageJsonContents(oursText: string, theirsText: string): string;
69
+ /**
70
+ * A worktree is ready to merge when every beads issue in its scope is closed.
71
+ * That means: at least one closed in-scope issue exists AND zero open in-scope
72
+ * issues remain. `bd ready` is insufficient (it only returns *unblocked* issues),
73
+ * so we list by status and filter with the same scope rule the loop uses.
74
+ */
75
+ export declare function isScopeComplete(scope: string, deps: MergeDeps): boolean;
76
+ /**
77
+ * Enumerate registered git worktrees, read each `.boop-worktree.json`, and decide
78
+ * which are ready to merge. Worktrees without metadata (e.g. unrelated worktrees)
79
+ * are ignored.
80
+ */
81
+ export declare function findMergeCandidates(deps: MergeDeps): MergeCandidate[];
82
+ export declare function runMerge(options: LoopOptions, deps?: MergeDeps): Promise<void>;
83
+ export {};
@@ -0,0 +1,436 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.pickHigherVersion = pickHigherVersion;
37
+ exports.mergePackageJsonContents = mergePackageJsonContents;
38
+ exports.isScopeComplete = isScopeComplete;
39
+ exports.findMergeCandidates = findMergeCandidates;
40
+ exports.runMerge = runMerge;
41
+ const child_process_1 = require("child_process");
42
+ const fs = __importStar(require("fs"));
43
+ const path = __importStar(require("path"));
44
+ const loop_1 = require("./loop");
45
+ // ── Semver helpers (package.json conflict resolution, subsumes boop-7rb) ──────
46
+ /** Strip range operators and parse a `x.y.z` prefix. Returns null if unparseable. */
47
+ function parseSemver(version) {
48
+ const m = version.trim().replace(/^[\^~>=<\s]+/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
49
+ if (!m)
50
+ return null;
51
+ return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
52
+ }
53
+ /**
54
+ * Pick the higher of two version specifiers for the same dependency key. Ties and
55
+ * unparseable specifiers prefer `ours` (the integration/main side) for stability.
56
+ */
57
+ function pickHigherVersion(ours, theirs) {
58
+ if (ours === theirs)
59
+ return ours;
60
+ const a = parseSemver(ours);
61
+ const b = parseSemver(theirs);
62
+ if (!a || !b)
63
+ return ours;
64
+ for (let i = 0; i < 3; i++) {
65
+ if (b[i] > a[i])
66
+ return theirs;
67
+ if (b[i] < a[i])
68
+ return ours;
69
+ }
70
+ return ours;
71
+ }
72
+ const DEP_MAPS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
73
+ /**
74
+ * Union-merge two package.json sides that conflicted during a rebase. Dependency
75
+ * maps are unioned (same-key version conflict → higher semver). Every other
76
+ * top-level key must already agree between the two sides; if any non-dependency
77
+ * key differs, the conflict is NOT known-safe and this throws so the caller punts
78
+ * to a human. Returns the merged JSON text (2-space indent, trailing newline).
79
+ */
80
+ function mergePackageJsonContents(oursText, theirsText) {
81
+ const ours = JSON.parse(oursText);
82
+ const theirs = JSON.parse(theirsText);
83
+ const merged = { ...ours };
84
+ for (const map of DEP_MAPS) {
85
+ const o = (ours[map] || {});
86
+ const t = (theirs[map] || {});
87
+ if (!ours[map] && !theirs[map])
88
+ continue;
89
+ const combined = { ...o };
90
+ for (const [name, version] of Object.entries(t)) {
91
+ combined[name] = name in combined ? pickHigherVersion(combined[name], version) : version;
92
+ }
93
+ // Sort keys for a deterministic, diff-friendly result.
94
+ merged[map] = Object.fromEntries(Object.entries(combined).sort(([a], [b]) => a.localeCompare(b)));
95
+ }
96
+ // Any non-dependency divergence is outside the known-safe pattern.
97
+ const keys = new Set([...Object.keys(ours), ...Object.keys(theirs)]);
98
+ for (const key of keys) {
99
+ if (DEP_MAPS.includes(key))
100
+ continue;
101
+ if (JSON.stringify(ours[key]) !== JSON.stringify(theirs[key])) {
102
+ throw new Error(`package.json conflict in non-dependency key "${key}" is not auto-resolvable`);
103
+ }
104
+ }
105
+ return JSON.stringify(merged, null, 2) + '\n';
106
+ }
107
+ // ── Scope-completion readiness ────────────────────────────────────────────────
108
+ function makeParentLookup(issues, getParentFn) {
109
+ const cache = new Map();
110
+ for (const i of issues)
111
+ cache.set(i.id, i.parent ?? null);
112
+ return (id) => {
113
+ if (cache.has(id))
114
+ return cache.get(id) ?? null;
115
+ const p = getParentFn(id);
116
+ cache.set(id, p);
117
+ return p;
118
+ };
119
+ }
120
+ /**
121
+ * A worktree is ready to merge when every beads issue in its scope is closed.
122
+ * That means: at least one closed in-scope issue exists AND zero open in-scope
123
+ * issues remain. `bd ready` is insufficient (it only returns *unblocked* issues),
124
+ * so we list by status and filter with the same scope rule the loop uses.
125
+ */
126
+ function isScopeComplete(scope, deps) {
127
+ const bdListFn = deps.bdListFn || defaultBdList(deps);
128
+ const getParentFn = deps.getParentFn || defaultGetParent(deps);
129
+ const open = bdListFn('open');
130
+ const closed = bdListFn('closed');
131
+ const getParent = makeParentLookup([...open, ...closed], getParentFn);
132
+ const inScope = (i) => (0, loop_1.isIssueInScope)(i, scope, getParent);
133
+ const openInScope = open.filter(inScope);
134
+ const closedInScope = closed.filter(inScope);
135
+ return openInScope.length === 0 && closedInScope.length > 0;
136
+ }
137
+ function defaultBdList(deps) {
138
+ const execSyncFn = deps.execSyncFn || defaultExec;
139
+ return (status) => {
140
+ try {
141
+ const raw = execSyncFn(`bd list --status=${status} --json 2>/dev/null`);
142
+ return JSON.parse(raw);
143
+ }
144
+ catch {
145
+ return [];
146
+ }
147
+ };
148
+ }
149
+ function defaultGetParent(deps) {
150
+ const execSyncFn = deps.execSyncFn || defaultExec;
151
+ return (id) => {
152
+ try {
153
+ const data = JSON.parse(execSyncFn(`bd show ${id} --json 2>/dev/null`));
154
+ return (data[0] || data).parent || null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ };
160
+ }
161
+ // ── Candidate discovery ───────────────────────────────────────────────────────
162
+ /**
163
+ * Enumerate registered git worktrees, read each `.boop-worktree.json`, and decide
164
+ * which are ready to merge. Worktrees without metadata (e.g. unrelated worktrees)
165
+ * are ignored.
166
+ */
167
+ function findMergeCandidates(deps) {
168
+ const projectDir = deps.projectDir || process.cwd();
169
+ const execSyncFn = deps.execSyncFn || defaultExec;
170
+ const existsSyncFn = deps.existsSyncFn || fs.existsSync;
171
+ const readFileFn = deps.readFileFn || ((p) => fs.readFileSync(p, 'utf8'));
172
+ let raw = '';
173
+ try {
174
+ raw = execSyncFn('git worktree list --porcelain', { cwd: projectDir });
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ const candidates = [];
180
+ for (const block of raw.split('\n\n')) {
181
+ const wtLine = block.split('\n').find((l) => l.startsWith('worktree '));
182
+ if (!wtLine)
183
+ continue;
184
+ const wtPath = path.resolve(wtLine.slice('worktree '.length).trim());
185
+ // Skip the integration tree itself.
186
+ if (wtPath === path.resolve(projectDir))
187
+ continue;
188
+ const metaPath = path.join(wtPath, '.boop-worktree.json');
189
+ if (!existsSyncFn(metaPath))
190
+ continue;
191
+ let meta;
192
+ try {
193
+ meta = JSON.parse(readFileFn(metaPath));
194
+ }
195
+ catch {
196
+ continue;
197
+ }
198
+ if (!meta.scope || !meta.branch)
199
+ continue;
200
+ candidates.push({
201
+ branch: meta.branch,
202
+ worktreePath: wtPath,
203
+ scope: meta.scope,
204
+ mode: meta.mode,
205
+ ready: isScopeComplete(meta.scope, deps),
206
+ });
207
+ }
208
+ return candidates;
209
+ }
210
+ // ── Merge engine ──────────────────────────────────────────────────────────────
211
+ const defaultExec = (cmd, opts) => (0, child_process_1.execSync)(cmd, { encoding: 'utf8', ...opts });
212
+ class MergeError extends Error {
213
+ constructor(message, detail) {
214
+ super(message);
215
+ this.detail = detail;
216
+ }
217
+ }
218
+ /** True if the integration tree is on `main` with a clean working tree. */
219
+ function assertCleanMain(execSyncFn, projectDir) {
220
+ const exec = execSyncFn;
221
+ const branch = exec('git rev-parse --abbrev-ref HEAD', { cwd: projectDir }).trim();
222
+ if (branch !== 'main') {
223
+ throw new Error(`Integration tree at ${projectDir} is on "${branch}", not "main". Checkout main before merging.`);
224
+ }
225
+ const status = exec('git status --porcelain', { cwd: projectDir }).trim();
226
+ if (status) {
227
+ throw new Error(`Integration tree at ${projectDir} has uncommitted changes; commit or stash before merging.`);
228
+ }
229
+ }
230
+ /**
231
+ * Rebase the candidate branch onto main inside its worktree, auto-resolving only
232
+ * package.json dependency conflicts. Throws MergeError (rebase aborted) on any
233
+ * other conflict.
234
+ */
235
+ function rebaseOntoMain(exec, wt) {
236
+ try {
237
+ exec('git rebase main', { cwd: wt, stdio: 'pipe' });
238
+ return;
239
+ }
240
+ catch {
241
+ // Conflict (or other failure). Inspect unmerged paths.
242
+ }
243
+ // Iteratively resolve package.json-only conflicts and continue.
244
+ for (let guard = 0; guard < 100; guard++) {
245
+ const conflicted = exec('git diff --name-only --diff-filter=U', { cwd: wt })
246
+ .split('\n')
247
+ .map((l) => l.trim())
248
+ .filter(Boolean);
249
+ if (conflicted.length === 0)
250
+ break; // nothing unmerged — fall through to continue/finish
251
+ const nonSafe = conflicted.filter((f) => path.basename(f) !== 'package.json');
252
+ if (nonSafe.length > 0) {
253
+ try {
254
+ exec('git rebase --abort', { cwd: wt });
255
+ }
256
+ catch {
257
+ /* leave it for the human */
258
+ }
259
+ throw new MergeError('rebase conflict not auto-resolvable', `Conflicting files: ${conflicted.join(', ')}`);
260
+ }
261
+ // All conflicts are package.json — union the two sides.
262
+ for (const file of conflicted) {
263
+ const ours = exec(`git show :2:${file}`, { cwd: wt });
264
+ const theirs = exec(`git show :3:${file}`, { cwd: wt });
265
+ let merged;
266
+ try {
267
+ merged = mergePackageJsonContents(ours, theirs);
268
+ }
269
+ catch (e) {
270
+ try {
271
+ exec('git rebase --abort', { cwd: wt });
272
+ }
273
+ catch {
274
+ /* noop */
275
+ }
276
+ throw new MergeError('package.json conflict not auto-resolvable', e.message);
277
+ }
278
+ fs.writeFileSync(path.join(wt, file), merged);
279
+ exec(`git add ${file}`, { cwd: wt });
280
+ }
281
+ try {
282
+ // GIT_EDITOR=true keeps `--continue` non-interactive (reuses the commit message).
283
+ exec('GIT_EDITOR=true git rebase --continue', { cwd: wt, stdio: 'pipe' });
284
+ return; // rebase finished
285
+ }
286
+ catch {
287
+ // More commits ahead may conflict again — loop and re-inspect.
288
+ }
289
+ }
290
+ }
291
+ function mergeOne(candidate, deps) {
292
+ const projectDir = deps.projectDir || process.cwd();
293
+ const exec = deps.execSyncFn || defaultExec;
294
+ const createIssue = deps.createIssueFn || defaultCreateIssue(deps);
295
+ const { branch, worktreePath: wt } = candidate;
296
+ assertCleanMain(exec, projectDir);
297
+ const origSha = exec('git rev-parse HEAD', { cwd: wt }).trim();
298
+ try {
299
+ rebaseOntoMain(exec, wt);
300
+ }
301
+ catch (e) {
302
+ const err = e;
303
+ const issueId = createIssue(`Merge conflict: ${branch}`, `Rebasing ${branch} onto main failed: ${err.message}\n\n${err.detail || ''}\n\nResolve by hand, then re-run \`boop loop --merge ${branch}\`.`);
304
+ return { branch, status: 'skipped', reason: err.message, issueId };
305
+ }
306
+ // Post-rebase test gate. Failure → restore the branch, never touch main.
307
+ try {
308
+ exec('npm test', { cwd: wt, stdio: 'pipe' });
309
+ }
310
+ catch (e) {
311
+ try {
312
+ exec(`git reset --hard ${origSha}`, { cwd: wt });
313
+ }
314
+ catch {
315
+ /* best-effort restore */
316
+ }
317
+ const issueId = createIssue(`Merge test failure: ${branch}`, `Tests failed after rebasing ${branch} onto main; branch reset to ${origSha} and main left untouched.\n\n${e.message}`);
318
+ return { branch, status: 'skipped', reason: 'tests failed post-rebase', issueId };
319
+ }
320
+ // Fast-forward into main (guaranteed possible: branch was just rebased onto it).
321
+ exec(`git merge --ff-only ${branch}`, { cwd: projectDir });
322
+ // Cleanup: remove the worktree and delete the now-merged branch.
323
+ try {
324
+ exec(`git worktree remove ${wt}`, { cwd: projectDir });
325
+ }
326
+ catch {
327
+ try {
328
+ exec(`git worktree remove --force ${wt}`, { cwd: projectDir });
329
+ }
330
+ catch {
331
+ /* leave the worktree; merge already succeeded */
332
+ }
333
+ }
334
+ try {
335
+ exec(`git branch -d ${branch}`, { cwd: projectDir });
336
+ }
337
+ catch {
338
+ /* branch may be kept by the operator; non-fatal */
339
+ }
340
+ return { branch, status: 'merged' };
341
+ }
342
+ function defaultCreateIssue(deps) {
343
+ const exec = deps.execSyncFn || defaultExec;
344
+ return (title, description) => {
345
+ try {
346
+ const raw = exec(`bd create ${JSON.stringify(title)} --description=${JSON.stringify(description)} -t bug -p 1 --json`);
347
+ return JSON.parse(raw).id || null;
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ };
353
+ }
354
+ // ── Orchestrator ──────────────────────────────────────────────────────────────
355
+ async function runMerge(options, deps) {
356
+ const d = deps || {};
357
+ const projectDir = d.projectDir || process.cwd();
358
+ d.projectDir = projectDir;
359
+ const sleepFn = d.sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
360
+ try {
361
+ (d.assertServerModeFn || ((dir) => (0, loop_1.assertDoltServerMode)(dir, undefined)))(projectDir);
362
+ }
363
+ catch (e) {
364
+ console.error(`\x1b[31m[bd-merge]\x1b[0m ${e.message}`);
365
+ process.exitCode = 1;
366
+ return;
367
+ }
368
+ const dryRun = !options.run;
369
+ const watch = options.watch || false;
370
+ const pollInterval = parseInt(options.pollInterval || '60', 10);
371
+ const explicitBranch = typeof options.merge === 'string' ? options.merge : undefined;
372
+ const collect = () => {
373
+ const all = findMergeCandidates(d);
374
+ if (explicitBranch) {
375
+ // Explicit override: merge the named branch regardless of readiness.
376
+ const match = all.find((c) => c.branch === explicitBranch || c.branch === `work/${explicitBranch}`);
377
+ if (match)
378
+ return [{ ...match, ready: true }];
379
+ console.error(`\x1b[31m[bd-merge]\x1b[0m No worktree found for branch "${explicitBranch}". Known: ${all.map((c) => c.branch).join(', ') || 'none'}`);
380
+ return [];
381
+ }
382
+ return all.filter((c) => c.ready);
383
+ };
384
+ const outcomes = [];
385
+ do {
386
+ const candidates = collect();
387
+ if (dryRun) {
388
+ console.log('\x1b[33m[bd-merge]\x1b[0m DRY RUN — add --run to execute');
389
+ if (candidates.length === 0) {
390
+ console.log('\x1b[36m[bd-merge]\x1b[0m No branches ready to merge.');
391
+ }
392
+ for (const c of candidates) {
393
+ console.log(` \x1b[34m${c.branch}\x1b[0m (scope ${c.scope}, ${c.mode}) — would rebase onto main, npm test, ff-merge, cleanup ${c.worktreePath}`);
394
+ }
395
+ break;
396
+ }
397
+ if (candidates.length === 0 && !watch) {
398
+ console.log('\x1b[36m[bd-merge]\x1b[0m No branches ready to merge.');
399
+ }
400
+ for (const c of candidates) {
401
+ console.log(`\x1b[32m[bd-merge]\x1b[0m Merging \x1b[34m${c.branch}\x1b[0m (scope ${c.scope})...`);
402
+ let outcome;
403
+ try {
404
+ outcome = mergeOne(c, d);
405
+ }
406
+ catch (e) {
407
+ // Unexpected failure (e.g. dirty main) — isolate and continue.
408
+ outcome = { branch: c.branch, status: 'skipped', reason: e.message };
409
+ console.error(`\x1b[31m[bd-merge]\x1b[0m ${c.branch}: ${e.message}`);
410
+ }
411
+ outcomes.push(outcome);
412
+ if (outcome.status === 'merged') {
413
+ console.log(`\x1b[32m[bd-merge]\x1b[0m ✓ merged ${c.branch} into main; cleaned up worktree`);
414
+ }
415
+ else {
416
+ console.warn(`\x1b[33m[bd-merge]\x1b[0m ⤳ skipped ${c.branch}: ${outcome.reason}${outcome.issueId ? ` (filed ${outcome.issueId})` : ''}`);
417
+ }
418
+ }
419
+ if (watch) {
420
+ console.log(`\x1b[36m[bd-merge]\x1b[0m Watching for ready branches; next poll in ${pollInterval}s...`);
421
+ await sleepFn(pollInterval * 1000);
422
+ }
423
+ } while (watch);
424
+ if (!dryRun && !watch) {
425
+ const merged = outcomes.filter((o) => o.status === 'merged').length;
426
+ const skipped = outcomes.filter((o) => o.status === 'skipped').length;
427
+ console.log('');
428
+ console.log('\x1b[32m[bd-merge]\x1b[0m ━━━ Merge summary ━━━');
429
+ console.log(`\x1b[32m[bd-merge]\x1b[0m Merged: ${merged}`);
430
+ if (skipped > 0)
431
+ console.log(`\x1b[33m[bd-merge]\x1b[0m Skipped: ${skipped}`);
432
+ }
433
+ if (options.json) {
434
+ console.log(JSON.stringify({ outcomes }, null, 2));
435
+ }
436
+ }