@boopkit/cli 0.4.1 → 0.6.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 +14 -10
- package/dist/capability-catalog.json +7 -0
- package/dist/capability-versions.json +12 -11
- package/dist/cli.js +40 -19
- package/dist/commands/loop-merge.d.ts +83 -0
- package/dist/commands/loop-merge.js +436 -0
- package/dist/commands/loop.d.ts +143 -0
- package/dist/commands/loop.js +572 -60
- package/docs/init.md +179 -0
- package/docs/loop.md +204 -0
- package/docs/skills.md +88 -0
- package/package.json +5 -1
|
@@ -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
|
+
}
|
package/dist/commands/loop.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface LoopOptions {
|
|
|
15
15
|
skipPerms?: boolean;
|
|
16
16
|
bare?: boolean;
|
|
17
17
|
json?: boolean;
|
|
18
|
+
merge?: boolean | string;
|
|
19
|
+
watch?: boolean;
|
|
18
20
|
}
|
|
19
21
|
interface ClaudeRunResult {
|
|
20
22
|
exitCode: number;
|
|
@@ -24,6 +26,79 @@ export interface PlanResult {
|
|
|
24
26
|
featureId: string | null;
|
|
25
27
|
costUsd: number | null;
|
|
26
28
|
}
|
|
29
|
+
export type WorktreeMode = 'work-flow' | 'prefix';
|
|
30
|
+
export interface WorktreeContext {
|
|
31
|
+
path: string;
|
|
32
|
+
branch: string;
|
|
33
|
+
scope: string;
|
|
34
|
+
mode: WorktreeMode;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Hard requirement: beads must run in Dolt server mode so concurrent worktrees
|
|
38
|
+
* share one issue DB over TCP. Reads `.beads/metadata.json` and throws an
|
|
39
|
+
* actionable error on embedded/missing — call before any `bd` work.
|
|
40
|
+
*/
|
|
41
|
+
export declare function assertDoltServerMode(projectDir: string, deps?: {
|
|
42
|
+
existsSyncFn?: (p: string) => boolean;
|
|
43
|
+
readFileFn?: (p: string, enc: string) => string;
|
|
44
|
+
}): void;
|
|
45
|
+
/**
|
|
46
|
+
* Discover the running shared Dolt server's port from `.beads/dolt-server.port`.
|
|
47
|
+
* Worktrees never start their own server — they reuse this one over TCP.
|
|
48
|
+
* Returns null when no port file is present (caller errors clearly). Liveness
|
|
49
|
+
* (pid still running) is best-effort and never blocks discovery.
|
|
50
|
+
*/
|
|
51
|
+
export declare function discoverDoltServer(projectDir: string, deps?: {
|
|
52
|
+
existsSyncFn?: (p: string) => boolean;
|
|
53
|
+
readFileFn?: (p: string, enc: string) => string;
|
|
54
|
+
isPidAliveFn?: (pid: number) => boolean;
|
|
55
|
+
}): {
|
|
56
|
+
port: number;
|
|
57
|
+
pid?: number;
|
|
58
|
+
alive?: boolean;
|
|
59
|
+
} | null;
|
|
60
|
+
/**
|
|
61
|
+
* Create (or reuse) the loop's own git worktree at `../boop-<scope>` on branch
|
|
62
|
+
* `work/<scope>`, then pre-warm `npm install --prefer-offline`. This is what
|
|
63
|
+
* makes N loops run concurrently without colliding — each gets its own tree and
|
|
64
|
+
* branch; main is never checked out.
|
|
65
|
+
*
|
|
66
|
+
* Collision (Decision #4): if a worktree already exists at the path on the same
|
|
67
|
+
* branch, reuse it (and skip install if node_modules is present); if the path is
|
|
68
|
+
* occupied by something else, throw a clear error rather than silently suffixing.
|
|
69
|
+
*/
|
|
70
|
+
export declare function createWorktree(scope: string, mode: WorktreeMode, projectDir: string, deps?: {
|
|
71
|
+
execSyncFn?: (cmd: string, opts: {
|
|
72
|
+
encoding: string;
|
|
73
|
+
cwd?: string;
|
|
74
|
+
stdio?: unknown;
|
|
75
|
+
}) => string;
|
|
76
|
+
existsSyncFn?: (p: string) => boolean;
|
|
77
|
+
copyFileFn?: (src: string, dest: string) => void;
|
|
78
|
+
readdirFn?: (p: string) => string[];
|
|
79
|
+
readFileFn?: (p: string) => string;
|
|
80
|
+
appendFileFn?: (p: string, data: string) => void;
|
|
81
|
+
}): WorktreeContext;
|
|
82
|
+
/**
|
|
83
|
+
* Write `.boop-worktree.json` into the worktree root. This is the contract
|
|
84
|
+
* Slice 2 (`boop loop --merge`) reads — `mode` drives its readiness query.
|
|
85
|
+
*/
|
|
86
|
+
export declare function writeWorktreeMetadata(ctx: WorktreeContext, meta: {
|
|
87
|
+
workPath?: string;
|
|
88
|
+
flowPath?: string;
|
|
89
|
+
boopVersion: string;
|
|
90
|
+
startTime: string;
|
|
91
|
+
operator?: string;
|
|
92
|
+
budget?: string;
|
|
93
|
+
model?: string;
|
|
94
|
+
}, deps?: {
|
|
95
|
+
writeFileFn?: (p: string, data: string) => void;
|
|
96
|
+
}): Record<string, unknown>;
|
|
97
|
+
interface ReadyIssue {
|
|
98
|
+
id: string;
|
|
99
|
+
issue_type: string;
|
|
100
|
+
parent?: string;
|
|
101
|
+
}
|
|
27
102
|
export declare function buildPlanningPrompt(workContent: string, flowContent: string, prefix?: string): string;
|
|
28
103
|
export type RunClaudeFn = (prompt: string, opts: {
|
|
29
104
|
budget: string;
|
|
@@ -32,6 +107,7 @@ export type RunClaudeFn = (prompt: string, opts: {
|
|
|
32
107
|
sessionId: string;
|
|
33
108
|
resume?: boolean;
|
|
34
109
|
bare?: boolean;
|
|
110
|
+
cwd?: string;
|
|
35
111
|
}, logFile: string, readableLogFile?: string) => Promise<ClaudeRunResult>;
|
|
36
112
|
export declare function planWork(opts: {
|
|
37
113
|
workPath: string;
|
|
@@ -55,9 +131,76 @@ export declare function findFallbackFeature(features: Array<{
|
|
|
55
131
|
export declare function extractFeatureId(text: string): string | null;
|
|
56
132
|
export declare function isUsageLimitText(text: string): boolean;
|
|
57
133
|
export declare function isStandaloneUsageLimitMessage(text: string): boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the model for a loop run. Precedence: an explicit `--model` flag, then
|
|
136
|
+
* the `ANTHROPIC_MODEL` environment variable, then the built-in default. The env
|
|
137
|
+
* fallback is what lets a nested `boop loop` (spawned by a Claude session that
|
|
138
|
+
* the parent loop launched with ANTHROPIC_MODEL set) inherit the same model
|
|
139
|
+
* instead of silently dropping to the default. Keep the default (claude-opus-4-8)
|
|
140
|
+
* in sync with the `--model` help text in cli.ts.
|
|
141
|
+
*/
|
|
142
|
+
export declare function resolveModel(optionModel: string | undefined, env?: NodeJS.ProcessEnv): string;
|
|
143
|
+
/**
|
|
144
|
+
* Whether the loop should resume an issue that a session left open. A `blocked`
|
|
145
|
+
* issue was deliberately halted by the prior session because it judged it could
|
|
146
|
+
* not safely proceed — resuming just spends budget re-halting and risks the
|
|
147
|
+
* resume prompt being read as authorization to work past the block. Stop and
|
|
148
|
+
* surface it for the operator instead. Any other open status is genuinely
|
|
149
|
+
* unfinished work and is fine to resume.
|
|
150
|
+
*/
|
|
151
|
+
export declare function shouldResumeUnclosedIssue(status: string | undefined): boolean;
|
|
58
152
|
export declare function isIssueInScope(issue: {
|
|
59
153
|
id: string;
|
|
60
154
|
parent?: string;
|
|
61
155
|
}, prefix: string, getParentById: (id: string) => string | null): boolean;
|
|
156
|
+
/**
|
|
157
|
+
* A workflow-owned feature root is a feature-type issue that owns child tasks
|
|
158
|
+
* (the workflow steps: understand → plan → … → closeout). Once those children
|
|
159
|
+
* close, `bd ready` surfaces the feature root itself — but it is a *container*,
|
|
160
|
+
* not directly claimable work. Claiming it lets a worker session absorb
|
|
161
|
+
* punch-list fixes or whole workflow steps (the boop-qna / wishfleet-O7 bug).
|
|
162
|
+
*
|
|
163
|
+
* `hasChildren` is only consulted for feature-type issues, so non-features never
|
|
164
|
+
* pay the cost of the children lookup.
|
|
165
|
+
*/
|
|
166
|
+
export declare function isClaimableReadyIssue(issue: {
|
|
167
|
+
id: string;
|
|
168
|
+
issue_type: string;
|
|
169
|
+
}, hasChildren: (id: string) => boolean): boolean;
|
|
170
|
+
/**
|
|
171
|
+
* Decide whether the loop should close a container root itself, as a safety net,
|
|
172
|
+
* when its workflow chain has completed. The intended owner of this close is the
|
|
173
|
+
* closeout child task (see the flow SKILL's closeout step), but if that task never
|
|
174
|
+
* closed the root — or no closeout task exists — the container would be orphaned
|
|
175
|
+
* open forever once filtered from claimable work. Applies to both container types
|
|
176
|
+
* the loop supports: a `feature` (feature-dev flow) or an `epic` (light-dev flow).
|
|
177
|
+
* Only close when the container is still open and every child is closed.
|
|
178
|
+
*/
|
|
179
|
+
export declare function shouldCloseFeatureRoot(feature: {
|
|
180
|
+
status: string;
|
|
181
|
+
issue_type: string;
|
|
182
|
+
} | null, children: {
|
|
183
|
+
status: string;
|
|
184
|
+
}[]): boolean;
|
|
185
|
+
export declare function getMatchingReadyIssues(prefix: string, includeFeatures: boolean, deps?: {
|
|
186
|
+
execSyncFn?: (cmd: string, opts: {
|
|
187
|
+
encoding: string;
|
|
188
|
+
}) => string;
|
|
189
|
+
}): ReadyIssue[];
|
|
190
|
+
/**
|
|
191
|
+
* Default per-issue budget cap in USD. The planning phase and each task each get
|
|
192
|
+
* this cap. Single source of truth — imported by cli.ts for its --budget help
|
|
193
|
+
* text so the documented default and the runtime fallback can't drift.
|
|
194
|
+
*/
|
|
195
|
+
export declare const DEFAULT_BUDGET_USD = "25.00";
|
|
196
|
+
/**
|
|
197
|
+
* Sent when resuming a session that ended without closing its issue. The opener
|
|
198
|
+
* must NOT read as operator authorization (the old "Approved. Continue..." text
|
|
199
|
+
* could be taken as permission to work past a block): an agent that marked the
|
|
200
|
+
* issue `blocked` made a deliberate safety call, and the block stands. The loop
|
|
201
|
+
* also skips resuming a `blocked` issue entirely (see shouldResumeUnclosedIssue)
|
|
202
|
+
* — this clause is the belt-and-suspenders for a block raised mid-resume.
|
|
203
|
+
*/
|
|
204
|
+
export declare const RESUME_PROMPT = "The previous session ended without closing this issue. Reload the state and continue.\n\nIMPORTANT: If the previous session marked this issue blocked with a diagnostic, the block stands. Do not re-open it or work around it. Read the issue's notes and the previous session's commits, confirm the block is still appropriate, and stop without taking further action.\n\nOtherwise, complete the workflow:\n- Finish any remaining implementation\n- Run tests to verify\n- Commit changes referencing the issue ID\n- Close the issue with bd close\n- Push with: git pull --rebase && git push\n\nDo not pause for review of completed work. Complete everything now.";
|
|
62
205
|
export declare function runLoop(options: LoopOptions): Promise<void>;
|
|
63
206
|
export {};
|