@boopkit/cli 0.1.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.
Files changed (53) hide show
  1. package/README.md +65 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +85 -0
  4. package/dist/commands/add.d.ts +6 -0
  5. package/dist/commands/add.js +173 -0
  6. package/dist/commands/adopt.d.ts +5 -0
  7. package/dist/commands/adopt.js +286 -0
  8. package/dist/commands/init.d.ts +5 -0
  9. package/dist/commands/init.js +295 -0
  10. package/dist/commands/list.d.ts +6 -0
  11. package/dist/commands/list.js +105 -0
  12. package/dist/commands/loop.d.ts +63 -0
  13. package/dist/commands/loop.js +765 -0
  14. package/dist/commands/status.d.ts +40 -0
  15. package/dist/commands/status.js +303 -0
  16. package/dist/commands/status.test.d.ts +1 -0
  17. package/dist/commands/status.test.js +118 -0
  18. package/dist/commands/update.d.ts +8 -0
  19. package/dist/commands/update.js +204 -0
  20. package/dist/lib/capability.d.ts +17 -0
  21. package/dist/lib/capability.js +172 -0
  22. package/dist/lib/config.d.ts +70 -0
  23. package/dist/lib/config.js +59 -0
  24. package/dist/lib/db-status.d.ts +64 -0
  25. package/dist/lib/db-status.js +257 -0
  26. package/dist/lib/env.d.ts +25 -0
  27. package/dist/lib/env.js +116 -0
  28. package/dist/lib/execute.d.ts +21 -0
  29. package/dist/lib/execute.js +228 -0
  30. package/dist/lib/interpolate.d.ts +4 -0
  31. package/dist/lib/interpolate.js +13 -0
  32. package/dist/lib/manifest.d.ts +54 -0
  33. package/dist/lib/manifest.js +11 -0
  34. package/dist/lib/preflight.d.ts +31 -0
  35. package/dist/lib/preflight.js +402 -0
  36. package/dist/lib/profile.d.ts +11 -0
  37. package/dist/lib/profile.js +73 -0
  38. package/dist/lib/project.d.ts +36 -0
  39. package/dist/lib/project.js +71 -0
  40. package/dist/lib/project.test.d.ts +1 -0
  41. package/dist/lib/project.test.js +118 -0
  42. package/dist/lib/rewire.d.ts +19 -0
  43. package/dist/lib/rewire.js +222 -0
  44. package/dist/lib/safe-io.d.ts +66 -0
  45. package/dist/lib/safe-io.js +167 -0
  46. package/dist/lib/scaffold.d.ts +87 -0
  47. package/dist/lib/scaffold.js +548 -0
  48. package/dist/lib/update.d.ts +73 -0
  49. package/dist/lib/update.js +379 -0
  50. package/package.json +46 -0
  51. package/profiles/web-app/profile.md +113 -0
  52. package/profiles/web-app/reference/architecture.ref.md +65 -0
  53. package/profiles/web-app/reference/message-queue-patterns.ref.md +247 -0
@@ -0,0 +1,765 @@
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.buildPlanningPrompt = buildPlanningPrompt;
37
+ exports.planWork = planWork;
38
+ exports.findFallbackFeature = findFallbackFeature;
39
+ exports.extractFeatureId = extractFeatureId;
40
+ exports.isUsageLimitText = isUsageLimitText;
41
+ exports.isStandaloneUsageLimitMessage = isStandaloneUsageLimitMessage;
42
+ exports.isIssueInScope = isIssueInScope;
43
+ exports.runLoop = runLoop;
44
+ const child_process_1 = require("child_process");
45
+ const fs = __importStar(require("fs"));
46
+ const path = __importStar(require("path"));
47
+ const crypto_1 = require("crypto");
48
+ function buildPlanningPrompt(workContent, flowContent, prefix) {
49
+ let prompt = `You are setting up work for a development project.
50
+
51
+ ## Work Specification
52
+ ${workContent}
53
+
54
+ ## Workflow Process
55
+ ${flowContent}
56
+
57
+ ## Instructions
58
+ 1. Read the work specification carefully
59
+ 2. Create a feature issue: \`bd create "<title>" -t feature -d "<description>"\`
60
+ - Derive the title from the work specification
61
+ - Include breadcrumbs in the description (links to the work file, relevant specs, skills, and code)
62
+ 3. Follow the workflow process to create child issues under this feature
63
+ (use \`--parent <feature-id>\` so they are children of the feature)
64
+ 4. Review all created issues: ensure each has breadcrumbs, then set
65
+ appropriate priorities and blocking deps between tasks
66
+
67
+ When finished, output exactly one line:
68
+ FEATURE_ID=<the-feature-issue-id>`;
69
+ if (prefix) {
70
+ prompt += `\n\nNaming hint: Use \`${prefix}\` as a naming hint for the feature title.`;
71
+ }
72
+ return prompt;
73
+ }
74
+ async function planWork(opts) {
75
+ const workContent = fs.readFileSync(opts.workPath, 'utf8');
76
+ const flowContent = fs.readFileSync(opts.flowPath, 'utf8');
77
+ const prompt = buildPlanningPrompt(workContent, flowContent, opts.prefix);
78
+ const sessionId = (0, crypto_1.randomUUID)();
79
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
80
+ const logFile = path.join(opts.logDir, `_planning_${opts.prefix}_${timestamp}.log`);
81
+ const readableLogFile = path.join(opts.logDir, `_planning_${opts.prefix}_${timestamp}.readable.log`);
82
+ // Write readable log header
83
+ fs.writeFileSync(readableLogFile, `=== Planning Session ===\nSession: ${sessionId}\nStarted: ${new Date().toISOString()}\n\n`);
84
+ console.log(`\x1b[32m[bd-loop]\x1b[0m 📋 Starting planning phase...`);
85
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Work: ${opts.workPath}`);
86
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Flow: ${opts.flowPath}`);
87
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Log: ${logFile}`);
88
+ const runner = opts.runClaudeFn || runClaude;
89
+ const result = await runner(prompt, {
90
+ budget: opts.budget,
91
+ model: opts.model,
92
+ skipPerms: opts.skipPerms,
93
+ bare: opts.bare,
94
+ sessionId,
95
+ }, logFile, readableLogFile);
96
+ if (result.exitCode !== 0 && result.limitHit) {
97
+ console.error('\x1b[31m[bd-loop]\x1b[0m Planning phase hit usage limit');
98
+ return { featureId: null, costUsd: null };
99
+ }
100
+ if (result.exitCode !== 0) {
101
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Planning phase exited with code ${result.exitCode}`);
102
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Check log: ${logFile}`);
103
+ return { featureId: null, costUsd: null };
104
+ }
105
+ // Read log file and parse assistant text + cost
106
+ let accumulated = '';
107
+ let costUsd = null;
108
+ try {
109
+ const logContent = fs.readFileSync(logFile, 'utf8');
110
+ const lines = logContent.split('\n').filter(Boolean);
111
+ for (const line of lines) {
112
+ try {
113
+ const msg = JSON.parse(line);
114
+ if (msg.type === 'assistant') {
115
+ const texts = (msg.message?.content || [])
116
+ .filter((c) => c.type === 'text')
117
+ .map((c) => c.text);
118
+ accumulated += texts.join('');
119
+ }
120
+ else if (msg.type === 'result' && typeof msg.total_cost_usd === 'number') {
121
+ costUsd = msg.total_cost_usd;
122
+ }
123
+ }
124
+ catch (e) {
125
+ console.warn(`\x1b[33m[bd-loop]\x1b[0m Failed to parse planning log line: ${e.message}`);
126
+ }
127
+ }
128
+ }
129
+ catch {
130
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to read planning log: ${logFile}`);
131
+ return { featureId: null, costUsd: null };
132
+ }
133
+ // Primary extraction
134
+ const featureId = extractFeatureId(accumulated);
135
+ if (featureId) {
136
+ return { featureId, costUsd };
137
+ }
138
+ // Fallback: query bd list for recently created features
139
+ console.log('\x1b[33m[bd-loop]\x1b[0m No FEATURE_ID in output, trying fallback query...');
140
+ try {
141
+ const execFn = opts.execSyncFn || ((cmd, o) => (0, child_process_1.execSync)(cmd, o));
142
+ const raw = execFn('bd list --json --type feature 2>/dev/null', { encoding: 'utf8' });
143
+ const features = JSON.parse(raw);
144
+ const fallbackId = findFallbackFeature(features, workContent, opts.workPath);
145
+ if (fallbackId) {
146
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Fallback found feature: ${fallbackId}`);
147
+ return { featureId: fallbackId, costUsd };
148
+ }
149
+ }
150
+ catch (e) {
151
+ console.warn(`\x1b[33m[bd-loop]\x1b[0m Fallback feature query failed: ${e.message}`);
152
+ }
153
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Planning phase failed to produce a feature ID`);
154
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Check planning log: ${logFile}`);
155
+ return { featureId: null, costUsd };
156
+ }
157
+ function findFallbackFeature(features, workContent, workPath) {
158
+ const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
159
+ // Extract keywords from work file H1 heading or filename
160
+ const h1Match = workContent.match(/^#\s+(.+)$/m);
161
+ const keywords = h1Match
162
+ ? h1Match[1].toLowerCase().split(/\s+/).filter((w) => w.length > 3).slice(0, 3)
163
+ : [path.basename(workPath, path.extname(workPath)).toLowerCase()];
164
+ const match = features.find((f) => {
165
+ if (!f.created_at || f.created_at < fiveMinAgo)
166
+ return false;
167
+ const title = (f.title || '').toLowerCase();
168
+ return keywords.some((kw) => title.includes(kw));
169
+ });
170
+ return match?.id ?? null;
171
+ }
172
+ function extractFeatureId(text) {
173
+ const match = text.match(/FEATURE_ID=([\w.-]+)/);
174
+ return match ? match[1] : null;
175
+ }
176
+ function isUsageLimitText(text) {
177
+ // Normalize all whitespace (including NBSP) so provider text variants still match.
178
+ const normalized = text.toLowerCase().replace(/\s+/gu, ' ').trim();
179
+ return (normalized.includes("you've hit your limit") ||
180
+ normalized.includes('usage limit') ||
181
+ normalized.includes('rate limit') ||
182
+ normalized.includes('quota') ||
183
+ normalized.includes('credit balance is too low') ||
184
+ normalized.includes('credit balance too low') ||
185
+ /credit\s+balance\s+(?:is\s+)?too\s+low/u.test(normalized) ||
186
+ normalized.includes('insufficient credit') ||
187
+ normalized.includes('insufficient funds'));
188
+ }
189
+ function isStandaloneUsageLimitMessage(text) {
190
+ const trimmed = text.trim();
191
+ if (!trimmed || trimmed.includes('\n')) {
192
+ return false;
193
+ }
194
+ // Real provider limit responses are usually short single-line messages.
195
+ if (trimmed.length > 200) {
196
+ return false;
197
+ }
198
+ const normalized = trimmed.toLowerCase().replace(/\s+/gu, ' ');
199
+ return (/^credit\s+balance\s+(?:is\s+)?too\s+low\b/u.test(normalized) ||
200
+ /^insufficient\s+credit\b/u.test(normalized) ||
201
+ /^insufficient\s+funds\b/u.test(normalized) ||
202
+ /^you'?ve\s+hit\s+your\s+limit\b/u.test(normalized) ||
203
+ /^rate\s+limit\s+reached\b/u.test(normalized) ||
204
+ /^error:\s*usage\s+limit\s+exceeded\b/u.test(normalized) ||
205
+ /^quota\s+exceeded\b/u.test(normalized));
206
+ }
207
+ function findClaudeBinary() {
208
+ try {
209
+ return (0, child_process_1.execSync)('command -v claude', { encoding: 'utf8' }).trim();
210
+ }
211
+ catch {
212
+ // Check common locations
213
+ const candidates = [
214
+ path.join(process.env.HOME || '', '.nvm/versions/node'),
215
+ path.join(process.env.HOME || '', '.local/bin/claude'),
216
+ '/usr/local/bin/claude',
217
+ ];
218
+ for (const candidate of candidates) {
219
+ if (candidate.includes('.nvm')) {
220
+ // Glob for nvm versions
221
+ try {
222
+ const result = (0, child_process_1.execSync)(`ls ${candidate}/*/bin/claude 2>/dev/null | head -1`, {
223
+ encoding: 'utf8',
224
+ }).trim();
225
+ if (result)
226
+ return result;
227
+ }
228
+ catch {
229
+ continue;
230
+ }
231
+ }
232
+ if (fs.existsSync(candidate))
233
+ return candidate;
234
+ }
235
+ return null;
236
+ }
237
+ }
238
+ function findNextIssue(prefix, includeFeatures) {
239
+ const matching = getMatchingReadyIssues(prefix, includeFeatures);
240
+ return matching.length > 0 ? matching[0].id : null;
241
+ }
242
+ function getIssueParentId(id) {
243
+ try {
244
+ const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
245
+ const data = JSON.parse(raw);
246
+ const issue = data[0] || data;
247
+ return issue.parent || null;
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
253
+ function isIssueInScope(issue, prefix, getParentById) {
254
+ if (issue.id.startsWith(prefix)) {
255
+ return true;
256
+ }
257
+ const seen = new Set();
258
+ let ancestor = issue.parent || null;
259
+ while (ancestor && !seen.has(ancestor)) {
260
+ if (ancestor.startsWith(prefix)) {
261
+ return true;
262
+ }
263
+ seen.add(ancestor);
264
+ ancestor = getParentById(ancestor);
265
+ }
266
+ return false;
267
+ }
268
+ function getMatchingReadyIssues(prefix, includeFeatures) {
269
+ try {
270
+ const raw = (0, child_process_1.execSync)('bd ready --json -n 20 2>/dev/null', { encoding: 'utf8' });
271
+ const issues = JSON.parse(raw);
272
+ const typeFilter = includeFeatures
273
+ ? (i) => i.issue_type !== 'epic'
274
+ : (i) => i.issue_type !== 'epic' && i.issue_type !== 'feature';
275
+ const parentCache = new Map();
276
+ for (const issue of issues) {
277
+ parentCache.set(issue.id, issue.parent || null);
278
+ }
279
+ const getParentById = (id) => {
280
+ if (parentCache.has(id)) {
281
+ return parentCache.get(id) ?? null;
282
+ }
283
+ const parent = getIssueParentId(id);
284
+ parentCache.set(id, parent);
285
+ return parent;
286
+ };
287
+ return issues
288
+ .filter(typeFilter)
289
+ .filter((issue) => isIssueInScope(issue, prefix, getParentById));
290
+ }
291
+ catch {
292
+ return [];
293
+ }
294
+ }
295
+ function getIssueInfo(id) {
296
+ try {
297
+ const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
298
+ const data = JSON.parse(raw);
299
+ const issue = data[0] || data;
300
+ return {
301
+ id,
302
+ title: issue.title || 'unknown',
303
+ priority: issue.priority ?? '?',
304
+ issue_type: issue.issue_type || 'task',
305
+ description: issue.description || 'No description',
306
+ };
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ }
312
+ function isIssueClosed(id) {
313
+ try {
314
+ const raw = (0, child_process_1.execSync)(`bd show ${id} --json 2>/dev/null`, { encoding: 'utf8' });
315
+ const data = JSON.parse(raw);
316
+ const status = (data[0] || data).status || 'unknown';
317
+ return status === 'closed';
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ }
323
+ function buildPrompt(issue) {
324
+ return `You are working on issue ${issue.id} in the boop project.
325
+
326
+ ## Issue: ${issue.title}
327
+ Type: ${issue.issue_type} | Priority: ${issue.priority}
328
+
329
+ ## Description
330
+ ${issue.description}
331
+
332
+ ## Instructions
333
+ 1. Read AGENTS.md for project orientation
334
+ 2. Claim the issue: bd update ${issue.id} --claim
335
+ 3. Read relevant skills from skills/index.md before making changes
336
+ 4. Implement the changes following TDD-Lite: observe, implement, verify
337
+ 5. Run tests if applicable: npm test
338
+ 6. Commit your changes with a message referencing ${issue.id}
339
+ 7. Close the issue: bd close ${issue.id} --reason "<what you did>"
340
+ 8. Push: git pull --rebase && git push
341
+
342
+ IMPORTANT: Complete the FULL workflow — implement, test, commit, close, and push.
343
+ Do NOT pause for review. Work autonomously through to completion.
344
+
345
+ If you discover additional issues during your work, file them with:
346
+ bd create "Title" -d "description" --deps discovered-from:${issue.id}`;
347
+ }
348
+ const RESUME_PROMPT = `Approved. Continue working on this issue. Complete the full workflow:
349
+ - Finish any remaining implementation
350
+ - Run tests to verify
351
+ - Commit changes referencing the issue ID
352
+ - Close the issue with bd close
353
+ - Push with: git pull --rebase && git push
354
+
355
+ Do NOT pause for review. Complete everything now.`;
356
+ function runClaude(prompt, opts, logFile, readableLogFile) {
357
+ return new Promise((resolve) => {
358
+ const args = [
359
+ '-p',
360
+ '--max-budget-usd', opts.budget,
361
+ '--output-format', 'stream-json',
362
+ '--verbose',
363
+ ];
364
+ if (opts.bare)
365
+ args.push('--bare');
366
+ if (opts.skipPerms)
367
+ args.push('--dangerously-skip-permissions');
368
+ if (opts.model)
369
+ args.push('--model', opts.model);
370
+ if (opts.resume) {
371
+ args.push('--resume', opts.sessionId);
372
+ }
373
+ else {
374
+ args.push('--session-id', opts.sessionId);
375
+ }
376
+ const claudeBin = findClaudeBinary();
377
+ if (!claudeBin) {
378
+ console.error('\x1b[31m[bd-loop]\x1b[0m claude binary not found');
379
+ resolve({ exitCode: 1, limitHit: false });
380
+ return;
381
+ }
382
+ const logStream = fs.createWriteStream(logFile, { flags: 'a' });
383
+ const readableStream = readableLogFile
384
+ ? fs.createWriteStream(readableLogFile, { flags: 'a' })
385
+ : null;
386
+ const child = (0, child_process_1.spawn)(claudeBin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
387
+ let limitHit = false;
388
+ child.stdin.write(prompt);
389
+ child.stdin.end();
390
+ child.stdout.on('data', (chunk) => {
391
+ logStream.write(chunk);
392
+ const lines = chunk.toString().split('\n').filter(Boolean);
393
+ for (const line of lines) {
394
+ try {
395
+ const msg = JSON.parse(line);
396
+ if (msg.type === 'assistant') {
397
+ const texts = (msg.message?.content || [])
398
+ .filter((c) => c.type === 'text')
399
+ .map((c) => c.text);
400
+ if (texts.length > 0) {
401
+ const textBlob = texts.join('');
402
+ if (isStandaloneUsageLimitMessage(textBlob)) {
403
+ limitHit = true;
404
+ }
405
+ const first = textBlob.split('\n')[0].substring(0, 120);
406
+ console.log(`\x1b[32m[bd-loop]\x1b[0m 💬 ${first}`);
407
+ if (readableStream) {
408
+ readableStream.write(`[text] ${textBlob}\n\n`);
409
+ }
410
+ }
411
+ const tools = (msg.message?.content || [])
412
+ .filter((c) => c.type === 'tool_use')
413
+ .map((c) => `${c.name} ${JSON.stringify(c.input).substring(0, 80)}`);
414
+ for (const t of tools) {
415
+ console.log(`\x1b[32m[bd-loop]\x1b[0m 🔧 ${t}`);
416
+ if (readableStream) {
417
+ readableStream.write(`[tool] ${t}\n`);
418
+ }
419
+ }
420
+ }
421
+ else if (msg.type === 'result') {
422
+ const resultText = typeof msg.result === 'string' ? msg.result : '';
423
+ if ((msg.is_error || msg.subtype === 'error') && isUsageLimitText(resultText)) {
424
+ limitHit = true;
425
+ }
426
+ const cost = msg.total_cost_usd || '?';
427
+ const dur = Math.round((msg.duration_ms || 0) / 1000);
428
+ const turns = msg.num_turns || '?';
429
+ console.log(`\x1b[32m[bd-loop]\x1b[0m 📊 ${turns} turns, $${cost}, ${dur}s`);
430
+ if (readableStream) {
431
+ readableStream.write(`\n=== Result ===\nTurns: ${turns} | Cost: $${cost} | Duration: ${dur}s\n\n${resultText}\n`);
432
+ }
433
+ }
434
+ }
435
+ catch {
436
+ if (isStandaloneUsageLimitMessage(line)) {
437
+ limitHit = true;
438
+ }
439
+ }
440
+ }
441
+ });
442
+ child.stderr.on('data', (chunk) => {
443
+ logStream.write(chunk);
444
+ if (isStandaloneUsageLimitMessage(chunk.toString())) {
445
+ limitHit = true;
446
+ }
447
+ });
448
+ child.on('close', (code) => {
449
+ logStream.end();
450
+ if (readableStream)
451
+ readableStream.end();
452
+ resolve({ exitCode: code ?? 1, limitHit });
453
+ });
454
+ });
455
+ }
456
+ async function processIssue(issue, opts) {
457
+ const sessionId = (0, crypto_1.randomUUID)();
458
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
459
+ const logFile = path.join(opts.logDir, `${issue.id}_${timestamp}.log`);
460
+ const readableLogFile = path.join(opts.logDir, `${issue.id}_${timestamp}.readable.log`);
461
+ // Write readable log header
462
+ fs.writeFileSync(readableLogFile, `=== ${issue.id}: ${issue.title} ===\nSession: ${sessionId}\nStarted: ${new Date().toISOString()}\n\n`);
463
+ console.log(`\x1b[32m[bd-loop]\x1b[0m ━━━ \x1b[34m${issue.id}\x1b[0m (P${issue.priority} ${issue.issue_type}) ━━━`);
464
+ console.log(`\x1b[32m[bd-loop]\x1b[0m ${issue.title}`);
465
+ let originalBranch;
466
+ if (!opts.noBranch && opts.perIssueBranch) {
467
+ const branchName = `fix/${issue.id}`;
468
+ originalBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim();
469
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Creating branch: ${branchName}`);
470
+ try {
471
+ (0, child_process_1.execSync)(`git checkout -b ${branchName} 2>/dev/null`, { encoding: 'utf8' });
472
+ }
473
+ catch {
474
+ try {
475
+ (0, child_process_1.execSync)(`git checkout ${branchName} 2>/dev/null`, { encoding: 'utf8' });
476
+ console.log(`\x1b[33m[bd-loop]\x1b[0m Branch ${branchName} exists, checked out`);
477
+ }
478
+ catch {
479
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to create/checkout branch`);
480
+ return { issueId: issue.id, closed: false, resumes: 0, logFile, limitHit: false };
481
+ }
482
+ }
483
+ }
484
+ // Initial run
485
+ const prompt = buildPrompt(issue);
486
+ console.log(`\x1b[32m[bd-loop]\x1b[0m 🚀 Starting Claude session (budget: $${opts.budget}, session: ${sessionId.substring(0, 8)}...)`);
487
+ const initialRun = await runClaude(prompt, {
488
+ budget: opts.budget,
489
+ model: opts.model,
490
+ skipPerms: opts.skipPerms,
491
+ bare: opts.bare,
492
+ sessionId,
493
+ }, logFile, readableLogFile);
494
+ let anyLimitHit = initialRun.limitHit;
495
+ if (initialRun.limitHit) {
496
+ console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached; stopping retries for this issue');
497
+ }
498
+ // Resume loop
499
+ let resumeCount = 0;
500
+ while (resumeCount < opts.resumeLimit && !initialRun.limitHit) {
501
+ if (isIssueClosed(issue.id)) {
502
+ console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Issue ${issue.id} closed successfully`);
503
+ break;
504
+ }
505
+ resumeCount++;
506
+ console.log(`\x1b[33m[bd-loop]\x1b[0m Issue not yet closed — resuming session (attempt ${resumeCount}/${opts.resumeLimit})`);
507
+ console.log(`\x1b[32m[bd-loop]\x1b[0m 🔄 Resuming session ${sessionId.substring(0, 8)}...`);
508
+ fs.appendFileSync(readableLogFile, `\n=== Resume attempt ${resumeCount} ===\nTime: ${new Date().toISOString()}\n\n`);
509
+ const resumeRun = await runClaude(RESUME_PROMPT, {
510
+ budget: opts.budget,
511
+ model: opts.model,
512
+ skipPerms: opts.skipPerms,
513
+ bare: opts.bare,
514
+ sessionId,
515
+ resume: true,
516
+ }, logFile, readableLogFile);
517
+ if (resumeRun.limitHit) {
518
+ anyLimitHit = true;
519
+ console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached during resume; stopping retries for this issue');
520
+ break;
521
+ }
522
+ }
523
+ const closed = isIssueClosed(issue.id);
524
+ if (closed) {
525
+ console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Done: ${issue.id}`);
526
+ }
527
+ else {
528
+ console.error(`\x1b[31m[bd-loop]\x1b[0m ⚠️ Issue ${issue.id} not closed after ${opts.resumeLimit} resume attempts`);
529
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Check log: ${logFile}`);
530
+ }
531
+ if (originalBranch) {
532
+ try {
533
+ (0, child_process_1.execSync)(`git checkout ${originalBranch} 2>/dev/null`);
534
+ }
535
+ catch {
536
+ // Best effort
537
+ }
538
+ }
539
+ return { issueId: issue.id, closed, resumes: resumeCount, logFile, limitHit: anyLimitHit };
540
+ }
541
+ function sleep(ms) {
542
+ return new Promise((resolve) => setTimeout(resolve, ms));
543
+ }
544
+ async function runLoop(options) {
545
+ let prefix = options.prefix;
546
+ const work = options.work;
547
+ const flow = options.flow;
548
+ // Validate: need either --prefix OR both --work and --flow
549
+ if (!prefix && !work && !flow) {
550
+ console.error('\x1b[31mERR\x1b[0m Either --prefix or both --work and --flow are required');
551
+ console.error('Usage: boop loop --prefix <issue-prefix> [--run] [--max N] [--budget N.NN]');
552
+ console.error(' boop loop --work <spec-file> --flow <workflow-file> [--run]');
553
+ process.exitCode = 1;
554
+ return;
555
+ }
556
+ // --work and --flow must be used together
557
+ if ((work && !flow) || (!work && flow)) {
558
+ console.error('\x1b[31mERR\x1b[0m --work and --flow must be used together');
559
+ process.exitCode = 1;
560
+ return;
561
+ }
562
+ // Validate that work/flow files exist
563
+ if (work && !fs.existsSync(work)) {
564
+ console.error(`\x1b[31mERR\x1b[0m --work file not found: ${work}`);
565
+ process.exitCode = 1;
566
+ return;
567
+ }
568
+ if (flow && !fs.existsSync(flow)) {
569
+ console.error(`\x1b[31mERR\x1b[0m --flow file not found: ${flow}`);
570
+ process.exitCode = 1;
571
+ return;
572
+ }
573
+ const dryRun = !options.run;
574
+ const maxIssues = parseInt(options.max || '0', 10);
575
+ const budget = options.budget || '5.00';
576
+ const resumeLimit = parseInt(options.resumeLimit || '3', 10);
577
+ const pollInterval = parseInt(options.pollInterval || '60', 10);
578
+ const model = options.model || 'claude-opus-4-6';
579
+ const perIssueBranch = options.perIssueBranch || false;
580
+ const noBranch = options.noBranch || false;
581
+ let includeFeatures = options.features || false;
582
+ const skipPerms = options.skipPerms !== false; // default true
583
+ const bare = options.bare || false;
584
+ const poll = options.poll || false;
585
+ const projectDir = process.cwd();
586
+ const logDir = path.join(projectDir, 'labs', 'claude-runs');
587
+ fs.mkdirSync(logDir, { recursive: true });
588
+ // Planning mode dry-run
589
+ if (dryRun && work && flow) {
590
+ const workContent = fs.readFileSync(work, 'utf8');
591
+ const flowContent = fs.readFileSync(flow, 'utf8');
592
+ console.log('[bd-loop] PLANNING MODE (dry-run)');
593
+ console.log(`[bd-loop] Work file: ${work}`);
594
+ console.log(`[bd-loop] Flow file: ${flow}`);
595
+ console.log('[bd-loop] Planning prompt that would be sent:');
596
+ console.log('---');
597
+ console.log(buildPlanningPrompt(workContent, flowContent, prefix));
598
+ console.log('---');
599
+ console.log('[bd-loop] After planning, loop would process tasks under the created feature.');
600
+ return;
601
+ }
602
+ // Planning phase: create feature tree before looping
603
+ if (work && flow) {
604
+ const planResult = await planWork({
605
+ workPath: work,
606
+ flowPath: flow,
607
+ prefix,
608
+ budget,
609
+ model,
610
+ skipPerms,
611
+ bare,
612
+ logDir,
613
+ });
614
+ if (!planResult.featureId) {
615
+ console.error('[bd-loop] Planning phase failed to produce a feature ID');
616
+ console.error('[bd-loop] Check logs in: ' + logDir);
617
+ process.exitCode = 1;
618
+ return;
619
+ }
620
+ // Warn if planning consumed more than half the budget
621
+ const budgetNum = parseFloat(budget);
622
+ if (planResult.costUsd != null && budgetNum > 0) {
623
+ const pct = (planResult.costUsd / budgetNum) * 100;
624
+ if (pct > 50) {
625
+ console.warn(`\x1b[33m[bd-loop]\x1b[0m ⚠️ Planning phase used $${planResult.costUsd.toFixed(2)} of $${budget} budget (${pct.toFixed(0)}%). ` +
626
+ `Per-task phases share the same budget cap — tasks may hit the limit early.`);
627
+ }
628
+ }
629
+ prefix = planResult.featureId;
630
+ includeFeatures = true;
631
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Planning complete. Feature: ${planResult.featureId}`);
632
+ }
633
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Starting bd-loop for prefix \x1b[34m${prefix}\x1b[0m`);
634
+ if (dryRun) {
635
+ console.log('\x1b[33m[bd-loop]\x1b[0m DRY RUN — add --run to execute');
636
+ }
637
+ // Set up shared branch
638
+ let originalBranch;
639
+ let sharedBranch;
640
+ if (!noBranch && !perIssueBranch) {
641
+ sharedBranch = `work/${prefix}`;
642
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Branch strategy: shared branch \x1b[34m${sharedBranch}\x1b[0m`);
643
+ }
644
+ if (sharedBranch && !dryRun) {
645
+ originalBranch = (0, child_process_1.execSync)('git branch --show-current', { encoding: 'utf8' }).trim();
646
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Checking out branch: ${sharedBranch}`);
647
+ try {
648
+ (0, child_process_1.execSync)(`git checkout -b ${sharedBranch} 2>/dev/null`, { encoding: 'utf8' });
649
+ }
650
+ catch {
651
+ try {
652
+ (0, child_process_1.execSync)(`git checkout ${sharedBranch}`, { encoding: 'utf8' });
653
+ console.log(`\x1b[33m[bd-loop]\x1b[0m Branch ${sharedBranch} already exists, checked out`);
654
+ }
655
+ catch {
656
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to checkout shared branch ${sharedBranch}`);
657
+ console.error('\x1b[31m[bd-loop]\x1b[0m You may have uncommitted changes — try: git stash');
658
+ process.exitCode = 1;
659
+ return;
660
+ }
661
+ }
662
+ }
663
+ let totalProcessed = 0;
664
+ let totalClosed = 0;
665
+ const results = [];
666
+ while (true) {
667
+ const nextId = prefix ? findNextIssue(prefix, includeFeatures) : null;
668
+ if (!nextId) {
669
+ if (poll) {
670
+ console.log(`\x1b[36m[bd-loop]\x1b[0m No ready issues for prefix '${prefix}'. Polling again in ${pollInterval}s...`);
671
+ await sleep(pollInterval * 1000);
672
+ continue;
673
+ }
674
+ else {
675
+ console.log(`\x1b[32m[bd-loop]\x1b[0m No more ready issues for prefix '${prefix}'.`);
676
+ break;
677
+ }
678
+ }
679
+ totalProcessed++;
680
+ if (maxIssues > 0 && totalProcessed > maxIssues) {
681
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Reached --max ${maxIssues} issues. Stopping.`);
682
+ break;
683
+ }
684
+ if (dryRun) {
685
+ // Show all matching issues in dry-run mode
686
+ try {
687
+ const matching = getMatchingReadyIssues(prefix || '', includeFeatures);
688
+ for (const m of matching) {
689
+ const info = getIssueInfo(m.id);
690
+ if (!info)
691
+ continue;
692
+ if (maxIssues > 0 && totalProcessed > maxIssues)
693
+ break;
694
+ console.log(` \x1b[34m${info.id}\x1b[0m (P${info.priority} ${info.issue_type}) — ${info.title}`);
695
+ console.log(` Would run: claude -p --max-budget-usd ${budget} --session-id <uuid>`);
696
+ if (sharedBranch) {
697
+ console.log(` Branch: ${sharedBranch} (shared)`);
698
+ }
699
+ else if (!noBranch) {
700
+ console.log(` Branch: fix/${info.id}`);
701
+ }
702
+ console.log(` Resume limit: ${resumeLimit} attempts`);
703
+ console.log('');
704
+ if (m.id !== nextId)
705
+ totalProcessed++;
706
+ }
707
+ }
708
+ catch {
709
+ // Fallback: just show the one issue
710
+ const info = getIssueInfo(nextId);
711
+ if (info) {
712
+ console.log(` \x1b[34m${info.id}\x1b[0m (P${info.priority} ${info.issue_type}) — ${info.title}`);
713
+ }
714
+ }
715
+ break;
716
+ }
717
+ // Process the issue
718
+ const issue = getIssueInfo(nextId);
719
+ if (!issue) {
720
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to get issue details for ${nextId}`);
721
+ continue;
722
+ }
723
+ const result = await processIssue(issue, {
724
+ budget,
725
+ model,
726
+ skipPerms,
727
+ bare,
728
+ resumeLimit,
729
+ perIssueBranch,
730
+ noBranch,
731
+ logDir,
732
+ });
733
+ results.push(result);
734
+ if (result.closed)
735
+ totalClosed++;
736
+ if (result.limitHit) {
737
+ console.error('\x1b[31m[bd-loop]\x1b[0m Credit/usage limit hit — stopping loop');
738
+ break;
739
+ }
740
+ }
741
+ // Return to original branch
742
+ if (sharedBranch && !dryRun && originalBranch) {
743
+ try {
744
+ (0, child_process_1.execSync)(`git checkout ${originalBranch} 2>/dev/null`);
745
+ }
746
+ catch {
747
+ // Best effort
748
+ }
749
+ }
750
+ // Summary
751
+ console.log('');
752
+ console.log('\x1b[32m[bd-loop]\x1b[0m ━━━ Summary ━━━');
753
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Prefix: ${prefix}`);
754
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Processed: ${totalProcessed}`);
755
+ if (!dryRun) {
756
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Closed: ${totalClosed}`);
757
+ if (totalProcessed > totalClosed) {
758
+ console.log(`\x1b[33m[bd-loop]\x1b[0m Not closed: ${totalProcessed - totalClosed}`);
759
+ }
760
+ }
761
+ console.log(`\x1b[32m[bd-loop]\x1b[0m Logs: ${logDir}/`);
762
+ if (options.json) {
763
+ console.log(JSON.stringify({ prefix, totalProcessed, totalClosed, results }, null, 2));
764
+ }
765
+ }