@boopkit/cli 0.8.0 → 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 +11 -10
  4. package/dist/cli.js +0 -0
  5. package/dist/commands/add.js +35 -7
  6. package/dist/commands/adopt.js +1 -1
  7. package/dist/commands/loop-merge.d.ts +11 -0
  8. package/dist/commands/loop-merge.js +135 -14
  9. package/dist/commands/loop.js +1 -1
  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 +11 -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
11
  "@boopkit/cap-oauth-server": "0.3.2",
12
- "@boopkit/cap-product-design": "0.6.11",
13
- "@boopkit/cap-project-mgmt": "0.4.13",
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
  }
package/dist/cli.js CHANGED
File without changes
@@ -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 {
@@ -915,7 +915,7 @@ function readInstalledSkills(projectDir) {
915
915
  /**
916
916
  * The installed skills an issue description names, by bare name or by path, in
917
917
  * the order each is first mentioned. A name only matches whole, so
918
- * boop-testing-testing does not match inside boop-testing-testing-express-web.
918
+ * boop-testing-testing does not match inside boop-testing-testing-integration.
919
919
  */
920
920
  function namedSkills(description, installed) {
921
921
  const found = [];
@@ -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;
@@ -46,6 +46,7 @@ const capability_1 = require("../lib/capability");
46
46
  const interpolate_1 = require("../lib/interpolate");
47
47
  const db_status_1 = require("../lib/db-status");
48
48
  const update_1 = require("../lib/update");
49
+ const moves_1 = require("../lib/moves");
49
50
  function formatStatus(manifest) {
50
51
  const caps = manifest.capabilities.length > 0
51
52
  ? manifest.capabilities.join(', ')
@@ -104,7 +105,7 @@ function getStatusData(manifest, dir) {
104
105
  const isSet = setKeys.has(varName) || hasLiteralDefault;
105
106
  envStatus[varName] = { required: decl.required, set: isSet };
106
107
  if (!isSet) {
107
- const isRequired = decl.required && decl.required !== 'false';
108
+ const isRequired = decl.required === 'boot' || decl.required === 'runtime';
108
109
  if (isRequired) {
109
110
  allRequiredSet = false;
110
111
  // Look for a setupCheck hint for this env var
@@ -164,6 +165,21 @@ function getStatusData(manifest, dir) {
164
165
  capabilities[capName].skillEdits = { ...counts, instruction: update_1.SKILL_EDITS_INSTRUCTION };
165
166
  }
166
167
  }
168
+ // Every move the move pass finds, listed under the capability the files are leaving
169
+ for (const move of (0, moves_1.planMoves)(dir, manifest, manifest.capabilities).moves) {
170
+ const entry = capabilities[move.fromCapability];
171
+ if (!entry)
172
+ continue;
173
+ const instruction = move.instruction
174
+ ?? (move.state === 'split-will-add' ? `\`boop update\` adds ${move.toCapability}.` : 'Run `boop update`.');
175
+ entry.moves = [...(entry.moves ?? []), {
176
+ path: move.from,
177
+ toCapability: move.toCapability,
178
+ toPath: move.to,
179
+ state: move.state,
180
+ instruction,
181
+ }];
182
+ }
167
183
  return { name: manifest.name, profile: manifest.profile, capabilities };
168
184
  }
169
185
  // Re-export for backward compatibility in tests
@@ -261,6 +277,15 @@ function formatCapabilityLine(capName, cap) {
261
277
  lines.push(` \x1b[33m◐\x1b[0m ${edited} skill${edited === 1 ? '' : 's'} edited in SKILL.md, ${changesWaiting} with boop changes waiting`);
262
278
  lines.push(` → ${instruction}`);
263
279
  }
280
+ for (const move of cap.moves ?? []) {
281
+ const from = move.path.replace(/\/$/, '');
282
+ const as = move.toPath.endsWith('/') ? ` as ${path.basename(move.toPath)}` : '';
283
+ const label = move.state === 'split-will-add'
284
+ ? `${move.toCapability} split from ${capName}: ${from} moves into it`
285
+ : `${from} moved to ${move.toCapability}${as}, waiting`;
286
+ lines.push(` \x1b[33m◐\x1b[0m ${label}`);
287
+ lines.push(` → ${move.instruction}`);
288
+ }
264
289
  return lines;
265
290
  }
266
291
  /**
@@ -301,7 +326,7 @@ async function runStatus(options) {
301
326
  throw err;
302
327
  }
303
328
  if (!manifest) {
304
- console.error('Not a boop project (no .boop/config.json found). Run `boop init` to create one.');
329
+ 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.');
305
330
  process.exitCode = 1;
306
331
  return;
307
332
  }