@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.
- package/README.md +8 -6
- package/dist/capability-catalog.json +10 -3
- package/dist/capability-versions.json +12 -11
- package/dist/commands/add.js +35 -7
- package/dist/commands/adopt.js +1 -1
- package/dist/commands/loop-merge.d.ts +11 -0
- package/dist/commands/loop-merge.js +135 -14
- package/dist/commands/loop.d.ts +19 -1
- package/dist/commands/loop.js +71 -3
- package/dist/commands/migrate-skill-edits.js +2 -2
- package/dist/commands/status.d.ts +10 -0
- package/dist/commands/status.js +27 -2
- package/dist/commands/update.js +157 -21
- package/dist/lib/capability.js +4 -1
- package/dist/lib/execute.js +19 -2
- package/dist/lib/install-layout.d.ts +5 -0
- package/dist/lib/install-layout.js +8 -0
- package/dist/lib/manifest.d.ts +1 -1
- package/dist/lib/moves.d.ts +88 -0
- package/dist/lib/moves.js +381 -0
- package/dist/lib/original-text.d.ts +2 -0
- package/dist/lib/original-text.js +15 -7
- package/dist/lib/preflight.d.ts +14 -1
- package/dist/lib/preflight.js +53 -17
- package/dist/lib/project.d.ts +17 -0
- package/dist/lib/project.js +26 -0
- package/dist/lib/scaffold.d.ts +12 -1
- package/dist/lib/scaffold.js +43 -4
- package/dist/lib/update.d.ts +36 -2
- package/dist/lib/update.js +82 -4
- package/docs/init.md +22 -0
- package/docs/loop.md +17 -3
- package/docs/skills.md +17 -13
- package/package.json +1 -1
- package/profiles/base/profile.md +13 -10
- package/profiles/development/profile.md +10 -8
- package/profiles/web-app/profile.md +6 -4
- package/profiles/web-app/reference/message-queue-patterns.ref.md +8 -13
- 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
|
-
| `
|
|
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
|
|
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
|
|
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": "
|
|
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
|
|
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.
|
|
3
|
+
"@boopkit/cap-background-jobs": "0.5.1",
|
|
4
4
|
"@boopkit/cap-cloud-storage": "0.5.0",
|
|
5
|
-
"@boopkit/cap-coding": "0.4.
|
|
6
|
-
"@boopkit/cap-database": "0.4.
|
|
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.
|
|
12
|
-
"@boopkit/cap-product-design": "0.6.
|
|
13
|
-
"@boopkit/cap-project-mgmt": "0.4.
|
|
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.
|
|
16
|
-
"@boopkit/cap-testing": "0.5.
|
|
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.
|
|
19
|
-
"@boopkit/cap-web": "0.5.
|
|
20
|
-
"@boopkit/cap-workflows": "0.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/commands/add.js
CHANGED
|
@@ -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:
|
|
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.
|
|
67
|
-
//
|
|
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
|
-
|
|
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)(
|
|
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) {
|
package/dist/commands/adopt.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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(
|
|
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
|
|
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
|
|
460
|
+
const projectDir = deps.projectDir || process.cwd();
|
|
346
461
|
return (title, description) => {
|
|
347
462
|
try {
|
|
348
|
-
const raw =
|
|
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 {
|
package/dist/commands/loop.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/loop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|