@boopkit/cli 0.4.1 → 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.
- package/dist/capability-catalog.json +7 -0
- package/dist/capability-versions.json +11 -10
- 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 +142 -0
- package/dist/commands/loop.js +561 -49
- package/package.json +1 -1
package/dist/commands/loop.js
CHANGED
|
@@ -33,18 +33,318 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.RESUME_PROMPT = exports.DEFAULT_BUDGET_USD = void 0;
|
|
37
|
+
exports.assertDoltServerMode = assertDoltServerMode;
|
|
38
|
+
exports.discoverDoltServer = discoverDoltServer;
|
|
39
|
+
exports.createWorktree = createWorktree;
|
|
40
|
+
exports.writeWorktreeMetadata = writeWorktreeMetadata;
|
|
36
41
|
exports.buildPlanningPrompt = buildPlanningPrompt;
|
|
37
42
|
exports.planWork = planWork;
|
|
38
43
|
exports.findFallbackFeature = findFallbackFeature;
|
|
39
44
|
exports.extractFeatureId = extractFeatureId;
|
|
40
45
|
exports.isUsageLimitText = isUsageLimitText;
|
|
41
46
|
exports.isStandaloneUsageLimitMessage = isStandaloneUsageLimitMessage;
|
|
47
|
+
exports.resolveModel = resolveModel;
|
|
48
|
+
exports.shouldResumeUnclosedIssue = shouldResumeUnclosedIssue;
|
|
42
49
|
exports.isIssueInScope = isIssueInScope;
|
|
50
|
+
exports.isClaimableReadyIssue = isClaimableReadyIssue;
|
|
51
|
+
exports.shouldCloseFeatureRoot = shouldCloseFeatureRoot;
|
|
52
|
+
exports.getMatchingReadyIssues = getMatchingReadyIssues;
|
|
43
53
|
exports.runLoop = runLoop;
|
|
44
54
|
const child_process_1 = require("child_process");
|
|
45
55
|
const fs = __importStar(require("fs"));
|
|
46
56
|
const path = __importStar(require("path"));
|
|
47
57
|
const crypto_1 = require("crypto");
|
|
58
|
+
/**
|
|
59
|
+
* Hard requirement: beads must run in Dolt server mode so concurrent worktrees
|
|
60
|
+
* share one issue DB over TCP. Reads `.beads/metadata.json` and throws an
|
|
61
|
+
* actionable error on embedded/missing — call before any `bd` work.
|
|
62
|
+
*/
|
|
63
|
+
function assertDoltServerMode(projectDir, deps) {
|
|
64
|
+
const existsSyncFn = deps?.existsSyncFn || fs.existsSync;
|
|
65
|
+
const readFileFn = deps?.readFileFn || ((p, enc) => fs.readFileSync(p, enc));
|
|
66
|
+
const metaPath = path.join(projectDir, '.beads', 'metadata.json');
|
|
67
|
+
const fix = 'Migrate beads to Dolt server mode before running the loop; see skills/boop-dolt-recovery/SKILL.md';
|
|
68
|
+
if (!existsSyncFn(metaPath)) {
|
|
69
|
+
throw new Error(`beads metadata not found at ${metaPath}. The loop requires Dolt server mode for concurrent worktrees. ${fix}`);
|
|
70
|
+
}
|
|
71
|
+
let mode;
|
|
72
|
+
try {
|
|
73
|
+
const meta = JSON.parse(readFileFn(metaPath, 'utf8'));
|
|
74
|
+
mode = meta.dolt_mode;
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
throw new Error(`Failed to parse ${metaPath}: ${e.message}. ${fix}`);
|
|
78
|
+
}
|
|
79
|
+
if (mode !== 'server') {
|
|
80
|
+
throw new Error(`beads is in dolt_mode="${mode ?? 'unset'}" (read from ${metaPath}); the loop requires dolt_mode="server" so concurrent worktrees share one issue DB. ${fix}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Discover the running shared Dolt server's port from `.beads/dolt-server.port`.
|
|
85
|
+
* Worktrees never start their own server — they reuse this one over TCP.
|
|
86
|
+
* Returns null when no port file is present (caller errors clearly). Liveness
|
|
87
|
+
* (pid still running) is best-effort and never blocks discovery.
|
|
88
|
+
*/
|
|
89
|
+
function discoverDoltServer(projectDir, deps) {
|
|
90
|
+
const existsSyncFn = deps?.existsSyncFn || fs.existsSync;
|
|
91
|
+
const readFileFn = deps?.readFileFn || ((p, enc) => fs.readFileSync(p, enc));
|
|
92
|
+
const isPidAliveFn = deps?.isPidAliveFn ||
|
|
93
|
+
((pid) => {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const portPath = path.join(projectDir, '.beads', 'dolt-server.port');
|
|
103
|
+
if (!existsSyncFn(portPath)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const port = parseInt(readFileFn(portPath, 'utf8').trim(), 10);
|
|
107
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
// Best-effort liveness: read pid file and probe; never throws/blocks.
|
|
111
|
+
let pid;
|
|
112
|
+
let alive;
|
|
113
|
+
const pidPath = path.join(projectDir, '.beads', 'dolt-server.pid');
|
|
114
|
+
if (existsSyncFn(pidPath)) {
|
|
115
|
+
const parsed = parseInt(readFileFn(pidPath, 'utf8').trim(), 10);
|
|
116
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
117
|
+
pid = parsed;
|
|
118
|
+
alive = isPidAliveFn(parsed);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { port, pid, alive };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create (or reuse) the loop's own git worktree at `../boop-<scope>` on branch
|
|
125
|
+
* `work/<scope>`, then pre-warm `npm install --prefer-offline`. This is what
|
|
126
|
+
* makes N loops run concurrently without colliding — each gets its own tree and
|
|
127
|
+
* branch; main is never checked out.
|
|
128
|
+
*
|
|
129
|
+
* Collision (Decision #4): if a worktree already exists at the path on the same
|
|
130
|
+
* branch, reuse it (and skip install if node_modules is present); if the path is
|
|
131
|
+
* occupied by something else, throw a clear error rather than silently suffixing.
|
|
132
|
+
*/
|
|
133
|
+
function createWorktree(scope, mode, projectDir, deps) {
|
|
134
|
+
const execSyncFn = deps?.execSyncFn ||
|
|
135
|
+
((cmd, o) => (0, child_process_1.execSync)(cmd, o));
|
|
136
|
+
const existsSyncFn = deps?.existsSyncFn || fs.existsSync;
|
|
137
|
+
const copyFileFn = deps?.copyFileFn || ((src, dest) => fs.copyFileSync(src, dest));
|
|
138
|
+
const readdirFn = deps?.readdirFn || ((p) => fs.readdirSync(p));
|
|
139
|
+
const readFileFn = deps?.readFileFn || ((p) => fs.readFileSync(p, 'utf8'));
|
|
140
|
+
const appendFileFn = deps?.appendFileFn || ((p, data) => fs.appendFileSync(p, data));
|
|
141
|
+
const branch = `work/${scope}`;
|
|
142
|
+
const worktreePath = path.resolve(projectDir, '..', `boop-${scope}`);
|
|
143
|
+
const ctx = { path: worktreePath, branch, scope, mode };
|
|
144
|
+
// A fresh worktree checks out its own (tracked) `.beads/` but NOT the gitignored
|
|
145
|
+
// runtime files dolt-server.port/pid. Without them, `bd` inside the worktree
|
|
146
|
+
// auto-starts a ROGUE Dolt server on a different port and can't see the issue DB.
|
|
147
|
+
// Propagate the shared server's discovery files so the worktree reuses it.
|
|
148
|
+
// (Verified in Task 1.7 — see specs/parallel-worktree-development-audit.md.)
|
|
149
|
+
const propagateServerFiles = () => {
|
|
150
|
+
for (const file of ['dolt-server.port', 'dolt-server.pid']) {
|
|
151
|
+
const src = path.join(projectDir, '.beads', file);
|
|
152
|
+
const dest = path.join(worktreePath, '.beads', file);
|
|
153
|
+
if (existsSyncFn(src) && existsSyncFn(path.join(worktreePath, '.beads'))) {
|
|
154
|
+
try {
|
|
155
|
+
copyFileFn(src, dest);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Best-effort; discovery already validated the shared server in runLoop.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
// Gitignored runtime config (`.env` and friends) lives only in the main working
|
|
164
|
+
// tree — `git worktree add` checks out tracked files only, so a fresh worktree
|
|
165
|
+
// would start with no environment and the agent's app/tests would fail. Seed
|
|
166
|
+
// copies of root-level `.env*` files. Copy (not symlink) to keep worktrees
|
|
167
|
+
// isolated, matching the spec's "each worktree is a full working copy (own
|
|
168
|
+
// node_modules, own .env)". Don't clobber a file the agent may already have
|
|
169
|
+
// customized in a reused worktree. (boop-bf3)
|
|
170
|
+
const propagateConfigFiles = () => {
|
|
171
|
+
let entries;
|
|
172
|
+
try {
|
|
173
|
+
entries = readdirFn(projectDir);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return; // best-effort — a missing/unreadable project dir shouldn't abort setup
|
|
177
|
+
}
|
|
178
|
+
for (const name of entries) {
|
|
179
|
+
if (name !== '.env' && !name.startsWith('.env.'))
|
|
180
|
+
continue;
|
|
181
|
+
const src = path.join(projectDir, name);
|
|
182
|
+
const dest = path.join(worktreePath, name);
|
|
183
|
+
try {
|
|
184
|
+
if (existsSyncFn(src) && !existsSyncFn(dest)) {
|
|
185
|
+
copyFileFn(src, dest);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Best-effort; one unreadable config file shouldn't abort worktree setup.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
// `.boop-worktree.json` is per-worktree runtime state (scope/branch/in-scope
|
|
194
|
+
// issues), not source. It must NEVER be tracked: if a closeout `git add` sweeps
|
|
195
|
+
// it into a commit, sibling worktree branches inherit a stale copy, and a later
|
|
196
|
+
// `git stash`/`git pull --rebase` can silently revert THIS worktree's metadata
|
|
197
|
+
// to another scope (the wf-bir→wf-ayn regression — boop-g0l). Register the
|
|
198
|
+
// ignore in the repo's local git exclude (resolved per-worktree via
|
|
199
|
+
// `git rev-parse --git-path info/exclude`) rather than the tracked `.gitignore`,
|
|
200
|
+
// so adding the rule never itself becomes an uncommitted tracked change that
|
|
201
|
+
// blocks the closeout's `git pull --rebase`. Idempotent and best-effort.
|
|
202
|
+
const ensureMetadataIgnored = () => {
|
|
203
|
+
try {
|
|
204
|
+
const raw = execSyncFn('git rev-parse --git-path info/exclude', {
|
|
205
|
+
encoding: 'utf8',
|
|
206
|
+
cwd: worktreePath,
|
|
207
|
+
});
|
|
208
|
+
const rel = (raw || '').trim();
|
|
209
|
+
if (!rel)
|
|
210
|
+
return;
|
|
211
|
+
const excludePath = path.isAbsolute(rel) ? rel : path.join(worktreePath, rel);
|
|
212
|
+
let current = '';
|
|
213
|
+
try {
|
|
214
|
+
current = readFileFn(excludePath);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Exclude file may not exist yet — treat as empty; append will create it.
|
|
218
|
+
}
|
|
219
|
+
const already = current.split('\n').some((l) => l.trim() === '.boop-worktree.json');
|
|
220
|
+
if (already)
|
|
221
|
+
return;
|
|
222
|
+
const sep = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
223
|
+
appendFileFn(excludePath, `${sep}.boop-worktree.json\n`);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Best-effort: worst case the metadata file shows as untracked — still safer
|
|
227
|
+
// than the pre-fix tracked state. Never abort worktree setup over this.
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
// Is this path already a registered worktree? Parse `git worktree list --porcelain`.
|
|
231
|
+
let existingBranch;
|
|
232
|
+
let pathAlreadyWorktree = false;
|
|
233
|
+
try {
|
|
234
|
+
const raw = execSyncFn('git worktree list --porcelain', { encoding: 'utf8', cwd: projectDir });
|
|
235
|
+
const blocks = raw.split('\n\n');
|
|
236
|
+
for (const block of blocks) {
|
|
237
|
+
const lines = block.split('\n');
|
|
238
|
+
const wtLine = lines.find((l) => l.startsWith('worktree '));
|
|
239
|
+
if (!wtLine)
|
|
240
|
+
continue;
|
|
241
|
+
const wtPath = path.resolve(wtLine.slice('worktree '.length).trim());
|
|
242
|
+
if (wtPath === worktreePath) {
|
|
243
|
+
pathAlreadyWorktree = true;
|
|
244
|
+
const brLine = lines.find((l) => l.startsWith('branch '));
|
|
245
|
+
if (brLine) {
|
|
246
|
+
// refs/heads/work/<scope> → work/<scope>
|
|
247
|
+
existingBranch = brLine.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// No git / no worktrees yet — proceed to create.
|
|
254
|
+
}
|
|
255
|
+
if (pathAlreadyWorktree) {
|
|
256
|
+
if (existingBranch && existingBranch !== branch) {
|
|
257
|
+
throw new Error(`Worktree at ${worktreePath} is on branch "${existingBranch}", not the expected "${branch}". ` +
|
|
258
|
+
`Remove it (git worktree remove ${worktreePath}) or choose a different scope.`);
|
|
259
|
+
}
|
|
260
|
+
// Seed gitignored runtime config before any (re)install/bd work.
|
|
261
|
+
propagateConfigFiles();
|
|
262
|
+
// Reuse: only install if dependencies are missing.
|
|
263
|
+
if (!existsSyncFn(path.join(worktreePath, 'node_modules'))) {
|
|
264
|
+
execSyncFn('npm install --prefer-offline', { encoding: 'utf8', cwd: worktreePath });
|
|
265
|
+
}
|
|
266
|
+
propagateServerFiles();
|
|
267
|
+
ensureMetadataIgnored();
|
|
268
|
+
return ctx;
|
|
269
|
+
}
|
|
270
|
+
if (existsSyncFn(worktreePath)) {
|
|
271
|
+
throw new Error(`Path ${worktreePath} already exists but is not a registered git worktree. ` +
|
|
272
|
+
`Remove it or choose a different scope before running the loop.`);
|
|
273
|
+
}
|
|
274
|
+
// Does the branch already exist? If so, attach the worktree to it; else create it.
|
|
275
|
+
let branchExists = false;
|
|
276
|
+
try {
|
|
277
|
+
execSyncFn(`git rev-parse --verify --quiet refs/heads/${branch}`, { encoding: 'utf8', cwd: projectDir });
|
|
278
|
+
branchExists = true;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
branchExists = false;
|
|
282
|
+
}
|
|
283
|
+
if (branchExists) {
|
|
284
|
+
execSyncFn(`git worktree add ${worktreePath} ${branch}`, { encoding: 'utf8', cwd: projectDir });
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
execSyncFn(`git worktree add ${worktreePath} -b ${branch}`, { encoding: 'utf8', cwd: projectDir });
|
|
288
|
+
}
|
|
289
|
+
// Point the worktree's bd at the shared server before any bd work happens.
|
|
290
|
+
propagateServerFiles();
|
|
291
|
+
// Seed gitignored runtime config (`.env` etc.) that `git worktree add` doesn't
|
|
292
|
+
// check out, so the app/tests have their environment before install/session. (boop-bf3)
|
|
293
|
+
propagateConfigFiles();
|
|
294
|
+
// Pre-warm dependencies so the agent doesn't burn budget on a deterministic step.
|
|
295
|
+
execSyncFn('npm install --prefer-offline', { encoding: 'utf8', cwd: worktreePath });
|
|
296
|
+
// Ignore the metadata file before it is ever written (writeWorktreeMetadata runs
|
|
297
|
+
// next in runLoop), so no later `git add` can track it.
|
|
298
|
+
ensureMetadataIgnored();
|
|
299
|
+
return ctx;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Write `.boop-worktree.json` into the worktree root. This is the contract
|
|
303
|
+
* Slice 2 (`boop loop --merge`) reads — `mode` drives its readiness query.
|
|
304
|
+
*/
|
|
305
|
+
function writeWorktreeMetadata(ctx, meta, deps) {
|
|
306
|
+
const writeFileFn = deps?.writeFileFn || ((p, data) => fs.writeFileSync(p, data));
|
|
307
|
+
const data = {
|
|
308
|
+
version: 1,
|
|
309
|
+
mode: ctx.mode,
|
|
310
|
+
scope: ctx.scope,
|
|
311
|
+
branch: ctx.branch,
|
|
312
|
+
worktreePath: ctx.path,
|
|
313
|
+
};
|
|
314
|
+
if (ctx.mode === 'work-flow') {
|
|
315
|
+
if (meta.workPath)
|
|
316
|
+
data.workPath = meta.workPath;
|
|
317
|
+
if (meta.flowPath)
|
|
318
|
+
data.flowPath = meta.flowPath;
|
|
319
|
+
}
|
|
320
|
+
data.startTime = meta.startTime;
|
|
321
|
+
data.boopVersion = meta.boopVersion;
|
|
322
|
+
if (meta.operator)
|
|
323
|
+
data.operator = meta.operator;
|
|
324
|
+
if (meta.budget)
|
|
325
|
+
data.budget = meta.budget;
|
|
326
|
+
if (meta.model)
|
|
327
|
+
data.model = meta.model;
|
|
328
|
+
writeFileFn(path.join(ctx.path, '.boop-worktree.json'), JSON.stringify(data, null, 2) + '\n');
|
|
329
|
+
return data;
|
|
330
|
+
}
|
|
331
|
+
function getBoopVersion() {
|
|
332
|
+
try {
|
|
333
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
334
|
+
return pkg.version || 'unknown';
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return 'unknown';
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function getGitUserName(projectDir) {
|
|
341
|
+
try {
|
|
342
|
+
return (0, child_process_1.execSync)('git config user.name', { encoding: 'utf8', cwd: projectDir }).trim() || undefined;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
48
348
|
function buildPlanningPrompt(workContent, flowContent, prefix) {
|
|
49
349
|
let prompt = `You are setting up work for a development project.
|
|
50
350
|
|
|
@@ -235,6 +535,28 @@ function findClaudeBinary() {
|
|
|
235
535
|
return null;
|
|
236
536
|
}
|
|
237
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Resolve the model for a loop run. Precedence: an explicit `--model` flag, then
|
|
540
|
+
* the `ANTHROPIC_MODEL` environment variable, then the built-in default. The env
|
|
541
|
+
* fallback is what lets a nested `boop loop` (spawned by a Claude session that
|
|
542
|
+
* the parent loop launched with ANTHROPIC_MODEL set) inherit the same model
|
|
543
|
+
* instead of silently dropping to the default. Keep the default (claude-opus-4-8)
|
|
544
|
+
* in sync with the `--model` help text in cli.ts.
|
|
545
|
+
*/
|
|
546
|
+
function resolveModel(optionModel, env = process.env) {
|
|
547
|
+
return optionModel || env.ANTHROPIC_MODEL || 'claude-opus-4-8';
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Whether the loop should resume an issue that a session left open. A `blocked`
|
|
551
|
+
* issue was deliberately halted by the prior session because it judged it could
|
|
552
|
+
* not safely proceed — resuming just spends budget re-halting and risks the
|
|
553
|
+
* resume prompt being read as authorization to work past the block. Stop and
|
|
554
|
+
* surface it for the operator instead. Any other open status is genuinely
|
|
555
|
+
* unfinished work and is fine to resume.
|
|
556
|
+
*/
|
|
557
|
+
function shouldResumeUnclosedIssue(status) {
|
|
558
|
+
return status !== 'blocked';
|
|
559
|
+
}
|
|
238
560
|
function findNextIssue(prefix, includeFeatures) {
|
|
239
561
|
const matching = getMatchingReadyIssues(prefix, includeFeatures);
|
|
240
562
|
return matching.length > 0 ? matching[0].id : null;
|
|
@@ -265,9 +587,98 @@ function isIssueInScope(issue, prefix, getParentById) {
|
|
|
265
587
|
}
|
|
266
588
|
return false;
|
|
267
589
|
}
|
|
268
|
-
|
|
590
|
+
/**
|
|
591
|
+
* A workflow-owned feature root is a feature-type issue that owns child tasks
|
|
592
|
+
* (the workflow steps: understand → plan → … → closeout). Once those children
|
|
593
|
+
* close, `bd ready` surfaces the feature root itself — but it is a *container*,
|
|
594
|
+
* not directly claimable work. Claiming it lets a worker session absorb
|
|
595
|
+
* punch-list fixes or whole workflow steps (the boop-qna / wishfleet-O7 bug).
|
|
596
|
+
*
|
|
597
|
+
* `hasChildren` is only consulted for feature-type issues, so non-features never
|
|
598
|
+
* pay the cost of the children lookup.
|
|
599
|
+
*/
|
|
600
|
+
function isClaimableReadyIssue(issue, hasChildren) {
|
|
601
|
+
if (issue.issue_type === 'feature' && hasChildren(issue.id)) {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Decide whether the loop should close a feature root itself, as a safety net,
|
|
608
|
+
* when its workflow chain has completed. The intended owner of this close is the
|
|
609
|
+
* closeout child task (see boop-feature-dev-workflow SKILL step 6), but if that
|
|
610
|
+
* task never closed the root — or no closeout task exists — the feature would be
|
|
611
|
+
* orphaned open forever once filtered from claimable work. Only close when the
|
|
612
|
+
* feature is still open and every child is closed.
|
|
613
|
+
*/
|
|
614
|
+
function shouldCloseFeatureRoot(feature, children) {
|
|
615
|
+
if (!feature || feature.issue_type !== 'feature') {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
if (feature.status === 'closed') {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
if (children.length === 0) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
return children.every((c) => c.status === 'closed');
|
|
625
|
+
}
|
|
626
|
+
function getIssueChildren(id) {
|
|
269
627
|
try {
|
|
270
|
-
const raw = (0, child_process_1.execSync)(
|
|
628
|
+
const raw = (0, child_process_1.execSync)(`bd show ${id} --children --json 2>/dev/null`, { encoding: 'utf8' });
|
|
629
|
+
const data = JSON.parse(raw);
|
|
630
|
+
const children = data[id];
|
|
631
|
+
return Array.isArray(children) ? children : [];
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return [];
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
function issueHasChildren(id) {
|
|
638
|
+
return getIssueChildren(id).length > 0;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Safety net for spec-driven runs: when the workflow chain completes but the
|
|
642
|
+
* closeout child did not close the feature root, close it here so it is not
|
|
643
|
+
* orphaned open after the filter removes it from claimable work. Returns true if
|
|
644
|
+
* it closed the feature. Best-effort — failures are logged, not fatal.
|
|
645
|
+
*/
|
|
646
|
+
function closeCompletedFeatureRoot(featureId) {
|
|
647
|
+
const feature = getIssueInfoWithStatus(featureId);
|
|
648
|
+
const children = getIssueChildren(featureId);
|
|
649
|
+
if (!shouldCloseFeatureRoot(feature, children)) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
(0, child_process_1.execSync)(`bd close ${featureId} --reason "bd-loop: closed feature root on workflow chain completion (all child tasks closed; closeout did not close it)" 2>/dev/null`, { encoding: 'utf8' });
|
|
654
|
+
console.log(`\x1b[32m[bd-loop]\x1b[0m Closed feature root ${featureId} — workflow chain complete (safety net; closeout had not closed it).`);
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
console.warn(`\x1b[33m[bd-loop]\x1b[0m Feature root ${featureId} chain is complete but \`bd close\` failed — close it manually.`);
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function getIssueInfoWithStatus(id) {
|
|
663
|
+
try {
|
|
664
|
+
const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
|
|
665
|
+
const data = JSON.parse(raw);
|
|
666
|
+
const issue = data[0] || data;
|
|
667
|
+
return { status: issue.status || 'unknown', issue_type: issue.issue_type || 'task' };
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function getMatchingReadyIssues(prefix, includeFeatures, deps) {
|
|
674
|
+
const execSyncFn = deps?.execSyncFn ||
|
|
675
|
+
((cmd, opts) => (0, child_process_1.execSync)(cmd, opts));
|
|
676
|
+
try {
|
|
677
|
+
// -n 0 = unlimited. A bounded window (formerly -n 20) starves the scope
|
|
678
|
+
// filter below: with more than 20 ready issues globally, in-scope issues
|
|
679
|
+
// sorting outside the window are never seen and the loop exits with group
|
|
680
|
+
// work undone (boop-pra).
|
|
681
|
+
const raw = execSyncFn('bd ready --json -n 0 2>/dev/null', { encoding: 'utf8' });
|
|
271
682
|
const issues = JSON.parse(raw);
|
|
272
683
|
const typeFilter = includeFeatures
|
|
273
684
|
? (i) => i.issue_type !== 'epic'
|
|
@@ -284,9 +695,20 @@ function getMatchingReadyIssues(prefix, includeFeatures) {
|
|
|
284
695
|
parentCache.set(id, parent);
|
|
285
696
|
return parent;
|
|
286
697
|
};
|
|
698
|
+
const childrenCache = new Map();
|
|
699
|
+
const hasChildren = (id) => {
|
|
700
|
+
const cached = childrenCache.get(id);
|
|
701
|
+
if (cached !== undefined) {
|
|
702
|
+
return cached;
|
|
703
|
+
}
|
|
704
|
+
const result = issueHasChildren(id);
|
|
705
|
+
childrenCache.set(id, result);
|
|
706
|
+
return result;
|
|
707
|
+
};
|
|
287
708
|
return issues
|
|
288
709
|
.filter(typeFilter)
|
|
289
|
-
.filter((issue) => isIssueInScope(issue, prefix, getParentById))
|
|
710
|
+
.filter((issue) => isIssueInScope(issue, prefix, getParentById))
|
|
711
|
+
.filter((issue) => isClaimableReadyIssue(issue, hasChildren));
|
|
290
712
|
}
|
|
291
713
|
catch {
|
|
292
714
|
return [];
|
|
@@ -345,14 +767,32 @@ Do NOT pause for review. Work autonomously through to completion.
|
|
|
345
767
|
If you discover additional issues during your work, file them with:
|
|
346
768
|
bd create "Title" -d "description" --deps discovered-from:${issue.id}`;
|
|
347
769
|
}
|
|
348
|
-
|
|
770
|
+
/**
|
|
771
|
+
* Default per-issue budget cap in USD. The planning phase and each task each get
|
|
772
|
+
* this cap. Single source of truth — imported by cli.ts for its --budget help
|
|
773
|
+
* text so the documented default and the runtime fallback can't drift.
|
|
774
|
+
*/
|
|
775
|
+
exports.DEFAULT_BUDGET_USD = '25.00';
|
|
776
|
+
/**
|
|
777
|
+
* Sent when resuming a session that ended without closing its issue. The opener
|
|
778
|
+
* must NOT read as operator authorization (the old "Approved. Continue..." text
|
|
779
|
+
* could be taken as permission to work past a block): an agent that marked the
|
|
780
|
+
* issue `blocked` made a deliberate safety call, and the block stands. The loop
|
|
781
|
+
* also skips resuming a `blocked` issue entirely (see shouldResumeUnclosedIssue)
|
|
782
|
+
* — this clause is the belt-and-suspenders for a block raised mid-resume.
|
|
783
|
+
*/
|
|
784
|
+
exports.RESUME_PROMPT = `The previous session ended without closing this issue. Reload the state and continue.
|
|
785
|
+
|
|
786
|
+
IMPORTANT: 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.
|
|
787
|
+
|
|
788
|
+
Otherwise, complete the workflow:
|
|
349
789
|
- Finish any remaining implementation
|
|
350
790
|
- Run tests to verify
|
|
351
791
|
- Commit changes referencing the issue ID
|
|
352
792
|
- Close the issue with bd close
|
|
353
793
|
- Push with: git pull --rebase && git push
|
|
354
794
|
|
|
355
|
-
Do
|
|
795
|
+
Do not pause for review of completed work. Complete everything now.`;
|
|
356
796
|
function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
357
797
|
return new Promise((resolve) => {
|
|
358
798
|
const args = [
|
|
@@ -383,7 +823,19 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
|
383
823
|
const readableStream = readableLogFile
|
|
384
824
|
? fs.createWriteStream(readableLogFile, { flags: 'a' })
|
|
385
825
|
: null;
|
|
386
|
-
|
|
826
|
+
// Propagate the chosen model beyond this session via the environment.
|
|
827
|
+
// `--model` (in args) sets this session; ANTHROPIC_MODEL additionally
|
|
828
|
+
// (a) reaches a nested `boop loop` — child processes inherit the env — so it
|
|
829
|
+
// doesn't reset to the default, and (b) is honored by any sub-agent whose
|
|
830
|
+
// definition opts into `model: inherit`. We deliberately do NOT set
|
|
831
|
+
// CLAUDE_CODE_SUBAGENT_MODEL: sub-agents keep their own declared model, so a
|
|
832
|
+
// deliberately-cheap agent (e.g. Explore on Haiku) stays cheap. The invoking
|
|
833
|
+
// agent can still override a sub-agent's model per-invocation when it needs
|
|
834
|
+
// to. See resolveModel().
|
|
835
|
+
const childEnv = opts.model
|
|
836
|
+
? { ...process.env, ANTHROPIC_MODEL: opts.model }
|
|
837
|
+
: process.env;
|
|
838
|
+
const child = (0, child_process_1.spawn)(claudeBin, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv });
|
|
387
839
|
let limitHit = false;
|
|
388
840
|
child.stdin.write(prompt);
|
|
389
841
|
child.stdin.end();
|
|
@@ -462,17 +914,19 @@ async function processIssue(issue, opts) {
|
|
|
462
914
|
fs.writeFileSync(readableLogFile, `=== ${issue.id}: ${issue.title} ===\nSession: ${sessionId}\nStarted: ${new Date().toISOString()}\n\n`);
|
|
463
915
|
console.log(`\x1b[32m[bd-loop]\x1b[0m ━━━ \x1b[34m${issue.id}\x1b[0m (P${issue.priority} ${issue.issue_type}) ━━━`);
|
|
464
916
|
console.log(`\x1b[32m[bd-loop]\x1b[0m ${issue.title}`);
|
|
917
|
+
// Per-issue branches (when requested) are created *inside* the worktree cwd,
|
|
918
|
+
// so they nest under the loop's isolated tree rather than mutating main.
|
|
465
919
|
let originalBranch;
|
|
466
920
|
if (!opts.noBranch && opts.perIssueBranch) {
|
|
467
921
|
const branchName = `fix/${issue.id}`;
|
|
468
|
-
originalBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim();
|
|
922
|
+
originalBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8', cwd: opts.cwd }).trim();
|
|
469
923
|
console.log(`\x1b[32m[bd-loop]\x1b[0m Creating branch: ${branchName}`);
|
|
470
924
|
try {
|
|
471
|
-
(0, child_process_1.execSync)(`git checkout -b ${branchName} 2>/dev/null`, { encoding: 'utf8' });
|
|
925
|
+
(0, child_process_1.execSync)(`git checkout -b ${branchName} 2>/dev/null`, { encoding: 'utf8', cwd: opts.cwd });
|
|
472
926
|
}
|
|
473
927
|
catch {
|
|
474
928
|
try {
|
|
475
|
-
(0, child_process_1.execSync)(`git checkout ${branchName} 2>/dev/null`, { encoding: 'utf8' });
|
|
929
|
+
(0, child_process_1.execSync)(`git checkout ${branchName} 2>/dev/null`, { encoding: 'utf8', cwd: opts.cwd });
|
|
476
930
|
console.log(`\x1b[33m[bd-loop]\x1b[0m Branch ${branchName} exists, checked out`);
|
|
477
931
|
}
|
|
478
932
|
catch {
|
|
@@ -490,6 +944,7 @@ async function processIssue(issue, opts) {
|
|
|
490
944
|
skipPerms: opts.skipPerms,
|
|
491
945
|
bare: opts.bare,
|
|
492
946
|
sessionId,
|
|
947
|
+
cwd: opts.cwd,
|
|
493
948
|
}, logFile, readableLogFile);
|
|
494
949
|
let anyLimitHit = initialRun.limitHit;
|
|
495
950
|
if (initialRun.limitHit) {
|
|
@@ -502,17 +957,25 @@ async function processIssue(issue, opts) {
|
|
|
502
957
|
console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Issue ${issue.id} closed successfully`);
|
|
503
958
|
break;
|
|
504
959
|
}
|
|
960
|
+
// Don't resume an issue the prior session deliberately blocked — that's a
|
|
961
|
+
// safety decision, not unfinished work. Resuming burns budget re-halting.
|
|
962
|
+
const statusInfo = getIssueInfoWithStatus(issue.id);
|
|
963
|
+
if (!shouldResumeUnclosedIssue(statusInfo?.status)) {
|
|
964
|
+
console.log(`\x1b[33m[bd-loop]\x1b[0m ⛔ Issue ${issue.id} is marked '${statusInfo?.status}' — not resuming; operator review needed.`);
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
505
967
|
resumeCount++;
|
|
506
968
|
console.log(`\x1b[33m[bd-loop]\x1b[0m Issue not yet closed — resuming session (attempt ${resumeCount}/${opts.resumeLimit})`);
|
|
507
969
|
console.log(`\x1b[32m[bd-loop]\x1b[0m 🔄 Resuming session ${sessionId.substring(0, 8)}...`);
|
|
508
970
|
fs.appendFileSync(readableLogFile, `\n=== Resume attempt ${resumeCount} ===\nTime: ${new Date().toISOString()}\n\n`);
|
|
509
|
-
const resumeRun = await runClaude(RESUME_PROMPT, {
|
|
971
|
+
const resumeRun = await runClaude(exports.RESUME_PROMPT, {
|
|
510
972
|
budget: opts.budget,
|
|
511
973
|
model: opts.model,
|
|
512
974
|
skipPerms: opts.skipPerms,
|
|
513
975
|
bare: opts.bare,
|
|
514
976
|
sessionId,
|
|
515
977
|
resume: true,
|
|
978
|
+
cwd: opts.cwd,
|
|
516
979
|
}, logFile, readableLogFile);
|
|
517
980
|
if (resumeRun.limitHit) {
|
|
518
981
|
anyLimitHit = true;
|
|
@@ -530,7 +993,7 @@ async function processIssue(issue, opts) {
|
|
|
530
993
|
}
|
|
531
994
|
if (originalBranch) {
|
|
532
995
|
try {
|
|
533
|
-
(0, child_process_1.execSync)(`git checkout ${originalBranch} 2>/dev/null
|
|
996
|
+
(0, child_process_1.execSync)(`git checkout ${originalBranch} 2>/dev/null`, { cwd: opts.cwd });
|
|
534
997
|
}
|
|
535
998
|
catch {
|
|
536
999
|
// Best effort
|
|
@@ -559,6 +1022,12 @@ async function runLoop(options) {
|
|
|
559
1022
|
process.exitCode = 1;
|
|
560
1023
|
return;
|
|
561
1024
|
}
|
|
1025
|
+
// --features is an issue-driven (--prefix) knob. Spec-driven runs always process
|
|
1026
|
+
// the feature tree the planning phase produces, so a user-supplied --features is
|
|
1027
|
+
// a no-op here — warn rather than silently ignore.
|
|
1028
|
+
if (options.features && work && flow) {
|
|
1029
|
+
console.warn('\x1b[33mWARN\x1b[0m --features has no effect with --work/--flow (spec-driven mode always processes the planned feature tree; feature roots that own child tasks are never claimed directly either way)');
|
|
1030
|
+
}
|
|
562
1031
|
// Validate that work/flow files exist
|
|
563
1032
|
if (work && !fs.existsSync(work)) {
|
|
564
1033
|
console.error(`\x1b[31mERR\x1b[0m --work file not found: ${work}`);
|
|
@@ -583,10 +1052,10 @@ async function runLoop(options) {
|
|
|
583
1052
|
}
|
|
584
1053
|
const dryRun = !options.run;
|
|
585
1054
|
const maxIssues = parseInt(options.max || '0', 10);
|
|
586
|
-
const budget = options.budget ||
|
|
1055
|
+
const budget = options.budget || exports.DEFAULT_BUDGET_USD;
|
|
587
1056
|
const resumeLimit = parseInt(options.resumeLimit || '3', 10);
|
|
588
1057
|
const pollInterval = parseInt(options.pollInterval || '60', 10);
|
|
589
|
-
const model = options.model
|
|
1058
|
+
const model = resolveModel(options.model);
|
|
590
1059
|
const perIssueBranch = options.perIssueBranch || false;
|
|
591
1060
|
const noBranch = options.branch === false;
|
|
592
1061
|
let includeFeatures = options.features || false;
|
|
@@ -596,6 +1065,17 @@ async function runLoop(options) {
|
|
|
596
1065
|
const projectDir = process.cwd();
|
|
597
1066
|
const logDir = path.join(projectDir, 'labs', 'claude-runs');
|
|
598
1067
|
fs.mkdirSync(logDir, { recursive: true });
|
|
1068
|
+
// Hard requirement (Decision #6): beads must be in Dolt server mode so concurrent
|
|
1069
|
+
// worktrees share one issue DB. Runs always — embedded mode is a footgun for any
|
|
1070
|
+
// bd concurrency and the check is cheap.
|
|
1071
|
+
try {
|
|
1072
|
+
assertDoltServerMode(projectDir);
|
|
1073
|
+
}
|
|
1074
|
+
catch (e) {
|
|
1075
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m ${e.message}`);
|
|
1076
|
+
process.exitCode = 1;
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
599
1079
|
// Planning mode dry-run
|
|
600
1080
|
if (dryRun && work && resolvedFlow) {
|
|
601
1081
|
const workContent = fs.readFileSync(work, 'utf8');
|
|
@@ -645,31 +1125,48 @@ async function runLoop(options) {
|
|
|
645
1125
|
if (dryRun) {
|
|
646
1126
|
console.log('\x1b[33m[bd-loop]\x1b[0m DRY RUN — add --run to execute');
|
|
647
1127
|
}
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
if (
|
|
656
|
-
|
|
657
|
-
|
|
1128
|
+
// Always-worktree (Slice 1): the loop runs inside its own git worktree at
|
|
1129
|
+
// ../boop-<scope> on branch work/<scope> so N loops run concurrently without
|
|
1130
|
+
// colliding; main is never checked out. --no-branch opts out (in-place, today's
|
|
1131
|
+
// single-tenant behavior). Manual merge is the v1 contract — no --merge yet.
|
|
1132
|
+
const worktreeMode = work && resolvedFlow ? 'work-flow' : 'prefix';
|
|
1133
|
+
let worktreeCtx;
|
|
1134
|
+
let loopCwd = projectDir;
|
|
1135
|
+
if (noBranch) {
|
|
1136
|
+
console.log(`\x1b[32m[bd-loop]\x1b[0m Branch strategy: in-place on current branch (--no-branch)`);
|
|
1137
|
+
}
|
|
1138
|
+
else if (!dryRun) {
|
|
1139
|
+
// Worktrees reuse the running shared Dolt server — they never start one.
|
|
1140
|
+
const server = discoverDoltServer(projectDir);
|
|
1141
|
+
if (!server) {
|
|
1142
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m No shared Dolt server discovered (.beads/dolt-server.port missing). ` +
|
|
1143
|
+
`Start it with \`bd dolt start\` before running the loop; worktrees never start their own server.`);
|
|
1144
|
+
process.exitCode = 1;
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (server.alive === false) {
|
|
1148
|
+
console.warn(`\x1b[33m[bd-loop]\x1b[0m Dolt server pid ${server.pid} on port ${server.port} does not appear to be running; ` +
|
|
1149
|
+
`bd calls may fail. Continuing (liveness check is best-effort).`);
|
|
1150
|
+
}
|
|
658
1151
|
try {
|
|
659
|
-
(
|
|
1152
|
+
worktreeCtx = createWorktree(prefix, worktreeMode, projectDir);
|
|
660
1153
|
}
|
|
661
|
-
catch {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
catch {
|
|
667
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to checkout shared branch ${sharedBranch}`);
|
|
668
|
-
console.error('\x1b[31m[bd-loop]\x1b[0m You may have uncommitted changes — try: git stash');
|
|
669
|
-
process.exitCode = 1;
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
1154
|
+
catch (e) {
|
|
1155
|
+
console.error(`\x1b[31m[bd-loop]\x1b[0m ${e.message}`);
|
|
1156
|
+
process.exitCode = 1;
|
|
1157
|
+
return;
|
|
672
1158
|
}
|
|
1159
|
+
loopCwd = worktreeCtx.path;
|
|
1160
|
+
writeWorktreeMetadata(worktreeCtx, {
|
|
1161
|
+
workPath: work,
|
|
1162
|
+
flowPath: resolvedFlow,
|
|
1163
|
+
boopVersion: getBoopVersion(),
|
|
1164
|
+
startTime: new Date().toISOString(),
|
|
1165
|
+
operator: getGitUserName(projectDir),
|
|
1166
|
+
budget,
|
|
1167
|
+
model,
|
|
1168
|
+
});
|
|
1169
|
+
console.log(`\x1b[32m[bd-loop]\x1b[0m Worktree: \x1b[34m${worktreeCtx.path}\x1b[0m on branch \x1b[34m${worktreeCtx.branch}\x1b[0m`);
|
|
673
1170
|
}
|
|
674
1171
|
let totalProcessed = 0;
|
|
675
1172
|
let totalClosed = 0;
|
|
@@ -677,6 +1174,13 @@ async function runLoop(options) {
|
|
|
677
1174
|
while (true) {
|
|
678
1175
|
const nextId = prefix ? findNextIssue(prefix, includeFeatures) : null;
|
|
679
1176
|
if (!nextId) {
|
|
1177
|
+
// Spec-driven safety net: the filter removes the workflow-owned feature
|
|
1178
|
+
// root from claimable work, so when its chain completes nobody may have
|
|
1179
|
+
// closed it. Close it here (no-op if the closeout child already did, or if
|
|
1180
|
+
// children remain open) before deciding to poll or terminate.
|
|
1181
|
+
if (work && resolvedFlow && prefix) {
|
|
1182
|
+
closeCompletedFeatureRoot(prefix);
|
|
1183
|
+
}
|
|
680
1184
|
if (poll) {
|
|
681
1185
|
console.log(`\x1b[36m[bd-loop]\x1b[0m No ready issues for prefix '${prefix}'. Polling again in ${pollInterval}s...`);
|
|
682
1186
|
await sleep(pollInterval * 1000);
|
|
@@ -704,11 +1208,14 @@ async function runLoop(options) {
|
|
|
704
1208
|
break;
|
|
705
1209
|
console.log(` \x1b[34m${info.id}\x1b[0m (P${info.priority} ${info.issue_type}) — ${info.title}`);
|
|
706
1210
|
console.log(` Would run: claude -p --max-budget-usd ${budget} --session-id <uuid>`);
|
|
707
|
-
if (
|
|
708
|
-
console.log(` Branch:
|
|
1211
|
+
if (noBranch) {
|
|
1212
|
+
console.log(` Branch: in-place (--no-branch)`);
|
|
709
1213
|
}
|
|
710
|
-
else
|
|
711
|
-
console.log(`
|
|
1214
|
+
else {
|
|
1215
|
+
console.log(` Worktree: ../boop-${prefix} (branch work/${prefix})`);
|
|
1216
|
+
if (perIssueBranch) {
|
|
1217
|
+
console.log(` Per-issue branch: fix/${info.id} (inside worktree)`);
|
|
1218
|
+
}
|
|
712
1219
|
}
|
|
713
1220
|
console.log(` Resume limit: ${resumeLimit} attempts`);
|
|
714
1221
|
console.log('');
|
|
@@ -740,6 +1247,7 @@ async function runLoop(options) {
|
|
|
740
1247
|
perIssueBranch,
|
|
741
1248
|
noBranch,
|
|
742
1249
|
logDir,
|
|
1250
|
+
cwd: loopCwd,
|
|
743
1251
|
});
|
|
744
1252
|
results.push(result);
|
|
745
1253
|
if (result.closed)
|
|
@@ -749,15 +1257,9 @@ async function runLoop(options) {
|
|
|
749
1257
|
break;
|
|
750
1258
|
}
|
|
751
1259
|
}
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
(0, child_process_1.execSync)(`git checkout ${originalBranch} 2>/dev/null`);
|
|
756
|
-
}
|
|
757
|
-
catch {
|
|
758
|
-
// Best effort
|
|
759
|
-
}
|
|
760
|
-
}
|
|
1260
|
+
// Always-worktree: leave the worktree and branch in place for the operator to
|
|
1261
|
+
// review and merge by hand (manual merge is the v1 contract). No checkout-back —
|
|
1262
|
+
// main's working tree was never touched.
|
|
761
1263
|
// Summary
|
|
762
1264
|
console.log('');
|
|
763
1265
|
console.log('\x1b[32m[bd-loop]\x1b[0m ━━━ Summary ━━━');
|
|
@@ -770,7 +1272,17 @@ async function runLoop(options) {
|
|
|
770
1272
|
}
|
|
771
1273
|
}
|
|
772
1274
|
console.log(`\x1b[32m[bd-loop]\x1b[0m Logs: ${logDir}/`);
|
|
1275
|
+
if (worktreeCtx) {
|
|
1276
|
+
console.log(`\x1b[32m[bd-loop]\x1b[0m Worktree: ${worktreeCtx.path} (branch ${worktreeCtx.branch})`);
|
|
1277
|
+
console.log(`\x1b[32m[bd-loop]\x1b[0m Review and merge by hand, e.g.: git -C ${projectDir} merge ${worktreeCtx.branch}`);
|
|
1278
|
+
}
|
|
773
1279
|
if (options.json) {
|
|
774
|
-
console.log(JSON.stringify({
|
|
1280
|
+
console.log(JSON.stringify({
|
|
1281
|
+
prefix,
|
|
1282
|
+
totalProcessed,
|
|
1283
|
+
totalClosed,
|
|
1284
|
+
results,
|
|
1285
|
+
worktree: worktreeCtx ? { path: worktreeCtx.path, branch: worktreeCtx.branch, mode: worktreeCtx.mode } : null,
|
|
1286
|
+
}, null, 2));
|
|
775
1287
|
}
|
|
776
1288
|
}
|