@boopkit/cli 0.6.14 → 0.6.15
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-versions.json +5 -5
- package/dist/cli.js +0 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/update.js +30 -1
- package/dist/lib/execute.js +3 -12
- package/dist/lib/scaffold.d.ts +36 -6
- package/dist/lib/scaffold.js +79 -10
- package/dist/lib/script-merge.d.ts +55 -0
- package/dist/lib/script-merge.js +144 -0
- package/dist/lib/stale-scripts.d.ts +3 -2
- package/dist/lib/stale-scripts.js +3 -2
- package/dist/lib/update.d.ts +7 -0
- package/dist/lib/update.js +67 -47
- package/package.json +1 -1
- package/dist/commands/loop.js.orig-pre-maxbuffer +0 -1455
- package/dist/commands/status.test.d.ts +0 -1
- package/dist/commands/status.test.js +0 -118
- package/dist/lib/project.test.d.ts +0 -1
- package/dist/lib/project.test.js +0 -118
|
@@ -1,1455 +0,0 @@
|
|
|
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.RESUME_PROMPT = exports.DEFAULT_BUDGET_USD = void 0;
|
|
37
|
-
exports.assertDoltServerMode = assertDoltServerMode;
|
|
38
|
-
exports.discoverDoltServer = discoverDoltServer;
|
|
39
|
-
exports.resolveDefaultBranch = resolveDefaultBranch;
|
|
40
|
-
exports.createWorktree = createWorktree;
|
|
41
|
-
exports.writeWorktreeMetadata = writeWorktreeMetadata;
|
|
42
|
-
exports.buildPlanningPrompt = buildPlanningPrompt;
|
|
43
|
-
exports.planWork = planWork;
|
|
44
|
-
exports.findFallbackFeature = findFallbackFeature;
|
|
45
|
-
exports.extractFeatureId = extractFeatureId;
|
|
46
|
-
exports.isUsageLimitText = isUsageLimitText;
|
|
47
|
-
exports.isStandaloneUsageLimitMessage = isStandaloneUsageLimitMessage;
|
|
48
|
-
exports.resolveModel = resolveModel;
|
|
49
|
-
exports.shouldResumeUnclosedIssue = shouldResumeUnclosedIssue;
|
|
50
|
-
exports.isIssueInScope = isIssueInScope;
|
|
51
|
-
exports.isClaimableReadyIssue = isClaimableReadyIssue;
|
|
52
|
-
exports.shouldCloseFeatureRoot = shouldCloseFeatureRoot;
|
|
53
|
-
exports.getMatchingReadyIssues = getMatchingReadyIssues;
|
|
54
|
-
exports.shouldRotateSession = shouldRotateSession;
|
|
55
|
-
exports.runLoop = runLoop;
|
|
56
|
-
const child_process_1 = require("child_process");
|
|
57
|
-
const fs = __importStar(require("fs"));
|
|
58
|
-
const path = __importStar(require("path"));
|
|
59
|
-
const crypto_1 = require("crypto");
|
|
60
|
-
/**
|
|
61
|
-
* Hard requirement: beads must run in Dolt server mode so concurrent worktrees
|
|
62
|
-
* share one issue DB over TCP. Reads `.beads/metadata.json` and throws an
|
|
63
|
-
* actionable error on embedded/missing — call before any `bd` work.
|
|
64
|
-
*/
|
|
65
|
-
function assertDoltServerMode(projectDir, deps) {
|
|
66
|
-
const existsSyncFn = deps?.existsSyncFn || fs.existsSync;
|
|
67
|
-
const readFileFn = deps?.readFileFn || ((p, enc) => fs.readFileSync(p, enc));
|
|
68
|
-
const metaPath = path.join(projectDir, '.beads', 'metadata.json');
|
|
69
|
-
const fix = 'Migrate beads to Dolt server mode before running the loop; see skills/boop-dolt-recovery/SKILL.md';
|
|
70
|
-
if (!existsSyncFn(metaPath)) {
|
|
71
|
-
throw new Error(`beads metadata not found at ${metaPath}. The loop requires Dolt server mode for concurrent worktrees. ${fix}`);
|
|
72
|
-
}
|
|
73
|
-
let mode;
|
|
74
|
-
try {
|
|
75
|
-
const meta = JSON.parse(readFileFn(metaPath, 'utf8'));
|
|
76
|
-
mode = meta.dolt_mode;
|
|
77
|
-
}
|
|
78
|
-
catch (e) {
|
|
79
|
-
throw new Error(`Failed to parse ${metaPath}: ${e.message}. ${fix}`);
|
|
80
|
-
}
|
|
81
|
-
if (mode !== 'server') {
|
|
82
|
-
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}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Discover the running shared Dolt server's port from `.beads/dolt-server.port`.
|
|
87
|
-
* Worktrees never start their own server — they reuse this one over TCP.
|
|
88
|
-
* Returns null when no port file is present (caller errors clearly). Liveness
|
|
89
|
-
* (pid still running) is best-effort and never blocks discovery.
|
|
90
|
-
*/
|
|
91
|
-
function discoverDoltServer(projectDir, deps) {
|
|
92
|
-
const existsSyncFn = deps?.existsSyncFn || fs.existsSync;
|
|
93
|
-
const readFileFn = deps?.readFileFn || ((p, enc) => fs.readFileSync(p, enc));
|
|
94
|
-
const isPidAliveFn = deps?.isPidAliveFn ||
|
|
95
|
-
((pid) => {
|
|
96
|
-
try {
|
|
97
|
-
process.kill(pid, 0);
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
const portPath = path.join(projectDir, '.beads', 'dolt-server.port');
|
|
105
|
-
if (!existsSyncFn(portPath)) {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
const port = parseInt(readFileFn(portPath, 'utf8').trim(), 10);
|
|
109
|
-
if (!Number.isFinite(port) || port <= 0) {
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
// Best-effort liveness: read pid file and probe; never throws/blocks.
|
|
113
|
-
let pid;
|
|
114
|
-
let alive;
|
|
115
|
-
const pidPath = path.join(projectDir, '.beads', 'dolt-server.pid');
|
|
116
|
-
if (existsSyncFn(pidPath)) {
|
|
117
|
-
const parsed = parseInt(readFileFn(pidPath, 'utf8').trim(), 10);
|
|
118
|
-
if (Number.isFinite(parsed) && parsed > 0) {
|
|
119
|
-
pid = parsed;
|
|
120
|
-
alive = isPidAliveFn(parsed);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return { port, pid, alive };
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Create (or reuse) the loop's own git worktree at `../boop-<scope>` on branch
|
|
127
|
-
* `work/<scope>`, then pre-warm `npm install --prefer-offline`. This is what
|
|
128
|
-
* makes N loops run concurrently without colliding — each gets its own tree and
|
|
129
|
-
* branch; main is never checked out.
|
|
130
|
-
*
|
|
131
|
-
* Collision (Decision #4): if a worktree already exists at the path on the same
|
|
132
|
-
* branch, reuse it (and skip install if node_modules is present); if the path is
|
|
133
|
-
* occupied by something else, throw a clear error rather than silently suffixing.
|
|
134
|
-
*/
|
|
135
|
-
/**
|
|
136
|
-
* Best-effort resolution of the repo's default branch, used to move a launch dir
|
|
137
|
-
* off a loop feature branch it should never have been parked on (boop-6iw).
|
|
138
|
-
* Prefers `origin/HEAD`, then a local `main`/`master`, then `main`.
|
|
139
|
-
*/
|
|
140
|
-
function resolveDefaultBranch(execSyncFn, cwd) {
|
|
141
|
-
try {
|
|
142
|
-
const ref = execSyncFn('git symbolic-ref --short refs/remotes/origin/HEAD', { encoding: 'utf8', cwd }).trim();
|
|
143
|
-
if (ref)
|
|
144
|
-
return ref.replace(/^origin\//, '');
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
// origin/HEAD not set — fall through to local candidates.
|
|
148
|
-
}
|
|
149
|
-
for (const cand of ['main', 'master']) {
|
|
150
|
-
try {
|
|
151
|
-
execSyncFn(`git rev-parse --verify --quiet refs/heads/${cand}`, { encoding: 'utf8', cwd });
|
|
152
|
-
return cand;
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
// not present — try next
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return 'main';
|
|
159
|
-
}
|
|
160
|
-
function createWorktree(scope, mode, projectDir, deps) {
|
|
161
|
-
const execSyncFn = deps?.execSyncFn ||
|
|
162
|
-
((cmd, o) => (0, child_process_1.execSync)(cmd, o));
|
|
163
|
-
const existsSyncFn = deps?.existsSyncFn || fs.existsSync;
|
|
164
|
-
const copyFileFn = deps?.copyFileFn || ((src, dest) => fs.copyFileSync(src, dest));
|
|
165
|
-
const readdirFn = deps?.readdirFn || ((p) => fs.readdirSync(p));
|
|
166
|
-
const readFileFn = deps?.readFileFn || ((p) => fs.readFileSync(p, 'utf8'));
|
|
167
|
-
const appendFileFn = deps?.appendFileFn || ((p, data) => fs.appendFileSync(p, data));
|
|
168
|
-
const branch = `work/${scope}`;
|
|
169
|
-
const worktreePath = path.resolve(projectDir, '..', `boop-${scope}`);
|
|
170
|
-
const ctx = { path: worktreePath, branch, scope, mode };
|
|
171
|
-
// A fresh worktree checks out its own (tracked) `.beads/` but NOT the gitignored
|
|
172
|
-
// runtime files dolt-server.port/pid. Without them, `bd` inside the worktree
|
|
173
|
-
// auto-starts a ROGUE Dolt server on a different port and can't see the issue DB.
|
|
174
|
-
// Propagate the shared server's discovery files so the worktree reuses it.
|
|
175
|
-
// (Verified in Task 1.7 — see specs/parallel-worktree-development-audit.md.)
|
|
176
|
-
const propagateServerFiles = () => {
|
|
177
|
-
for (const file of ['dolt-server.port', 'dolt-server.pid']) {
|
|
178
|
-
const src = path.join(projectDir, '.beads', file);
|
|
179
|
-
const dest = path.join(worktreePath, '.beads', file);
|
|
180
|
-
if (existsSyncFn(src) && existsSyncFn(path.join(worktreePath, '.beads'))) {
|
|
181
|
-
try {
|
|
182
|
-
copyFileFn(src, dest);
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
// Best-effort; discovery already validated the shared server in runLoop.
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
// Gitignored runtime config (`.env` and friends) lives only in the main working
|
|
191
|
-
// tree — `git worktree add` checks out tracked files only, so a fresh worktree
|
|
192
|
-
// would start with no environment and the agent's app/tests would fail. Seed
|
|
193
|
-
// copies of root-level `.env*` files. Copy (not symlink) to keep worktrees
|
|
194
|
-
// isolated, matching the spec's "each worktree is a full working copy (own
|
|
195
|
-
// node_modules, own .env)". Don't clobber a file the agent may already have
|
|
196
|
-
// customized in a reused worktree. (boop-bf3)
|
|
197
|
-
const propagateConfigFiles = () => {
|
|
198
|
-
let entries;
|
|
199
|
-
try {
|
|
200
|
-
entries = readdirFn(projectDir);
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
return; // best-effort — a missing/unreadable project dir shouldn't abort setup
|
|
204
|
-
}
|
|
205
|
-
for (const name of entries) {
|
|
206
|
-
if (name !== '.env' && !name.startsWith('.env.'))
|
|
207
|
-
continue;
|
|
208
|
-
const src = path.join(projectDir, name);
|
|
209
|
-
const dest = path.join(worktreePath, name);
|
|
210
|
-
try {
|
|
211
|
-
if (existsSyncFn(src) && !existsSyncFn(dest)) {
|
|
212
|
-
copyFileFn(src, dest);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
catch {
|
|
216
|
-
// Best-effort; one unreadable config file shouldn't abort worktree setup.
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
// `.boop-worktree.json` is per-worktree runtime state (scope/branch/in-scope
|
|
221
|
-
// issues), not source. It must NEVER be tracked: if a closeout `git add` sweeps
|
|
222
|
-
// it into a commit, sibling worktree branches inherit a stale copy, and a later
|
|
223
|
-
// `git stash`/`git pull --rebase` can silently revert THIS worktree's metadata
|
|
224
|
-
// to another scope (the wf-bir→wf-ayn regression — boop-g0l). Register the
|
|
225
|
-
// ignore in the repo's local git exclude (resolved per-worktree via
|
|
226
|
-
// `git rev-parse --git-path info/exclude`) rather than the tracked `.gitignore`,
|
|
227
|
-
// so adding the rule never itself becomes an uncommitted tracked change that
|
|
228
|
-
// blocks the closeout's `git pull --rebase`. Idempotent and best-effort.
|
|
229
|
-
const ensureMetadataIgnored = () => {
|
|
230
|
-
try {
|
|
231
|
-
const raw = execSyncFn('git rev-parse --git-path info/exclude', {
|
|
232
|
-
encoding: 'utf8',
|
|
233
|
-
cwd: worktreePath,
|
|
234
|
-
});
|
|
235
|
-
const rel = (raw || '').trim();
|
|
236
|
-
if (!rel)
|
|
237
|
-
return;
|
|
238
|
-
const excludePath = path.isAbsolute(rel) ? rel : path.join(worktreePath, rel);
|
|
239
|
-
let current = '';
|
|
240
|
-
try {
|
|
241
|
-
current = readFileFn(excludePath);
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
// Exclude file may not exist yet — treat as empty; append will create it.
|
|
245
|
-
}
|
|
246
|
-
const already = current.split('\n').some((l) => l.trim() === '.boop-worktree.json');
|
|
247
|
-
if (already)
|
|
248
|
-
return;
|
|
249
|
-
const sep = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
250
|
-
appendFileFn(excludePath, `${sep}.boop-worktree.json\n`);
|
|
251
|
-
}
|
|
252
|
-
catch {
|
|
253
|
-
// Best-effort: worst case the metadata file shows as untracked — still safer
|
|
254
|
-
// than the pre-fix tracked state. Never abort worktree setup over this.
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
// Is this path already a registered worktree? Parse `git worktree list --porcelain`.
|
|
258
|
-
let existingBranch;
|
|
259
|
-
let pathAlreadyWorktree = false;
|
|
260
|
-
try {
|
|
261
|
-
const raw = execSyncFn('git worktree list --porcelain', { encoding: 'utf8', cwd: projectDir });
|
|
262
|
-
const blocks = raw.split('\n\n');
|
|
263
|
-
for (const block of blocks) {
|
|
264
|
-
const lines = block.split('\n');
|
|
265
|
-
const wtLine = lines.find((l) => l.startsWith('worktree '));
|
|
266
|
-
if (!wtLine)
|
|
267
|
-
continue;
|
|
268
|
-
const wtPath = path.resolve(wtLine.slice('worktree '.length).trim());
|
|
269
|
-
if (wtPath === worktreePath) {
|
|
270
|
-
pathAlreadyWorktree = true;
|
|
271
|
-
const brLine = lines.find((l) => l.startsWith('branch '));
|
|
272
|
-
if (brLine) {
|
|
273
|
-
// refs/heads/work/<scope> → work/<scope>
|
|
274
|
-
existingBranch = brLine.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
// No git / no worktrees yet — proceed to create.
|
|
281
|
-
}
|
|
282
|
-
if (pathAlreadyWorktree) {
|
|
283
|
-
if (existingBranch && existingBranch !== branch) {
|
|
284
|
-
throw new Error(`Worktree at ${worktreePath} is on branch "${existingBranch}", not the expected "${branch}". ` +
|
|
285
|
-
`Remove it (git worktree remove ${worktreePath}) or choose a different scope.`);
|
|
286
|
-
}
|
|
287
|
-
// Seed gitignored runtime config before any (re)install/bd work.
|
|
288
|
-
propagateConfigFiles();
|
|
289
|
-
// Reuse: only install if dependencies are missing.
|
|
290
|
-
if (!existsSyncFn(path.join(worktreePath, 'node_modules'))) {
|
|
291
|
-
execSyncFn('npm install --prefer-offline', { encoding: 'utf8', cwd: worktreePath });
|
|
292
|
-
}
|
|
293
|
-
propagateServerFiles();
|
|
294
|
-
ensureMetadataIgnored();
|
|
295
|
-
return ctx;
|
|
296
|
-
}
|
|
297
|
-
if (existsSyncFn(worktreePath)) {
|
|
298
|
-
throw new Error(`Path ${worktreePath} already exists but is not a registered git worktree. ` +
|
|
299
|
-
`Remove it or choose a different scope before running the loop.`);
|
|
300
|
-
}
|
|
301
|
-
// Does the branch already exist? If so, attach the worktree to it; else create it.
|
|
302
|
-
let branchExists = false;
|
|
303
|
-
try {
|
|
304
|
-
execSyncFn(`git rev-parse --verify --quiet refs/heads/${branch}`, { encoding: 'utf8', cwd: projectDir });
|
|
305
|
-
branchExists = true;
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
branchExists = false;
|
|
309
|
-
}
|
|
310
|
-
if (branchExists) {
|
|
311
|
-
// Guard (boop-6iw): if the loop branch is checked out in the launch dir — e.g. a
|
|
312
|
-
// planning phase that ran `git checkout -b work/<id>` in place before the worktree
|
|
313
|
-
// existed — then `git worktree add <path> <branch>` fails with "already checked
|
|
314
|
-
// out at <launch-dir>". It also leaves the launch dir parked on the feature branch,
|
|
315
|
-
// so a concurrent session's next commit lands on the wrong branch. Move the launch
|
|
316
|
-
// dir back to its default branch first: it both unblocks the worktree add and undoes
|
|
317
|
-
// the mis-parking. (Flows should not create the branch during planning — see the
|
|
318
|
-
// light-dev-workflow skill — but this makes any that slip through non-fatal.)
|
|
319
|
-
let launchBranch = '';
|
|
320
|
-
try {
|
|
321
|
-
launchBranch = execSyncFn('git branch --show-current', { encoding: 'utf8', cwd: projectDir }).trim();
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
// Detached HEAD or no git — nothing of ours is parked; proceed.
|
|
325
|
-
}
|
|
326
|
-
if (launchBranch === branch) {
|
|
327
|
-
const defaultBranch = resolveDefaultBranch(execSyncFn, projectDir);
|
|
328
|
-
try {
|
|
329
|
-
execSyncFn(`git checkout ${defaultBranch}`, { encoding: 'utf8', cwd: projectDir });
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
throw new Error(`Loop branch "${branch}" is checked out in the launch directory (${projectDir}), which blocks ` +
|
|
333
|
-
`creating its worktree. Moving the launch dir to "${defaultBranch}" failed — likely uncommitted ` +
|
|
334
|
-
`changes there. Commit or stash them (or run \`git checkout ${defaultBranch}\`) and retry.`);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
execSyncFn(`git worktree add ${worktreePath} ${branch}`, { encoding: 'utf8', cwd: projectDir });
|
|
338
|
-
}
|
|
339
|
-
else {
|
|
340
|
-
execSyncFn(`git worktree add ${worktreePath} -b ${branch}`, { encoding: 'utf8', cwd: projectDir });
|
|
341
|
-
}
|
|
342
|
-
// Point the worktree's bd at the shared server before any bd work happens.
|
|
343
|
-
propagateServerFiles();
|
|
344
|
-
// Seed gitignored runtime config (`.env` etc.) that `git worktree add` doesn't
|
|
345
|
-
// check out, so the app/tests have their environment before install/session. (boop-bf3)
|
|
346
|
-
propagateConfigFiles();
|
|
347
|
-
// Pre-warm dependencies so the agent doesn't burn budget on a deterministic step.
|
|
348
|
-
execSyncFn('npm install --prefer-offline', { encoding: 'utf8', cwd: worktreePath });
|
|
349
|
-
// Ignore the metadata file before it is ever written (writeWorktreeMetadata runs
|
|
350
|
-
// next in runLoop), so no later `git add` can track it.
|
|
351
|
-
ensureMetadataIgnored();
|
|
352
|
-
return ctx;
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Write `.boop-worktree.json` into the worktree root. This is the contract
|
|
356
|
-
* Slice 2 (`boop loop --merge`) reads — `mode` drives its readiness query.
|
|
357
|
-
*/
|
|
358
|
-
function writeWorktreeMetadata(ctx, meta, deps) {
|
|
359
|
-
const writeFileFn = deps?.writeFileFn || ((p, data) => fs.writeFileSync(p, data));
|
|
360
|
-
const data = {
|
|
361
|
-
version: 1,
|
|
362
|
-
mode: ctx.mode,
|
|
363
|
-
scope: ctx.scope,
|
|
364
|
-
branch: ctx.branch,
|
|
365
|
-
worktreePath: ctx.path,
|
|
366
|
-
};
|
|
367
|
-
if (ctx.mode === 'work-flow') {
|
|
368
|
-
if (meta.workPath)
|
|
369
|
-
data.workPath = meta.workPath;
|
|
370
|
-
if (meta.flowPath)
|
|
371
|
-
data.flowPath = meta.flowPath;
|
|
372
|
-
}
|
|
373
|
-
data.startTime = meta.startTime;
|
|
374
|
-
data.boopVersion = meta.boopVersion;
|
|
375
|
-
if (meta.operator)
|
|
376
|
-
data.operator = meta.operator;
|
|
377
|
-
if (meta.budget)
|
|
378
|
-
data.budget = meta.budget;
|
|
379
|
-
if (meta.model)
|
|
380
|
-
data.model = meta.model;
|
|
381
|
-
writeFileFn(path.join(ctx.path, '.boop-worktree.json'), JSON.stringify(data, null, 2) + '\n');
|
|
382
|
-
return data;
|
|
383
|
-
}
|
|
384
|
-
function getBoopVersion() {
|
|
385
|
-
try {
|
|
386
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
387
|
-
return pkg.version || 'unknown';
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
return 'unknown';
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
function getGitUserName(projectDir) {
|
|
394
|
-
try {
|
|
395
|
-
return (0, child_process_1.execSync)('git config user.name', { encoding: 'utf8', cwd: projectDir }).trim() || undefined;
|
|
396
|
-
}
|
|
397
|
-
catch {
|
|
398
|
-
return undefined;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
function buildPlanningPrompt(workContent, flowContent, prefix) {
|
|
402
|
-
let prompt = `You are setting up work for a development project.
|
|
403
|
-
|
|
404
|
-
## Work Specification
|
|
405
|
-
${workContent}
|
|
406
|
-
|
|
407
|
-
## Workflow Process
|
|
408
|
-
${flowContent}
|
|
409
|
-
|
|
410
|
-
## Instructions
|
|
411
|
-
1. Read the work specification carefully.
|
|
412
|
-
2. Follow the Workflow Process above to set up the work. The Workflow Process
|
|
413
|
-
governs the stages, the container issue (its type and title), and the child
|
|
414
|
-
issues created under it — use \`--parent <container-id>\` so they are its children.
|
|
415
|
-
3. The setup MUST produce a single container issue with its child issues, each
|
|
416
|
-
carrying breadcrumbs (links to the work file, relevant specs, skills, and code),
|
|
417
|
-
with appropriate priorities and blocking deps.
|
|
418
|
-
|
|
419
|
-
When finished, output exactly one line with the id of the container issue you created:
|
|
420
|
-
FEATURE_ID=<the-container-issue-id>`;
|
|
421
|
-
if (prefix) {
|
|
422
|
-
prompt += `\n\nNaming hint: Use \`${prefix}\` as a naming hint for the container title.`;
|
|
423
|
-
}
|
|
424
|
-
return prompt;
|
|
425
|
-
}
|
|
426
|
-
async function planWork(opts) {
|
|
427
|
-
const workContent = fs.readFileSync(opts.workPath, 'utf8');
|
|
428
|
-
const flowContent = fs.readFileSync(opts.flowPath, 'utf8');
|
|
429
|
-
const prompt = buildPlanningPrompt(workContent, flowContent, opts.prefix);
|
|
430
|
-
const sessionId = (0, crypto_1.randomUUID)();
|
|
431
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
|
432
|
-
const logFile = path.join(opts.logDir, `_planning_${opts.prefix}_${timestamp}.log`);
|
|
433
|
-
const readableLogFile = path.join(opts.logDir, `_planning_${opts.prefix}_${timestamp}.readable.log`);
|
|
434
|
-
// Write readable log header
|
|
435
|
-
fs.writeFileSync(readableLogFile, `=== Planning Session ===\nSession: ${sessionId}\nStarted: ${new Date().toISOString()}\n\n`);
|
|
436
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m 📋 Starting planning phase...`);
|
|
437
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Work: ${opts.workPath}`);
|
|
438
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Flow: ${opts.flowPath}`);
|
|
439
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Log: ${logFile}`);
|
|
440
|
-
const runner = opts.runClaudeFn || runClaude;
|
|
441
|
-
const result = await runner(prompt, {
|
|
442
|
-
budget: opts.budget,
|
|
443
|
-
model: opts.model,
|
|
444
|
-
skipPerms: opts.skipPerms,
|
|
445
|
-
bare: opts.bare,
|
|
446
|
-
sessionId,
|
|
447
|
-
}, logFile, readableLogFile);
|
|
448
|
-
if (result.exitCode !== 0 && result.limitHit) {
|
|
449
|
-
console.error('\x1b[31m[bd-loop]\x1b[0m Planning phase hit usage limit');
|
|
450
|
-
return { featureId: null, costUsd: null };
|
|
451
|
-
}
|
|
452
|
-
if (result.exitCode !== 0) {
|
|
453
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Planning phase exited with code ${result.exitCode}`);
|
|
454
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Check log: ${logFile}`);
|
|
455
|
-
return { featureId: null, costUsd: null };
|
|
456
|
-
}
|
|
457
|
-
// Read log file and parse assistant text + cost
|
|
458
|
-
let accumulated = '';
|
|
459
|
-
let costUsd = null;
|
|
460
|
-
try {
|
|
461
|
-
const logContent = fs.readFileSync(logFile, 'utf8');
|
|
462
|
-
const lines = logContent.split('\n').filter(Boolean);
|
|
463
|
-
for (const line of lines) {
|
|
464
|
-
try {
|
|
465
|
-
const msg = JSON.parse(line);
|
|
466
|
-
if (msg.type === 'assistant') {
|
|
467
|
-
const texts = (msg.message?.content || [])
|
|
468
|
-
.filter((c) => c.type === 'text')
|
|
469
|
-
.map((c) => c.text);
|
|
470
|
-
accumulated += texts.join('');
|
|
471
|
-
}
|
|
472
|
-
else if (msg.type === 'result' && typeof msg.total_cost_usd === 'number') {
|
|
473
|
-
costUsd = msg.total_cost_usd;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
catch (e) {
|
|
477
|
-
console.warn(`\x1b[33m[bd-loop]\x1b[0m Failed to parse planning log line: ${e.message}`);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
catch {
|
|
482
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to read planning log: ${logFile}`);
|
|
483
|
-
return { featureId: null, costUsd: null };
|
|
484
|
-
}
|
|
485
|
-
// Primary extraction
|
|
486
|
-
const featureId = extractFeatureId(accumulated);
|
|
487
|
-
if (featureId) {
|
|
488
|
-
return { featureId, costUsd };
|
|
489
|
-
}
|
|
490
|
-
// Fallback: query bd list for recently created features
|
|
491
|
-
console.log('\x1b[33m[bd-loop]\x1b[0m No FEATURE_ID in output, trying fallback query...');
|
|
492
|
-
try {
|
|
493
|
-
const execFn = opts.execSyncFn || ((cmd, o) => (0, child_process_1.execSync)(cmd, o));
|
|
494
|
-
const raw = execFn('bd list --json --type feature 2>/dev/null', { encoding: 'utf8' });
|
|
495
|
-
const features = JSON.parse(raw);
|
|
496
|
-
const fallbackId = findFallbackFeature(features, workContent, opts.workPath);
|
|
497
|
-
if (fallbackId) {
|
|
498
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Fallback found feature: ${fallbackId}`);
|
|
499
|
-
return { featureId: fallbackId, costUsd };
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
catch (e) {
|
|
503
|
-
console.warn(`\x1b[33m[bd-loop]\x1b[0m Fallback feature query failed: ${e.message}`);
|
|
504
|
-
}
|
|
505
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Planning phase failed to produce a feature ID`);
|
|
506
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Check planning log: ${logFile}`);
|
|
507
|
-
return { featureId: null, costUsd };
|
|
508
|
-
}
|
|
509
|
-
function findFallbackFeature(features, workContent, workPath) {
|
|
510
|
-
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
511
|
-
// Extract keywords from work file H1 heading or filename
|
|
512
|
-
const h1Match = workContent.match(/^#\s+(.+)$/m);
|
|
513
|
-
const keywords = h1Match
|
|
514
|
-
? h1Match[1].toLowerCase().split(/\s+/).filter((w) => w.length > 3).slice(0, 3)
|
|
515
|
-
: [path.basename(workPath, path.extname(workPath)).toLowerCase()];
|
|
516
|
-
const match = features.find((f) => {
|
|
517
|
-
if (!f.created_at || f.created_at < fiveMinAgo)
|
|
518
|
-
return false;
|
|
519
|
-
const title = (f.title || '').toLowerCase();
|
|
520
|
-
return keywords.some((kw) => title.includes(kw));
|
|
521
|
-
});
|
|
522
|
-
return match?.id ?? null;
|
|
523
|
-
}
|
|
524
|
-
function extractFeatureId(text) {
|
|
525
|
-
const match = text.match(/FEATURE_ID=([\w.-]+)/);
|
|
526
|
-
return match ? match[1] : null;
|
|
527
|
-
}
|
|
528
|
-
function isUsageLimitText(text) {
|
|
529
|
-
// Normalize all whitespace (including NBSP) so provider text variants still match.
|
|
530
|
-
const normalized = text.toLowerCase().replace(/\s+/gu, ' ').trim();
|
|
531
|
-
return (normalized.includes("you've hit your limit") ||
|
|
532
|
-
normalized.includes('usage limit') ||
|
|
533
|
-
normalized.includes('rate limit') ||
|
|
534
|
-
normalized.includes('quota') ||
|
|
535
|
-
normalized.includes('credit balance is too low') ||
|
|
536
|
-
normalized.includes('credit balance too low') ||
|
|
537
|
-
/credit\s+balance\s+(?:is\s+)?too\s+low/u.test(normalized) ||
|
|
538
|
-
normalized.includes('insufficient credit') ||
|
|
539
|
-
normalized.includes('insufficient funds'));
|
|
540
|
-
}
|
|
541
|
-
function isStandaloneUsageLimitMessage(text) {
|
|
542
|
-
const trimmed = text.trim();
|
|
543
|
-
if (!trimmed || trimmed.includes('\n')) {
|
|
544
|
-
return false;
|
|
545
|
-
}
|
|
546
|
-
// Real provider limit responses are usually short single-line messages.
|
|
547
|
-
if (trimmed.length > 200) {
|
|
548
|
-
return false;
|
|
549
|
-
}
|
|
550
|
-
const normalized = trimmed.toLowerCase().replace(/\s+/gu, ' ');
|
|
551
|
-
return (/^credit\s+balance\s+(?:is\s+)?too\s+low\b/u.test(normalized) ||
|
|
552
|
-
/^insufficient\s+credit\b/u.test(normalized) ||
|
|
553
|
-
/^insufficient\s+funds\b/u.test(normalized) ||
|
|
554
|
-
/^you'?ve\s+hit\s+your\s+limit\b/u.test(normalized) ||
|
|
555
|
-
/^rate\s+limit\s+reached\b/u.test(normalized) ||
|
|
556
|
-
/^error:\s*usage\s+limit\s+exceeded\b/u.test(normalized) ||
|
|
557
|
-
/^quota\s+exceeded\b/u.test(normalized));
|
|
558
|
-
}
|
|
559
|
-
function findClaudeBinary() {
|
|
560
|
-
try {
|
|
561
|
-
return (0, child_process_1.execSync)('command -v claude', { encoding: 'utf8' }).trim();
|
|
562
|
-
}
|
|
563
|
-
catch {
|
|
564
|
-
// Check common locations
|
|
565
|
-
const candidates = [
|
|
566
|
-
path.join(process.env.HOME || '', '.nvm/versions/node'),
|
|
567
|
-
path.join(process.env.HOME || '', '.local/bin/claude'),
|
|
568
|
-
'/usr/local/bin/claude',
|
|
569
|
-
];
|
|
570
|
-
for (const candidate of candidates) {
|
|
571
|
-
if (candidate.includes('.nvm')) {
|
|
572
|
-
// Glob for nvm versions
|
|
573
|
-
try {
|
|
574
|
-
const result = (0, child_process_1.execSync)(`ls ${candidate}/*/bin/claude 2>/dev/null | head -1`, {
|
|
575
|
-
encoding: 'utf8',
|
|
576
|
-
}).trim();
|
|
577
|
-
if (result)
|
|
578
|
-
return result;
|
|
579
|
-
}
|
|
580
|
-
catch {
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
if (fs.existsSync(candidate))
|
|
585
|
-
return candidate;
|
|
586
|
-
}
|
|
587
|
-
return null;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Resolve the model for a loop run. Precedence: an explicit `--model` flag, then
|
|
592
|
-
* the `ANTHROPIC_MODEL` environment variable, then the built-in default. The env
|
|
593
|
-
* fallback is what lets a nested `boop loop` (spawned by a Claude session that
|
|
594
|
-
* the parent loop launched with ANTHROPIC_MODEL set) inherit the same model
|
|
595
|
-
* instead of silently dropping to the default. Keep the default (claude-opus-5)
|
|
596
|
-
* in sync with the `--model` help text in cli.ts.
|
|
597
|
-
*/
|
|
598
|
-
function resolveModel(optionModel, env = process.env) {
|
|
599
|
-
return optionModel || env.ANTHROPIC_MODEL || 'claude-opus-5';
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* Whether the loop should resume an issue that a session left open. A `blocked`
|
|
603
|
-
* issue was deliberately halted by the prior session because it judged it could
|
|
604
|
-
* not safely proceed — resuming just spends budget re-halting and risks the
|
|
605
|
-
* resume prompt being read as authorization to work past the block. Stop and
|
|
606
|
-
* surface it for the operator instead. Any other open status is genuinely
|
|
607
|
-
* unfinished work and is fine to resume.
|
|
608
|
-
*/
|
|
609
|
-
function shouldResumeUnclosedIssue(status) {
|
|
610
|
-
return status !== 'blocked';
|
|
611
|
-
}
|
|
612
|
-
function findNextIssue(prefix, includeFeatures) {
|
|
613
|
-
const matching = getMatchingReadyIssues(prefix, includeFeatures);
|
|
614
|
-
return matching.length > 0 ? matching[0].id : null;
|
|
615
|
-
}
|
|
616
|
-
function getIssueParentId(id) {
|
|
617
|
-
try {
|
|
618
|
-
const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
|
|
619
|
-
const data = JSON.parse(raw);
|
|
620
|
-
const issue = data[0] || data;
|
|
621
|
-
return issue.parent || null;
|
|
622
|
-
}
|
|
623
|
-
catch {
|
|
624
|
-
return null;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
function isIssueInScope(issue, prefix, getParentById) {
|
|
628
|
-
if (issue.id.startsWith(prefix)) {
|
|
629
|
-
return true;
|
|
630
|
-
}
|
|
631
|
-
const seen = new Set();
|
|
632
|
-
let ancestor = issue.parent || null;
|
|
633
|
-
while (ancestor && !seen.has(ancestor)) {
|
|
634
|
-
if (ancestor.startsWith(prefix)) {
|
|
635
|
-
return true;
|
|
636
|
-
}
|
|
637
|
-
seen.add(ancestor);
|
|
638
|
-
ancestor = getParentById(ancestor);
|
|
639
|
-
}
|
|
640
|
-
return false;
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* A workflow-owned feature root is a feature-type issue that owns child tasks
|
|
644
|
-
* (the workflow steps: understand → plan → … → closeout). Once those children
|
|
645
|
-
* close, `bd ready` surfaces the feature root itself — but it is a *container*,
|
|
646
|
-
* not directly claimable work. Claiming it lets a worker session absorb
|
|
647
|
-
* punch-list fixes or whole workflow steps (the boop-qna / wishfleet-O7 bug).
|
|
648
|
-
*
|
|
649
|
-
* `hasChildren` is only consulted for feature-type issues, so non-features never
|
|
650
|
-
* pay the cost of the children lookup.
|
|
651
|
-
*/
|
|
652
|
-
function isClaimableReadyIssue(issue, hasChildren) {
|
|
653
|
-
if (issue.issue_type === 'feature' && hasChildren(issue.id)) {
|
|
654
|
-
return false;
|
|
655
|
-
}
|
|
656
|
-
return true;
|
|
657
|
-
}
|
|
658
|
-
/**
|
|
659
|
-
* Decide whether the loop should close a container root itself, as a safety net,
|
|
660
|
-
* when its workflow chain has completed. The intended owner of this close is the
|
|
661
|
-
* closeout child task (see the flow SKILL's closeout step), but if that task never
|
|
662
|
-
* closed the root — or no closeout task exists — the container would be orphaned
|
|
663
|
-
* open forever once filtered from claimable work. Applies to both container types
|
|
664
|
-
* the loop supports: a `feature` (feature-dev flow) or an `epic` (light-dev flow).
|
|
665
|
-
* Only close when the container is still open and every child is closed.
|
|
666
|
-
*/
|
|
667
|
-
function shouldCloseFeatureRoot(feature, children) {
|
|
668
|
-
if (!feature || (feature.issue_type !== 'feature' && feature.issue_type !== 'epic')) {
|
|
669
|
-
return false;
|
|
670
|
-
}
|
|
671
|
-
if (feature.status === 'closed') {
|
|
672
|
-
return false;
|
|
673
|
-
}
|
|
674
|
-
if (children.length === 0) {
|
|
675
|
-
return false;
|
|
676
|
-
}
|
|
677
|
-
return children.every((c) => c.status === 'closed');
|
|
678
|
-
}
|
|
679
|
-
function getIssueChildren(id) {
|
|
680
|
-
try {
|
|
681
|
-
const raw = (0, child_process_1.execSync)(`bd show ${id} --children --json 2>/dev/null`, { encoding: 'utf8' });
|
|
682
|
-
const data = JSON.parse(raw);
|
|
683
|
-
const children = data[id];
|
|
684
|
-
return Array.isArray(children) ? children : [];
|
|
685
|
-
}
|
|
686
|
-
catch {
|
|
687
|
-
return [];
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
function issueHasChildren(id) {
|
|
691
|
-
return getIssueChildren(id).length > 0;
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Safety net for spec-driven runs: when the workflow chain completes but the
|
|
695
|
-
* closeout child did not close the feature root, close it here so it is not
|
|
696
|
-
* orphaned open after the filter removes it from claimable work. Returns true if
|
|
697
|
-
* it closed the feature. Best-effort — failures are logged, not fatal.
|
|
698
|
-
*/
|
|
699
|
-
function closeCompletedFeatureRoot(featureId) {
|
|
700
|
-
const feature = getIssueInfoWithStatus(featureId);
|
|
701
|
-
const children = getIssueChildren(featureId);
|
|
702
|
-
if (!shouldCloseFeatureRoot(feature, children)) {
|
|
703
|
-
return false;
|
|
704
|
-
}
|
|
705
|
-
try {
|
|
706
|
-
(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' });
|
|
707
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Closed feature root ${featureId} — workflow chain complete (safety net; closeout had not closed it).`);
|
|
708
|
-
return true;
|
|
709
|
-
}
|
|
710
|
-
catch {
|
|
711
|
-
console.warn(`\x1b[33m[bd-loop]\x1b[0m Feature root ${featureId} chain is complete but \`bd close\` failed — close it manually.`);
|
|
712
|
-
return false;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
function getIssueInfoWithStatus(id) {
|
|
716
|
-
try {
|
|
717
|
-
const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
|
|
718
|
-
const data = JSON.parse(raw);
|
|
719
|
-
const issue = data[0] || data;
|
|
720
|
-
return { status: issue.status || 'unknown', issue_type: issue.issue_type || 'task' };
|
|
721
|
-
}
|
|
722
|
-
catch {
|
|
723
|
-
return null;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
function getMatchingReadyIssues(prefix, includeFeatures, deps) {
|
|
727
|
-
const execSyncFn = deps?.execSyncFn ||
|
|
728
|
-
((cmd, opts) => (0, child_process_1.execSync)(cmd, opts));
|
|
729
|
-
try {
|
|
730
|
-
// -n 0 = unlimited. A bounded window (formerly -n 20) starves the scope
|
|
731
|
-
// filter below: with more than 20 ready issues globally, in-scope issues
|
|
732
|
-
// sorting outside the window are never seen and the loop exits with group
|
|
733
|
-
// work undone (boop-pra).
|
|
734
|
-
const raw = execSyncFn('bd ready --json -n 0 2>/dev/null', { encoding: 'utf8' });
|
|
735
|
-
const issues = JSON.parse(raw);
|
|
736
|
-
const typeFilter = includeFeatures
|
|
737
|
-
? (i) => i.issue_type !== 'epic'
|
|
738
|
-
: (i) => i.issue_type !== 'epic' && i.issue_type !== 'feature';
|
|
739
|
-
const parentCache = new Map();
|
|
740
|
-
for (const issue of issues) {
|
|
741
|
-
parentCache.set(issue.id, issue.parent || null);
|
|
742
|
-
}
|
|
743
|
-
const getParentById = (id) => {
|
|
744
|
-
if (parentCache.has(id)) {
|
|
745
|
-
return parentCache.get(id) ?? null;
|
|
746
|
-
}
|
|
747
|
-
const parent = getIssueParentId(id);
|
|
748
|
-
parentCache.set(id, parent);
|
|
749
|
-
return parent;
|
|
750
|
-
};
|
|
751
|
-
const childrenCache = new Map();
|
|
752
|
-
const hasChildren = (id) => {
|
|
753
|
-
const cached = childrenCache.get(id);
|
|
754
|
-
if (cached !== undefined) {
|
|
755
|
-
return cached;
|
|
756
|
-
}
|
|
757
|
-
const result = issueHasChildren(id);
|
|
758
|
-
childrenCache.set(id, result);
|
|
759
|
-
return result;
|
|
760
|
-
};
|
|
761
|
-
return issues
|
|
762
|
-
.filter(typeFilter)
|
|
763
|
-
.filter((issue) => isIssueInScope(issue, prefix, getParentById))
|
|
764
|
-
.filter((issue) => isClaimableReadyIssue(issue, hasChildren));
|
|
765
|
-
}
|
|
766
|
-
catch {
|
|
767
|
-
return [];
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
function getIssueInfo(id) {
|
|
771
|
-
try {
|
|
772
|
-
const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
|
|
773
|
-
const data = JSON.parse(raw);
|
|
774
|
-
const issue = data[0] || data;
|
|
775
|
-
return {
|
|
776
|
-
id,
|
|
777
|
-
title: issue.title || 'unknown',
|
|
778
|
-
priority: issue.priority ?? '?',
|
|
779
|
-
issue_type: issue.issue_type || 'task',
|
|
780
|
-
description: issue.description || 'No description',
|
|
781
|
-
};
|
|
782
|
-
}
|
|
783
|
-
catch {
|
|
784
|
-
return null;
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
function isIssueClosed(id) {
|
|
788
|
-
try {
|
|
789
|
-
const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
|
|
790
|
-
const data = JSON.parse(raw);
|
|
791
|
-
const status = (data[0] || data).status || 'unknown';
|
|
792
|
-
return status === 'closed';
|
|
793
|
-
}
|
|
794
|
-
catch {
|
|
795
|
-
return false;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
function buildPrompt(issue) {
|
|
799
|
-
return `You are working on issue ${issue.id} in the boop project.
|
|
800
|
-
|
|
801
|
-
## Issue: ${issue.title}
|
|
802
|
-
Type: ${issue.issue_type} | Priority: ${issue.priority}
|
|
803
|
-
|
|
804
|
-
## Description
|
|
805
|
-
${issue.description}
|
|
806
|
-
|
|
807
|
-
## Instructions
|
|
808
|
-
1. Read AGENTS.md for project orientation
|
|
809
|
-
2. Claim the issue: bd update ${issue.id} --claim
|
|
810
|
-
3. Read relevant skills from skills/ (list folders, read each SKILL.md) before making changes
|
|
811
|
-
4. Implement the changes following TDD-Lite: observe, implement, verify
|
|
812
|
-
5. Run tests if applicable: npm test
|
|
813
|
-
6. Commit your changes with a message referencing ${issue.id}
|
|
814
|
-
7. Close the issue: bd close ${issue.id} --reason "<what you did>"
|
|
815
|
-
8. Push: git pull --rebase && git push
|
|
816
|
-
|
|
817
|
-
IMPORTANT: Complete the FULL workflow — implement, test, commit, close, and push.
|
|
818
|
-
Do NOT pause for review. Work autonomously through to completion.
|
|
819
|
-
|
|
820
|
-
If you discover additional issues during your work, file them with:
|
|
821
|
-
bd create "Title" -d "description" --deps discovered-from:${issue.id}`;
|
|
822
|
-
}
|
|
823
|
-
/**
|
|
824
|
-
* Default per-issue budget cap in USD. The planning phase and each task each get
|
|
825
|
-
* this cap. Single source of truth — imported by cli.ts for its --budget help
|
|
826
|
-
* text so the documented default and the runtime fallback can't drift.
|
|
827
|
-
*/
|
|
828
|
-
exports.DEFAULT_BUDGET_USD = '25.00';
|
|
829
|
-
/**
|
|
830
|
-
* Sent when resuming a session that ended without closing its issue. The opener
|
|
831
|
-
* must NOT read as operator authorization (the old "Approved. Continue..." text
|
|
832
|
-
* could be taken as permission to work past a block): an agent that marked the
|
|
833
|
-
* issue `blocked` made a deliberate safety call, and the block stands. The loop
|
|
834
|
-
* also skips resuming a `blocked` issue entirely (see shouldResumeUnclosedIssue)
|
|
835
|
-
* — this clause is the belt-and-suspenders for a block raised mid-resume.
|
|
836
|
-
*/
|
|
837
|
-
exports.RESUME_PROMPT = `The previous session ended without closing this issue. Reload the state and continue.
|
|
838
|
-
|
|
839
|
-
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.
|
|
840
|
-
|
|
841
|
-
Otherwise, complete the workflow:
|
|
842
|
-
- Finish any remaining implementation
|
|
843
|
-
- Run tests to verify
|
|
844
|
-
- Commit changes referencing the issue ID
|
|
845
|
-
- Close the issue with bd close
|
|
846
|
-
- Push with: git pull --rebase && git push
|
|
847
|
-
|
|
848
|
-
Do not pause for review of completed work. Complete everything now.`;
|
|
849
|
-
/**
|
|
850
|
-
* Fraction of the context window at which a reused session is rotated for a fresh
|
|
851
|
-
* one. Deliberately well below Claude Code's auto-compaction point — compaction
|
|
852
|
-
* silently discards the context the reuse exists to keep, so we rotate first.
|
|
853
|
-
* The one knob a trial run should tune (specs/loop-session-reuse.spec.md).
|
|
854
|
-
*/
|
|
855
|
-
const ROTATE_FRACTION = 0.55;
|
|
856
|
-
/** Fallback context window (tokens) before the stream has reported a real one. */
|
|
857
|
-
const DEFAULT_CONTEXT_WINDOW = 1000000;
|
|
858
|
-
/**
|
|
859
|
-
* Live context-window occupancy for one turn, from a stream-json `usage` object:
|
|
860
|
-
* everything the model had to read this turn (fresh input + cached prompt).
|
|
861
|
-
*/
|
|
862
|
-
function contextOccupancy(usage) {
|
|
863
|
-
if (!usage)
|
|
864
|
-
return 0;
|
|
865
|
-
return (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
866
|
-
}
|
|
867
|
-
/**
|
|
868
|
-
* Decide whether a reused session should rotate before taking the next issue.
|
|
869
|
-
* Rotates once peak observed occupancy reaches ROTATE_FRACTION of the window.
|
|
870
|
-
* Pure so it is unit-testable without a running loop.
|
|
871
|
-
*/
|
|
872
|
-
function shouldRotateSession(peakContext, contextWindow, fraction = ROTATE_FRACTION) {
|
|
873
|
-
const window = contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW;
|
|
874
|
-
return peakContext >= fraction * window;
|
|
875
|
-
}
|
|
876
|
-
function runClaude(prompt, opts, logFile, readableLogFile) {
|
|
877
|
-
return new Promise((resolve) => {
|
|
878
|
-
const args = [
|
|
879
|
-
'-p',
|
|
880
|
-
'--max-budget-usd', opts.budget,
|
|
881
|
-
'--output-format', 'stream-json',
|
|
882
|
-
'--verbose',
|
|
883
|
-
];
|
|
884
|
-
if (opts.bare)
|
|
885
|
-
args.push('--bare');
|
|
886
|
-
if (opts.skipPerms)
|
|
887
|
-
args.push('--dangerously-skip-permissions');
|
|
888
|
-
if (opts.model)
|
|
889
|
-
args.push('--model', opts.model);
|
|
890
|
-
if (opts.resume) {
|
|
891
|
-
args.push('--resume', opts.sessionId);
|
|
892
|
-
}
|
|
893
|
-
else {
|
|
894
|
-
args.push('--session-id', opts.sessionId);
|
|
895
|
-
}
|
|
896
|
-
const claudeBin = findClaudeBinary();
|
|
897
|
-
if (!claudeBin) {
|
|
898
|
-
console.error('\x1b[31m[bd-loop]\x1b[0m claude binary not found');
|
|
899
|
-
resolve({ exitCode: 1, limitHit: false });
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
const logStream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
903
|
-
const readableStream = readableLogFile
|
|
904
|
-
? fs.createWriteStream(readableLogFile, { flags: 'a' })
|
|
905
|
-
: null;
|
|
906
|
-
// Propagate the chosen model beyond this session via the environment.
|
|
907
|
-
// `--model` (in args) sets this session; ANTHROPIC_MODEL additionally
|
|
908
|
-
// (a) reaches a nested `boop loop` — child processes inherit the env — so it
|
|
909
|
-
// doesn't reset to the default, and (b) is honored by any sub-agent whose
|
|
910
|
-
// definition opts into `model: inherit`. We deliberately do NOT set
|
|
911
|
-
// CLAUDE_CODE_SUBAGENT_MODEL: sub-agents keep their own declared model, so a
|
|
912
|
-
// deliberately-cheap agent (e.g. Explore on Haiku) stays cheap. The invoking
|
|
913
|
-
// agent can still override a sub-agent's model per-invocation when it needs
|
|
914
|
-
// to. See resolveModel().
|
|
915
|
-
const childEnv = opts.model
|
|
916
|
-
? { ...process.env, ANTHROPIC_MODEL: opts.model }
|
|
917
|
-
: process.env;
|
|
918
|
-
const child = (0, child_process_1.spawn)(claudeBin, args, { cwd: opts.cwd, stdio: ['pipe', 'pipe', 'pipe'], env: childEnv });
|
|
919
|
-
let limitHit = false;
|
|
920
|
-
// Track context-window pressure from outside the agent (spec: the loop reads
|
|
921
|
-
// the stream it already parses; the agent is never told). Peak occupancy over
|
|
922
|
-
// the run drives session rotation; contextWindow comes from the result.
|
|
923
|
-
let peakContext = 0;
|
|
924
|
-
let contextWindow;
|
|
925
|
-
child.stdin.write(prompt);
|
|
926
|
-
child.stdin.end();
|
|
927
|
-
child.stdout.on('data', (chunk) => {
|
|
928
|
-
logStream.write(chunk);
|
|
929
|
-
const lines = chunk.toString().split('\n').filter(Boolean);
|
|
930
|
-
for (const line of lines) {
|
|
931
|
-
try {
|
|
932
|
-
const msg = JSON.parse(line);
|
|
933
|
-
if (msg.type === 'assistant') {
|
|
934
|
-
// Live context occupancy for this turn (read from outside the agent).
|
|
935
|
-
const occ = contextOccupancy(msg.message?.usage);
|
|
936
|
-
if (occ > peakContext)
|
|
937
|
-
peakContext = occ;
|
|
938
|
-
const texts = (msg.message?.content || [])
|
|
939
|
-
.filter((c) => c.type === 'text')
|
|
940
|
-
.map((c) => c.text);
|
|
941
|
-
if (texts.length > 0) {
|
|
942
|
-
const textBlob = texts.join('');
|
|
943
|
-
if (isStandaloneUsageLimitMessage(textBlob)) {
|
|
944
|
-
limitHit = true;
|
|
945
|
-
}
|
|
946
|
-
const first = textBlob.split('\n')[0].substring(0, 120);
|
|
947
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m 💬 ${first}`);
|
|
948
|
-
if (readableStream) {
|
|
949
|
-
readableStream.write(`[text] ${textBlob}\n\n`);
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
const tools = (msg.message?.content || [])
|
|
953
|
-
.filter((c) => c.type === 'tool_use')
|
|
954
|
-
.map((c) => `${c.name} ${JSON.stringify(c.input).substring(0, 80)}`);
|
|
955
|
-
for (const t of tools) {
|
|
956
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m 🔧 ${t}`);
|
|
957
|
-
if (readableStream) {
|
|
958
|
-
readableStream.write(`[tool] ${t}\n`);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
else if (msg.type === 'result') {
|
|
963
|
-
const resultText = typeof msg.result === 'string' ? msg.result : '';
|
|
964
|
-
if ((msg.is_error || msg.subtype === 'error') && isUsageLimitText(resultText)) {
|
|
965
|
-
limitHit = true;
|
|
966
|
-
}
|
|
967
|
-
// The result reports the model's context window under modelUsage.<model>.
|
|
968
|
-
const mu = msg.modelUsage;
|
|
969
|
-
if (mu) {
|
|
970
|
-
for (const m of Object.values(mu)) {
|
|
971
|
-
if (typeof m?.contextWindow === 'number' && m.contextWindow > 0) {
|
|
972
|
-
contextWindow = m.contextWindow;
|
|
973
|
-
break;
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
const cost = msg.total_cost_usd || '?';
|
|
978
|
-
const dur = Math.round((msg.duration_ms || 0) / 1000);
|
|
979
|
-
const turns = msg.num_turns || '?';
|
|
980
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m 📊 ${turns} turns, $${cost}, ${dur}s`);
|
|
981
|
-
if (readableStream) {
|
|
982
|
-
readableStream.write(`\n=== Result ===\nTurns: ${turns} | Cost: $${cost} | Duration: ${dur}s\n\n${resultText}\n`);
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
catch {
|
|
987
|
-
if (isStandaloneUsageLimitMessage(line)) {
|
|
988
|
-
limitHit = true;
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
});
|
|
993
|
-
child.stderr.on('data', (chunk) => {
|
|
994
|
-
logStream.write(chunk);
|
|
995
|
-
if (isStandaloneUsageLimitMessage(chunk.toString())) {
|
|
996
|
-
limitHit = true;
|
|
997
|
-
}
|
|
998
|
-
});
|
|
999
|
-
child.on('close', (code) => {
|
|
1000
|
-
logStream.end();
|
|
1001
|
-
if (readableStream)
|
|
1002
|
-
readableStream.end();
|
|
1003
|
-
resolve({ exitCode: code ?? 1, limitHit, peakContext, contextWindow });
|
|
1004
|
-
});
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
|
-
async function processIssue(issue, opts) {
|
|
1008
|
-
const sessionId = opts.session?.id ?? (0, crypto_1.randomUUID)();
|
|
1009
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
|
|
1010
|
-
const logFile = path.join(opts.logDir, `${issue.id}_${timestamp}.log`);
|
|
1011
|
-
const readableLogFile = path.join(opts.logDir, `${issue.id}_${timestamp}.readable.log`);
|
|
1012
|
-
// Write readable log header
|
|
1013
|
-
fs.writeFileSync(readableLogFile, `=== ${issue.id}: ${issue.title} ===\nSession: ${sessionId}\nStarted: ${new Date().toISOString()}\n\n`);
|
|
1014
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m ━━━ \x1b[34m${issue.id}\x1b[0m (P${issue.priority} ${issue.issue_type}) ━━━`);
|
|
1015
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m ${issue.title}`);
|
|
1016
|
-
// Per-issue branches (when requested) are created *inside* the worktree cwd,
|
|
1017
|
-
// so they nest under the loop's isolated tree rather than mutating main.
|
|
1018
|
-
let originalBranch;
|
|
1019
|
-
if (!opts.noBranch && opts.perIssueBranch) {
|
|
1020
|
-
const branchName = `fix/${issue.id}`;
|
|
1021
|
-
originalBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8', cwd: opts.cwd }).trim();
|
|
1022
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Creating branch: ${branchName}`);
|
|
1023
|
-
try {
|
|
1024
|
-
(0, child_process_1.execSync)(`git checkout -b ${branchName} 2>/dev/null`, { encoding: 'utf8', cwd: opts.cwd });
|
|
1025
|
-
}
|
|
1026
|
-
catch {
|
|
1027
|
-
try {
|
|
1028
|
-
(0, child_process_1.execSync)(`git checkout ${branchName} 2>/dev/null`, { encoding: 'utf8', cwd: opts.cwd });
|
|
1029
|
-
console.log(`\x1b[33m[bd-loop]\x1b[0m Branch ${branchName} exists, checked out`);
|
|
1030
|
-
}
|
|
1031
|
-
catch {
|
|
1032
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to create/checkout branch`);
|
|
1033
|
-
return { issueId: issue.id, closed: false, resumes: 0, logFile, limitHit: false };
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
// Initial run
|
|
1038
|
-
const prompt = buildPrompt(issue);
|
|
1039
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m 🚀 Starting Claude session (budget: $${opts.budget}, session: ${sessionId.substring(0, 8)}...)`);
|
|
1040
|
-
const initialRun = await runClaude(prompt, {
|
|
1041
|
-
budget: opts.budget,
|
|
1042
|
-
model: opts.model,
|
|
1043
|
-
skipPerms: opts.skipPerms,
|
|
1044
|
-
bare: opts.bare,
|
|
1045
|
-
sessionId,
|
|
1046
|
-
// Continue a carried session for this new issue (its normal prompt via --resume).
|
|
1047
|
-
resume: opts.session?.resume,
|
|
1048
|
-
cwd: opts.cwd,
|
|
1049
|
-
}, logFile, readableLogFile);
|
|
1050
|
-
let anyLimitHit = initialRun.limitHit;
|
|
1051
|
-
let peakContext = initialRun.peakContext ?? 0;
|
|
1052
|
-
let contextWindow = initialRun.contextWindow;
|
|
1053
|
-
if (initialRun.limitHit) {
|
|
1054
|
-
console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached; stopping retries for this issue');
|
|
1055
|
-
}
|
|
1056
|
-
// Resume loop
|
|
1057
|
-
let resumeCount = 0;
|
|
1058
|
-
while (resumeCount < opts.resumeLimit && !initialRun.limitHit) {
|
|
1059
|
-
if (isIssueClosed(issue.id)) {
|
|
1060
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Issue ${issue.id} closed successfully`);
|
|
1061
|
-
break;
|
|
1062
|
-
}
|
|
1063
|
-
// Don't resume an issue the prior session deliberately blocked — that's a
|
|
1064
|
-
// safety decision, not unfinished work. Resuming burns budget re-halting.
|
|
1065
|
-
const statusInfo = getIssueInfoWithStatus(issue.id);
|
|
1066
|
-
if (!shouldResumeUnclosedIssue(statusInfo?.status)) {
|
|
1067
|
-
console.log(`\x1b[33m[bd-loop]\x1b[0m ⛔ Issue ${issue.id} is marked '${statusInfo?.status}' — not resuming; operator review needed.`);
|
|
1068
|
-
break;
|
|
1069
|
-
}
|
|
1070
|
-
resumeCount++;
|
|
1071
|
-
console.log(`\x1b[33m[bd-loop]\x1b[0m Issue not yet closed — resuming session (attempt ${resumeCount}/${opts.resumeLimit})`);
|
|
1072
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m 🔄 Resuming session ${sessionId.substring(0, 8)}...`);
|
|
1073
|
-
fs.appendFileSync(readableLogFile, `\n=== Resume attempt ${resumeCount} ===\nTime: ${new Date().toISOString()}\n\n`);
|
|
1074
|
-
const resumeRun = await runClaude(exports.RESUME_PROMPT, {
|
|
1075
|
-
budget: opts.budget,
|
|
1076
|
-
model: opts.model,
|
|
1077
|
-
skipPerms: opts.skipPerms,
|
|
1078
|
-
bare: opts.bare,
|
|
1079
|
-
sessionId,
|
|
1080
|
-
resume: true,
|
|
1081
|
-
cwd: opts.cwd,
|
|
1082
|
-
}, logFile, readableLogFile);
|
|
1083
|
-
if (resumeRun.peakContext && resumeRun.peakContext > peakContext)
|
|
1084
|
-
peakContext = resumeRun.peakContext;
|
|
1085
|
-
if (resumeRun.contextWindow)
|
|
1086
|
-
contextWindow = resumeRun.contextWindow;
|
|
1087
|
-
if (resumeRun.limitHit) {
|
|
1088
|
-
anyLimitHit = true;
|
|
1089
|
-
console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached during resume; stopping retries for this issue');
|
|
1090
|
-
break;
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
const closed = isIssueClosed(issue.id);
|
|
1094
|
-
if (closed) {
|
|
1095
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Done: ${issue.id}`);
|
|
1096
|
-
}
|
|
1097
|
-
else {
|
|
1098
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m ⚠️ Issue ${issue.id} not closed after ${opts.resumeLimit} resume attempts`);
|
|
1099
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Check log: ${logFile}`);
|
|
1100
|
-
}
|
|
1101
|
-
if (originalBranch) {
|
|
1102
|
-
try {
|
|
1103
|
-
(0, child_process_1.execSync)(`git checkout ${originalBranch} 2>/dev/null`, { cwd: opts.cwd });
|
|
1104
|
-
}
|
|
1105
|
-
catch {
|
|
1106
|
-
// Best effort
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
return { issueId: issue.id, closed, resumes: resumeCount, logFile, limitHit: anyLimitHit, sessionId, peakContext, contextWindow };
|
|
1110
|
-
}
|
|
1111
|
-
function sleep(ms) {
|
|
1112
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1113
|
-
}
|
|
1114
|
-
async function runLoop(options) {
|
|
1115
|
-
let prefix = options.prefix;
|
|
1116
|
-
const work = options.work;
|
|
1117
|
-
const flow = options.flow;
|
|
1118
|
-
// Validate: need either --prefix OR both --work and --flow
|
|
1119
|
-
if (!prefix && !work && !flow) {
|
|
1120
|
-
console.error('\x1b[31mERR\x1b[0m Either --prefix or both --work and --flow are required');
|
|
1121
|
-
console.error('Usage: boop loop --prefix <issue-prefix> [--run] [--max N] [--budget N.NN]');
|
|
1122
|
-
console.error(' boop loop --work <spec-file> --flow <workflow-file> [--run]');
|
|
1123
|
-
process.exitCode = 1;
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
// --work and --flow must be used together
|
|
1127
|
-
if ((work && !flow) || (!work && flow)) {
|
|
1128
|
-
console.error('\x1b[31mERR\x1b[0m --work and --flow must be used together');
|
|
1129
|
-
process.exitCode = 1;
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
// --features is an issue-driven (--prefix) knob. Spec-driven runs always process
|
|
1133
|
-
// the feature tree the planning phase produces, so a user-supplied --features is
|
|
1134
|
-
// a no-op here — warn rather than silently ignore.
|
|
1135
|
-
if (options.features && work && flow) {
|
|
1136
|
-
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)');
|
|
1137
|
-
}
|
|
1138
|
-
// Validate that work/flow files exist
|
|
1139
|
-
if (work && !fs.existsSync(work)) {
|
|
1140
|
-
console.error(`\x1b[31mERR\x1b[0m --work file not found: ${work}`);
|
|
1141
|
-
process.exitCode = 1;
|
|
1142
|
-
return;
|
|
1143
|
-
}
|
|
1144
|
-
if (flow && !fs.existsSync(flow)) {
|
|
1145
|
-
console.error(`\x1b[31mERR\x1b[0m --flow path not found: ${flow}`);
|
|
1146
|
-
process.exitCode = 1;
|
|
1147
|
-
return;
|
|
1148
|
-
}
|
|
1149
|
-
// If --flow points to a directory (e.g. a skill folder), resolve to its SKILL.md
|
|
1150
|
-
let resolvedFlow = flow;
|
|
1151
|
-
if (flow && fs.statSync(flow).isDirectory()) {
|
|
1152
|
-
const skillPath = path.join(flow, 'SKILL.md');
|
|
1153
|
-
if (!fs.existsSync(skillPath)) {
|
|
1154
|
-
console.error(`\x1b[31mERR\x1b[0m --flow directory has no SKILL.md: ${flow}`);
|
|
1155
|
-
process.exitCode = 1;
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
resolvedFlow = skillPath;
|
|
1159
|
-
}
|
|
1160
|
-
const dryRun = !options.run;
|
|
1161
|
-
const maxIssues = parseInt(options.max || '0', 10);
|
|
1162
|
-
const budget = options.budget || exports.DEFAULT_BUDGET_USD;
|
|
1163
|
-
const resumeLimit = parseInt(options.resumeLimit || '3', 10);
|
|
1164
|
-
const pollInterval = parseInt(options.pollInterval || '60', 10);
|
|
1165
|
-
const model = resolveModel(options.model);
|
|
1166
|
-
const perIssueBranch = options.perIssueBranch || false;
|
|
1167
|
-
const noBranch = options.branch === false;
|
|
1168
|
-
let includeFeatures = options.features || false;
|
|
1169
|
-
const skipPerms = options.skipPerms !== false; // default true
|
|
1170
|
-
const bare = options.bare || false;
|
|
1171
|
-
const poll = options.poll || false;
|
|
1172
|
-
const reuseSession = options.reuseSession || false;
|
|
1173
|
-
// --reuse-session is a --prefix-mode knob, mutually exclusive with the two things
|
|
1174
|
-
// that imply one-session-per-issue. Errors are plain, not silent winners.
|
|
1175
|
-
if (reuseSession && (work || flow)) {
|
|
1176
|
-
console.error('\x1b[31mERR\x1b[0m --reuse-session is only for --prefix runs, not --work/--flow (spec-driven children are already session-sized)');
|
|
1177
|
-
process.exitCode = 1;
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
if (reuseSession && perIssueBranch) {
|
|
1181
|
-
console.error('\x1b[31mERR\x1b[0m --reuse-session and --per-issue-branch are mutually exclusive (a branch per issue implies a session per issue)');
|
|
1182
|
-
process.exitCode = 1;
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
const projectDir = process.cwd();
|
|
1186
|
-
const logDir = path.join(projectDir, 'labs', 'claude-runs');
|
|
1187
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
1188
|
-
// Hard requirement (Decision #6): beads must be in Dolt server mode so concurrent
|
|
1189
|
-
// worktrees share one issue DB. Runs always — embedded mode is a footgun for any
|
|
1190
|
-
// bd concurrency and the check is cheap.
|
|
1191
|
-
try {
|
|
1192
|
-
assertDoltServerMode(projectDir);
|
|
1193
|
-
}
|
|
1194
|
-
catch (e) {
|
|
1195
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m ${e.message}`);
|
|
1196
|
-
process.exitCode = 1;
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
// Planning mode dry-run
|
|
1200
|
-
if (dryRun && work && resolvedFlow) {
|
|
1201
|
-
const workContent = fs.readFileSync(work, 'utf8');
|
|
1202
|
-
const flowContent = fs.readFileSync(resolvedFlow, 'utf8');
|
|
1203
|
-
console.log('[bd-loop] PLANNING MODE (dry-run)');
|
|
1204
|
-
console.log(`[bd-loop] Work file: ${work}`);
|
|
1205
|
-
console.log(`[bd-loop] Flow file: ${resolvedFlow}${resolvedFlow !== flow ? ` (resolved from ${flow})` : ''}`);
|
|
1206
|
-
console.log('[bd-loop] Planning prompt that would be sent:');
|
|
1207
|
-
console.log('---');
|
|
1208
|
-
console.log(buildPlanningPrompt(workContent, flowContent, prefix));
|
|
1209
|
-
console.log('---');
|
|
1210
|
-
console.log('[bd-loop] After planning, loop would process tasks under the created feature.');
|
|
1211
|
-
return;
|
|
1212
|
-
}
|
|
1213
|
-
// Planning phase: create feature tree before looping
|
|
1214
|
-
if (work && resolvedFlow) {
|
|
1215
|
-
const planResult = await planWork({
|
|
1216
|
-
workPath: work,
|
|
1217
|
-
flowPath: resolvedFlow,
|
|
1218
|
-
prefix,
|
|
1219
|
-
budget,
|
|
1220
|
-
model,
|
|
1221
|
-
skipPerms,
|
|
1222
|
-
bare,
|
|
1223
|
-
logDir,
|
|
1224
|
-
});
|
|
1225
|
-
if (!planResult.featureId) {
|
|
1226
|
-
console.error('[bd-loop] Planning phase failed to produce a feature ID');
|
|
1227
|
-
console.error('[bd-loop] Check logs in: ' + logDir);
|
|
1228
|
-
process.exitCode = 1;
|
|
1229
|
-
return;
|
|
1230
|
-
}
|
|
1231
|
-
// Warn if planning consumed more than half the budget
|
|
1232
|
-
const budgetNum = parseFloat(budget);
|
|
1233
|
-
if (planResult.costUsd != null && budgetNum > 0) {
|
|
1234
|
-
const pct = (planResult.costUsd / budgetNum) * 100;
|
|
1235
|
-
if (pct > 50) {
|
|
1236
|
-
console.warn(`\x1b[33m[bd-loop]\x1b[0m ⚠️ Planning phase used $${planResult.costUsd.toFixed(2)} of $${budget} budget (${pct.toFixed(0)}%). ` +
|
|
1237
|
-
`Per-task phases share the same budget cap — tasks may hit the limit early.`);
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
prefix = planResult.featureId;
|
|
1241
|
-
includeFeatures = true;
|
|
1242
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Planning complete. Feature: ${planResult.featureId}`);
|
|
1243
|
-
}
|
|
1244
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Starting bd-loop for prefix \x1b[34m${prefix}\x1b[0m`);
|
|
1245
|
-
if (dryRun) {
|
|
1246
|
-
console.log('\x1b[33m[bd-loop]\x1b[0m DRY RUN — add --run to execute');
|
|
1247
|
-
}
|
|
1248
|
-
// Always-worktree (Slice 1): the loop runs inside its own git worktree at
|
|
1249
|
-
// ../boop-<scope> on branch work/<scope> so N loops run concurrently without
|
|
1250
|
-
// colliding; main is never checked out. --no-branch opts out (in-place, today's
|
|
1251
|
-
// single-tenant behavior). Manual merge is the v1 contract — no --merge yet.
|
|
1252
|
-
const worktreeMode = work && resolvedFlow ? 'work-flow' : 'prefix';
|
|
1253
|
-
let worktreeCtx;
|
|
1254
|
-
let loopCwd = projectDir;
|
|
1255
|
-
if (noBranch) {
|
|
1256
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Branch strategy: in-place on current branch (--no-branch)`);
|
|
1257
|
-
}
|
|
1258
|
-
else if (!dryRun) {
|
|
1259
|
-
// Worktrees reuse the running shared Dolt server — they never start one.
|
|
1260
|
-
const server = discoverDoltServer(projectDir);
|
|
1261
|
-
if (!server) {
|
|
1262
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m No shared Dolt server discovered (.beads/dolt-server.port missing). ` +
|
|
1263
|
-
`Start it with \`bd dolt start\` before running the loop; worktrees never start their own server.`);
|
|
1264
|
-
process.exitCode = 1;
|
|
1265
|
-
return;
|
|
1266
|
-
}
|
|
1267
|
-
if (server.alive === false) {
|
|
1268
|
-
console.warn(`\x1b[33m[bd-loop]\x1b[0m Dolt server pid ${server.pid} on port ${server.port} does not appear to be running; ` +
|
|
1269
|
-
`bd calls may fail. Continuing (liveness check is best-effort).`);
|
|
1270
|
-
}
|
|
1271
|
-
try {
|
|
1272
|
-
worktreeCtx = createWorktree(prefix, worktreeMode, projectDir);
|
|
1273
|
-
}
|
|
1274
|
-
catch (e) {
|
|
1275
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m ${e.message}`);
|
|
1276
|
-
process.exitCode = 1;
|
|
1277
|
-
return;
|
|
1278
|
-
}
|
|
1279
|
-
loopCwd = worktreeCtx.path;
|
|
1280
|
-
writeWorktreeMetadata(worktreeCtx, {
|
|
1281
|
-
workPath: work,
|
|
1282
|
-
flowPath: resolvedFlow,
|
|
1283
|
-
boopVersion: getBoopVersion(),
|
|
1284
|
-
startTime: new Date().toISOString(),
|
|
1285
|
-
operator: getGitUserName(projectDir),
|
|
1286
|
-
budget,
|
|
1287
|
-
model,
|
|
1288
|
-
});
|
|
1289
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Worktree: \x1b[34m${worktreeCtx.path}\x1b[0m on branch \x1b[34m${worktreeCtx.branch}\x1b[0m`);
|
|
1290
|
-
}
|
|
1291
|
-
let totalProcessed = 0;
|
|
1292
|
-
let totalClosed = 0;
|
|
1293
|
-
const results = [];
|
|
1294
|
-
// --reuse-session state: a healthy session survives to the next issue; a fresh
|
|
1295
|
-
// one is minted only on rotation (context full) or death. Off → activeSession
|
|
1296
|
-
// stays null and every issue mints its own, exactly as before.
|
|
1297
|
-
let activeSession = null;
|
|
1298
|
-
let sessionCount = 0;
|
|
1299
|
-
// Issue id at each session START (the first entry is the initial session, not a
|
|
1300
|
-
// rotation). Actual rotations are everything after the first — see the summary.
|
|
1301
|
-
const sessionStarts = [];
|
|
1302
|
-
while (true) {
|
|
1303
|
-
const nextId = prefix ? findNextIssue(prefix, includeFeatures) : null;
|
|
1304
|
-
if (!nextId) {
|
|
1305
|
-
// Spec-driven safety net: the filter removes the workflow-owned feature
|
|
1306
|
-
// root from claimable work, so when its chain completes nobody may have
|
|
1307
|
-
// closed it. Close it here (no-op if the closeout child already did, or if
|
|
1308
|
-
// children remain open) before deciding to poll or terminate.
|
|
1309
|
-
if (work && resolvedFlow && prefix) {
|
|
1310
|
-
closeCompletedFeatureRoot(prefix);
|
|
1311
|
-
}
|
|
1312
|
-
if (poll) {
|
|
1313
|
-
console.log(`\x1b[36m[bd-loop]\x1b[0m No ready issues for prefix '${prefix}'. Polling again in ${pollInterval}s...`);
|
|
1314
|
-
await sleep(pollInterval * 1000);
|
|
1315
|
-
continue;
|
|
1316
|
-
}
|
|
1317
|
-
else {
|
|
1318
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m No more ready issues for prefix '${prefix}'.`);
|
|
1319
|
-
break;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
totalProcessed++;
|
|
1323
|
-
if (maxIssues > 0 && totalProcessed > maxIssues) {
|
|
1324
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Reached --max ${maxIssues} issues. Stopping.`);
|
|
1325
|
-
break;
|
|
1326
|
-
}
|
|
1327
|
-
if (dryRun) {
|
|
1328
|
-
// Show all matching issues in dry-run mode
|
|
1329
|
-
try {
|
|
1330
|
-
const matching = getMatchingReadyIssues(prefix || '', includeFeatures);
|
|
1331
|
-
for (const m of matching) {
|
|
1332
|
-
const info = getIssueInfo(m.id);
|
|
1333
|
-
if (!info)
|
|
1334
|
-
continue;
|
|
1335
|
-
if (maxIssues > 0 && totalProcessed > maxIssues)
|
|
1336
|
-
break;
|
|
1337
|
-
console.log(` \x1b[34m${info.id}\x1b[0m (P${info.priority} ${info.issue_type}) — ${info.title}`);
|
|
1338
|
-
console.log(` Would run: claude -p --max-budget-usd ${budget} --session-id <uuid>`);
|
|
1339
|
-
if (noBranch) {
|
|
1340
|
-
console.log(` Branch: in-place (--no-branch)`);
|
|
1341
|
-
}
|
|
1342
|
-
else {
|
|
1343
|
-
console.log(` Worktree: ../boop-${prefix} (branch work/${prefix})`);
|
|
1344
|
-
if (perIssueBranch) {
|
|
1345
|
-
console.log(` Per-issue branch: fix/${info.id} (inside worktree)`);
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
console.log(` Resume limit: ${resumeLimit} attempts`);
|
|
1349
|
-
console.log('');
|
|
1350
|
-
if (m.id !== nextId)
|
|
1351
|
-
totalProcessed++;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
catch {
|
|
1355
|
-
// Fallback: just show the one issue
|
|
1356
|
-
const info = getIssueInfo(nextId);
|
|
1357
|
-
if (info) {
|
|
1358
|
-
console.log(` \x1b[34m${info.id}\x1b[0m (P${info.priority} ${info.issue_type}) — ${info.title}`);
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
break;
|
|
1362
|
-
}
|
|
1363
|
-
// Process the issue
|
|
1364
|
-
const issue = getIssueInfo(nextId);
|
|
1365
|
-
if (!issue) {
|
|
1366
|
-
console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to get issue details for ${nextId}`);
|
|
1367
|
-
continue;
|
|
1368
|
-
}
|
|
1369
|
-
// Decide the session this issue runs under. Reuse a healthy carried session;
|
|
1370
|
-
// otherwise (first issue, or the last one filled up) mint a fresh one. The
|
|
1371
|
-
// decision lands here, between issues, so rotation can never split an issue.
|
|
1372
|
-
let session;
|
|
1373
|
-
if (reuseSession) {
|
|
1374
|
-
if (activeSession && !shouldRotateSession(activeSession.peakContext, activeSession.contextWindow)) {
|
|
1375
|
-
session = { id: activeSession.id, resume: true };
|
|
1376
|
-
}
|
|
1377
|
-
else {
|
|
1378
|
-
if (activeSession) {
|
|
1379
|
-
console.log(`\x1b[36m[bd-loop]\x1b[0m Session context full (${Math.round(activeSession.peakContext / 1000)}k/${Math.round(activeSession.contextWindow / 1000)}k) — rotating to a fresh session at ${issue.id}`);
|
|
1380
|
-
}
|
|
1381
|
-
session = { id: (0, crypto_1.randomUUID)(), resume: false };
|
|
1382
|
-
activeSession = { id: session.id, peakContext: 0, contextWindow: DEFAULT_CONTEXT_WINDOW };
|
|
1383
|
-
sessionCount++;
|
|
1384
|
-
sessionStarts.push(issue.id);
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
const result = await processIssue(issue, {
|
|
1388
|
-
budget,
|
|
1389
|
-
model,
|
|
1390
|
-
skipPerms,
|
|
1391
|
-
bare,
|
|
1392
|
-
resumeLimit,
|
|
1393
|
-
perIssueBranch,
|
|
1394
|
-
noBranch,
|
|
1395
|
-
logDir,
|
|
1396
|
-
cwd: loopCwd,
|
|
1397
|
-
session,
|
|
1398
|
-
});
|
|
1399
|
-
results.push(result);
|
|
1400
|
-
if (result.closed)
|
|
1401
|
-
totalClosed++;
|
|
1402
|
-
// Fold this issue's observed context back into the carried session so the next
|
|
1403
|
-
// iteration's rotation decision is current. A limit/death drops the session so
|
|
1404
|
-
// the next issue starts clean (recovery lands between issues, same as rotation).
|
|
1405
|
-
if (reuseSession && activeSession) {
|
|
1406
|
-
if (result.peakContext)
|
|
1407
|
-
activeSession.peakContext = Math.max(activeSession.peakContext, result.peakContext);
|
|
1408
|
-
if (result.contextWindow)
|
|
1409
|
-
activeSession.contextWindow = result.contextWindow;
|
|
1410
|
-
if (result.limitHit)
|
|
1411
|
-
activeSession = null;
|
|
1412
|
-
}
|
|
1413
|
-
if (result.limitHit) {
|
|
1414
|
-
console.error('\x1b[31m[bd-loop]\x1b[0m Credit/usage limit hit — stopping loop');
|
|
1415
|
-
break;
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
// Always-worktree: leave the worktree and branch in place for the operator to
|
|
1419
|
-
// review and merge by hand (manual merge is the v1 contract). No checkout-back —
|
|
1420
|
-
// main's working tree was never touched.
|
|
1421
|
-
// Summary
|
|
1422
|
-
console.log('');
|
|
1423
|
-
console.log('\x1b[32m[bd-loop]\x1b[0m ━━━ Summary ━━━');
|
|
1424
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Prefix: ${prefix}`);
|
|
1425
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Processed: ${totalProcessed}`);
|
|
1426
|
-
if (!dryRun) {
|
|
1427
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Closed: ${totalClosed}`);
|
|
1428
|
-
if (totalProcessed > totalClosed) {
|
|
1429
|
-
console.log(`\x1b[33m[bd-loop]\x1b[0m Not closed: ${totalProcessed - totalClosed}`);
|
|
1430
|
-
}
|
|
1431
|
-
if (reuseSession) {
|
|
1432
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Sessions: ${sessionCount} (--reuse-session)`);
|
|
1433
|
-
// The first session start is not a rotation; only entries after it are.
|
|
1434
|
-
const rotatedAt = sessionStarts.slice(1);
|
|
1435
|
-
if (rotatedAt.length > 0) {
|
|
1436
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Rotated at: ${rotatedAt.join(', ')}`);
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Logs: ${logDir}/`);
|
|
1441
|
-
if (worktreeCtx) {
|
|
1442
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Worktree: ${worktreeCtx.path} (branch ${worktreeCtx.branch})`);
|
|
1443
|
-
console.log(`\x1b[32m[bd-loop]\x1b[0m Review and merge by hand, e.g.: git -C ${projectDir} merge ${worktreeCtx.branch}`);
|
|
1444
|
-
}
|
|
1445
|
-
if (options.json) {
|
|
1446
|
-
console.log(JSON.stringify({
|
|
1447
|
-
prefix,
|
|
1448
|
-
totalProcessed,
|
|
1449
|
-
totalClosed,
|
|
1450
|
-
results,
|
|
1451
|
-
worktree: worktreeCtx ? { path: worktreeCtx.path, branch: worktreeCtx.branch, mode: worktreeCtx.mode } : null,
|
|
1452
|
-
sessions: reuseSession ? { count: sessionCount, rotatedAt: sessionStarts.slice(1) } : null,
|
|
1453
|
-
}, null, 2));
|
|
1454
|
-
}
|
|
1455
|
-
}
|