@boopkit/cli 0.7.1 → 0.8.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.
Files changed (39) hide show
  1. package/README.md +8 -6
  2. package/dist/capability-catalog.json +10 -3
  3. package/dist/capability-versions.json +12 -11
  4. package/dist/commands/add.js +35 -7
  5. package/dist/commands/adopt.js +1 -1
  6. package/dist/commands/loop-merge.d.ts +11 -0
  7. package/dist/commands/loop-merge.js +135 -14
  8. package/dist/commands/loop.d.ts +19 -1
  9. package/dist/commands/loop.js +71 -3
  10. package/dist/commands/migrate-skill-edits.js +2 -2
  11. package/dist/commands/status.d.ts +10 -0
  12. package/dist/commands/status.js +27 -2
  13. package/dist/commands/update.js +157 -21
  14. package/dist/lib/capability.js +4 -1
  15. package/dist/lib/execute.js +19 -2
  16. package/dist/lib/install-layout.d.ts +5 -0
  17. package/dist/lib/install-layout.js +8 -0
  18. package/dist/lib/manifest.d.ts +1 -1
  19. package/dist/lib/moves.d.ts +88 -0
  20. package/dist/lib/moves.js +381 -0
  21. package/dist/lib/original-text.d.ts +2 -0
  22. package/dist/lib/original-text.js +15 -7
  23. package/dist/lib/preflight.d.ts +14 -1
  24. package/dist/lib/preflight.js +53 -17
  25. package/dist/lib/project.d.ts +17 -0
  26. package/dist/lib/project.js +26 -0
  27. package/dist/lib/scaffold.d.ts +12 -1
  28. package/dist/lib/scaffold.js +43 -4
  29. package/dist/lib/update.d.ts +36 -2
  30. package/dist/lib/update.js +82 -4
  31. package/docs/init.md +22 -0
  32. package/docs/loop.md +17 -3
  33. package/docs/skills.md +17 -13
  34. package/package.json +1 -1
  35. package/profiles/base/profile.md +13 -10
  36. package/profiles/development/profile.md +10 -8
  37. package/profiles/web-app/profile.md +6 -4
  38. package/profiles/web-app/reference/message-queue-patterns.ref.md +8 -13
  39. package/src/templates/kernel/tests/app.test.ts.hbs +17 -0
package/README.md CHANGED
@@ -129,7 +129,8 @@ Pick what the product needs. `boop add <name>` handles the wiring — npm depend
129
129
  | Name | What it adds |
130
130
  |------|--------------|
131
131
  | `coding` | Development conventions and coding skills for AI agents |
132
- | `testing` | Jest setup and testing skills (TDD workflow, Express testing patterns) |
132
+ | `typescript` | The TypeScript toolchain: Jest with ts-jest, `tsconfig.test.json`, and the `test` and `typecheck` scripts |
133
+ | `testing` | Testing principles and the TDD-Lite workflow skills |
133
134
  | `project-mgmt` | Beads issue-tracking and planning skills |
134
135
  | `product-design` | Product design skills — feature requirements, engineering design, documentation, design thought-partnership, and technical prose |
135
136
  | `ux` | UX judgement (purpose + visual) and the app-UI evaluation and delivery workflows |
@@ -146,12 +147,13 @@ Adding a capability installs its skills into your project's `skills/` directory.
146
147
  **Development capabilities (skills only — no runtime code)**
147
148
 
148
149
  - **`coding`** — boop-coding-beads-flow, boop-coding-development, boop-coding-commit-changes, boop-coding-review-changes, boop-coding-debugging
149
- - **`product-design`** — boop-product-design-engineering-design, boop-product-design-feature-documentation, boop-product-design-feature-requirements, boop-product-design-reference-integration, boop-product-design-thought-partnership, boop-writing-technical-prose, boop-writing-documents, boop-writing-semantic-line-breaks
150
- - **`project-mgmt`** — boop-project-mgmt-beads-planning, boop-project-mgmt-engineering-planning
150
+ - **`product-design`** — boop-product-design-engineering-design, boop-product-design-feature-documentation, boop-product-design-feature-requirements, boop-product-design-reference-integration, boop-product-design-knowledge-backfill, boop-product-design-thought-partnership, boop-writing-technical-prose, boop-writing-documents, boop-writing-semantic-line-breaks
151
+ - **`project-mgmt`** — boop-project-mgmt-beads-planning, boop-project-mgmt-engineering-planning, boop-project-mgmt-issue-sweep-workflow, boop-project-mgmt-stray-disposition
151
152
  - **`skills`** — boop-skill-authoring, boop-skills-discovery, boop-skills-agent-surface-design, boop-get-smart, boop-so-what, boop-skills-hyperlinking, boop-skills-edit-triage
152
- - **`testing`** — boop-testing-tdd-light, boop-testing-testing-express-web, boop-testing-testing
153
+ - **`testing`** — boop-testing-tdd-light, boop-testing-testing
154
+ - **`typescript`** — boop-typescript-testing
153
155
  - **`ux`** — boop-ux-purpose-judgement, boop-ux-visual-judgement, boop-ux-eval-and-refine-workflow, boop-ux-app-ui-dev-workflow
154
- - **`workflows`** — boop-feature-dev-workflow, boop-light-dev-workflow, boop-refactor-dev-workflow, boop-test-audit-workflow, boop-architecture-audit-workflow, boop-issue-sweep-workflow, boop-website-dev-workflow, boop-design-handoff-workflow, boop-knowledge-backfill, boop-stray-disposition
156
+ - **`workflows`** — boop-feature-dev-workflow, boop-light-dev-workflow, boop-refactor-dev-workflow, boop-test-audit-workflow, boop-architecture-audit-workflow
155
157
 
156
158
  **Application capabilities**
157
159
 
@@ -162,7 +164,7 @@ Adding a capability installs its skills into your project's `skills/` directory.
162
164
  - **`email`** — boop-email-email-design
163
165
  - **`services`** — boop-services-controllers, boop-services-services
164
166
  - **`users-and-accounts`** — boop-users-user-data-modeling
165
- - **`web`** — boop-web-web-ui-design
167
+ - **`web`** — boop-web-web-ui-design, boop-web-testing, boop-web-website-dev-workflow, boop-web-design-handoff-workflow
166
168
 
167
169
  _One-line description of each skill: see the bundled `docs/skills.md`._
168
170
  <!-- SKILLS-INDEX:END -->
@@ -95,7 +95,7 @@
95
95
  },
96
96
  {
97
97
  "name": "project-mgmt",
98
- "description": "Project management with beads",
98
+ "description": "Project management with beads: planning, issue sweeps and stray disposition",
99
99
  "type": "development",
100
100
  "package": "@boopkit/cap-project-mgmt",
101
101
  "requires": []
@@ -116,11 +116,18 @@
116
116
  },
117
117
  {
118
118
  "name": "testing",
119
- "description": "Jest testing setup",
119
+ "description": "Testing method: the testing principles and the TDD-Lite workflow",
120
120
  "type": "development",
121
121
  "package": "@boopkit/cap-testing",
122
122
  "requires": []
123
123
  },
124
+ {
125
+ "name": "typescript",
126
+ "description": "TypeScript toolchain: Jest with ts-jest, and the whole-program type check",
127
+ "type": "development",
128
+ "package": "@boopkit/cap-typescript",
129
+ "requires": []
130
+ },
124
131
  {
125
132
  "name": "users-and-accounts",
126
133
  "description": "Users, identities, properties, and authentication via Prisma",
@@ -148,7 +155,7 @@
148
155
  },
149
156
  {
150
157
  "name": "workflows",
151
- "description": "Reusable end-to-end development and writing workflows",
158
+ "description": "Reusable end-to-end development and audit workflows for code",
152
159
  "type": "development",
153
160
  "package": "@boopkit/cap-workflows",
154
161
  "requires": []
@@ -1,22 +1,23 @@
1
1
  {
2
2
  "@boopkit/cap-api": "0.4.5",
3
- "@boopkit/cap-background-jobs": "0.5.0",
3
+ "@boopkit/cap-background-jobs": "0.5.1",
4
4
  "@boopkit/cap-cloud-storage": "0.5.0",
5
- "@boopkit/cap-coding": "0.4.7",
6
- "@boopkit/cap-database": "0.4.5",
5
+ "@boopkit/cap-coding": "0.4.8",
6
+ "@boopkit/cap-database": "0.4.6",
7
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.3.1",
12
- "@boopkit/cap-product-design": "0.6.11",
13
- "@boopkit/cap-project-mgmt": "0.4.12",
11
+ "@boopkit/cap-oauth-server": "0.3.2",
12
+ "@boopkit/cap-product-design": "0.6.12",
13
+ "@boopkit/cap-project-mgmt": "0.4.14",
14
14
  "@boopkit/cap-services": "0.4.3",
15
- "@boopkit/cap-skills": "0.5.6",
16
- "@boopkit/cap-testing": "0.5.8",
15
+ "@boopkit/cap-skills": "0.5.7",
16
+ "@boopkit/cap-testing": "0.5.9",
17
+ "@boopkit/cap-typescript": "0.1.0",
17
18
  "@boopkit/cap-users": "0.9.0",
18
- "@boopkit/cap-ux": "0.2.3",
19
- "@boopkit/cap-web": "0.5.6",
20
- "@boopkit/cap-workflows": "0.2.2",
19
+ "@boopkit/cap-ux": "0.2.4",
20
+ "@boopkit/cap-web": "0.5.7",
21
+ "@boopkit/cap-workflows": "0.2.3",
21
22
  "@boopkit/logger": "0.4.0"
22
23
  }
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.runAdd = runAdd;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
+ const os = __importStar(require("os"));
39
40
  const child_process_1 = require("child_process");
40
41
  const preflight_1 = require("../lib/preflight");
41
42
  const execute_1 = require("../lib/execute");
@@ -61,33 +62,51 @@ function runAdd(capabilityName, options) {
61
62
  return;
62
63
  }
63
64
  const targetDir = process.cwd();
64
- // --- Phase 0: Ensure capability package is available locally ---
65
+ // --- Phase 0: Make the capability package readable without touching the project ---
65
66
  // When the CLI is installed globally, capability packages aren't in
66
- // node_modules yet. Install the package so preflight can read its
67
- // capability.json, scaffold files, etc.
67
+ // node_modules yet. Pre-flight needs the package's capability.json and
68
+ // scaffold files, so install it into a staging folder for pre-flight to read;
69
+ // the project gets it only once the checks pass.
68
70
  // With --tag, install from that dist-tag even when a package is present
69
71
  // (the operator asked for that version), and skip npm's cached registry
70
72
  // metadata, which can predate a just-published beta.
73
+ let stagingDir;
74
+ let installCommand;
71
75
  if (options.tag || !(0, capability_1.getPackageDir)(capabilityName, [targetDir])) {
72
76
  const catalogEntry = (0, capability_1.getCatalogEntry)(capabilityName);
73
77
  if (catalogEntry?.package) {
74
78
  const spec = options.tag ? `${catalogEntry.package}@${options.tag}` : catalogEntry.package;
75
79
  const flags = options.tag ? ' --prefer-online' : '';
76
- console.error(`Installing ${spec}...`);
80
+ installCommand = `npm install ${spec}${flags}`;
81
+ stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boop-add-'));
82
+ console.error(`Fetching ${spec}...`);
77
83
  try {
78
- (0, child_process_1.execSync)(`npm install ${spec}${flags}`, { cwd: targetDir, stdio: 'pipe' });
84
+ (0, child_process_1.execSync)(`${installCommand} --prefix ${JSON.stringify(stagingDir)}`, { cwd: stagingDir, stdio: 'pipe' });
79
85
  }
80
86
  catch (err) {
81
87
  console.error(`\x1b[31mERR\x1b[0m Failed to install ${spec}: ${err.message}`);
88
+ fs.rmSync(stagingDir, { recursive: true, force: true });
82
89
  process.exitCode = 1;
83
90
  return;
84
91
  }
85
92
  }
86
93
  }
94
+ try {
95
+ addCapability(targetDir, capabilityName, options, stagingDir, installCommand);
96
+ }
97
+ finally {
98
+ if (stagingDir)
99
+ fs.rmSync(stagingDir, { recursive: true, force: true });
100
+ }
101
+ }
102
+ function addCapability(targetDir, capabilityName, options, stagingDir, installCommand) {
87
103
  // --- Phase 1: Pre-flight (read-only) ---
88
104
  let plan;
89
105
  try {
90
- plan = (0, preflight_1.planAdd)(targetDir, capabilityName);
106
+ plan = (0, preflight_1.planAdd)(targetDir, capabilityName, {
107
+ searchPaths: stagingDir ? [targetDir, stagingDir] : [targetDir],
108
+ installCommand,
109
+ });
91
110
  }
92
111
  catch (err) {
93
112
  if (err instanceof SyntaxError) {
@@ -107,7 +126,7 @@ function runAdd(capabilityName, options) {
107
126
  }
108
127
  throw err;
109
128
  }
110
- const { manifest, cap, projectManifest, filesToCopy, warnings } = plan;
129
+ const { manifest, cap, projectManifest, filesToCopy, warnings, creates } = plan;
111
130
  // Emit any warnings from pre-flight
112
131
  for (const w of warnings) {
113
132
  console.error(w);
@@ -166,6 +185,7 @@ function runAdd(capabilityName, options) {
166
185
  console.log(JSON.stringify({
167
186
  capability: capabilityName,
168
187
  project: projectManifest.name,
188
+ created: creates,
169
189
  files: fileEntries,
170
190
  skills: skillEntries,
171
191
  nextSteps,
@@ -190,6 +210,14 @@ function runAdd(capabilityName, options) {
190
210
  // Human-readable output
191
211
  console.log(`\n${'─'.repeat(40)}`);
192
212
  console.log(`Added capability "${capabilityName}" to ${projectManifest.name}\n`);
213
+ if (creates.includes('package.json')) {
214
+ console.log('Created package.json — a minimal one, because boop records each capability as an npm package there.');
215
+ }
216
+ if (creates.includes('.boop/config.json')) {
217
+ console.log(`Created .boop/config.json — this project is now a boop project, with profile "${projectManifest.profile}".`);
218
+ }
219
+ if (creates.length > 0)
220
+ console.log('');
193
221
  if (fileEntries.length > 0) {
194
222
  console.log('Files:');
195
223
  for (const f of fileEntries) {
@@ -65,7 +65,7 @@ function runAdopt(capabilityName, options) {
65
65
  throw err;
66
66
  }
67
67
  if (!manifest) {
68
- console.error('Not a boop project (no .boop/config.json found). Run `boop init` to create one.');
68
+ console.error('Not a boop project (no .boop/config.json found). Run `boop init` in an empty folder, or `boop add <capability>` in an existing project, to create one.');
69
69
  process.exitCode = 1;
70
70
  return;
71
71
  }
@@ -80,5 +80,16 @@ export declare function isScopeComplete(scope: string, deps: MergeDeps): boolean
80
80
  * are ignored.
81
81
  */
82
82
  export declare function findMergeCandidates(deps: MergeDeps): MergeCandidate[];
83
+ /**
84
+ * A readable, bounded issue description for a post-rebase test failure: the
85
+ * cause, the restored commit, the first failures, the Jest summary, and where
86
+ * the full output lives.
87
+ */
88
+ export declare function testFailureDescription(o: {
89
+ branch: string;
90
+ origSha: string;
91
+ output: string;
92
+ logPath: string | null;
93
+ }): string;
83
94
  export declare function runMerge(options: LoopOptions, deps?: MergeDeps): Promise<void>;
84
95
  export {};
@@ -37,6 +37,7 @@ exports.pickHigherVersion = pickHigherVersion;
37
37
  exports.mergePackageJsonContents = mergePackageJsonContents;
38
38
  exports.isScopeComplete = isScopeComplete;
39
39
  exports.findMergeCandidates = findMergeCandidates;
40
+ exports.testFailureDescription = testFailureDescription;
40
41
  exports.runMerge = runMerge;
41
42
  const child_process_1 = require("child_process");
42
43
  const fs = __importStar(require("fs"));
@@ -212,35 +213,76 @@ function findMergeCandidates(deps) {
212
213
  // ── Merge engine ──────────────────────────────────────────────────────────────
213
214
  const defaultExec = (cmd, opts) => (0, child_process_1.execSync)(cmd, { encoding: 'utf8', ...opts });
214
215
  class MergeError extends Error {
215
- constructor(message, detail) {
216
+ constructor(message, detail, title = 'Merge conflict') {
216
217
  super(message);
217
218
  this.detail = detail;
219
+ this.title = title;
218
220
  }
219
221
  }
220
- /** True if the integration tree is on `main` with a clean working tree. */
222
+ // Files bd rewrites in the main checkout on every command, including the ones a
223
+ // loop session runs from its own worktree. A change to them is beads bookkeeping,
224
+ // not work in progress, so it does not block a merge on its own.
225
+ const BEADS_EXPORT_FILES = ['.beads/interactions.jsonl', '.beads/issues.jsonl'];
226
+ /**
227
+ * Throws unless the integration tree is on `main` with no uncommitted changes
228
+ * beyond beads' export files. Returns the export files that are uncommitted.
229
+ */
221
230
  function assertCleanMain(execSyncFn, projectDir) {
222
231
  const exec = execSyncFn;
223
232
  const branch = exec('git rev-parse --abbrev-ref HEAD', { cwd: projectDir }).trim();
224
233
  if (branch !== 'main') {
225
234
  throw new Error(`Integration tree at ${projectDir} is on "${branch}", not "main". Checkout main before merging.`);
226
235
  }
227
- const status = exec('git status --porcelain', { cwd: projectDir }).trim();
228
- if (status) {
229
- throw new Error(`Integration tree at ${projectDir} has uncommitted changes; commit or stash before merging.`);
236
+ // Not trimmed: each porcelain line starts with a two-column status that may be a space.
237
+ const changed = exec('git status --porcelain', { cwd: projectDir })
238
+ .split('\n')
239
+ .filter((l) => l.trim())
240
+ .map((l) => l.slice(3));
241
+ const others = changed.filter((f) => !BEADS_EXPORT_FILES.includes(f));
242
+ if (others.length > 0) {
243
+ throw new Error(`Integration tree at ${projectDir} has uncommitted changes (${others.join(', ')}); commit or stash before merging.`);
244
+ }
245
+ return changed;
246
+ }
247
+ /**
248
+ * Throws if the branch changes a beads export file that is uncommitted in main,
249
+ * since the fast-forward would then run over that uncommitted change.
250
+ */
251
+ function assertBranchAvoids(exec, projectDir, branch, dirty) {
252
+ if (dirty.length === 0)
253
+ return;
254
+ const touched = exec(`git diff --name-only main...${branch}`, { cwd: projectDir })
255
+ .split('\n')
256
+ .map((l) => l.trim());
257
+ const overlap = dirty.filter((f) => touched.includes(f));
258
+ if (overlap.length > 0) {
259
+ throw new Error(`${branch} changes ${overlap.join(', ')}, which is uncommitted in the integration tree at ${projectDir}; commit it in main before merging.`);
230
260
  }
231
261
  }
232
262
  /**
233
263
  * Rebase the candidate branch onto main inside its worktree, auto-resolving only
234
264
  * package.json dependency conflicts. Throws MergeError (rebase aborted) on any
235
- * other conflict.
265
+ * other conflict, and on any rebase that does not complete, so the branch is
266
+ * never tested or merged un-rebased.
236
267
  */
237
268
  function rebaseOntoMain(exec, wt) {
269
+ // git refuses to rebase over uncommitted tracked changes (`npm install` can
270
+ // rewrite package-lock.json); checking first lets the issue name the files.
271
+ const uncommitted = exec('git status --porcelain --untracked-files=no', { cwd: wt })
272
+ .split('\n')
273
+ .filter((l) => l.trim())
274
+ .map((l) => l.slice(3));
275
+ if (uncommitted.length > 0) {
276
+ throw new MergeError(`worktree has uncommitted changes (${uncommitted.join(', ')})`, `Uncommitted files in ${wt}: ${uncommitted.join(', ')}. Commit or discard them in the worktree.`, 'Merge blocked');
277
+ }
278
+ let failure = '';
238
279
  try {
239
280
  exec('git rebase main', { cwd: wt, stdio: 'pipe' });
240
281
  return;
241
282
  }
242
- catch {
283
+ catch (e) {
243
284
  // Conflict (or other failure). Inspect unmerged paths.
285
+ failure = commandOutput(e);
244
286
  }
245
287
  // Iteratively resolve package.json-only conflicts and continue.
246
288
  for (let guard = 0; guard < 100; guard++) {
@@ -285,29 +327,40 @@ function rebaseOntoMain(exec, wt) {
285
327
  exec('GIT_EDITOR=true git rebase --continue', { cwd: wt, stdio: 'pipe' });
286
328
  return; // rebase finished
287
329
  }
288
- catch {
330
+ catch (e) {
289
331
  // More commits ahead may conflict again — loop and re-inspect.
332
+ failure = commandOutput(e);
290
333
  }
291
334
  }
335
+ // No unmerged paths, yet the rebase did not finish: git refused it outright
336
+ // (e.g. untracked files main would overwrite). Abort any partial rebase.
337
+ try {
338
+ exec('git rebase --abort', { cwd: wt, stdio: 'pipe' });
339
+ }
340
+ catch {
341
+ /* no rebase in progress — git refused before starting */
342
+ }
343
+ throw new MergeError('rebase did not complete', `git said:\n\n${failure.trim()}`, 'Merge blocked');
292
344
  }
293
345
  function mergeOne(candidate, deps) {
294
346
  const projectDir = deps.projectDir || process.cwd();
295
347
  const exec = deps.execSyncFn || defaultExec;
296
348
  const createIssue = deps.createIssueFn || defaultCreateIssue(deps);
297
349
  const { branch, worktreePath: wt } = candidate;
298
- assertCleanMain(exec, projectDir);
350
+ const dirtyExports = assertCleanMain(exec, projectDir);
351
+ assertBranchAvoids(exec, projectDir, branch, dirtyExports);
299
352
  const origSha = exec('git rev-parse HEAD', { cwd: wt }).trim();
300
353
  try {
301
354
  rebaseOntoMain(exec, wt);
302
355
  }
303
356
  catch (e) {
304
357
  const err = e;
305
- const issueId = createIssue(`Merge conflict: ${branch}`, `Rebasing ${branch} onto main failed: ${err.message}\n\n${err.detail || ''}\n\nResolve by hand, then re-run \`boop loop --merge ${branch}\`.`);
358
+ const issueId = createIssue(`${err.title}: ${branch}`, `Rebasing ${branch} onto main failed: ${err.message}\n\n${err.detail || ''}\n\nResolve by hand, then re-run \`boop loop --merge ${branch}\`.`);
306
359
  return { branch, status: 'skipped', reason: err.message, issueId };
307
360
  }
308
361
  // Post-rebase test gate. Failure → restore the branch, never touch main.
309
362
  try {
310
- exec('npm test', { cwd: wt, stdio: 'pipe' });
363
+ exec('npm test', { cwd: wt, stdio: 'pipe', maxBuffer: TEST_OUTPUT_MAX_BUFFER });
311
364
  }
312
365
  catch (e) {
313
366
  try {
@@ -316,7 +369,9 @@ function mergeOne(candidate, deps) {
316
369
  catch {
317
370
  /* best-effort restore */
318
371
  }
319
- const issueId = createIssue(`Merge test failure: ${branch}`, `Tests failed after rebasing ${branch} onto main; branch reset to ${origSha} and main left untouched.\n\n${e.message}`);
372
+ const output = commandOutput(e);
373
+ const logPath = writeTestLog(projectDir, branch, output);
374
+ const issueId = createIssue(`Merge test failure: ${branch}`, testFailureDescription({ branch, origSha, output, logPath }));
320
375
  return { branch, status: 'skipped', reason: 'tests failed post-rebase', issueId };
321
376
  }
322
377
  // Fast-forward into main (guaranteed possible: branch was just rebased onto it).
@@ -341,11 +396,77 @@ function mergeOne(candidate, deps) {
341
396
  }
342
397
  return { branch, status: 'merged' };
343
398
  }
399
+ // A Jest run's stderr can run to hundreds of kilobytes; past the 1 MiB default
400
+ // execSync throws ENOBUFS and the summary at the end is lost.
401
+ const TEST_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
402
+ const DESCRIPTION_FAILURES_MAX = 4000;
403
+ const DESCRIPTION_SUMMARY_MAX = 2000;
404
+ /** stdout + stderr of a failed execSync, falling back to the error message. */
405
+ function commandOutput(e) {
406
+ const err = e;
407
+ const text = [err.stdout, err.stderr]
408
+ .map((s) => (s == null ? '' : String(s)))
409
+ .filter(Boolean)
410
+ .join('\n');
411
+ return text || err.message || '';
412
+ }
413
+ /** Save the full test output beside the loop's other run logs. Returns the path, or null. */
414
+ function writeTestLog(projectDir, branch, output) {
415
+ try {
416
+ const logDir = path.join(projectDir, 'labs', 'claude-runs');
417
+ fs.mkdirSync(logDir, { recursive: true });
418
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
419
+ const logPath = path.join(logDir, `_merge_${branch.replace(/[^\w.-]+/g, '_')}_${timestamp}.log`);
420
+ fs.writeFileSync(logPath, output);
421
+ return logPath;
422
+ }
423
+ catch {
424
+ return null;
425
+ }
426
+ }
427
+ /**
428
+ * A readable, bounded issue description for a post-rebase test failure: the
429
+ * cause, the restored commit, the first failures, the Jest summary, and where
430
+ * the full output lives.
431
+ */
432
+ function testFailureDescription(o) {
433
+ const lines = o.output.split('\n');
434
+ const summaryAt = lines.map((l) => l.startsWith('Test Suites:')).lastIndexOf(true);
435
+ const summary = summaryAt >= 0 ? lines.slice(summaryAt).join('\n').trim() : '';
436
+ const firstFailureAt = lines.findIndex((l) => l.trimStart().startsWith('● '));
437
+ const failures = firstFailureAt >= 0
438
+ ? lines.slice(firstFailureAt, summaryAt >= 0 ? summaryAt : undefined).join('\n')
439
+ : summary
440
+ ? ''
441
+ : o.output.slice(-DESCRIPTION_FAILURES_MAX);
442
+ const parts = [
443
+ `Tests failed after rebasing ${o.branch} onto main; branch reset to ${o.origSha} and main left untouched.`,
444
+ ];
445
+ if (failures)
446
+ parts.push(`First failures:\n\n${truncate(failures.trim(), DESCRIPTION_FAILURES_MAX)}`);
447
+ if (summary)
448
+ parts.push(`Summary:\n\n${truncate(summary, DESCRIPTION_SUMMARY_MAX)}`);
449
+ parts.push(o.logPath ? `Full test output: ${o.logPath}` : 'The full test output could not be saved.');
450
+ return parts.join('\n\n');
451
+ }
452
+ function truncate(text, max) {
453
+ return text.length <= max ? text : `${text.slice(0, max)}\n… (truncated)`;
454
+ }
455
+ // bd runs from an argument array, not a shell: titles and descriptions carry
456
+ // branch names, error messages and test output, where backticks and `$(...)`
457
+ // would otherwise run as commands. `env` is passed explicitly so a PATH set on
458
+ // this module's process.env (as a test sandbox does) decides which bd runs.
344
459
  function defaultCreateIssue(deps) {
345
- const exec = deps.execSyncFn || defaultExec;
460
+ const projectDir = deps.projectDir || process.cwd();
346
461
  return (title, description) => {
347
462
  try {
348
- const raw = exec(`bd create ${JSON.stringify(title)} --description=${JSON.stringify(description)} -t bug -p 1 --json`);
463
+ const raw = (0, child_process_1.execFileSync)('bd', ['create', title, '--description', description, '-t', 'bug', '-p', '1', '--json'], {
464
+ cwd: projectDir,
465
+ env: process.env,
466
+ encoding: 'utf8',
467
+ stdio: ['ignore', 'pipe', 'pipe'],
468
+ maxBuffer: loop_1.BD_READ_MAX_BUFFER,
469
+ });
349
470
  return JSON.parse(raw).id || null;
350
471
  }
351
472
  catch {
@@ -244,6 +244,17 @@ export declare function getMatchingReadyIssues(prefix: string, includeFeatures:
244
244
  maxBuffer?: number;
245
245
  }) => string;
246
246
  }): ReadyIssue[];
247
+ /** A skill folder in a project's skills/ directory. */
248
+ export interface InstalledSkill {
249
+ name: string;
250
+ hasProjectFile: boolean;
251
+ }
252
+ /**
253
+ * The skills installed in a project folder: each folder under skills/ that holds
254
+ * a SKILL.md. statSync follows symlinks, which matters because boop's own
255
+ * skills/ entries are symlinks into each capability package's skills/ folder.
256
+ */
257
+ export declare function readInstalledSkills(projectDir: string): InstalledSkill[];
247
258
  /**
248
259
  * The instructions every loop session starts from. A session acts on this text
249
260
  * literally and reads it whether or not it opens a skill, so step 5 carries the
@@ -251,8 +262,15 @@ export declare function getMatchingReadyIssues(prefix: string, includeFeatures:
251
262
  * when the issue asks) and names no command, because the text is the same in
252
263
  * every project and the commands are not. For the same reason it names no
253
264
  * project.
265
+ *
266
+ * Step 3 lists, as paths, the installed skills the issue names: each SKILL.md,
267
+ * plus the PROJECT.md beside it where there is one. Sessions open a skill they
268
+ * only see named about one time in seven, but read a path the prompt spells out.
269
+ * An issue that names no installed skill is pointed at boop-skills-discovery
270
+ * instead, and without `installed` (or without discovery installed) step 3 keeps
271
+ * the general "read relevant skills" wording.
254
272
  */
255
- export declare function buildPrompt(issue: IssueInfo): string;
273
+ export declare function buildPrompt(issue: IssueInfo, installed?: InstalledSkill[]): string;
256
274
  /**
257
275
  * Default per-issue budget cap in USD. The planning phase and each task each get
258
276
  * this cap. Single source of truth — imported by cli.ts for its --budget help
@@ -52,6 +52,7 @@ exports.isIssueInScope = isIssueInScope;
52
52
  exports.isClaimableReadyIssue = isClaimableReadyIssue;
53
53
  exports.shouldCloseFeatureRoot = shouldCloseFeatureRoot;
54
54
  exports.getMatchingReadyIssues = getMatchingReadyIssues;
55
+ exports.readInstalledSkills = readInstalledSkills;
55
56
  exports.buildPrompt = buildPrompt;
56
57
  exports.shouldRotateSession = shouldRotateSession;
57
58
  exports.sessionDidWork = sessionDidWork;
@@ -883,6 +884,65 @@ function isIssueClosed(id) {
883
884
  return false;
884
885
  }
885
886
  }
887
+ /**
888
+ * The skills installed in a project folder: each folder under skills/ that holds
889
+ * a SKILL.md. statSync follows symlinks, which matters because boop's own
890
+ * skills/ entries are symlinks into each capability package's skills/ folder.
891
+ */
892
+ function readInstalledSkills(projectDir) {
893
+ const skillsDir = path.join(projectDir, 'skills');
894
+ let entries;
895
+ try {
896
+ entries = fs.readdirSync(skillsDir);
897
+ }
898
+ catch {
899
+ return [];
900
+ }
901
+ const skills = [];
902
+ for (const name of entries) {
903
+ const dir = path.join(skillsDir, name);
904
+ try {
905
+ if (!fs.statSync(dir).isDirectory() || !fs.existsSync(path.join(dir, 'SKILL.md')))
906
+ continue;
907
+ }
908
+ catch {
909
+ continue;
910
+ }
911
+ skills.push({ name, hasProjectFile: fs.existsSync(path.join(dir, 'PROJECT.md')) });
912
+ }
913
+ return skills;
914
+ }
915
+ /**
916
+ * The installed skills an issue description names, by bare name or by path, in
917
+ * the order each is first mentioned. A name only matches whole, so
918
+ * boop-testing-testing does not match inside boop-testing-testing-integration.
919
+ */
920
+ function namedSkills(description, installed) {
921
+ const found = [];
922
+ for (const skill of installed) {
923
+ const escaped = skill.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
924
+ const match = new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`).exec(description);
925
+ if (match)
926
+ found.push({ skill, at: match.index });
927
+ }
928
+ return found.sort((a, b) => a.at - b.at).map((f) => f.skill);
929
+ }
930
+ function skillStep(issue, installed) {
931
+ const general = '3. Read relevant skills from skills/ (list folders, read each SKILL.md) before making changes';
932
+ if (!installed)
933
+ return general;
934
+ const named = namedSkills(issue.description, installed);
935
+ if (named.length > 0) {
936
+ const lines = named.map((s) => s.hasProjectFile
937
+ ? ` - skills/${s.name}/SKILL.md and skills/${s.name}/PROJECT.md`
938
+ : ` - skills/${s.name}/SKILL.md`);
939
+ return `3. Before making changes, read each of these in full, unless you have already read it in this session:\n${lines.join('\n')}`;
940
+ }
941
+ if (installed.some((s) => s.name === 'boop-skills-discovery')) {
942
+ return '3. Follow skills/boop-skills-discovery/SKILL.md to choose the skills relevant to this work, and read those skills before making changes';
943
+ }
944
+ return general;
945
+ }
886
946
  /**
887
947
  * The instructions every loop session starts from. A session acts on this text
888
948
  * literally and reads it whether or not it opens a skill, so step 5 carries the
@@ -890,8 +950,15 @@ function isIssueClosed(id) {
890
950
  * when the issue asks) and names no command, because the text is the same in
891
951
  * every project and the commands are not. For the same reason it names no
892
952
  * project.
953
+ *
954
+ * Step 3 lists, as paths, the installed skills the issue names: each SKILL.md,
955
+ * plus the PROJECT.md beside it where there is one. Sessions open a skill they
956
+ * only see named about one time in seven, but read a path the prompt spells out.
957
+ * An issue that names no installed skill is pointed at boop-skills-discovery
958
+ * instead, and without `installed` (or without discovery installed) step 3 keeps
959
+ * the general "read relevant skills" wording.
893
960
  */
894
- function buildPrompt(issue) {
961
+ function buildPrompt(issue, installed) {
895
962
  return `You are working on issue ${issue.id} in this project.
896
963
 
897
964
  ## Issue: ${issue.title}
@@ -903,7 +970,7 @@ ${issue.description}
903
970
  ## Instructions
904
971
  1. Read AGENTS.md for project orientation
905
972
  2. Claim the issue: bd update ${issue.id} --claim
906
- 3. Read relevant skills from skills/ (list folders, read each SKILL.md) before making changes
973
+ ${skillStep(issue, installed)}
907
974
  4. Implement the changes following TDD-Lite: observe, implement, verify
908
975
  5. Run the tests this issue needs, and no more:
909
976
  - 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.
@@ -1201,7 +1268,8 @@ async function processIssue(issue, opts) {
1201
1268
  }
1202
1269
  }
1203
1270
  // Initial run
1204
- const prompt = buildPrompt(issue);
1271
+ // opts.cwd is the worktree the session runs in, so its skills/ is the one the session sees.
1272
+ const prompt = buildPrompt(issue, readInstalledSkills(opts.cwd ?? process.cwd()));
1205
1273
  console.log(`\x1b[32m[bd-loop]\x1b[0m 🚀 Starting Claude session (budget: $${opts.budget}, session: ${sessionId.substring(0, 8)}...)`);
1206
1274
  const initialRun = await runClaude(prompt, {
1207
1275
  budget: opts.budget,
@@ -51,7 +51,7 @@ function runMigrateSkillEdits(deps = {}) {
51
51
  const projectDir = process.cwd();
52
52
  const manifest = (0, project_1.readProjectManifest)(projectDir);
53
53
  if (!manifest) {
54
- console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` first.');
54
+ console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` in an empty folder, or `boop add <capability>` in an existing project, first.');
55
55
  process.exitCode = 1;
56
56
  return;
57
57
  }
@@ -67,7 +67,7 @@ async function runApplySkillEdits(deps = terminalDeps()) {
67
67
  const projectDir = process.cwd();
68
68
  const manifest = (0, project_1.readProjectManifest)(projectDir);
69
69
  if (!manifest) {
70
- console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` first.');
70
+ console.error('\x1b[31mERR\x1b[0m Not a boop project (no .boop/config.json). Run `boop init` in an empty folder, or `boop add <capability>` in an existing project, first.');
71
71
  process.exitCode = 1;
72
72
  return;
73
73
  }
@@ -1,4 +1,5 @@
1
1
  import { BoopManifest } from '../lib/project';
2
+ import { MoveState } from '../lib/moves';
2
3
  export interface CapabilityStatusEnvVar {
3
4
  required?: string;
4
5
  set: boolean;
@@ -13,6 +14,15 @@ export interface CapabilityStatus {
13
14
  reachable?: boolean;
14
15
  /** Skills whose SKILL.md this project has edited, which no boop update can improve until the edits move out */
15
16
  skillEdits?: SkillEditsStatus;
17
+ /** Paths leaving this capability for another, which the move pass of `boop update` has not settled */
18
+ moves?: PendingMove[];
19
+ }
20
+ export interface PendingMove {
21
+ path: string;
22
+ toCapability: string;
23
+ toPath: string;
24
+ state: MoveState;
25
+ instruction: string;
16
26
  }
17
27
  export interface SkillEditsStatus {
18
28
  edited: number;