@boopkit/cli 0.6.13 → 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.
@@ -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
  */