@boopkit/cli 0.6.12 → 0.6.14
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 +27 -6
- package/dist/capability-versions.json +16 -16
- package/dist/cli.js +9 -1
- package/dist/commands/add.js +49 -2
- package/dist/commands/init.d.ts +17 -1
- package/dist/commands/init.js +151 -26
- package/dist/commands/loop-merge.d.ts +1 -0
- package/dist/commands/loop-merge.js +4 -2
- package/dist/commands/loop.d.ts +21 -2
- package/dist/commands/loop.js +105 -16
- package/dist/commands/loop.js.orig-pre-maxbuffer +1455 -0
- package/dist/commands/migrate-skill-edits.d.ts +24 -0
- package/dist/commands/migrate-skill-edits.js +190 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.js +13 -0
- package/dist/commands/update.js +55 -7
- package/dist/lib/apply-skill-edits.d.ts +76 -0
- package/dist/lib/apply-skill-edits.js +307 -0
- package/dist/lib/execute.js +1 -0
- package/dist/lib/original-text.d.ts +43 -0
- package/dist/lib/original-text.js +165 -0
- package/dist/lib/scaffold.d.ts +41 -7
- package/dist/lib/scaffold.js +96 -15
- package/dist/lib/skill-edits.d.ts +88 -0
- package/dist/lib/skill-edits.js +293 -0
- package/dist/lib/update.d.ts +38 -2
- package/dist/lib/update.js +149 -6
- package/docs/init.md +132 -86
- package/docs/loop.md +16 -0
- package/docs/skills.md +1 -0
- package/package.json +3 -2
- package/profiles/base/profile.md +105 -0
- package/profiles/base/templates/.github/copilot-instructions.md +3 -0
- package/profiles/base/templates/.gitignore.hbs +6 -0
- package/profiles/base/templates/AGENTS.md.hbs +50 -0
- package/profiles/base/templates/CLAUDE.md.hbs +3 -0
- package/profiles/base/templates/README.md.hbs +15 -0
- package/profiles/base/templates/docs/README.md +12 -0
- package/profiles/base/templates/package.json.hbs +7 -0
- package/profiles/base/templates/reference/README.md +16 -0
- package/profiles/base/templates/specs/README.md +12 -0
- package/profiles/development/profile.md +8 -1
- package/src/templates/skill-project/PROJECT.md +28 -0
package/dist/lib/scaffold.js
CHANGED
|
@@ -37,10 +37,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.renderTemplate = renderTemplate;
|
|
40
|
+
exports.getTemplatesDir = getTemplatesDir;
|
|
41
|
+
exports.profileHasTemplates = profileHasTemplates;
|
|
40
42
|
exports.scaffoldKernel = scaffoldKernel;
|
|
41
43
|
exports.hashFile = hashFile;
|
|
44
|
+
exports.hashContent = hashContent;
|
|
42
45
|
exports.copyCapabilityFiles = copyCapabilityFiles;
|
|
43
46
|
exports.linkSkillsToClaude = linkSkillsToClaude;
|
|
47
|
+
exports.createSkillProjectFiles = createSkillProjectFiles;
|
|
48
|
+
exports.hasOfferedRules = hasOfferedRules;
|
|
49
|
+
exports.readSkillProjectTemplate = readSkillProjectTemplate;
|
|
44
50
|
exports.getBoopkitVersion = getBoopkitVersion;
|
|
45
51
|
exports.formatUnpinnedDependencyWarning = formatUnpinnedDependencyWarning;
|
|
46
52
|
exports.mergePackageJson = mergePackageJson;
|
|
@@ -68,19 +74,33 @@ function renderTemplate(templateContent, variables) {
|
|
|
68
74
|
const compiled = handlebars_1.default.compile(templateContent, { noEscape: true });
|
|
69
75
|
return compiled(variables);
|
|
70
76
|
}
|
|
77
|
+
function getKernelTemplatesDir() {
|
|
78
|
+
return path.join((0, config_1.getBoopRoot)(), 'src', 'templates', 'kernel');
|
|
79
|
+
}
|
|
71
80
|
/**
|
|
72
|
-
*
|
|
81
|
+
* Resolve the template directory `boop init` renders for a profile.
|
|
82
|
+
*
|
|
83
|
+
* A profile that ships its own `profiles/<name>/templates/` folder gets that
|
|
84
|
+
* folder, which must be complete on its own — nothing is merged from the kernel.
|
|
85
|
+
* Every other profile falls back to `src/templates/kernel/`. The fallback is
|
|
86
|
+
* what keeps `web-app`, which ships no templates/ folder, scaffolding exactly
|
|
87
|
+
* what it always has.
|
|
73
88
|
*/
|
|
74
|
-
function getTemplatesDir() {
|
|
75
|
-
return
|
|
89
|
+
function getTemplatesDir(profileName) {
|
|
90
|
+
return profileHasTemplates(profileName) ? getProfileTemplatesDir(profileName) : getKernelTemplatesDir();
|
|
91
|
+
}
|
|
92
|
+
function profileHasTemplates(profileName) {
|
|
93
|
+
return fs.existsSync(getProfileTemplatesDir(profileName));
|
|
94
|
+
}
|
|
95
|
+
function getProfileTemplatesDir(profileName) {
|
|
96
|
+
return path.join((0, config_1.getBoopRoot)(), 'profiles', profileName, 'templates');
|
|
76
97
|
}
|
|
77
98
|
/**
|
|
78
|
-
* Copy and render
|
|
79
|
-
* Creates directories as needed. Does
|
|
80
|
-
* unless overwrite is true.
|
|
99
|
+
* Copy and render every file in a templates directory (the kernel templates
|
|
100
|
+
* by default) into a target directory. Creates directories as needed. Does
|
|
101
|
+
* not overwrite existing files unless overwrite is true.
|
|
81
102
|
*/
|
|
82
|
-
function scaffoldKernel(targetDir, variables, overwrite = false) {
|
|
83
|
-
const templatesDir = getTemplatesDir();
|
|
103
|
+
function scaffoldKernel(targetDir, variables, overwrite = false, templatesDir = getKernelTemplatesDir()) {
|
|
84
104
|
const written = [];
|
|
85
105
|
function processDir(srcDir, destDir) {
|
|
86
106
|
if (!fs.existsSync(destDir)) {
|
|
@@ -101,12 +121,13 @@ function scaffoldKernel(targetDir, variables, overwrite = false) {
|
|
|
101
121
|
if (!overwrite && fs.existsSync(destPath)) {
|
|
102
122
|
continue;
|
|
103
123
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
// Only .hbs files are text by contract; anything else may be binary, so copy its bytes untouched
|
|
125
|
+
if (entry.name.endsWith('.hbs')) {
|
|
126
|
+
fs.writeFileSync(destPath, renderTemplate(fs.readFileSync(srcPath, 'utf-8'), variables), 'utf-8');
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
fs.copyFileSync(srcPath, destPath);
|
|
130
|
+
}
|
|
110
131
|
written.push(path.relative(targetDir, destPath));
|
|
111
132
|
}
|
|
112
133
|
}
|
|
@@ -118,7 +139,13 @@ function scaffoldKernel(targetDir, variables, overwrite = false) {
|
|
|
118
139
|
* Compute SHA-256 hash of a file's content.
|
|
119
140
|
*/
|
|
120
141
|
function hashFile(filePath) {
|
|
121
|
-
|
|
142
|
+
return hashContent(fs.readFileSync(filePath));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The hash `hashFile` records, for content that is not on disk — a past version
|
|
146
|
+
* read from git, or a file inside a downloaded package.
|
|
147
|
+
*/
|
|
148
|
+
function hashContent(content) {
|
|
122
149
|
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
|
123
150
|
return `sha256:${hash}`;
|
|
124
151
|
}
|
|
@@ -226,6 +253,60 @@ function linkSkillsToClaude(targetDir, only) {
|
|
|
226
253
|
}
|
|
227
254
|
return linked;
|
|
228
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Give every skill folder under `<targetDir>/skills/` that lacks one a
|
|
258
|
+
* `PROJECT.md`, where the project records how it applies the skill. Each boop
|
|
259
|
+
* `SKILL.md` opens by pointing at it; the file being present is the backstop
|
|
260
|
+
* for an agent that skipped the pointer.
|
|
261
|
+
*
|
|
262
|
+
* The file is the project's: boop never hashes it, so `boop update` can never
|
|
263
|
+
* report it, and an existing one is never opened for writing.
|
|
264
|
+
*
|
|
265
|
+
* Returns the relative paths of the files created.
|
|
266
|
+
*/
|
|
267
|
+
function createSkillProjectFiles(targetDir) {
|
|
268
|
+
const skillsDir = path.join(targetDir, 'skills');
|
|
269
|
+
if (!fs.existsSync(skillsDir))
|
|
270
|
+
return [];
|
|
271
|
+
const template = readSkillProjectTemplate();
|
|
272
|
+
const created = [];
|
|
273
|
+
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
274
|
+
// A symlinked skill folder is someone else's. In the boop repository each one
|
|
275
|
+
// points into a capability package, from which a PROJECT.md would ship to every project.
|
|
276
|
+
if (!entry.isDirectory())
|
|
277
|
+
continue;
|
|
278
|
+
const filePath = path.join(skillsDir, entry.name, 'PROJECT.md');
|
|
279
|
+
if (fs.lstatSync(filePath, { throwIfNoEntry: false }))
|
|
280
|
+
continue;
|
|
281
|
+
fs.writeFileSync(filePath, template, { flag: 'wx' });
|
|
282
|
+
created.push(path.join('skills', entry.name, 'PROJECT.md'));
|
|
283
|
+
}
|
|
284
|
+
return created;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether a skill's PROJECT.md has rules under "Offered to boop" — anything in
|
|
288
|
+
* that section beyond the template's own explanation of it.
|
|
289
|
+
*/
|
|
290
|
+
function hasOfferedRules(projectFilePath) {
|
|
291
|
+
if (!fs.existsSync(projectFilePath))
|
|
292
|
+
return false;
|
|
293
|
+
const section = offeredToBoopSection(fs.readFileSync(projectFilePath, 'utf-8'));
|
|
294
|
+
if (section === null)
|
|
295
|
+
return false;
|
|
296
|
+
const explanation = new Set((offeredToBoopSection(readSkillProjectTemplate()) ?? '').split('\n').map(line => line.trim()));
|
|
297
|
+
return section.split('\n').some(line => line.trim() !== '' && !explanation.has(line.trim()));
|
|
298
|
+
}
|
|
299
|
+
function offeredToBoopSection(text) {
|
|
300
|
+
const heading = /^##[ \t]+Offered to boop[ \t]*$/m.exec(text);
|
|
301
|
+
if (!heading)
|
|
302
|
+
return null;
|
|
303
|
+
const rest = text.slice(heading.index + heading[0].length);
|
|
304
|
+
const nextHeading = rest.search(/^#{1,2}[ \t]/m);
|
|
305
|
+
return nextHeading === -1 ? rest : rest.slice(0, nextHeading);
|
|
306
|
+
}
|
|
307
|
+
function readSkillProjectTemplate() {
|
|
308
|
+
return fs.readFileSync(path.join((0, config_1.getBoopRoot)(), 'src', 'templates', 'skill-project', 'PROJECT.md'), 'utf-8');
|
|
309
|
+
}
|
|
229
310
|
/**
|
|
230
311
|
* Copy capability files from the capabilities/ source tree (test fixtures only).
|
|
231
312
|
*/
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mechanical half of splitting a drifted skill: find each skill whose
|
|
3
|
+
* SKILL.md the project edited, recover the text boop installed, list every
|
|
4
|
+
* edit, and write one plan per skill for an agent to decide. Nothing here
|
|
5
|
+
* decides where an edit belongs.
|
|
6
|
+
*/
|
|
7
|
+
import { FindOriginalTextOptions } from './original-text';
|
|
8
|
+
import { BoopManifest } from './project';
|
|
9
|
+
/** Where plans are written, relative to the project. Everything in `.boop/` but config.json is gitignored. */
|
|
10
|
+
export declare const SKILL_EDITS_DIR: string;
|
|
11
|
+
/** One continuous change between a base text and the project's copy, as line ranges. */
|
|
12
|
+
export interface Edit {
|
|
13
|
+
/** Lines [baseStart, baseEnd) of the base text that the project replaced */
|
|
14
|
+
baseStart: number;
|
|
15
|
+
baseEnd: number;
|
|
16
|
+
/** Lines [projectStart, projectEnd) of the project's copy that replace them */
|
|
17
|
+
projectStart: number;
|
|
18
|
+
projectEnd: number;
|
|
19
|
+
}
|
|
20
|
+
/** A blank decision, for the agent following the triage skill to fill in. */
|
|
21
|
+
export interface EditDecision {
|
|
22
|
+
/** keep | offer | drop */
|
|
23
|
+
decision: null;
|
|
24
|
+
/** For a kept edit: adds | narrows | overrides */
|
|
25
|
+
kind: null;
|
|
26
|
+
/** For a kept or offered edit: the edit rewritten as a rule that stands alone */
|
|
27
|
+
rule: null;
|
|
28
|
+
/** For a dropped edit: why */
|
|
29
|
+
reason: null;
|
|
30
|
+
}
|
|
31
|
+
export interface SkillEditPlanData {
|
|
32
|
+
skill: string;
|
|
33
|
+
capability: string;
|
|
34
|
+
path: string;
|
|
35
|
+
original: {
|
|
36
|
+
source: 'none';
|
|
37
|
+
} | ({
|
|
38
|
+
source: 'git';
|
|
39
|
+
commit: string;
|
|
40
|
+
}) | ({
|
|
41
|
+
source: 'npm';
|
|
42
|
+
package: string;
|
|
43
|
+
version: string;
|
|
44
|
+
});
|
|
45
|
+
/** True when the original could not be found, so every difference from boop's copy needs a decision */
|
|
46
|
+
comparedWithoutOriginal: boolean;
|
|
47
|
+
/** Whether boop has changed the skill since the project installed it; unknown without the original */
|
|
48
|
+
boopChanged: boolean | null;
|
|
49
|
+
/** As they were when the plan was written, so applying it can refuse a plan made for other text */
|
|
50
|
+
hashes: {
|
|
51
|
+
installed: string;
|
|
52
|
+
project: string;
|
|
53
|
+
boop: string;
|
|
54
|
+
};
|
|
55
|
+
decisions: Record<string, EditDecision>;
|
|
56
|
+
}
|
|
57
|
+
export type SkillEditOutcome = {
|
|
58
|
+
kind: 'written';
|
|
59
|
+
skill: string;
|
|
60
|
+
planPath: string;
|
|
61
|
+
edits: number;
|
|
62
|
+
boopChanged: boolean | null;
|
|
63
|
+
comparedWithoutOriginal: boolean;
|
|
64
|
+
replacedStale: boolean;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'kept';
|
|
67
|
+
skill: string;
|
|
68
|
+
planPath: string;
|
|
69
|
+
} | {
|
|
70
|
+
kind: 'hand-split';
|
|
71
|
+
skill: string;
|
|
72
|
+
relativePath: string;
|
|
73
|
+
upstreamPath?: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Find every skill edited in SKILL.md across the project's capabilities and
|
|
77
|
+
* write a plan for each to `.boop/skill-edits/<skill-name>.md`.
|
|
78
|
+
*
|
|
79
|
+
* A plan already written for the current project copy and boop copy is left
|
|
80
|
+
* alone, because it may hold decisions. One written for other text is backed
|
|
81
|
+
* up and rewritten. A skill boop has no stored hash for gets no plan.
|
|
82
|
+
*/
|
|
83
|
+
export declare function writeSkillEditPlans(projectDir: string, manifest: BoopManifest, options?: FindOriginalTextOptions): SkillEditOutcome[];
|
|
84
|
+
/**
|
|
85
|
+
* The edits that turn `base` into `project`: each maximal run of lines that
|
|
86
|
+
* are not part of a longest common subsequence of the two.
|
|
87
|
+
*/
|
|
88
|
+
export declare function diffLines(base: string[], project: string[]): Edit[];
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The mechanical half of splitting a drifted skill: find each skill whose
|
|
4
|
+
* SKILL.md the project edited, recover the text boop installed, list every
|
|
5
|
+
* edit, and write one plan per skill for an agent to decide. Nothing here
|
|
6
|
+
* decides where an edit belongs.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
42
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.SKILL_EDITS_DIR = void 0;
|
|
46
|
+
exports.writeSkillEditPlans = writeSkillEditPlans;
|
|
47
|
+
exports.diffLines = diffLines;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const gray_matter_1 = __importDefault(require("gray-matter"));
|
|
51
|
+
const original_text_1 = require("./original-text");
|
|
52
|
+
const safe_io_1 = require("./safe-io");
|
|
53
|
+
const install_layout_1 = require("./install-layout");
|
|
54
|
+
const update_1 = require("./update");
|
|
55
|
+
/** Where plans are written, relative to the project. Everything in `.boop/` but config.json is gitignored. */
|
|
56
|
+
exports.SKILL_EDITS_DIR = path.join('.boop', 'skill-edits');
|
|
57
|
+
/**
|
|
58
|
+
* Find every skill edited in SKILL.md across the project's capabilities and
|
|
59
|
+
* write a plan for each to `.boop/skill-edits/<skill-name>.md`.
|
|
60
|
+
*
|
|
61
|
+
* A plan already written for the current project copy and boop copy is left
|
|
62
|
+
* alone, because it may hold decisions. One written for other text is backed
|
|
63
|
+
* up and rewritten. A skill boop has no stored hash for gets no plan.
|
|
64
|
+
*/
|
|
65
|
+
function writeSkillEditPlans(projectDir, manifest, options = {}) {
|
|
66
|
+
const outcomes = [];
|
|
67
|
+
for (const capability of manifest.capabilities) {
|
|
68
|
+
const plan = (0, update_1.planCapabilityUpdate)(projectDir, capability, manifest);
|
|
69
|
+
for (const action of plan?.actions ?? []) {
|
|
70
|
+
if (action.fileKind !== 'skill' || action.action !== 'skip')
|
|
71
|
+
continue;
|
|
72
|
+
const skill = path.basename(path.dirname(action.relativePath));
|
|
73
|
+
if (!action.storedHash) {
|
|
74
|
+
// Untracked; a copy identical to boop's holds nothing to split
|
|
75
|
+
if (action.currentHash && action.currentHash !== action.newHash) {
|
|
76
|
+
outcomes.push({ kind: 'hand-split', skill, relativePath: action.relativePath, upstreamPath: action.upstreamPath });
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (action.drift !== 'project-only' && action.drift !== 'both-changed')
|
|
81
|
+
continue;
|
|
82
|
+
outcomes.push(writePlan(projectDir, capability, skill, action, plan.packageDir, options));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return outcomes;
|
|
86
|
+
}
|
|
87
|
+
function writePlan(projectDir, capability, skill, action, packageDir, options) {
|
|
88
|
+
const planRelPath = path.join(exports.SKILL_EDITS_DIR, `${skill}.md`);
|
|
89
|
+
const planPath = path.join(projectDir, planRelPath);
|
|
90
|
+
const hashes = { installed: action.storedHash, project: action.currentHash, boop: action.newHash };
|
|
91
|
+
const existing = readPlanHashes(planPath);
|
|
92
|
+
if (existing && existing.project === hashes.project && existing.boop === hashes.boop) {
|
|
93
|
+
return { kind: 'kept', skill, planPath: planRelPath };
|
|
94
|
+
}
|
|
95
|
+
const projectText = fs.readFileSync(path.join(projectDir, action.relativePath), 'utf-8');
|
|
96
|
+
const boopText = fs.readFileSync(boopCopyPath(packageDir, action.relativePath), 'utf-8');
|
|
97
|
+
const original = (0, original_text_1.findOriginalText)(projectDir, action.relativePath.split(path.sep).join('/'), hashes.installed, options);
|
|
98
|
+
const baseText = original.found ? original.content : boopText;
|
|
99
|
+
const baseLines = baseText.split('\n');
|
|
100
|
+
const projectLines = projectText.split('\n');
|
|
101
|
+
const edits = diffLines(baseLines, projectLines);
|
|
102
|
+
const boopChanged = original.found ? action.drift === 'both-changed' : null;
|
|
103
|
+
const data = {
|
|
104
|
+
skill,
|
|
105
|
+
capability,
|
|
106
|
+
path: action.relativePath.split(path.sep).join('/'),
|
|
107
|
+
original: original.found ? sourceFields(original.source) : { source: 'none' },
|
|
108
|
+
comparedWithoutOriginal: !original.found,
|
|
109
|
+
boopChanged,
|
|
110
|
+
hashes,
|
|
111
|
+
decisions: Object.fromEntries(edits.map((_, i) => [editId(i), { decision: null, kind: null, rule: null, reason: null }])),
|
|
112
|
+
};
|
|
113
|
+
const body = original.found
|
|
114
|
+
? renderBody(data, edits, baseLines, projectLines, boopText.split('\n'))
|
|
115
|
+
: renderBody(data, edits, baseLines, projectLines, null);
|
|
116
|
+
fs.mkdirSync(path.dirname(planPath), { recursive: true });
|
|
117
|
+
const replacedStale = existing !== null;
|
|
118
|
+
if (replacedStale)
|
|
119
|
+
(0, safe_io_1.createBackup)(planPath);
|
|
120
|
+
fs.writeFileSync(planPath, gray_matter_1.default.stringify(body, data));
|
|
121
|
+
return {
|
|
122
|
+
kind: 'written',
|
|
123
|
+
skill,
|
|
124
|
+
planPath: planRelPath,
|
|
125
|
+
edits: edits.length,
|
|
126
|
+
boopChanged,
|
|
127
|
+
comparedWithoutOriginal: !original.found,
|
|
128
|
+
replacedStale,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** The hashes an existing plan was written against, or null when there is no readable plan. */
|
|
132
|
+
function readPlanHashes(planPath) {
|
|
133
|
+
if (!fs.existsSync(planPath))
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
return (0, gray_matter_1.default)(fs.readFileSync(planPath, 'utf-8')).data.hashes ?? {};
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function boopCopyPath(packageDir, relativePath) {
|
|
143
|
+
// planCapabilityUpdate hashed this file, so its placement exists
|
|
144
|
+
return (0, install_layout_1.capabilityPlacementsFromDir)(packageDir).find(p => p.dest === relativePath).source;
|
|
145
|
+
}
|
|
146
|
+
function sourceFields(source) {
|
|
147
|
+
return source.kind === 'git'
|
|
148
|
+
? { source: 'git', commit: source.commit }
|
|
149
|
+
: { source: 'npm', package: source.package, version: source.version };
|
|
150
|
+
}
|
|
151
|
+
function editId(index) {
|
|
152
|
+
return `edit-${index + 1}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The edits that turn `base` into `project`: each maximal run of lines that
|
|
156
|
+
* are not part of a longest common subsequence of the two.
|
|
157
|
+
*/
|
|
158
|
+
function diffLines(base, project) {
|
|
159
|
+
const edits = [];
|
|
160
|
+
let baseAt = 0;
|
|
161
|
+
let projectAt = 0;
|
|
162
|
+
for (const [i, j] of [...matchLines(base, project), [base.length, project.length]]) {
|
|
163
|
+
if (i > baseAt || j > projectAt) {
|
|
164
|
+
edits.push({ baseStart: baseAt, baseEnd: i, projectStart: projectAt, projectEnd: j });
|
|
165
|
+
}
|
|
166
|
+
baseAt = i + 1;
|
|
167
|
+
projectAt = j + 1;
|
|
168
|
+
}
|
|
169
|
+
return edits;
|
|
170
|
+
}
|
|
171
|
+
/** Index pairs of the lines two texts share, in order: a longest common subsequence. */
|
|
172
|
+
function matchLines(a, b) {
|
|
173
|
+
// Most edits leave long runs at both ends untouched, which keeps the table small
|
|
174
|
+
let prefix = 0;
|
|
175
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix])
|
|
176
|
+
prefix++;
|
|
177
|
+
let suffix = 0;
|
|
178
|
+
while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix])
|
|
179
|
+
suffix++;
|
|
180
|
+
const n = a.length - prefix - suffix;
|
|
181
|
+
const m = b.length - prefix - suffix;
|
|
182
|
+
// lcs[x][y]: length of the common subsequence of a's middle from x and b's middle from y
|
|
183
|
+
const width = m + 1;
|
|
184
|
+
const lcs = new Uint32Array((n + 1) * (m + 1));
|
|
185
|
+
for (let x = n - 1; x >= 0; x--) {
|
|
186
|
+
for (let y = m - 1; y >= 0; y--) {
|
|
187
|
+
lcs[x * width + y] = a[prefix + x] === b[prefix + y]
|
|
188
|
+
? lcs[(x + 1) * width + y + 1] + 1
|
|
189
|
+
: Math.max(lcs[(x + 1) * width + y], lcs[x * width + y + 1]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const pairs = [];
|
|
193
|
+
for (let k = 0; k < prefix; k++)
|
|
194
|
+
pairs.push([k, k]);
|
|
195
|
+
let x = 0;
|
|
196
|
+
let y = 0;
|
|
197
|
+
while (x < n && y < m) {
|
|
198
|
+
if (a[prefix + x] === b[prefix + y]) {
|
|
199
|
+
pairs.push([prefix + x, prefix + y]);
|
|
200
|
+
x++;
|
|
201
|
+
y++;
|
|
202
|
+
}
|
|
203
|
+
else if (lcs[(x + 1) * width + y] >= lcs[x * width + y + 1]) {
|
|
204
|
+
x++;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
y++;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (let k = suffix; k > 0; k--)
|
|
211
|
+
pairs.push([a.length - k, b.length - k]);
|
|
212
|
+
return pairs;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* What boop now ships in place of base lines [start, end): the smallest
|
|
216
|
+
* region around them bounded on each side by a line boop kept, so a change
|
|
217
|
+
* boop made just beside an edit is shown with it.
|
|
218
|
+
*/
|
|
219
|
+
function boopRegion(matches, upstreamLength, baseLength, start, end) {
|
|
220
|
+
let before = [-1, -1];
|
|
221
|
+
let after = [baseLength, upstreamLength];
|
|
222
|
+
for (const pair of matches) {
|
|
223
|
+
if (pair[0] < start)
|
|
224
|
+
before = pair;
|
|
225
|
+
else if (pair[0] >= end) {
|
|
226
|
+
after = pair;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { baseStart: before[0] + 1, baseEnd: after[0], upstreamStart: before[1] + 1, upstreamEnd: after[1] };
|
|
231
|
+
}
|
|
232
|
+
function renderBody(data, edits, base, project, upstream) {
|
|
233
|
+
const baseLabel = upstream ? 'The text boop installed' : 'boop ships now';
|
|
234
|
+
const out = [
|
|
235
|
+
`# Edits to ${data.path}`,
|
|
236
|
+
'',
|
|
237
|
+
upstream
|
|
238
|
+
? `This project's copy differs from the text boop installed in ${edits.length} place${edits.length === 1 ? '' : 's'}. ` +
|
|
239
|
+
`Each is shown below with boop's own changes to the same text, so you can see where boop has since covered an edit.`
|
|
240
|
+
: `boop could not find the text it installed, in this project's git history or in the published versions of its package, ` +
|
|
241
|
+
`so this plan compares the project's copy with what boop ships now. That comparison cannot tell this project's edits ` +
|
|
242
|
+
`from boop's own changes: every difference below needs a decision, including ones boop made.`,
|
|
243
|
+
'',
|
|
244
|
+
`Record a decision for every edit under \`decisions\` in the frontmatter, following the ${update_1.TRIAGE_SKILL} skill: ` +
|
|
245
|
+
'`keep` with a `rule` and a `kind` (`adds`, `narrows` or `overrides`), `offer` with a `rule`, or `drop` with a `reason`.',
|
|
246
|
+
];
|
|
247
|
+
const upstreamMatches = upstream ? matchLines(base, upstream) : [];
|
|
248
|
+
edits.forEach((edit, index) => {
|
|
249
|
+
out.push('', `## ${editId(index)}`, '', locationLine(edit, base, baseLabel));
|
|
250
|
+
out.push('', `**${baseLabel}:**`, '', ...textBlock(base.slice(edit.baseStart, edit.baseEnd), 'this project added the text below'));
|
|
251
|
+
out.push('', "**This project's copy:**", '', ...textBlock(project.slice(edit.projectStart, edit.projectEnd), 'this project removed the text above'));
|
|
252
|
+
if (upstream) {
|
|
253
|
+
const region = boopRegion(upstreamMatches, upstream.length, base.length, edit.baseStart, edit.baseEnd);
|
|
254
|
+
const before = base.slice(region.baseStart, region.baseEnd);
|
|
255
|
+
const after = upstream.slice(region.upstreamStart, region.upstreamEnd);
|
|
256
|
+
out.push('', '**boop now ships:**', '');
|
|
257
|
+
if (before.join('\n') === after.join('\n')) {
|
|
258
|
+
out.push('_boop has not changed this text._');
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
if (region.baseStart !== edit.baseStart || region.baseEnd !== edit.baseEnd) {
|
|
262
|
+
out.push(`_boop's change reaches past this edit. This is what boop now ships in place of ${lineRange(region.baseStart, region.baseEnd)} of the installed text:_`, '');
|
|
263
|
+
}
|
|
264
|
+
out.push(...textBlock(after, 'boop removed this text'));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return out.join('\n') + '\n';
|
|
269
|
+
}
|
|
270
|
+
function locationLine(edit, base, baseLabel) {
|
|
271
|
+
const label = baseLabel.charAt(0).toLowerCase() + baseLabel.slice(1);
|
|
272
|
+
const where = edit.baseEnd > edit.baseStart
|
|
273
|
+
? `${capitalize(lineRange(edit.baseStart, edit.baseEnd))} of ${label}.`
|
|
274
|
+
: edit.baseStart === 0 ? `At the start of ${label}.` : `After line ${edit.baseStart} of ${label}.`;
|
|
275
|
+
const preceding = base.slice(0, edit.baseStart).reverse().find(line => line.trim() !== '');
|
|
276
|
+
return preceding ? `${where} It follows:\n\n> ${preceding.trim()}` : where;
|
|
277
|
+
}
|
|
278
|
+
/** Lines [start, end), numbered from 1 */
|
|
279
|
+
function lineRange(start, end) {
|
|
280
|
+
return end - start === 1 ? `line ${start + 1}` : `lines ${start + 1}–${end}`;
|
|
281
|
+
}
|
|
282
|
+
function capitalize(text) {
|
|
283
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
284
|
+
}
|
|
285
|
+
/** Lines in a code fence longer than any backtick run inside them, or a note when there are none. */
|
|
286
|
+
function textBlock(lines, whenEmpty) {
|
|
287
|
+
if (lines.every(line => line === '')) {
|
|
288
|
+
return lines.length === 0 ? [`_(none: ${whenEmpty})_`] : ['_(blank lines only)_'];
|
|
289
|
+
}
|
|
290
|
+
const longestRun = Math.max(0, ...lines.flatMap(line => (line.match(/`+/g) ?? []).map(run => run.length)));
|
|
291
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
292
|
+
return [fence, ...lines, fence];
|
|
293
|
+
}
|
package/dist/lib/update.d.ts
CHANGED
|
@@ -6,11 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { BoopManifest } from './project';
|
|
8
8
|
export type FileState = 'match' | 'modified' | 'missing';
|
|
9
|
+
/**
|
|
10
|
+
* How a locally modified file stands against the base (what boop shipped last)
|
|
11
|
+
* and upstream (what boop ships now). Only 'both-changed' is a conflict.
|
|
12
|
+
*/
|
|
13
|
+
export type Drift = 'project-only' | 'both-changed' | 'already-upstream';
|
|
14
|
+
export type FileKind = 'skill' | 'reference' | 'code';
|
|
9
15
|
export interface FileUpdateAction {
|
|
10
16
|
/** Relative path from project root */
|
|
11
17
|
relativePath: string;
|
|
12
18
|
/** What will happen to this file */
|
|
13
|
-
action: 'overwrite' | 'skip' | 'add' | 'deprecate' | 'migrate';
|
|
19
|
+
action: 'overwrite' | 'skip' | 'add' | 'deprecate' | 'migrate' | 'record-hash';
|
|
14
20
|
/** Why this action was chosen */
|
|
15
21
|
reason: string;
|
|
16
22
|
/** Stored hash from config */
|
|
@@ -19,8 +25,23 @@ export interface FileUpdateAction {
|
|
|
19
25
|
currentHash?: string;
|
|
20
26
|
/** New hash from package */
|
|
21
27
|
newHash?: string;
|
|
22
|
-
/** For migrate actions: the old file path being replaced */
|
|
28
|
+
/** For migrate actions, and a folder file held back by an edited old one: the old file path being replaced */
|
|
23
29
|
oldPath?: string;
|
|
30
|
+
/** For an edited old single-file skill: the skill folder that replaces it */
|
|
31
|
+
replacedBy?: string;
|
|
32
|
+
/** For a file whose hash differs from its stored base: how it stands against upstream */
|
|
33
|
+
drift?: Drift;
|
|
34
|
+
/** What sort of file this is, from its destination path */
|
|
35
|
+
fileKind?: FileKind;
|
|
36
|
+
/** What whoever ran the update should do about this file, when the report lists it */
|
|
37
|
+
instruction?: string;
|
|
38
|
+
/** boop's current copy of the file, when the instruction points at it */
|
|
39
|
+
upstreamPath?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface FileInstruction {
|
|
42
|
+
relativePath: string;
|
|
43
|
+
instruction: string;
|
|
44
|
+
upstreamPath?: string;
|
|
24
45
|
}
|
|
25
46
|
export interface UpdatePlan {
|
|
26
47
|
capability: string;
|
|
@@ -52,7 +73,13 @@ export interface UpdateResult {
|
|
|
52
73
|
migrated: string[];
|
|
53
74
|
/** Count of files unchanged in both package and locally */
|
|
54
75
|
unchangedCount: number;
|
|
76
|
+
/** What to do about the files this update skipped or replaced, where the report says anything */
|
|
77
|
+
instructions: FileInstruction[];
|
|
55
78
|
}
|
|
79
|
+
/** The skill that splits a drifted SKILL.md — keep in step with the folder name boop-rm2l.8 gives it */
|
|
80
|
+
export declare const TRIAGE_SKILL = "boop-skills-edit-triage";
|
|
81
|
+
/** What to do about skills edited in SKILL.md, for a report that counts them */
|
|
82
|
+
export declare const SKILL_EDITS_INSTRUCTION = "Move each skill's edits into the PROJECT.md beside it: run `boop migrate-skill-edits` and follow the boop-skills-edit-triage skill.";
|
|
56
83
|
/**
|
|
57
84
|
* Compare a file's current hash against a stored hash.
|
|
58
85
|
*/
|
|
@@ -71,6 +98,15 @@ export declare function executeUpdatePlan(plan: UpdatePlan, options?: {
|
|
|
71
98
|
restore?: boolean;
|
|
72
99
|
prune?: boolean;
|
|
73
100
|
}): UpdateResult;
|
|
101
|
+
/**
|
|
102
|
+
* Count the skills a capability's project copy has edited in SKILL.md, and how
|
|
103
|
+
* many of those boop has changed since — from the same comparison `boop update`
|
|
104
|
+
* makes. Null when there are none, or the capability has no package to compare with.
|
|
105
|
+
*/
|
|
106
|
+
export declare function countSkillEdits(targetDir: string, capabilityName: string, projectManifest: BoopManifest): {
|
|
107
|
+
edited: number;
|
|
108
|
+
changesWaiting: number;
|
|
109
|
+
} | null;
|
|
74
110
|
/**
|
|
75
111
|
* Format an update result as a human-readable summary line.
|
|
76
112
|
*/
|