@boopkit/cli 0.6.18 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "@boopkit/cap-api": "0.4.5",
3
- "@boopkit/cap-background-jobs": "0.4.3",
3
+ "@boopkit/cap-background-jobs": "0.5.0",
4
4
  "@boopkit/cap-cloud-storage": "0.5.0",
5
- "@boopkit/cap-coding": "0.4.6",
6
- "@boopkit/cap-database": "0.4.4",
7
- "@boopkit/cap-email": "0.4.3",
5
+ "@boopkit/cap-coding": "0.4.7",
6
+ "@boopkit/cap-database": "0.4.5",
7
+ "@boopkit/cap-email": "0.4.4",
8
8
  "@boopkit/cap-hello": "0.4.1",
9
9
  "@boopkit/cap-noticeboard": "0.4.1",
10
10
  "@boopkit/cap-oauth-client": "0.1.1",
11
- "@boopkit/cap-oauth-server": "0.1.4",
12
- "@boopkit/cap-product-design": "0.6.10",
13
- "@boopkit/cap-project-mgmt": "0.4.11",
11
+ "@boopkit/cap-oauth-server": "0.3.1",
12
+ "@boopkit/cap-product-design": "0.6.11",
13
+ "@boopkit/cap-project-mgmt": "0.4.12",
14
14
  "@boopkit/cap-services": "0.4.3",
15
15
  "@boopkit/cap-skills": "0.5.6",
16
- "@boopkit/cap-testing": "0.5.7",
17
- "@boopkit/cap-users": "0.8.1",
16
+ "@boopkit/cap-testing": "0.5.8",
17
+ "@boopkit/cap-users": "0.9.0",
18
18
  "@boopkit/cap-ux": "0.2.3",
19
- "@boopkit/cap-web": "0.5.5",
19
+ "@boopkit/cap-web": "0.5.6",
20
20
  "@boopkit/cap-workflows": "0.2.2",
21
21
  "@boopkit/logger": "0.4.0"
22
22
  }
@@ -29,6 +29,13 @@ export interface LoopOptions {
29
29
  watch?: boolean;
30
30
  reuseSession?: boolean;
31
31
  }
32
+ interface IssueInfo {
33
+ id: string;
34
+ title: string;
35
+ priority: string;
36
+ issue_type: string;
37
+ description: string;
38
+ }
32
39
  interface ClaudeRunResult {
33
40
  exitCode: number;
34
41
  limitHit: boolean;
@@ -36,6 +43,14 @@ interface ClaudeRunResult {
36
43
  peakContext?: number;
37
44
  /** The model's context window (tokens), from the result's modelUsage. */
38
45
  contextWindow?: number;
46
+ /** How many tools the session called. None means it did no work (see sessionDidWork). */
47
+ toolCalls?: number;
48
+ /**
49
+ * What the session said as it ended: the text of its result, or failing that
50
+ * its last message, or failing that the last line it wrote to stderr. When a
51
+ * session fails to start, this is where the cause is named.
52
+ */
53
+ finalText?: string;
39
54
  }
40
55
  export interface PlanResult {
41
56
  featureId: string | null;
@@ -229,6 +244,15 @@ export declare function getMatchingReadyIssues(prefix: string, includeFeatures:
229
244
  maxBuffer?: number;
230
245
  }) => string;
231
246
  }): ReadyIssue[];
247
+ /**
248
+ * The instructions every loop session starts from. A session acts on this text
249
+ * literally and reads it whether or not it opens a skill, so step 5 carries the
250
+ * test-run rule itself (focused tests plus the static check; the full suite only
251
+ * when the issue asks) and names no command, because the text is the same in
252
+ * every project and the commands are not. For the same reason it names no
253
+ * project.
254
+ */
255
+ export declare function buildPrompt(issue: IssueInfo): string;
232
256
  /**
233
257
  * Default per-issue budget cap in USD. The planning phase and each task each get
234
258
  * this cap. Single source of truth — imported by cli.ts for its --budget help
@@ -243,12 +267,48 @@ export declare const DEFAULT_BUDGET_USD = "25.00";
243
267
  * also skips resuming a `blocked` issue entirely (see shouldResumeUnclosedIssue)
244
268
  * — this clause is the belt-and-suspenders for a block raised mid-resume.
245
269
  */
246
- export declare const RESUME_PROMPT = "The previous session ended without closing this issue. Reload the state and continue.\n\nIMPORTANT: If the previous session marked this issue blocked with a diagnostic, the block stands. Do not re-open it or work around it. Read the issue's notes and the previous session's commits, confirm the block is still appropriate, and stop without taking further action.\n\nOtherwise, complete the workflow:\n- Finish any remaining implementation\n- Run tests to verify\n- Commit changes referencing the issue ID\n- Close the issue with bd close\n- Push with: git pull --rebase && git push\n\nDo not pause for review of completed work. Complete everything now.";
270
+ export declare const RESUME_PROMPT = "The previous session ended without closing this issue. Reload the state and continue.\n\nIMPORTANT: If the previous session marked this issue blocked with a diagnostic, the block stands. Do not re-open it or work around it. Read the issue's notes and the previous session's commits, confirm the block is still appropriate, and stop without taking further action.\n\nOtherwise, complete the workflow:\n- Finish any remaining implementation\n- Run the tests that step 5 of your first instructions describes: the issue's own test scope if it states one, otherwise the focused tests for what you changed plus the static check, and not the full suite unless the issue asks for it\n- Commit changes referencing the issue ID\n- Close the issue with bd close\n- Push with: git pull --rebase && git push\n\nDo not pause for review of completed work. Complete everything now.";
247
271
  /**
248
272
  * Decide whether a reused session should rotate before taking the next issue.
249
273
  * Rotates once peak observed occupancy reaches ROTATE_FRACTION of the window.
250
274
  * Pure so it is unit-testable without a running loop.
251
275
  */
252
276
  export declare function shouldRotateSession(peakContext: number, contextWindow: number, fraction?: number): boolean;
253
- export declare function runLoop(options: LoopOptions): Promise<void>;
277
+ /**
278
+ * How many sessions in a row may end without doing any work before the loop
279
+ * stops. Fixed, with no flag: no caller needs a different number. A claim that
280
+ * fails before a session starts counts as one such session (boop-rfof).
281
+ *
282
+ * A claim that cannot start work costs four sessions at the default
283
+ * --resume-limit (the first run plus three resumes), so five means the loop has
284
+ * claimed an issue a second time and that claim did not start either. In the
285
+ * incident this rule comes from (boop-to45.2), expired credentials produced
286
+ * 1,543 such sessions in 64 minutes, each one turn long and over within a second.
287
+ */
288
+ export declare const NO_WORK_SESSION_LIMIT = 5;
289
+ /**
290
+ * Whether a session did any work, which here means it called at least one tool.
291
+ * A session cannot read a file, claim its issue or run a command without one,
292
+ * so a session with no tool calls started nothing, whatever the reason: expired
293
+ * or missing credentials, an upstream outage, a `claude` binary that is not
294
+ * there. The test deliberately ignores how long the session lasted and what its
295
+ * error said, because an outage that retries for a minute before giving up is
296
+ * the same fault as a credential check that fails at once.
297
+ */
298
+ export declare function sessionDidWork(run: ClaudeRunResult): boolean;
299
+ /**
300
+ * Split a session's stream-json output into complete lines. A stdout chunk ends
301
+ * wherever the pipe buffer happened to fill, so the last piece of a chunk may be
302
+ * the first part of a line whose remainder arrives in the next chunk: it comes
303
+ * back as `rest`, for the caller to pass in as `carry` with the next chunk and
304
+ * to flush when the stream closes. Pure so a test can feed it a line cut in two.
305
+ */
306
+ export declare function splitStreamLines(carry: string, chunk: string): {
307
+ lines: string[];
308
+ rest: string;
309
+ };
310
+ export declare function runLoop(options: LoopOptions, deps?: {
311
+ /** Runs one Claude session for an issue. Tests substitute this; the planning phase is not affected. */
312
+ runClaudeFn?: RunClaudeFn;
313
+ }): Promise<void>;
254
314
  export {};
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.RESUME_PROMPT = exports.DEFAULT_BUDGET_USD = exports.DEFAULT_MODEL = exports.BD_READ_MAX_BUFFER = void 0;
36
+ exports.NO_WORK_SESSION_LIMIT = exports.RESUME_PROMPT = exports.DEFAULT_BUDGET_USD = exports.DEFAULT_MODEL = exports.BD_READ_MAX_BUFFER = void 0;
37
37
  exports.assertDoltServerMode = assertDoltServerMode;
38
38
  exports.discoverDoltServer = discoverDoltServer;
39
39
  exports.resolveDefaultBranch = resolveDefaultBranch;
@@ -52,12 +52,16 @@ exports.isIssueInScope = isIssueInScope;
52
52
  exports.isClaimableReadyIssue = isClaimableReadyIssue;
53
53
  exports.shouldCloseFeatureRoot = shouldCloseFeatureRoot;
54
54
  exports.getMatchingReadyIssues = getMatchingReadyIssues;
55
+ exports.buildPrompt = buildPrompt;
55
56
  exports.shouldRotateSession = shouldRotateSession;
57
+ exports.sessionDidWork = sessionDidWork;
58
+ exports.splitStreamLines = splitStreamLines;
56
59
  exports.runLoop = runLoop;
57
60
  const child_process_1 = require("child_process");
58
61
  const fs = __importStar(require("fs"));
59
62
  const path = __importStar(require("path"));
60
63
  const crypto_1 = require("crypto");
64
+ const string_decoder_1 = require("string_decoder");
61
65
  /**
62
66
  * Output cap for every `bd … --json` read the loop makes.
63
67
  *
@@ -879,8 +883,16 @@ function isIssueClosed(id) {
879
883
  return false;
880
884
  }
881
885
  }
886
+ /**
887
+ * The instructions every loop session starts from. A session acts on this text
888
+ * literally and reads it whether or not it opens a skill, so step 5 carries the
889
+ * test-run rule itself (focused tests plus the static check; the full suite only
890
+ * when the issue asks) and names no command, because the text is the same in
891
+ * every project and the commands are not. For the same reason it names no
892
+ * project.
893
+ */
882
894
  function buildPrompt(issue) {
883
- return `You are working on issue ${issue.id} in the boop project.
895
+ return `You are working on issue ${issue.id} in this project.
884
896
 
885
897
  ## Issue: ${issue.title}
886
898
  Type: ${issue.issue_type} | Priority: ${issue.priority}
@@ -893,7 +905,10 @@ ${issue.description}
893
905
  2. Claim the issue: bd update ${issue.id} --claim
894
906
  3. Read relevant skills from skills/ (list folders, read each SKILL.md) before making changes
895
907
  4. Implement the changes following TDD-Lite: observe, implement, verify
896
- 5. Run tests if applicable: npm test
908
+ 5. Run the tests this issue needs, and no more:
909
+ - If the issue description states which tests to run, run exactly those and skip the rest of this step. The issue's scope replaces the default below; do not run both.
910
+ - Otherwise, run the focused tests for the files you changed, plus the project's whole-program static check. The boop-testing-testing skill (skills/boop-testing-testing/SKILL.md) and the PROJECT.md beside it give this project's commands for both. If the project has no static check, run the focused tests alone.
911
+ - Run the full test suite only when the issue description asks for it. It runs once per branch, on the task that completes the branch, and not on the tasks before it.
897
912
  6. Commit your changes with a message referencing ${issue.id}
898
913
  7. Close the issue: bd close ${issue.id} --reason "<what you did>"
899
914
  8. Push: git pull --rebase && git push
@@ -924,7 +939,7 @@ IMPORTANT: If the previous session marked this issue blocked with a diagnostic,
924
939
 
925
940
  Otherwise, complete the workflow:
926
941
  - Finish any remaining implementation
927
- - Run tests to verify
942
+ - Run the tests that step 5 of your first instructions describes: the issue's own test scope if it states one, otherwise the focused tests for what you changed plus the static check, and not the full suite unless the issue asks for it
928
943
  - Commit changes referencing the issue ID
929
944
  - Close the issue with bd close
930
945
  - Push with: git pull --rebase && git push
@@ -957,6 +972,42 @@ function shouldRotateSession(peakContext, contextWindow, fraction = ROTATE_FRACT
957
972
  const window = contextWindow > 0 ? contextWindow : DEFAULT_CONTEXT_WINDOW;
958
973
  return peakContext >= fraction * window;
959
974
  }
975
+ /**
976
+ * How many sessions in a row may end without doing any work before the loop
977
+ * stops. Fixed, with no flag: no caller needs a different number. A claim that
978
+ * fails before a session starts counts as one such session (boop-rfof).
979
+ *
980
+ * A claim that cannot start work costs four sessions at the default
981
+ * --resume-limit (the first run plus three resumes), so five means the loop has
982
+ * claimed an issue a second time and that claim did not start either. In the
983
+ * incident this rule comes from (boop-to45.2), expired credentials produced
984
+ * 1,543 such sessions in 64 minutes, each one turn long and over within a second.
985
+ */
986
+ exports.NO_WORK_SESSION_LIMIT = 5;
987
+ /**
988
+ * Whether a session did any work, which here means it called at least one tool.
989
+ * A session cannot read a file, claim its issue or run a command without one,
990
+ * so a session with no tool calls started nothing, whatever the reason: expired
991
+ * or missing credentials, an upstream outage, a `claude` binary that is not
992
+ * there. The test deliberately ignores how long the session lasted and what its
993
+ * error said, because an outage that retries for a minute before giving up is
994
+ * the same fault as a credential check that fails at once.
995
+ */
996
+ function sessionDidWork(run) {
997
+ return (run.toolCalls ?? 0) > 0;
998
+ }
999
+ /**
1000
+ * Split a session's stream-json output into complete lines. A stdout chunk ends
1001
+ * wherever the pipe buffer happened to fill, so the last piece of a chunk may be
1002
+ * the first part of a line whose remainder arrives in the next chunk: it comes
1003
+ * back as `rest`, for the caller to pass in as `carry` with the next chunk and
1004
+ * to flush when the stream closes. Pure so a test can feed it a line cut in two.
1005
+ */
1006
+ function splitStreamLines(carry, chunk) {
1007
+ const pieces = (carry + chunk).split('\n');
1008
+ const rest = pieces.pop() ?? '';
1009
+ return { lines: pieces.filter(Boolean), rest };
1010
+ }
960
1011
  function runClaude(prompt, opts, logFile, readableLogFile) {
961
1012
  return new Promise((resolve) => {
962
1013
  const args = [
@@ -980,7 +1031,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
980
1031
  const claudeBin = findClaudeBinary();
981
1032
  if (!claudeBin) {
982
1033
  console.error('\x1b[31m[bd-loop]\x1b[0m claude binary not found');
983
- resolve({ exitCode: 1, limitHit: false });
1034
+ resolve({ exitCode: 1, limitHit: false, toolCalls: 0, finalText: 'claude binary not found' });
984
1035
  return;
985
1036
  }
986
1037
  const logStream = fs.createWriteStream(logFile, { flags: 'a' });
@@ -1006,11 +1057,20 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
1006
1057
  // the run drives session rotation; contextWindow comes from the result.
1007
1058
  let peakContext = 0;
1008
1059
  let contextWindow;
1060
+ // What the no-work rule in runLoop needs from this session: whether it called
1061
+ // any tool, and what it said last.
1062
+ let toolCalls = 0;
1063
+ let lastAssistantText = '';
1064
+ let resultText = '';
1065
+ let lastStderrLine = '';
1009
1066
  child.stdin.write(prompt);
1010
1067
  child.stdin.end();
1011
- child.stdout.on('data', (chunk) => {
1012
- logStream.write(chunk);
1013
- const lines = chunk.toString().split('\n').filter(Boolean);
1068
+ // A line longer than the pipe buffer arrives in several chunks, so the
1069
+ // unterminated tail of each chunk waits here for the rest of its line. The
1070
+ // decoder does the same for a multi-byte character cut between chunks.
1071
+ const stdoutDecoder = new string_decoder_1.StringDecoder('utf8');
1072
+ let stdoutTail = '';
1073
+ const readLines = (lines) => {
1014
1074
  for (const line of lines) {
1015
1075
  try {
1016
1076
  const msg = JSON.parse(line);
@@ -1024,6 +1084,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
1024
1084
  .map((c) => c.text);
1025
1085
  if (texts.length > 0) {
1026
1086
  const textBlob = texts.join('');
1087
+ lastAssistantText = textBlob;
1027
1088
  if (isStandaloneUsageLimitMessage(textBlob)) {
1028
1089
  limitHit = true;
1029
1090
  }
@@ -1036,6 +1097,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
1036
1097
  const tools = (msg.message?.content || [])
1037
1098
  .filter((c) => c.type === 'tool_use')
1038
1099
  .map((c) => `${c.name} ${JSON.stringify(c.input).substring(0, 80)}`);
1100
+ toolCalls += tools.length;
1039
1101
  for (const t of tools) {
1040
1102
  console.log(`\x1b[32m[bd-loop]\x1b[0m 🔧 ${t}`);
1041
1103
  if (readableStream) {
@@ -1044,7 +1106,7 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
1044
1106
  }
1045
1107
  }
1046
1108
  else if (msg.type === 'result') {
1047
- const resultText = typeof msg.result === 'string' ? msg.result : '';
1109
+ resultText = typeof msg.result === 'string' ? msg.result : '';
1048
1110
  if ((msg.is_error || msg.subtype === 'error') && isUsageLimitText(resultText)) {
1049
1111
  limitHit = true;
1050
1112
  }
@@ -1073,22 +1135,38 @@ function runClaude(prompt, opts, logFile, readableLogFile) {
1073
1135
  }
1074
1136
  }
1075
1137
  }
1138
+ };
1139
+ child.stdout.on('data', (chunk) => {
1140
+ logStream.write(chunk);
1141
+ const split = splitStreamLines(stdoutTail, stdoutDecoder.write(chunk));
1142
+ stdoutTail = split.rest;
1143
+ readLines(split.lines);
1076
1144
  });
1077
1145
  child.stderr.on('data', (chunk) => {
1078
1146
  logStream.write(chunk);
1147
+ const stderrLines = chunk.toString().split('\n').filter((l) => l.trim());
1148
+ if (stderrLines.length > 0)
1149
+ lastStderrLine = stderrLines[stderrLines.length - 1];
1079
1150
  if (isStandaloneUsageLimitMessage(chunk.toString())) {
1080
1151
  limitHit = true;
1081
1152
  }
1082
1153
  });
1083
1154
  child.on('close', (code) => {
1155
+ // Output that ended without a newline is still a line.
1156
+ const lastLine = stdoutTail + stdoutDecoder.end();
1157
+ stdoutTail = '';
1158
+ if (lastLine)
1159
+ readLines([lastLine]);
1084
1160
  logStream.end();
1085
1161
  if (readableStream)
1086
1162
  readableStream.end();
1087
- resolve({ exitCode: code ?? 1, limitHit, peakContext, contextWindow });
1163
+ const finalText = (resultText || lastAssistantText || lastStderrLine).trim() || undefined;
1164
+ resolve({ exitCode: code ?? 1, limitHit, peakContext, contextWindow, toolCalls, finalText });
1088
1165
  });
1089
1166
  });
1090
1167
  }
1091
1168
  async function processIssue(issue, opts) {
1169
+ const runClaude = opts.runClaudeFn;
1092
1170
  const sessionId = opts.session?.id ?? (0, crypto_1.randomUUID)();
1093
1171
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
1094
1172
  const logFile = path.join(opts.logDir, `${issue.id}_${timestamp}.log`);
@@ -1109,12 +1187,16 @@ async function processIssue(issue, opts) {
1109
1187
  }
1110
1188
  catch {
1111
1189
  try {
1112
- (0, child_process_1.execSync)(`git checkout ${branchName} 2>/dev/null`, { encoding: 'utf8', cwd: opts.cwd });
1190
+ (0, child_process_1.execSync)(`git checkout ${branchName}`, { encoding: 'utf8', cwd: opts.cwd, stdio: 'pipe' });
1113
1191
  console.log(`\x1b[33m[bd-loop]\x1b[0m Branch ${branchName} exists, checked out`);
1114
1192
  }
1115
- catch {
1116
- console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to create/checkout branch`);
1117
- return { issueId: issue.id, closed: false, resumes: 0, logFile, limitHit: false };
1193
+ catch (e) {
1194
+ // The issue stays open and ready, so this claim counts as an attempt
1195
+ // that did no work; otherwise the loop would claim it again indefinitely.
1196
+ const gitError = String(e.stderr || e.message).trim();
1197
+ const claimFailure = `Failed to create/checkout branch ${branchName}: ${gitError}`;
1198
+ console.error(`\x1b[31m[bd-loop]\x1b[0m ${claimFailure}`);
1199
+ return { issueId: issue.id, closed: false, resumes: 0, logFile, limitHit: false, noWorkStreak: opts.noWorkStreak + 1, claimFailure };
1118
1200
  }
1119
1201
  }
1120
1202
  }
@@ -1134,12 +1216,21 @@ async function processIssue(issue, opts) {
1134
1216
  let anyLimitHit = initialRun.limitHit;
1135
1217
  let peakContext = initialRun.peakContext ?? 0;
1136
1218
  let contextWindow = initialRun.contextWindow;
1219
+ // Any session that does work starts the count again, so an issue that takes
1220
+ // many working sessions never approaches the limit.
1221
+ let noWorkStreak = sessionDidWork(initialRun) ? 0 : opts.noWorkStreak + 1;
1222
+ let lastSessionText = initialRun.finalText;
1137
1223
  if (initialRun.limitHit) {
1138
1224
  console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached; stopping retries for this issue');
1139
1225
  }
1140
1226
  // Resume loop
1141
1227
  let resumeCount = 0;
1142
1228
  while (resumeCount < opts.resumeLimit && !initialRun.limitHit) {
1229
+ // Enough sessions in a row have done no work. Another resume would be one
1230
+ // more of the same, so hand back to runLoop, which stops the loop.
1231
+ if (noWorkStreak >= exports.NO_WORK_SESSION_LIMIT) {
1232
+ break;
1233
+ }
1143
1234
  if (isIssueClosed(issue.id)) {
1144
1235
  console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Issue ${issue.id} closed successfully`);
1145
1236
  break;
@@ -1168,6 +1259,8 @@ async function processIssue(issue, opts) {
1168
1259
  peakContext = resumeRun.peakContext;
1169
1260
  if (resumeRun.contextWindow)
1170
1261
  contextWindow = resumeRun.contextWindow;
1262
+ noWorkStreak = sessionDidWork(resumeRun) ? 0 : noWorkStreak + 1;
1263
+ lastSessionText = resumeRun.finalText;
1171
1264
  if (resumeRun.limitHit) {
1172
1265
  anyLimitHit = true;
1173
1266
  console.error('\x1b[31m[bd-loop]\x1b[0m Claude usage limit reached during resume; stopping retries for this issue');
@@ -1179,7 +1272,7 @@ async function processIssue(issue, opts) {
1179
1272
  console.log(`\x1b[32m[bd-loop]\x1b[0m ✅ Done: ${issue.id}`);
1180
1273
  }
1181
1274
  else {
1182
- console.error(`\x1b[31m[bd-loop]\x1b[0m ⚠️ Issue ${issue.id} not closed after ${opts.resumeLimit} resume attempts`);
1275
+ console.error(`\x1b[31m[bd-loop]\x1b[0m ⚠️ Issue ${issue.id} not closed after ${resumeCount} resume attempts`);
1183
1276
  console.error(`\x1b[31m[bd-loop]\x1b[0m Check log: ${logFile}`);
1184
1277
  }
1185
1278
  if (originalBranch) {
@@ -1190,12 +1283,12 @@ async function processIssue(issue, opts) {
1190
1283
  // Best effort
1191
1284
  }
1192
1285
  }
1193
- return { issueId: issue.id, closed, resumes: resumeCount, logFile, limitHit: anyLimitHit, sessionId, peakContext, contextWindow };
1286
+ return { issueId: issue.id, closed, resumes: resumeCount, logFile, limitHit: anyLimitHit, sessionId, peakContext, contextWindow, noWorkStreak, lastSessionText };
1194
1287
  }
1195
1288
  function sleep(ms) {
1196
1289
  return new Promise((resolve) => setTimeout(resolve, ms));
1197
1290
  }
1198
- async function runLoop(options) {
1291
+ async function runLoop(options, deps) {
1199
1292
  let prefix = options.prefix;
1200
1293
  const work = options.work;
1201
1294
  const flow = options.flow;
@@ -1386,6 +1479,34 @@ async function runLoop(options) {
1386
1479
  // Issue id at each session START (the first entry is the initial session, not a
1387
1480
  // rotation). Actual rotations are everything after the first — see the summary.
1388
1481
  const sessionStarts = [];
1482
+ // Attempts in a row that did no work, counted across claims and across
1483
+ // issues: sessions that called no tool, and claims that failed before a
1484
+ // session started. See NO_WORK_SESSION_LIMIT.
1485
+ let noWorkStreak = 0;
1486
+ // An issue left open is claimed again on the next pass, and --resume-limit
1487
+ // only bounds one claim, so without this stop a loop whose claims cannot
1488
+ // start work would claim the same issue indefinitely (boop-to45.2). It
1489
+ // applies under --poll as well: waiting does not repair a credential.
1490
+ // Returns whether the loop has to stop.
1491
+ const stopIfWorkIsNotStarting = (last) => {
1492
+ if (noWorkStreak < exports.NO_WORK_SESSION_LIMIT)
1493
+ return false;
1494
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Stopping: the last ${noWorkStreak} attempts in a row did no work ` +
1495
+ `(a session that ended without calling a single tool, or a claim that failed before a session started), ` +
1496
+ `so work is not starting. This is NOT a clean finish.`);
1497
+ if (last.claimFailure) {
1498
+ console.error(`\x1b[31m[bd-loop]\x1b[0m The last claim, of ${last.issueId}, failed before a session started: ${last.claimFailure}`);
1499
+ }
1500
+ else {
1501
+ const said = last.lastSessionText
1502
+ ? last.lastSessionText.replace(/\s+/gu, ' ').substring(0, 500)
1503
+ : '(nothing: it produced no output)';
1504
+ console.error(`\x1b[31m[bd-loop]\x1b[0m The last session, on ${last.issueId}, said: ${said}`);
1505
+ }
1506
+ console.error(`\x1b[31m[bd-loop]\x1b[0m Fix that cause and start the loop again.${last.logFile ? ` Log: ${last.logFile}` : ''}`);
1507
+ process.exitCode = 1;
1508
+ return true;
1509
+ };
1389
1510
  while (true) {
1390
1511
  let nextId = null;
1391
1512
  if (prefix) {
@@ -1470,7 +1591,12 @@ async function runLoop(options) {
1470
1591
  // Process the issue
1471
1592
  const issue = getIssueInfo(nextId);
1472
1593
  if (!issue) {
1473
- console.error(`\x1b[31m[bd-loop]\x1b[0m Failed to get issue details for ${nextId}`);
1594
+ const claimFailure = `Failed to get issue details for ${nextId}`;
1595
+ console.error(`\x1b[31m[bd-loop]\x1b[0m ${claimFailure}`);
1596
+ // The issue is still ready, so the next pass finds it again at once.
1597
+ noWorkStreak++;
1598
+ if (stopIfWorkIsNotStarting({ issueId: nextId, claimFailure }))
1599
+ break;
1474
1600
  continue;
1475
1601
  }
1476
1602
  // Decide the session this issue runs under. Reuse a healthy carried session;
@@ -1502,8 +1628,11 @@ async function runLoop(options) {
1502
1628
  logDir,
1503
1629
  cwd: loopCwd,
1504
1630
  session,
1631
+ noWorkStreak,
1632
+ runClaudeFn: deps?.runClaudeFn || runClaude,
1505
1633
  });
1506
1634
  results.push(result);
1635
+ noWorkStreak = result.noWorkStreak;
1507
1636
  if (result.closed)
1508
1637
  totalClosed++;
1509
1638
  // Fold this issue's observed context back into the carried session so the next
@@ -1521,6 +1650,9 @@ async function runLoop(options) {
1521
1650
  console.error('\x1b[31m[bd-loop]\x1b[0m Credit/usage limit hit — stopping loop');
1522
1651
  break;
1523
1652
  }
1653
+ // A failed claim wrote no session log, so there is none to point at.
1654
+ if (stopIfWorkIsNotStarting(result.claimFailure ? { ...result, logFile: undefined } : result))
1655
+ break;
1524
1656
  }
1525
1657
  // Always-worktree: leave the worktree and branch in place for the operator to
1526
1658
  // review and merge by hand (manual merge is the v1 contract). No checkout-back —
package/docs/loop.md CHANGED
@@ -29,6 +29,8 @@ Without `--run`, the command shows what it would do without executing anything.
29
29
 
30
30
  Each Claude session gets a full prompt with issue details and instructions to work autonomously through the complete workflow: claim, implement, test, commit, close, and push.
31
31
 
32
+ The prompt does not tell a session to run the whole test suite. If the issue says which tests to run, the session runs those. Otherwise it runs the focused tests for the files it changed plus the project's static check, using the commands the `boop-testing-testing` skill and the `PROJECT.md` beside it give. The full suite runs only when an issue asks for it, which is normally the task that completes the branch, and again when `boop loop --merge` integrates the branch.
33
+
32
34
  ### Planning mode (`--work` + `--flow`)
33
35
 
34
36
  When `--work` and `--flow` are provided, boop loop runs a **plan-then-execute pipeline**:
@@ -120,7 +122,8 @@ Session logs are written to `labs/claude-runs/` in the project directory. Each i
120
122
 
121
123
  - **Dry-run by default** — nothing executes without `--run`
122
124
  - **Budget caps** — each issue is capped at `--budget` USD
123
- - **Resume limits** — prevents infinite retry loops (default: 3 attempts)
125
+ - **Resume limits** — bounds the sessions spent on one claim of an issue (default: 3 resumes after the first run)
126
+ - **Sessions that do no work stop the loop** — if five sessions in a row end without calling a single tool, the loop prints what the last one said and stops with exit code 1. This is what ends a run whose sessions cannot start, for example because the Claude Code credentials have expired or the API is down, which would otherwise claim the same issue again indefinitely. A claim that fails before any session starts counts as well: the issue's details could not be read, or with `--per-issue-branch` its branch could not be created or checked out. The loop then prints that failure in place of a session's last words. Any session that does work sets the count back to zero. The number is fixed.
124
127
  - **Permission skipping** — enabled by default for unattended operation; use `--no-skip-perms` to require approval for each tool call
125
128
  - **A failed queue read is never a clean finish** — if the loop cannot read the ready queue it says so and stops with exit code 1, rather than reporting "No more ready issues" and exiting 0. See [Reading the ready queue](#reading-the-ready-queue).
126
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boopkit/cli",
3
- "version": "0.6.18",
3
+ "version": "0.7.1",
4
4
  "description": "boop: the opinionated bootstrap for founder friendly, AI-first web apps - code plus agent skills, composable from day one.",
5
5
  "main": "dist/cli.js",
6
6
  "workspaces": [
@@ -26,6 +26,29 @@ await MessageQueueService.publish('batch-ready', [message1, message2]);
26
26
  - **Error recovery**: If `publish()` catches an error (e.g., channel closed), it resets connection state and returns `false` — the next call will reconnect
27
27
  - **Shutdown**: The publish connection is tracked and closed by `close()` alongside the listener connection
28
28
 
29
+ ### Publishing to a queue that does not exist
30
+
31
+ `publish()` does not declare the queue; only `listen()` does.
32
+ A broker confirms a message that it cannot put in any queue and then discards it,
33
+ so a publish to a name that no consumer has ever listened on returns `true` and the message is lost.
34
+
35
+ Pass `{ mandatory: true }` as the third argument when the publisher cannot be sure the queue exists:
36
+ when the name is composed at run time (for example with `targetPrefix`),
37
+ or when the publisher can start before the consumer ever has.
38
+
39
+ ```typescript
40
+ const queued = await MessageQueueService.publish('batch-ready', message, { targetPrefix, mandatory: true });
41
+ if (!queued) {
42
+ // Not queued anywhere: record or report it.
43
+ }
44
+ ```
45
+
46
+ - The message is sent with the AMQP `mandatory` flag, and the broker returns it when no queue has the destination's name
47
+ - `publish()` then logs `publish to "<queue>" was returned by the broker: no queue has that name` and returns `false`
48
+ - The connection state is **not** reset in this case — the connection is healthy, and the next `publish()` reuses it
49
+ - A queue that exists but has no consumer running still takes the message; the option reports a missing queue, not a missing worker
50
+ - Without the option, `publish()` is unchanged
51
+
29
52
  ### Why persistent (not per-call)
30
53
 
31
54
  The original implementation opened a new AMQP connection for every `publish()` call, then immediately closed it. This works for infrequent publishing but fails under rapid iteration (e.g., `ch-export-push` publishing 177 batch-ready messages in seconds). CloudAMQP's shared plan rate-limits connection churn, causing `ECONNREFUSED` errors.
@@ -44,8 +67,12 @@ The original implementation opened a new AMQP connection for every `publish()` c
44
67
 
45
68
  - **Lazy init**: No connection is opened until the first `listen()` call
46
69
  - **Shared**: All listeners share the same connection; each gets its own channel with independent prefetch and consumer
47
- - **Auto-reconnect**: If the connection drops (`error` or `close` event), the cached reference is cleared and the next `listen()` call opens a fresh connection
48
- - **Shutdown**: The listener connection is closed by `close()` alongside the publish connection
70
+ - **Auto-reconnect**: If the connection drops (`error` or `close` event), the service opens one new connection and attaches every consumer again — same queue, options and handler — without another `listen()` call. While the broker is unreachable it retries, waiting 1s and doubling up to 30s
71
+ - **Logging**: The drop is logged at `warn` (`listener connection lost … re-attaching N consumer(s)`), each failed attempt at `error`, and each consumer that is consuming again at `info` (`re-attached consumer on "<queue>"`)
72
+ - **Redelivery**: A message that was delivered but not yet ACKed when the connection dropped is delivered again by the broker, so a handler can see the same message twice. Handlers must tolerate processing a message a second time. This repeat does not count as a retry (see [Retry & Dead Letter Queue](#retry--dead-letter-queue-dlq)). An ACK or NACK for the first delivery — including a deferred one in manual-ACK mode — cannot be sent on the closed channel; the service logs it at `warn` and does not throw
73
+ - **Cancelled consumers**: A `Subscription` that was cancelled is not attached again. `cancel()` after a re-attach stops the consumer that is current at that moment, and `consumerTag` reports the new broker-assigned tag
74
+ - **Closed channel**: Only the connection is watched. A single consumer's channel that the broker closes while the connection stays up is not re-attached
75
+ - **Shutdown**: The listener connection is closed by `close()` alongside the publish connection. `close()` ends consumption for good and never starts a re-attach
49
76
 
50
77
  ### Why shared (not per-queue)
51
78
 
@@ -61,10 +88,11 @@ The `MessageQueueService.listen()` function supports two modes controlled by `op
61
88
  MessageQueueService.listen('queue-name', handler);
62
89
  ```
63
90
 
64
- - Handler signature: `async handler(msg) → boolean`
91
+ - Handler signature: `async handler(msg, delivery) → boolean`
65
92
  - Returns `true` → message ACKed immediately
66
- - Returns `false` → sent to retry queue with exponential backoff
67
- - Throws → NACKed to DLQ after max retries
93
+ - Returns `false` → the message is delivered again after a backoff, and moves to the DLQ when its last attempt fails
94
+ - Throws → the same as returning `false`, and the error is logged
95
+ - `delivery` is `{ retryCount, lastAttempt }` (the `Delivery` type exported by `@boopkit/cap-background-jobs`); a handler that takes only `msg` keeps working
68
96
  - **Use case**: Simple request-response consumers (e.g., entity replication, long running jobs)
69
97
 
70
98
  ### Manual-ACK Mode
@@ -76,7 +104,8 @@ MessageQueueService.listen('queue-name', handler, { manualAck: true, prefetch: N
76
104
  - Handler signature: `async handler(msg, ack, nack)`
77
105
  - `ack()` and `nack()` are guarded — only the first call takes effect
78
106
  - Handler is responsible for calling `ack()` or `nack()` (can be deferred)
79
- - If handler throws without settling → message moved to DLQ
107
+ - `nack()`, a throw without settling, or resolving without settling → the message is delivered again after a backoff, and moves to the DLQ when its last attempt fails
108
+ - The handler does not receive a `Delivery`
80
109
  - **Use case**: Buffered/batch consumers where ACK is deferred until a batch operation completes
81
110
 
82
111
  ## Retry & Dead Letter Queue (DLQ)
@@ -90,10 +119,34 @@ Every queue gets automatic retry and DLQ infrastructure:
90
119
  | `{name}.failed` | DLQ — messages that exceed max retries land here |
91
120
 
92
121
  **Retry behavior**:
93
- - Exponential backoff: `min(BASE * 2^retryCount, MAX)` — default BASE=4s, MAX=60s
94
- - Max retries: 5 (configurable via `MAX_MESSAGE_RETRIES` env var)
122
+ - A failed message is ACKed and a copy is published to the retry queue's exchange (`{name}.retry.exchange`) with a TTL. When the TTL expires the broker dead-letters it to the main exchange, which delivers it to the main queue, and the handler is called again
123
+ - Exponential backoff: `min(BASE * 2^retryCount, MAX)` — BASE=4s, MAX=60s, so the waits are 4s, 8s, 16s, 32s, then 60s
124
+ - Max retries: 5 (configurable via `MAX_MESSAGE_RETRIES` env var). With the default, a handler that keeps failing is called six times: the first delivery and five retries
95
125
  - Retry count tracked in `x-retry-count` message header
96
- - After max retries → NACK without requeue → routed to DLQ via dead letter exchange
126
+ - When the last attempt fails → the message is published to the `{name}.failed` queue and the delivery is ACKed
127
+
128
+ **Handlers must be safe to run more than once for one message.** Every failure leads to another call with the same payload, so a side effect the handler completed before it failed (an email sent, a row inserted) happens again unless the handler checks first. Make the work idempotent: check whether it was already done, or upsert on an id carried in the payload.
129
+
130
+ Versions of `@boopkit/cap-background-jobs` up to 0.4.3 did not redeliver: the broker discarded a failed message when its retry TTL expired, and nothing reached the DLQ. A project upgrading from one of those versions has handlers that have never been called twice for a message, so review each one.
131
+
132
+ **The last attempt.** An auto-ACK handler's second argument tells it which delivery it is handling:
133
+
134
+ | Field | Meaning |
135
+ |-------|---------|
136
+ | `retryCount` | How many times the message has already been retried; `0` on its first delivery |
137
+ | `lastAttempt` | `true` on the delivery whose failure moves the message to the DLQ, `false` while retries remain |
138
+
139
+ Use `lastAttempt` for something that should happen once per failed message and not once per attempt:
140
+
141
+ ```typescript
142
+ import type { Delivery } from '@boopkit/cap-background-jobs';
143
+
144
+ async function onMessage(msg: unknown, delivery: Delivery): Promise<boolean> {
145
+ const ok = await recordInboundEmail(msg);
146
+ if (!ok && delivery.lastAttempt) await notifyAdmin('an inbound email failed on every attempt', msg);
147
+ return ok;
148
+ }
149
+ ```
97
150
 
98
151
  ## Buffered Batch ACK Pattern
99
152
 
@@ -23,5 +23,5 @@ Read [reference/boop-guide.ref.md](reference/boop-guide.ref.md) to understand ho
23
23
  - `skills/` — process skills for agents (each skill is a folder with a `SKILL.md`); also linked from `.claude/skills/` for Claude Code's built-in skill discovery
24
24
  - `reference/` — architecture and coding standards ("what exists now")
25
25
  - `specs/` — design docs and specifications ("future state")
26
- - `tests/` — `npm test` (Jest)
26
+ - `tests/` — tests. Before you run or write one, read [skills/boop-testing-testing/SKILL.md](skills/boop-testing-testing/SKILL.md) and [skills/boop-testing-testing/PROJECT.md](skills/boop-testing-testing/PROJECT.md): they say which tests a change runs, when the whole suite runs, and this project's commands for both
27
27
  - `.boop/` — project manifest (managed by boop)