@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.
@@ -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
- function getMatchingReadyIssues(prefix, includeFeatures) {
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)('bd ready --json -n 20 2>/dev/null', { encoding: 'utf8' });
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
- const RESUME_PROMPT = `Approved. Continue working on this issue. Complete the full workflow:
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 NOT pause for review. Complete everything now.`;
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
- const child = (0, child_process_1.spawn)(claudeBin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
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 || '5.00';
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 || 'claude-opus-4-6';
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
- // Set up shared branch
649
- let originalBranch;
650
- let sharedBranch;
651
- if (!noBranch && !perIssueBranch) {
652
- sharedBranch = `work/${prefix}`;
653
- console.log(`\x1b[32m[bd-loop]\x1b[0m Branch strategy: shared branch \x1b[34m${sharedBranch}\x1b[0m`);
654
- }
655
- if (sharedBranch && !dryRun) {
656
- originalBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim();
657
- console.log(`\x1b[32m[bd-loop]\x1b[0m Checking out branch: ${sharedBranch}`);
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
- (0, child_process_1.execSync)(`git checkout -b ${sharedBranch} 2>/dev/null`, { encoding: 'utf8' });
1152
+ worktreeCtx = createWorktree(prefix, worktreeMode, projectDir);
660
1153
  }
661
- catch {
662
- try {
663
- (0, child_process_1.execSync)(`git checkout ${sharedBranch}`, { encoding: 'utf8' });
664
- console.log(`\x1b[33m[bd-loop]\x1b[0m Branch ${sharedBranch} already exists, checked out`);
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 (sharedBranch) {
708
- console.log(` Branch: ${sharedBranch} (shared)`);
1211
+ if (noBranch) {
1212
+ console.log(` Branch: in-place (--no-branch)`);
709
1213
  }
710
- else if (!noBranch) {
711
- console.log(` Branch: fix/${info.id}`);
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
- // Return to original branch
753
- if (sharedBranch && !dryRun && originalBranch) {
754
- try {
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({ prefix, totalProcessed, totalClosed, results }, null, 2));
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
  }