@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.
Files changed (43) hide show
  1. package/README.md +27 -6
  2. package/dist/capability-versions.json +16 -16
  3. package/dist/cli.js +9 -1
  4. package/dist/commands/add.js +49 -2
  5. package/dist/commands/init.d.ts +17 -1
  6. package/dist/commands/init.js +151 -26
  7. package/dist/commands/loop-merge.d.ts +1 -0
  8. package/dist/commands/loop-merge.js +4 -2
  9. package/dist/commands/loop.d.ts +21 -2
  10. package/dist/commands/loop.js +105 -16
  11. package/dist/commands/loop.js.orig-pre-maxbuffer +1455 -0
  12. package/dist/commands/migrate-skill-edits.d.ts +24 -0
  13. package/dist/commands/migrate-skill-edits.js +190 -0
  14. package/dist/commands/status.d.ts +8 -0
  15. package/dist/commands/status.js +13 -0
  16. package/dist/commands/update.js +55 -7
  17. package/dist/lib/apply-skill-edits.d.ts +76 -0
  18. package/dist/lib/apply-skill-edits.js +307 -0
  19. package/dist/lib/execute.js +1 -0
  20. package/dist/lib/original-text.d.ts +43 -0
  21. package/dist/lib/original-text.js +165 -0
  22. package/dist/lib/scaffold.d.ts +41 -7
  23. package/dist/lib/scaffold.js +96 -15
  24. package/dist/lib/skill-edits.d.ts +88 -0
  25. package/dist/lib/skill-edits.js +293 -0
  26. package/dist/lib/update.d.ts +38 -2
  27. package/dist/lib/update.js +149 -6
  28. package/docs/init.md +132 -86
  29. package/docs/loop.md +16 -0
  30. package/docs/skills.md +1 -0
  31. package/package.json +3 -2
  32. package/profiles/base/profile.md +105 -0
  33. package/profiles/base/templates/.github/copilot-instructions.md +3 -0
  34. package/profiles/base/templates/.gitignore.hbs +6 -0
  35. package/profiles/base/templates/AGENTS.md.hbs +50 -0
  36. package/profiles/base/templates/CLAUDE.md.hbs +3 -0
  37. package/profiles/base/templates/README.md.hbs +15 -0
  38. package/profiles/base/templates/docs/README.md +12 -0
  39. package/profiles/base/templates/package.json.hbs +7 -0
  40. package/profiles/base/templates/reference/README.md +16 -0
  41. package/profiles/base/templates/specs/README.md +12 -0
  42. package/profiles/development/profile.md +8 -1
  43. package/src/templates/skill-project/PROJECT.md +28 -0
@@ -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
- * Get the path to the kernel templates directory.
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 path.join((0, config_1.getBoopRoot)(), 'src', 'templates', 'kernel');
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 all kernel templates into a target directory.
79
- * Creates directories as needed. Does not overwrite existing files
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
- const content = fs.readFileSync(srcPath, 'utf-8');
105
- // Only render .hbs files through Handlebars
106
- const rendered = entry.name.endsWith('.hbs')
107
- ? renderTemplate(content, variables)
108
- : content;
109
- fs.writeFileSync(destPath, rendered, 'utf-8');
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
- const content = fs.readFileSync(filePath);
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
+ }
@@ -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
  */