@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.
@@ -39,9 +39,11 @@ var __importStar = (this && this.__importStar) || (function () {
39
39
  };
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.SKILL_EDITS_INSTRUCTION = exports.TRIAGE_SKILL = void 0;
42
43
  exports.compareFileHash = compareFileHash;
43
44
  exports.planCapabilityUpdate = planCapabilityUpdate;
44
45
  exports.executeUpdatePlan = executeUpdatePlan;
46
+ exports.countSkillEdits = countSkillEdits;
45
47
  exports.formatUpdateSummary = formatUpdateSummary;
46
48
  const fs = __importStar(require("fs"));
47
49
  const path = __importStar(require("path"));
@@ -50,6 +52,10 @@ const capability_1 = require("./capability");
50
52
  const project_1 = require("./project");
51
53
  const safe_io_1 = require("./safe-io");
52
54
  const install_layout_1 = require("./install-layout");
55
+ /** The skill that splits a drifted SKILL.md — keep in step with the folder name boop-rm2l.8 gives it */
56
+ exports.TRIAGE_SKILL = 'boop-skills-edit-triage';
57
+ /** What to do about skills edited in SKILL.md, for a report that counts them */
58
+ exports.SKILL_EDITS_INSTRUCTION = `Move each skill's edits into the PROJECT.md beside it: run \`boop migrate-skill-edits\` and follow the ${exports.TRIAGE_SKILL} skill.`;
53
59
  /**
54
60
  * Compare a file's current hash against a stored hash.
55
61
  */
@@ -96,9 +102,22 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
96
102
  const destPath = path.join(targetDir, relPath);
97
103
  const storedHash = storedHashes[relPath];
98
104
  const newHash = (0, scaffold_1.hashFile)(srcPath);
105
+ const fileKind = fileKindOf(relPath);
99
106
  if (!storedHash) {
100
107
  // New file — not in stored hashes
101
- if (fs.existsSync(destPath)) {
108
+ if (fs.existsSync(destPath) && (0, scaffold_1.hashFile)(destPath) === newHash) {
109
+ // Untracked but already holds what boop ships, as a hand-split skill does — start tracking it
110
+ actions.push({
111
+ relativePath: relPath,
112
+ action: 'record-hash',
113
+ reason: 'Locally matches package',
114
+ currentHash: newHash,
115
+ newHash,
116
+ drift: 'already-upstream',
117
+ fileKind,
118
+ });
119
+ }
120
+ else if (fs.existsSync(destPath)) {
102
121
  // File exists but wasn't tracked — treat as modified (skip)
103
122
  actions.push({
104
123
  relativePath: relPath,
@@ -106,6 +125,8 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
106
125
  reason: 'File exists but has no stored hash (untracked)',
107
126
  currentHash: (0, scaffold_1.hashFile)(destPath),
108
127
  newHash,
128
+ fileKind,
129
+ ...skipInstruction(targetDir, relPath, srcPath, fileKind),
109
130
  });
110
131
  }
111
132
  else {
@@ -114,6 +135,7 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
114
135
  action: 'add',
115
136
  reason: 'New file from package',
116
137
  newHash,
138
+ fileKind,
117
139
  });
118
140
  }
119
141
  continue;
@@ -134,19 +156,40 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
134
156
  storedHash,
135
157
  currentHash: storedHash,
136
158
  newHash,
159
+ fileKind,
160
+ instruction: fileKind === 'skill' ? offeredRulesInstruction(targetDir, relPath) : undefined,
137
161
  });
138
162
  break;
139
- case 'modified':
140
- // Locally modified — skip with warning
163
+ case 'modified': {
164
+ const currentHash = (0, scaffold_1.hashFile)(destPath);
165
+ if (currentHash === newHash) {
166
+ // Already holds what boop ships — move the base forward, or it reads as modified forever
167
+ actions.push({
168
+ relativePath: relPath,
169
+ action: 'record-hash',
170
+ reason: 'Locally matches package',
171
+ storedHash,
172
+ currentHash,
173
+ newHash,
174
+ drift: 'already-upstream',
175
+ fileKind,
176
+ });
177
+ break;
178
+ }
179
+ const drift = newHash === storedHash ? 'project-only' : 'both-changed';
141
180
  actions.push({
142
181
  relativePath: relPath,
143
182
  action: 'skip',
144
183
  reason: 'Locally modified',
145
184
  storedHash,
146
- currentHash: (0, scaffold_1.hashFile)(destPath),
185
+ currentHash,
147
186
  newHash,
187
+ drift,
188
+ fileKind,
189
+ ...skipInstruction(targetDir, relPath, srcPath, fileKind, drift),
148
190
  });
149
191
  break;
192
+ }
150
193
  case 'missing':
151
194
  // File was deleted locally — respect user's deletion, skip by default
152
195
  actions.push({
@@ -155,6 +198,7 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
155
198
  reason: 'Locally deleted',
156
199
  storedHash,
157
200
  newHash,
201
+ fileKind,
158
202
  });
159
203
  break;
160
204
  }
@@ -185,18 +229,25 @@ function planCapabilityUpdate(targetDir, capabilityName, projectManifest) {
185
229
  const oldFilePath = path.join(targetDir, oldRelPath);
186
230
  const oldState = compareFileHash(oldFilePath, oldStoredHash);
187
231
  if (oldState === 'modified') {
188
- // User modified the old file — skip new folder files, keep old file
232
+ // User modified the old file — keep it, and don't install the folder that replaces it.
233
+ // A folder file already installed has its own comparison, so only an install is held back.
189
234
  for (const action of actions) {
190
- if (action.relativePath.startsWith(newPrefix) || action.relativePath === path.join('skills', newSkillName)) {
235
+ if (action.action === 'add' && (action.relativePath.startsWith(newPrefix) || action.relativePath === path.join('skills', newSkillName))) {
191
236
  action.action = 'skip';
192
237
  action.reason = `Skipped: old skill file '${oldRelPath}' was locally modified`;
238
+ action.oldPath = oldRelPath;
239
+ action.instruction = `boop installs this once ${oldRelPath} is gone; see what to do about that file.`;
193
240
  }
194
241
  }
195
242
  // Change deprecate to skip for the old file
196
243
  const oldAction = actions.find(a => a.relativePath === oldRelPath);
197
244
  if (oldAction) {
245
+ const projectFile = path.join('skills', newSkillName, 'PROJECT.md');
198
246
  oldAction.action = 'skip';
199
247
  oldAction.reason = 'Locally modified (migration skipped)';
248
+ oldAction.replacedBy = newSkillName;
249
+ oldAction.instruction = `This is the old single-file form of the ${newSkillName} skill, and this project edited it. ` +
250
+ `Move the rules this project needs from it into ${projectFile}, following the ${exports.TRIAGE_SKILL} skill, then delete it and run \`boop update\`.`;
200
251
  }
201
252
  }
202
253
  else if (oldState === 'match') {
@@ -245,6 +296,7 @@ function executeUpdatePlan(plan, options = {}) {
245
296
  pruned: [],
246
297
  migrated: [],
247
298
  unchangedCount: plan.unchangedCount,
299
+ instructions: [],
248
300
  };
249
301
  const pkgJson = JSON.parse(fs.readFileSync(path.join(plan.packageDir, 'package.json'), 'utf-8'));
250
302
  const boopkit = pkgJson.boopkit;
@@ -264,9 +316,16 @@ function executeUpdatePlan(plan, options = {}) {
264
316
  fs.copyFileSync(srcPath, destPath);
265
317
  newHashes[action.relativePath] = (0, scaffold_1.hashFile)(destPath);
266
318
  result.overwritten.push(action.relativePath);
319
+ if (action.instruction) {
320
+ result.instructions.push({ relativePath: action.relativePath, instruction: action.instruction });
321
+ }
267
322
  }
268
323
  break;
269
324
  }
325
+ case 'record-hash':
326
+ newHashes[action.relativePath] = action.newHash;
327
+ result.unchangedCount++;
328
+ break;
270
329
  case 'skip': {
271
330
  const isLocallyDeleted = action.reason === 'Locally deleted';
272
331
  if (isLocallyDeleted && options.restore) {
@@ -291,6 +350,10 @@ function executeUpdatePlan(plan, options = {}) {
291
350
  newHashes[action.relativePath] = (0, scaffold_1.hashFile)(destPath);
292
351
  result.overwritten.push(action.relativePath);
293
352
  result.forcedOverwrite.push(action.relativePath);
353
+ const instruction = action.fileKind === 'skill' ? offeredRulesInstruction(plan.targetDir, action.relativePath) : undefined;
354
+ if (instruction) {
355
+ result.instructions.push({ relativePath: action.relativePath, instruction });
356
+ }
294
357
  console.warn(`\x1b[33mWARN\x1b[0m Force-overwriting modified file: ${action.relativePath} (backup saved as ${action.relativePath}.bak)`);
295
358
  }
296
359
  }
@@ -299,6 +362,13 @@ function executeUpdatePlan(plan, options = {}) {
299
362
  if (isLocallyDeleted) {
300
363
  result.skippedDeleted.push(action.relativePath);
301
364
  }
365
+ if (action.instruction) {
366
+ result.instructions.push({
367
+ relativePath: action.relativePath,
368
+ instruction: action.instruction,
369
+ upstreamPath: action.upstreamPath,
370
+ });
371
+ }
302
372
  // Keep existing hash unchanged
303
373
  }
304
374
  break;
@@ -355,6 +425,79 @@ function executeUpdatePlan(plan, options = {}) {
355
425
  }
356
426
  return result;
357
427
  }
428
+ /**
429
+ * What to tell whoever ran the update about a file it skipped, or nothing.
430
+ * `drift` is absent for a file boop has no stored hash for.
431
+ *
432
+ * An edited SKILL.md always gets an instruction: any edit there is project
433
+ * content in boop's file. An edited reference page or code file is doing what
434
+ * those files are for, so it gets one only when boop has changed it too.
435
+ */
436
+ function skipInstruction(targetDir, relPath, srcPath, fileKind, drift) {
437
+ const upstreamPath = displayPath(targetDir, srcPath);
438
+ if (!drift) {
439
+ const untracked = `boop has no record of installing this file, so it cannot tell this project's changes from its own. boop's current copy is at ${upstreamPath}`;
440
+ if (fileKind === 'skill') {
441
+ const projectFile = path.join(path.dirname(relPath), 'PROJECT.md');
442
+ return {
443
+ instruction: `${untracked}. Split it by hand: move this project's rules into ${projectFile}, following the ${exports.TRIAGE_SKILL} skill, then copy boop's copy into place, and the next update tracks it.`,
444
+ upstreamPath,
445
+ };
446
+ }
447
+ return { instruction: `${untracked}; compare the two by hand.`, upstreamPath };
448
+ }
449
+ if (fileKind === 'skill') {
450
+ const projectFile = path.join(path.dirname(relPath), 'PROJECT.md');
451
+ return {
452
+ instruction: `Move this project's edits into ${projectFile}: run \`boop migrate-skill-edits\` and follow the ${exports.TRIAGE_SKILL} skill.`,
453
+ };
454
+ }
455
+ if (drift !== 'both-changed')
456
+ return {};
457
+ const advice = fileKind === 'reference'
458
+ ? 'Blend in what applies to this project, following the boop-product-design-reference-integration skill.'
459
+ : 'Compare the two and take what applies.';
460
+ return { instruction: `boop's current copy is at ${upstreamPath}. ${advice}`, upstreamPath };
461
+ }
462
+ /**
463
+ * When an update replaces a SKILL.md whose PROJECT.md offers rules to boop,
464
+ * boop's new text may now cover them, and a rule left behind would duplicate it.
465
+ */
466
+ function offeredRulesInstruction(targetDir, relPath) {
467
+ const projectFile = path.join(path.dirname(relPath), 'PROJECT.md');
468
+ if (!(0, scaffold_1.hasOfferedRules)(path.join(targetDir, projectFile)))
469
+ return undefined;
470
+ return `boop replaced this skill, and ${projectFile} offers rules to boop. Check whether boop's new text covers each rule under "Offered to boop", and delete any it does.`;
471
+ }
472
+ /** A path relative to the project when it lies inside it, as a package in node_modules does */
473
+ function displayPath(targetDir, filePath) {
474
+ const relative = path.relative(targetDir, filePath);
475
+ return relative.startsWith('..') || path.isAbsolute(relative) ? filePath : relative;
476
+ }
477
+ /**
478
+ * Count the skills a capability's project copy has edited in SKILL.md, and how
479
+ * many of those boop has changed since — from the same comparison `boop update`
480
+ * makes. Null when there are none, or the capability has no package to compare with.
481
+ */
482
+ function countSkillEdits(targetDir, capabilityName, projectManifest) {
483
+ const plan = planCapabilityUpdate(targetDir, capabilityName, projectManifest);
484
+ const edited = (plan?.actions ?? []).filter(a => a.action === 'skip' && a.fileKind === 'skill' && (a.drift === 'project-only' || a.drift === 'both-changed'));
485
+ if (edited.length === 0)
486
+ return null;
487
+ return { edited: edited.length, changesWaiting: edited.filter(a => a.drift === 'both-changed').length };
488
+ }
489
+ /**
490
+ * Classify a project-relative destination path: a skill's SKILL.md, a
491
+ * reference page, or anything else (code, views, config).
492
+ */
493
+ function fileKindOf(relativePath) {
494
+ const segments = relativePath.split(path.sep);
495
+ if (segments.length === 3 && segments[0] === 'skills' && segments[2] === 'SKILL.md')
496
+ return 'skill';
497
+ if (relativePath.endsWith('.ref.md'))
498
+ return 'reference';
499
+ return 'code';
500
+ }
358
501
  /**
359
502
  * Resolve the source path a project-relative file was installed from — the
360
503
  * placement list read backwards, so it cannot disagree with what was copied.
package/docs/skills.md CHANGED
@@ -38,6 +38,7 @@ Every capability ships **agent skills** — focused `SKILL.md` guides that `boop
38
38
  - **boop-get-smart** — When there is a recurring fault in your own behaviour during a chat, run the five whys to find its root cause, and agree a change with the operator that makes it less likely.
39
39
  - **boop-so-what** — When the operator asks for the so-what, or says some version of 'thanks for the essay', compress what you just produced into three sentences about what they should care about.
40
40
  - **boop-skills-hyperlinking** — When you need to add, review, or repair a `## Related` section that points one skill at another, follow these rules for what earns a link, how to phrase it, which way it points, how many are too many, and how to write a path that survives `boop add`.
41
+ - **boop-skills-edit-triage** — When you need to move a project's edits out of a boop skill's SKILL.md — because `boop update` or `boop status` reports the skill as edited here, or `boop migrate-skill-edits` wrote a plan for it — decide whether each edit is kept in this project, offered to boop, or dropped, and rewrite each kept edit as a standalone rule for the PROJECT.md beside it. Not for reference pages, which are blended rather than split.
41
42
 
42
43
  ### `testing` — Jest testing setup
43
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boopkit/cli",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "description": "boop: the opinionated bootstrap for founder friendly, AI-first web apps - code plus agent skills, composable from day one.",
5
5
  "main": "dist/cli.js",
6
6
  "workspaces": [
@@ -0,0 +1,28 @@
1
+ # How this project applies this skill
2
+
3
+ This file belongs to the project.
4
+ `SKILL.md`, beside it, belongs to boop and is replaced on every `boop update`, so a rule written there is lost.
5
+ boop created this file and never tracks, updates or overwrites it, so write this project's rules here.
6
+ Where this file and `SKILL.md` conflict, this file wins.
7
+
8
+ Put each rule under the heading that describes it.
9
+ To decide between this file and `SKILL.md`, ask whether the rule would still be true in a brand-new boop project scaffolded tomorrow.
10
+ If it would not, it belongs here.
11
+ If it would, it belongs under "Offered to boop".
12
+
13
+ ## Adds
14
+
15
+ Rules this project follows that the skill's guidance does not cover.
16
+
17
+ ## Narrows
18
+
19
+ Where the skill offers a choice, the one this project always makes.
20
+
21
+ ## Overrides
22
+
23
+ Where this project deliberately contradicts the skill's guidance, and why.
24
+
25
+ ## Offered to boop
26
+
27
+ Rules that apply here and that this project thinks boop should adopt for every project.
28
+ They stay in effect here until boop ships them.