@boopkit/cli 0.8.0 → 0.8.2

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 (43) hide show
  1. package/README.md +8 -6
  2. package/dist/capability-catalog.json +10 -3
  3. package/dist/capability-versions.json +13 -12
  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/init.d.ts +5 -0
  8. package/dist/commands/init.js +12 -5
  9. package/dist/commands/loop-merge.d.ts +11 -0
  10. package/dist/commands/loop-merge.js +135 -14
  11. package/dist/commands/loop.d.ts +5 -1
  12. package/dist/commands/loop.js +38 -19
  13. package/dist/commands/migrate-skill-edits.js +2 -2
  14. package/dist/commands/status.d.ts +10 -0
  15. package/dist/commands/status.js +27 -2
  16. package/dist/commands/update.js +157 -21
  17. package/dist/lib/capability.js +4 -1
  18. package/dist/lib/execute.js +30 -3
  19. package/dist/lib/install-layout.d.ts +5 -0
  20. package/dist/lib/install-layout.js +8 -0
  21. package/dist/lib/manifest.d.ts +1 -1
  22. package/dist/lib/moves.d.ts +88 -0
  23. package/dist/lib/moves.js +381 -0
  24. package/dist/lib/original-text.d.ts +2 -0
  25. package/dist/lib/original-text.js +15 -7
  26. package/dist/lib/preflight.d.ts +14 -1
  27. package/dist/lib/preflight.js +53 -17
  28. package/dist/lib/project.d.ts +17 -0
  29. package/dist/lib/project.js +26 -0
  30. package/dist/lib/scaffold.d.ts +12 -1
  31. package/dist/lib/scaffold.js +43 -4
  32. package/dist/lib/stale-scripts.js +54 -14
  33. package/dist/lib/update.d.ts +36 -2
  34. package/dist/lib/update.js +82 -4
  35. package/docs/init.md +22 -0
  36. package/docs/loop.md +13 -3
  37. package/docs/skills.md +23 -15
  38. package/package.json +1 -1
  39. package/profiles/base/profile.md +13 -10
  40. package/profiles/development/profile.md +10 -8
  41. package/profiles/web-app/profile.md +6 -4
  42. package/profiles/web-app/reference/message-queue-patterns.ref.md +8 -13
  43. 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-docs-sweep-workflow, boop-product-design-reference-restructure-workflow, 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-css, boop-web-testing, boop-web-website-dev-workflow, boop-web-design-handoff-workflow, boop-web-design-handoff-bundle
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.9",
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.2",
12
- "@boopkit/cap-product-design": "0.6.11",
13
- "@boopkit/cap-project-mgmt": "0.4.13",
11
+ "@boopkit/cap-oauth-server": "0.3.3",
12
+ "@boopkit/cap-product-design": "0.6.13",
13
+ "@boopkit/cap-project-mgmt": "0.4.15",
14
14
  "@boopkit/cap-services": "0.4.3",
15
- "@boopkit/cap-skills": "0.5.6",
16
- "@boopkit/cap-testing": "0.5.8",
17
- "@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",
15
+ "@boopkit/cap-skills": "0.5.8",
16
+ "@boopkit/cap-testing": "0.5.10",
17
+ "@boopkit/cap-typescript": "0.1.0",
18
+ "@boopkit/cap-users": "0.9.1",
19
+ "@boopkit/cap-ux": "0.2.5",
20
+ "@boopkit/cap-web": "0.5.8",
21
+ "@boopkit/cap-workflows": "0.2.4",
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
  }
@@ -17,5 +17,10 @@ export interface InitDeps {
17
17
  * pins @beads/bd as a devDependency, then whatever is on PATH.
18
18
  */
19
19
  export declare function findBd(projectDir: string, searchPath?: string): string | null;
20
+ /**
21
+ * The `bd init` arguments that give a project a beads database in Dolt server
22
+ * mode, which boop loop requires. bd derives its database name from the prefix.
23
+ */
24
+ export declare function bdInitArgs(projectName: string): string[];
20
25
  export declare function runInit(name: string, options: InitOptions, deps?: InitDeps): void;
21
26
  export {};
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.findBd = findBd;
37
+ exports.bdInitArgs = bdInitArgs;
37
38
  exports.runInit = runInit;
38
39
  const fs = __importStar(require("fs"));
39
40
  const path = __importStar(require("path"));
@@ -106,6 +107,13 @@ function commitScaffold(targetDir, name, run) {
106
107
  console.error(`Warning: Unable to commit the scaffold: ${err.message}`);
107
108
  }
108
109
  }
110
+ /**
111
+ * The `bd init` arguments that give a project a beads database in Dolt server
112
+ * mode, which boop loop requires. bd derives its database name from the prefix.
113
+ */
114
+ function bdInitArgs(projectName) {
115
+ return ['init', '--non-interactive', '--server', '-p', projectName];
116
+ }
109
117
  /** Commit the scaffold, then run `bd init` in the new project; report and return false rather than throw. */
110
118
  function initBeads(targetDir, name, args, deps) {
111
119
  const findBdFn = deps.findBdFn ?? findBd;
@@ -490,18 +498,17 @@ function runInit(name, options, deps = {}) {
490
498
  // they exist but creates them when they don't, and the templates never
491
499
  // overwrite an existing file. Server mode, because boop loop refuses a
492
500
  // project whose beads runs embedded. Git is touched only here, just before
493
- // bd init, because that is when a repository gets created anyway. bd derives
494
- // its database name from the prefix.
495
- const bdInitArgs = ['init', '--non-interactive', '--server', '-p', projectName];
501
+ // bd init, because that is when a repository gets created anyway.
502
+ const initArgs = bdInitArgs(projectName);
496
503
  const beadsPending = installedCapabilities.includes('project-mgmt') &&
497
- !(shouldInitBeads && initBeads(targetDir, projectName, bdInitArgs, deps));
504
+ !(shouldInitBeads && initBeads(targetDir, projectName, initArgs, deps));
498
505
  console.log(`\nNext steps:`);
499
506
  console.log(` cd ${name}`);
500
507
  if (!shouldInstall) {
501
508
  console.log(' npm install');
502
509
  }
503
510
  if (beadsPending) {
504
- console.log(` bd ${bdInitArgs.join(' ')}`);
511
+ console.log(` bd ${initArgs.join(' ')}`);
505
512
  }
506
513
  console.log(' boop status ← check what needs configuration');
507
514
  if (kernel) {
@@ -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 {
@@ -66,7 +66,10 @@ export interface WorktreeContext {
66
66
  /**
67
67
  * Hard requirement: beads must run in Dolt server mode so concurrent worktrees
68
68
  * share one issue DB over TCP. Reads `.beads/metadata.json` and throws an
69
- * actionable error on embedded/missing — call before any `bd` work.
69
+ * actionable error on embedded/missing — call before any `bd` work. A project
70
+ * with no `.beads/` at all (one started with `boop add`) is told the `bd init`
71
+ * command `boop init` would have run. The messages name no skill file, because
72
+ * an installed project may not have the one boop's own repository does.
70
73
  */
71
74
  export declare function assertDoltServerMode(projectDir: string, deps?: {
72
75
  existsSyncFn?: (p: string) => boolean;
@@ -325,6 +328,7 @@ export declare function splitStreamLines(carry: string, chunk: string): {
325
328
  lines: string[];
326
329
  rest: string;
327
330
  };
331
+ export declare function sessionEnv(model: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
328
332
  export declare function runLoop(options: LoopOptions, deps?: {
329
333
  /** Runs one Claude session for an issue. Tests substitute this; the planning phase is not affected. */
330
334
  runClaudeFn?: RunClaudeFn;