@canonical/pragma-cli 0.35.0 → 0.36.0

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 (230) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +16 -15
  13. package/dist/pragma.conf.js +236 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  18. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  19. package/dist/src/capabilities/create/mount.js +4 -3
  20. package/dist/src/capabilities/create/mount.js.map +1 -1
  21. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  22. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  24. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  27. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  28. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  30. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  31. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  32. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  33. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  34. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  35. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  36. package/dist/src/capabilities/graph/query.render.js +2 -2
  37. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  38. package/dist/src/capabilities/info/collectInfo.js +8 -2
  39. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  40. package/dist/src/capabilities/info/info.render.js +6 -2
  41. package/dist/src/capabilities/info/info.render.js.map +1 -1
  42. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  43. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  44. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  45. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  46. package/dist/src/capabilities/setup/mode.js +2 -2
  47. package/dist/src/capabilities/setup/operations/index.js +4 -4
  48. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/setupGenerator.js +92 -45
  50. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupLsp.js +88 -36
  52. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupMcp.js +97 -51
  54. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupSkills.js +325 -47
  56. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  57. package/dist/src/capabilities/setup/plan.js.map +1 -1
  58. package/dist/src/capabilities/setup/plan.render.js +144 -66
  59. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  60. package/dist/src/capabilities/setup/setup.render.js +1 -1
  61. package/dist/src/capabilities/setup/setup.verb.js +86 -33
  62. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  63. package/dist/src/capabilities/setup/targets.js +110 -45
  64. package/dist/src/capabilities/setup/targets.js.map +1 -1
  65. package/dist/src/capabilities/shared/index.js +6 -5
  66. package/dist/src/capabilities/shared/index.js.map +1 -1
  67. package/dist/src/capabilities/shared/packageManager.js +300 -54
  68. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  69. package/dist/src/capabilities/skill/discover.js +97 -11
  70. package/dist/src/capabilities/skill/discover.js.map +1 -1
  71. package/dist/src/capabilities/skill/render.js +2 -2
  72. package/dist/src/capabilities/skill/render.js.map +1 -1
  73. package/dist/src/capabilities/sources/installSkills.js +145 -3
  74. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  75. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  76. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  77. package/dist/src/capabilities/sources/update.verb.js +3 -3
  78. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  79. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  80. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  81. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  82. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  83. package/dist/src/constants.js +6 -1
  84. package/dist/src/constants.js.map +1 -1
  85. package/dist/src/kernel/index.js +11 -2
  86. package/dist/src/kernel/index.js.map +1 -1
  87. package/dist/src/kernel/interactivity.js +68 -0
  88. package/dist/src/kernel/interactivity.js.map +1 -0
  89. package/dist/src/kernel/packs/renderPack.js +3 -3
  90. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  91. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  92. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  93. package/dist/src/kernel/packs/runBodies.js +57 -4
  94. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  95. package/dist/src/kernel/packs/schema.js +54 -2
  96. package/dist/src/kernel/packs/schema.js.map +1 -1
  97. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  98. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +306 -43
  100. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  101. package/dist/src/kernel/packs/types.js.map +1 -1
  102. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  103. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  104. package/dist/src/kernel/project/cli/dispatch.js +23 -23
  105. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  106. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  107. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  108. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  109. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  110. package/dist/src/kernel/project/cli/rootHelp.js +34 -4
  111. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  112. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  113. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  114. package/dist/src/kernel/render/renderers.js +1 -1
  115. package/dist/src/kernel/render/style.js +29 -9
  116. package/dist/src/kernel/render/style.js.map +1 -1
  117. package/dist/src/kernel/render/vocabulary.js +174 -0
  118. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  119. package/dist/src/kernel/spec/emitReference.js +1 -1
  120. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  121. package/package.json +16 -15
  122. package/pragma.conf.ts +241 -34
  123. package/src/bin.ts +8 -1
  124. package/src/capabilities/block.parity.test.ts +25 -11
  125. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  126. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  127. package/src/capabilities/colophon/colophon.render.ts +1 -2
  128. package/src/capabilities/colophon/colophon.test.ts +2 -2
  129. package/src/capabilities/create/createGrammar.test.ts +1 -1
  130. package/src/capabilities/create/mount.ts +3 -3
  131. package/src/capabilities/distribution.parity.test.ts +4 -0
  132. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  133. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  134. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  135. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  136. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  137. package/src/capabilities/doctor/checks/index.ts +6 -6
  138. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  139. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  140. package/src/capabilities/doctor/doctor.render.ts +56 -80
  141. package/src/capabilities/doctor/doctor.test.ts +247 -35
  142. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  143. package/src/capabilities/doctor/runChecks.ts +14 -11
  144. package/src/capabilities/doctor/types.ts +15 -24
  145. package/src/capabilities/emptyRecovery.test.ts +10 -2
  146. package/src/capabilities/graph/query.render.ts +2 -2
  147. package/src/capabilities/info/collectInfo.ts +8 -1
  148. package/src/capabilities/info/info.render.ts +6 -2
  149. package/src/capabilities/info/info.test.ts +37 -1
  150. package/src/capabilities/info/types.ts +10 -2
  151. package/src/capabilities/prompt/prompt.render.ts +2 -2
  152. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  153. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  154. package/src/capabilities/setup/buildPlan.ts +45 -41
  155. package/src/capabilities/setup/index.ts +1 -1
  156. package/src/capabilities/setup/mode.ts +2 -2
  157. package/src/capabilities/setup/operations/index.ts +6 -1
  158. package/src/capabilities/setup/operations/setupGenerator.ts +144 -70
  159. package/src/capabilities/setup/operations/setupLsp.ts +129 -37
  160. package/src/capabilities/setup/operations/setupMcp.ts +132 -61
  161. package/src/capabilities/setup/operations/setupSkills.ts +392 -58
  162. package/src/capabilities/setup/plan.render.ts +171 -76
  163. package/src/capabilities/setup/plan.ts +17 -12
  164. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  165. package/src/capabilities/setup/setup.render.test.ts +50 -36
  166. package/src/capabilities/setup/setup.render.ts +1 -1
  167. package/src/capabilities/setup/setup.test.ts +881 -50
  168. package/src/capabilities/setup/setup.verb.ts +91 -33
  169. package/src/capabilities/setup/targets.ts +120 -52
  170. package/src/capabilities/setup/types.ts +10 -18
  171. package/src/capabilities/shared/index.ts +17 -6
  172. package/src/capabilities/shared/packageManager.test.ts +421 -6
  173. package/src/capabilities/shared/packageManager.ts +394 -59
  174. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  175. package/src/capabilities/skill/discover.ts +100 -11
  176. package/src/capabilities/skill/render.ts +2 -2
  177. package/src/capabilities/skill/skill.test.ts +17 -4
  178. package/src/capabilities/sources/installSkills.ts +181 -3
  179. package/src/capabilities/sources/runUpdate.ts +78 -2
  180. package/src/capabilities/sources/sources.test.ts +228 -0
  181. package/src/capabilities/sources/update.verb.ts +3 -3
  182. package/src/capabilities/surface.test.ts +2 -2
  183. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  184. package/src/capabilities/upgrade/types.ts +10 -2
  185. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  186. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  187. package/src/constants.ts +6 -1
  188. package/src/identity.test.ts +18 -5
  189. package/src/kernel/completion/entitySource.test.ts +15 -2
  190. package/src/kernel/completion/safety.test.ts +16 -9
  191. package/src/kernel/config/defaults.test.ts +2 -1
  192. package/src/kernel/index.ts +11 -2
  193. package/src/kernel/interactivity.test.ts +76 -0
  194. package/src/kernel/interactivity.ts +69 -0
  195. package/src/kernel/packs/compile.test.ts +92 -0
  196. package/src/kernel/packs/renderPack.ts +3 -3
  197. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  198. package/src/kernel/packs/resolveEntity.ts +88 -24
  199. package/src/kernel/packs/runBodies.ts +84 -19
  200. package/src/kernel/packs/schema.ts +60 -4
  201. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  202. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  203. package/src/kernel/packs/sparql/buildLookupQuery.ts +323 -45
  204. package/src/kernel/packs/toolDescription.test.ts +35 -1
  205. package/src/kernel/packs/types.ts +125 -2
  206. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  207. package/src/kernel/project/cli/buildProgram.ts +8 -1
  208. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  209. package/src/kernel/project/cli/dispatch.ts +25 -24
  210. package/src/kernel/project/cli/globalFlags.ts +4 -1
  211. package/src/kernel/project/cli/helpFormat.ts +1 -2
  212. package/src/kernel/project/cli/rootHelp.ts +36 -3
  213. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  214. package/src/kernel/render/renderers.ts +1 -1
  215. package/src/kernel/render/style.ts +32 -9
  216. package/src/kernel/render/vocabulary.ts +276 -0
  217. package/src/kernel/spec/emitReference.ts +1 -1
  218. package/src/kernel/spec/types.ts +35 -6
  219. package/src/testing/PARITY_GAPS.ts +9 -3
  220. package/src/testing/behavioral/docExamples.test.ts +4 -2
  221. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  222. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  223. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  224. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  225. package/src/testing/eval/cases/readNouns.ts +245 -9
  226. package/src/testing/fixtures/blockGraph.ts +28 -13
  227. package/src/testing/fixtures/graph/canonical.ts +95 -12
  228. package/dist/src/capabilities/shared/bands.js +0 -16
  229. package/dist/src/capabilities/shared/bands.js.map +0 -1
  230. package/src/capabilities/shared/bands.ts +0 -16
@@ -26,9 +26,20 @@
26
26
  * accurate; the composed Task performs only the symlink/delete effects.
27
27
  */
28
28
 
29
- import { existsSync, lstatSync, readdirSync, readlinkSync } from "node:fs";
29
+ import {
30
+ existsSync,
31
+ lstatSync,
32
+ readdirSync,
33
+ readlinkSync,
34
+ statSync,
35
+ } from "node:fs";
30
36
  import { basename, join, resolve } from "node:path";
31
37
  import type { ResolvedPackage } from "../../kernel/runtime/refs/index.js";
38
+ // The ownership test, imported rather than reimplemented: `setup skills` and
39
+ // this module must not be able to disagree about which links belong to pragma.
40
+ // `setupSkills.ts` reaches `@canonical/harnesses` only through a DYNAMIC
41
+ // import, so this edge adds no static one (`capabilities/lazy.test.ts`).
42
+ import { withinAnyRoot } from "../setup/operations/setupSkills.js";
32
43
  import { installedSkillsDir } from "../skill/discover.js";
33
44
 
34
45
  /** One planned skill symlink into the installed-skills root. */
@@ -94,7 +105,11 @@ function packageSkillDirs(root: string): string[] {
94
105
  for (const name of names) {
95
106
  const dir = join(skillsDir, name);
96
107
  try {
97
- if (lstatSync(dir).isDirectory() && existsSync(join(dir, "SKILL.md"))) {
108
+ // `stat`, not `lstat`: a package that ships `skills/<name>` as a SYMLINK
109
+ // — the ordinary pnpm / monorepo / `file:` layout — contributed nothing
110
+ // at all, silently, because `lstat` reports the link and not the
111
+ // directory behind it. Discovery uses `stat`; so does this now.
112
+ if (statSync(dir).isDirectory() && existsSync(join(dir, "SKILL.md"))) {
98
113
  out.push(dir);
99
114
  }
100
115
  } catch {
@@ -176,10 +191,17 @@ export function planSkillInstall(
176
191
  seen.add(folderName);
177
192
  const linkPath = resolve(dest, folderName);
178
193
  const state = linkState(linkPath);
194
+ // Already correct means the link RESOLVES to the skill dir, not that its
195
+ // raw `readlink` string equals an absolute path. A relative link was
196
+ // therefore never "already correct": it was torn down and rebuilt on
197
+ // every single update, and the sibling planner reported it as drift.
198
+ const resolvesToSkill =
199
+ state.kind === "symlink" &&
200
+ resolve(dest, state.target) === resolve(skillDir);
179
201
  const action: SkillLinkAction["action"] =
180
202
  state.kind === "absent"
181
203
  ? "created"
182
- : state.kind === "symlink" && state.target === skillDir
204
+ : resolvesToSkill
183
205
  ? "skipped"
184
206
  : state.kind === "symlink"
185
207
  ? "replaced"
@@ -199,3 +221,159 @@ export function planSkillInstall(
199
221
  actions.push(...planStaleLinkPrunes(dest, seen));
200
222
  return actions;
201
223
  }
224
+
225
+ /**
226
+ * One planned link action in a HARNESS skills directory (layer 2).
227
+ *
228
+ * Layer 1 (`<installedSkillsDir>/<name>` → the package's folder) is what
229
+ * {@link planSkillInstall} owns. This is the layer above it: the links inside
230
+ * `~/.claude/skills` and `~/.agents/skills` that point AT layer 1 — a link to a
231
+ * link — and that `setup skills` owns.
232
+ */
233
+ export interface HarnessLinkAction {
234
+ readonly target: string;
235
+ readonly linkPath: string;
236
+ readonly action: "created" | "replaced" | "pruned";
237
+ readonly folderName: string;
238
+ /** The harness directory's display name, for the progress line. */
239
+ readonly dirName: string;
240
+ /**
241
+ * The target the link ALREADY holds, recorded only for a `replaced` action so
242
+ * its undo can put that link back. Without it the undo could only delete what
243
+ * the forward run created, which restores an ABSENT path — a state that never
244
+ * existed — instead of the link the run overwrote.
245
+ *
246
+ * Stored as `readlink` returned it, not resolved: a relative link is relative
247
+ * to its own directory, which for a candidate of `dir` is `dir` itself, so
248
+ * re-linking the raw value reproduces the link exactly as found.
249
+ */
250
+ readonly previousTarget?: string;
251
+ }
252
+
253
+ /**
254
+ * Plan the CONVERGE-ONLY refresh of the global scope's harness skill links.
255
+ *
256
+ * The reported bug lives here. `sources update` installed and pruned layer 1
257
+ * and stopped, so a new pack skill reached no harness directory until the user
258
+ * separately ran `setup skills`, and a dropped one left its layer-2 link
259
+ * dangling with nothing that would ever clear it. This closes the loop, under
260
+ * two rules that are the entire safety argument:
261
+ *
262
+ * 1. CONVERGE-ONLY. `dirs` are directories that already exist — this function
263
+ * never creates one, and the caller composes no `mkdir`. A `sources update`
264
+ * that materialised `~/.claude/skills` would be litter, and would silently
265
+ * opt the user into linking they never asked for.
266
+ * 2. OWNERSHIP, by the SAME test `setup skills` uses. A path is this command's
267
+ * to touch only when it holds a symlink resolving INSIDE one of the global
268
+ * scope's roots (`withinAnyRoot`, a pure path-SEGMENT test that reads nothing
269
+ * — so it still answers correctly after the layer-1 target is gone, which is
270
+ * exactly the stale case). A real directory is a hand-installed skill; a
271
+ * symlink pointing anywhere else is the user's own; a `<root>-backup/foo`
272
+ * sibling is not "inside" despite sharing a string prefix. None is removed.
273
+ *
274
+ * THE SET, NOT JUST THE INSTALLED ROOT, and that is what keeps precedence
275
+ * true one layer up. A harness link left pointing at the BUNDLED snapshot
276
+ * for a skill this update has just installed for real is owned, so it is
277
+ * `replaced` onto the installed copy. Tested against the installed root
278
+ * alone it read as "someone else's link" and was skipped — and the user who
279
+ * deliberately ran `sources update` would have kept running the shipped copy.
280
+ *
281
+ * The plan is derived from the layer-1 plan rather than from a filesystem
282
+ * re-read, because at planning time the layer-1 writes have not happened yet:
283
+ * `surviving` is the post-update truth, and reading the disk would see the
284
+ * pre-update state. That is also why a surviving name always targets
285
+ * `roots[0]`: layer 1 is about to hold it, whether or not it does now.
286
+ *
287
+ * A RETIRED NAME IS NOT AUTOMATICALLY DEBRIS any more. The fallback roots
288
+ * (today: the bundled snapshot) are NOT written by this update, so their
289
+ * contents ARE readable at plan time — and a skill the packages dropped that
290
+ * the shipped snapshot still carries is still discoverable, still listed by
291
+ * `skill list`, and so still belongs in the harness directory. Such a link is
292
+ * RETARGETED onto the surviving root rather than pruned; only a name no root
293
+ * provides at all is deleted.
294
+ *
295
+ * @param dirs - The EXISTING global link directories.
296
+ * @param roots - The global scope's source roots in PRECEDENCE order.
297
+ * `roots[0]` is the installed root (layer 1) every surviving name targets;
298
+ * the rest are fallbacks a retired name may still resolve in. The whole set
299
+ * is the ownership set.
300
+ * @param surviving - Folder names layer 1 will hold after this update.
301
+ * @param retired - Folder names layer 1 is pruning in this update.
302
+ * @returns The layer-2 actions, for the plan and its effects.
303
+ * @note Impure — lstats each candidate link path, and stats the fallback roots
304
+ * for each retired name.
305
+ */
306
+ export function planHarnessSkillLinks(
307
+ dirs: readonly { dir: string; name: string }[],
308
+ roots: readonly string[],
309
+ surviving: readonly string[],
310
+ retired: readonly string[],
311
+ ): HarnessLinkAction[] {
312
+ const dest = resolve(roots[0] as string);
313
+ /** The first FALLBACK root that still holds `folderName`, if any. */
314
+ const fallback = (folderName: string): string | undefined =>
315
+ roots
316
+ .slice(1)
317
+ .map((root) => resolve(root, folderName))
318
+ .find((candidate) => existsSync(candidate));
319
+ const out: HarnessLinkAction[] = [];
320
+ for (const { dir, name } of dirs) {
321
+ for (const folderName of surviving) {
322
+ const linkPath = resolve(dir, folderName);
323
+ const target = resolve(dest, folderName);
324
+ const state = linkState(linkPath);
325
+ if (state.kind === "other") continue; // A hand-placed entry — never ours.
326
+ if (state.kind === "absent") {
327
+ out.push({
328
+ target,
329
+ linkPath,
330
+ action: "created",
331
+ folderName,
332
+ dirName: name,
333
+ });
334
+ continue;
335
+ }
336
+ if (resolve(dir, state.target) === target) continue; // Already correct.
337
+ // A symlink pointing outside every scope root is the user's own link into
338
+ // their own checkout. Unplanned does not make it ours to replace.
339
+ if (!withinAnyRoot(roots, linkPath, state.target)) continue;
340
+ out.push({
341
+ target,
342
+ linkPath,
343
+ action: "replaced",
344
+ folderName,
345
+ dirName: name,
346
+ previousTarget: state.target,
347
+ });
348
+ }
349
+ for (const folderName of retired) {
350
+ const linkPath = resolve(dir, folderName);
351
+ const state = linkState(linkPath);
352
+ if (state.kind !== "symlink") continue;
353
+ if (!withinAnyRoot(roots, linkPath, state.target)) continue;
354
+ const surviving = fallback(folderName);
355
+ if (surviving !== undefined) {
356
+ // Dropped upstream, still shipped: retarget rather than delete. Already
357
+ // correct is nothing to do.
358
+ if (resolve(dir, state.target) === surviving) continue;
359
+ out.push({
360
+ target: surviving,
361
+ linkPath,
362
+ action: "replaced",
363
+ folderName,
364
+ dirName: name,
365
+ previousTarget: state.target,
366
+ });
367
+ continue;
368
+ }
369
+ out.push({
370
+ target: resolve(dir, state.target),
371
+ linkPath,
372
+ action: "pruned",
373
+ folderName,
374
+ dirName: name,
375
+ });
376
+ }
377
+ }
378
+ return out;
379
+ }
@@ -49,8 +49,12 @@ import {
49
49
  redactUrl,
50
50
  resolvePackage,
51
51
  } from "../../kernel/runtime/refs/index.js";
52
- import { installedSkillsDir } from "../skill/discover.js";
53
- import { planSkillInstall } from "./installSkills.js";
52
+ // The global scope's EXISTING link directories, from the module that owns the
53
+ // scope covenant — so `sources update` cannot drift away from what
54
+ // `setup skills --global` links, or widen the scope on its own.
55
+ import { existingGlobalSkillDirs } from "../setup/operations/setupSkills.js";
56
+ import { globalSkillRoots, installedSkillsDir } from "../skill/discover.js";
57
+ import { planHarnessSkillLinks, planSkillInstall } from "./installSkills.js";
54
58
  import type { SourcesUpdateData } from "./types.js";
55
59
 
56
60
  /** Generic-core prefixes; config `prefixes` merge over them (config wins). */
@@ -343,6 +347,45 @@ export async function buildUpdateTask(
343
347
  `Removing ${staleLinks.length} skill link(s) no longer provided by any package`,
344
348
  );
345
349
 
350
+ // Converge the GLOBAL scope (layer 2). Layer 1 above is the installed root;
351
+ // the harness directories link AT it, and until now nothing here touched
352
+ // them — a new pack skill reached no harness directory until the user
353
+ // separately ran `setup skills`, and a dropped one left a dangling link that
354
+ // nothing would ever clear. That two-step was the reported bug.
355
+ //
356
+ // CONVERGE-ONLY, and this is a policy line, not an optimisation:
357
+ // `existingGlobalSkillDirs` returns only directories that ALREADY EXIST, and
358
+ // nothing below composes a `mkdir`. `sources update` is `mcp: {expose:true}`
359
+ // with `mutates: true` and `needsNetwork: true`, so creating directories here
360
+ // would mean an agent, over MCP, after a network fetch, conjuring
361
+ // `~/.claude/skills` into being and opting the user into linking they never
362
+ // asked for. Refresh what is there; never bring a directory into existence.
363
+ //
364
+ // Ownership is the SAME `withinAnyRoot` test `setup skills` applies, over the
365
+ // SAME root set (`globalSkillRoots`), so a real directory, a still-resolving
366
+ // link into the user's own checkout, and a `<root>-backup/foo` sibling are all
367
+ // left exactly as found — while a link still pointing at the BUNDLED snapshot
368
+ // for a skill this update just installed is ours, and is moved onto it.
369
+ const harnessPlan = planHarnessSkillLinks(
370
+ await existingGlobalSkillDirs(runtime.cwd),
371
+ globalSkillRoots(),
372
+ skillPlan
373
+ .filter((link) => link.action !== "pruned")
374
+ .map((link) => link.folderName),
375
+ staleLinks.map((link) => link.folderName),
376
+ );
377
+ const harnessLinks = harnessPlan.filter((link) => link.action !== "pruned");
378
+ const harnessStale = harnessPlan.filter((link) => link.action === "pruned");
379
+ if (harnessLinks.length > 0)
380
+ report?.(
381
+ `Linking ${harnessLinks.length} skill(s) into your harness folders`,
382
+ );
383
+ if (harnessStale.length > 0)
384
+ report?.(`Removing ${harnessStale.length} stale harness skill link(s)`);
385
+ if (verbose)
386
+ for (const link of harnessPlan)
387
+ report?.(` ${link.action} ${link.folderName} in ${link.dirName}`);
388
+
346
389
  report?.(`Pointing ${runtime.cwd} at pack ${built.contentHash.slice(0, 12)}`);
347
390
  return gen(function* () {
348
391
  yield* $(writeFile(path, built.contentHash, { undo }));
@@ -376,6 +419,39 @@ export async function buildUpdateTask(
376
419
  }),
377
420
  );
378
421
  }
422
+ // Layer 2, in the same Task and so under the same `--undo`. Still no
423
+ // `mkdir`: every directory here was proven to exist at plan time, and
424
+ // creating one is the thing converge-only exists to forbid.
425
+ for (const link of harnessLinks) {
426
+ // A `created` link undoes by deletion — absent IS the state it replaced.
427
+ // A `replaced` one does not: the path held a link, and the delete above
428
+ // carries no undo of its own, so deleting what the symlink created would
429
+ // leave the path ABSENT rather than pointing where it pointed before.
430
+ // Its undo is therefore the same delete-then-relink shape the prunes
431
+ // below use, against the target detection recorded — and it clears the
432
+ // path first because undo collection MOCKS forward effects, so the link
433
+ // may still be there and `fs.symlink` refuses an existing path.
434
+ const previous = link.previousTarget;
435
+ const undoLink =
436
+ link.action === "replaced" && previous !== undefined
437
+ ? gen(function* () {
438
+ yield* $(deleteFile(link.linkPath));
439
+ yield* $(symlink(previous, link.linkPath));
440
+ })
441
+ : deleteFile(link.linkPath);
442
+ if (link.action === "replaced") yield* $(deleteFile(link.linkPath));
443
+ yield* $(symlink(link.target, link.linkPath, { undo: undoLink }));
444
+ }
445
+ for (const stale of harnessStale) {
446
+ yield* $(
447
+ deleteFile(stale.linkPath, {
448
+ undo: gen(function* () {
449
+ yield* $(deleteFile(stale.linkPath));
450
+ yield* $(symlink(stale.target, stale.linkPath));
451
+ }),
452
+ }),
453
+ );
454
+ }
379
455
  return data;
380
456
  });
381
457
  }
@@ -714,14 +714,25 @@ describe("sources update — installs package skills (U10)", () => {
714
714
  return pkg;
715
715
  }
716
716
 
717
+ let savedHome: string | undefined;
718
+ let home: string;
719
+
717
720
  beforeEach(() => {
718
721
  savedDataHome = process.env.XDG_DATA_HOME;
719
722
  dataHome = tmp("pragma-datahome-");
720
723
  process.env.XDG_DATA_HOME = dataHome;
724
+ // HOME is isolated because an update now converges the GLOBAL scope, and
725
+ // the global scope's link directories live under it. A test that read the
726
+ // real home would be writing into the developer's own harness folders.
727
+ savedHome = process.env.HOME;
728
+ home = tmp("pragma-home-");
729
+ process.env.HOME = home;
721
730
  });
722
731
  afterEach(() => {
723
732
  if (savedDataHome === undefined) delete process.env.XDG_DATA_HOME;
724
733
  else process.env.XDG_DATA_HOME = savedDataHome;
734
+ if (savedHome === undefined) delete process.env.HOME;
735
+ else process.env.HOME = savedHome;
725
736
  });
726
737
 
727
738
  it("symlinks a package skill into the installed root so `skill list` finds it", async () => {
@@ -920,6 +931,223 @@ describe("sources update — installs package skills (U10)", () => {
920
931
 
921
932
  expect(readdirSync(join(dataHome, "pragma", "skills"))).toEqual(["foo"]);
922
933
  });
934
+
935
+ it("sees a package that ships `skills/<name>` as a SYMLINK", async () => {
936
+ // `lstat().isDirectory()` reports the LINK, not the directory behind it, so
937
+ // a pnpm / monorepo / `file:` layout contributed nothing at all, silently.
938
+ const real = tmp("pragma-realskill-");
939
+ mkdirSync(join(real, "linked-skill"), { recursive: true });
940
+ writeFileSync(
941
+ join(real, "linked-skill", "SKILL.md"),
942
+ "---\nname: linked-skill\ndescription: Behind a link.\n---\n",
943
+ );
944
+ const pkg = filePackage();
945
+ mkdirSync(join(pkg, "skills"), { recursive: true });
946
+ symlinkSync(
947
+ join(real, "linked-skill"),
948
+ join(pkg, "skills", "linked-skill"),
949
+ );
950
+
951
+ const cwd = tmp("pragma-proj-");
952
+ await runTask(
953
+ await buildUpdateTask(
954
+ runtimeFor(cwd, [{ name: "pkg-a", source: `file://${pkg}` }]),
955
+ ),
956
+ );
957
+ expect(discoverSkills(cwd).map((s) => s.name)).toContain("linked-skill");
958
+ });
959
+ });
960
+
961
+ describe("sources update — converges the global scope (converge-only)", () => {
962
+ let savedDataHome: string | undefined;
963
+ let savedHome: string | undefined;
964
+ let dataHome: string;
965
+ let home: string;
966
+
967
+ /** A local package that also ships `skills/<name>/SKILL.md`. */
968
+ function skillPackage(...skillNames: string[]): string {
969
+ const pkg = filePackage();
970
+ for (const skillName of skillNames) {
971
+ const skillDir = join(pkg, "skills", skillName);
972
+ mkdirSync(skillDir, { recursive: true });
973
+ writeFileSync(
974
+ join(skillDir, "SKILL.md"),
975
+ `---\nname: ${skillName}\ndescription: From a package.\n---\nBody.`,
976
+ );
977
+ }
978
+ return pkg;
979
+ }
980
+
981
+ beforeEach(() => {
982
+ savedDataHome = process.env.XDG_DATA_HOME;
983
+ savedHome = process.env.HOME;
984
+ dataHome = tmp("pragma-datahome-");
985
+ home = tmp("pragma-home-");
986
+ process.env.XDG_DATA_HOME = dataHome;
987
+ process.env.HOME = home;
988
+ });
989
+ afterEach(() => {
990
+ if (savedDataHome === undefined) delete process.env.XDG_DATA_HOME;
991
+ else process.env.XDG_DATA_HOME = savedDataHome;
992
+ if (savedHome === undefined) delete process.env.HOME;
993
+ else process.env.HOME = savedHome;
994
+ });
995
+
996
+ /** The installed root (layer 1). */
997
+ const installedRoot = (): string => join(dataHome, "pragma", "skills");
998
+ /** The cross-client harness dir (layer 2), created so it is convergeable. */
999
+ const harnessDir = (): string => {
1000
+ const dir = join(home, ".agents", "skills");
1001
+ mkdirSync(dir, { recursive: true });
1002
+ return dir;
1003
+ };
1004
+ /** Retire a skill from the package IN PLACE — how a pack upgrade drops one. */
1005
+ const dropSkill = (pkg: string, name: string): void => {
1006
+ rmSync(join(pkg, "skills", name), { recursive: true, force: true });
1007
+ };
1008
+ const update = async (pkg: string, cwd: string): Promise<void> => {
1009
+ await runTask(
1010
+ await buildUpdateTask(
1011
+ runtimeFor(cwd, [{ name: "design-system", source: `file://${pkg}` }]),
1012
+ ),
1013
+ );
1014
+ };
1015
+
1016
+ it("a NEW pack skill reaches the harness folder in one step", async () => {
1017
+ // The two-step: `sources update` wrote layer 1 and stopped, so a new pack
1018
+ // skill appeared in no harness directory until the user separately ran
1019
+ // `setup skills`.
1020
+ const dir = harnessDir();
1021
+ await update(skillPackage("added"), tmp("pragma-proj-"));
1022
+ expect(readlinkSync(join(dir, "added"))).toBe(
1023
+ join(installedRoot(), "added"),
1024
+ );
1025
+ });
1026
+
1027
+ it("a DROPPED pack skill has its harness link removed (the reported case)", async () => {
1028
+ const dir = harnessDir();
1029
+ const cwd = tmp("pragma-proj-");
1030
+ const pkg = skillPackage("kept", "dropped");
1031
+ // 0.1.2 shipped both...
1032
+ await update(pkg, cwd);
1033
+ expect(existsSync(join(dir, "dropped"))).toBe(true);
1034
+ // ...0.2.0 drops one, in place, so the layer-1 link now dangles and its
1035
+ // prune fires. Layer 2 must follow it.
1036
+ dropSkill(pkg, "dropped");
1037
+ await update(pkg, cwd);
1038
+ // `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
1039
+ expect(() => lstatSync(join(dir, "dropped"))).toThrow();
1040
+ expect(readlinkSync(join(dir, "kept"))).toBe(join(installedRoot(), "kept"));
1041
+ });
1042
+
1043
+ it("CONVERGE-ONLY: a machine that never ran `setup skills` gets no new dirs", async () => {
1044
+ // The operator ruling, and the reason this is safe to expose over MCP:
1045
+ // `sources update` is `mutates: true` AND `expose: true`, so an agent must
1046
+ // not be able to make it conjure `~/.claude/skills` into existence.
1047
+ await update(skillPackage("added"), tmp("pragma-proj-"));
1048
+ expect(existsSync(join(home, ".agents"))).toBe(false);
1049
+ expect(existsSync(join(home, ".claude"))).toBe(false);
1050
+ // Layer 1 is still installed — only the scope convergence stood down.
1051
+ expect(existsSync(join(installedRoot(), "added"))).toBe(true);
1052
+ });
1053
+
1054
+ it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
1055
+ const dir = harnessDir();
1056
+ const cwd = tmp("pragma-proj-");
1057
+ const pkg = skillPackage("kept", "dropped");
1058
+ await update(pkg, cwd);
1059
+
1060
+ // A hand-placed skill directory, at a name the pack also provides.
1061
+ const real = join(dir, "hand-placed");
1062
+ mkdirSync(real, { recursive: true });
1063
+ writeFileSync(join(real, "SKILL.md"), "hand-placed\n");
1064
+ // Someone's own link into their own checkout.
1065
+ const mine = join(tmp("pragma-mine-"), "mine");
1066
+ mkdirSync(mine, { recursive: true });
1067
+ symlinkSync(mine, join(dir, "mine"));
1068
+ // A link into a SIBLING whose name merely EXTENDS the root's — the old
1069
+ // string-prefix bug classified this as ours and deleted it.
1070
+ const backup = join(`${installedRoot()}-backup`, "dropped");
1071
+ mkdirSync(backup, { recursive: true });
1072
+ rmSync(join(dir, "dropped"));
1073
+ symlinkSync(backup, join(dir, "dropped"));
1074
+
1075
+ dropSkill(pkg, "dropped"); // retired upstream
1076
+ await update(pkg, cwd);
1077
+
1078
+ expect(readFileSync(join(real, "SKILL.md"), "utf-8")).toBe("hand-placed\n");
1079
+ expect(readlinkSync(join(dir, "mine"))).toBe(mine);
1080
+ // Not ours despite sharing a string prefix with the installed root.
1081
+ expect(readlinkSync(join(dir, "dropped"))).toBe(backup);
1082
+ });
1083
+
1084
+ it("is idempotent — a second update re-links nothing", async () => {
1085
+ const dir = harnessDir();
1086
+ const cwd = tmp("pragma-proj-");
1087
+ const pkg = skillPackage("stable");
1088
+ await update(pkg, cwd);
1089
+ const before = lstatSync(join(dir, "stable")).mtimeMs;
1090
+ await update(pkg, cwd);
1091
+ expect(lstatSync(join(dir, "stable")).mtimeMs).toBe(before);
1092
+ });
1093
+
1094
+ it("is reversible — undo RESTORES a replaced harness link's old target", async () => {
1095
+ // The `created` case below undoes by deletion, because absent is the state
1096
+ // it replaced. A `replaced` link's is not: the path already held a link,
1097
+ // the forward delete carries no undo, and deleting what the symlink
1098
+ // created therefore left the path ABSENT — a state it was never in. So the
1099
+ // undo must delete-then-relink the target detection recorded.
1100
+ const dir = harnessDir();
1101
+ const cwd = tmp("pragma-proj-");
1102
+ const pkg = skillPackage("kept", "other");
1103
+ await update(pkg, cwd);
1104
+
1105
+ // Drift the harness link for `kept` onto a SIBLING inside the installed
1106
+ // root: still owned (so the next plan is entitled to fix it), wrong target
1107
+ // (so the plan calls it `replaced`).
1108
+ const link = join(dir, "kept");
1109
+ const previous = join(installedRoot(), "other");
1110
+ rmSync(link);
1111
+ symlinkSync(previous, link);
1112
+
1113
+ // Two tasks off the SAME pre-run state: `gen` drives one iterator, so a
1114
+ // task that has been run forward cannot also be walked for its undos, and
1115
+ // re-planning after the forward run would see a converged tree with
1116
+ // nothing left to reverse.
1117
+ const runtime = runtimeFor(cwd, [
1118
+ { name: "design-system", source: `file://${pkg}` },
1119
+ ]);
1120
+ const forward = await buildUpdateTask(runtime);
1121
+ const reverse = await buildUpdateTask(runtime);
1122
+
1123
+ await runTask(forward);
1124
+ expect(readlinkSync(link)).toBe(join(installedRoot(), "kept")); // repointed
1125
+
1126
+ const { runUndo } = await import("@canonical/task/node");
1127
+ await runUndo(reverse);
1128
+
1129
+ // `existsSync` FOLLOWS a link and would false-negative on a dangling one —
1130
+ // and, more to the point here, an existence check alone passes the buggy
1131
+ // shape too. The TARGET is the assertion.
1132
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
1133
+ expect(readlinkSync(link)).toBe(previous);
1134
+ });
1135
+
1136
+ it("is reversible — undo removes the harness link it created", async () => {
1137
+ const dir = harnessDir();
1138
+ const { runUndo } = await import("@canonical/task/node");
1139
+ await runUndo(
1140
+ await buildUpdateTask(
1141
+ runtimeFor(tmp("pragma-proj-"), [
1142
+ {
1143
+ name: "design-system",
1144
+ source: `file://${skillPackage("added")}`,
1145
+ },
1146
+ ]),
1147
+ ),
1148
+ );
1149
+ expect(existsSync(join(dir, "added"))).toBe(false);
1150
+ });
923
1151
  });
924
1152
 
925
1153
  describe("sources status — entityCount from the manifest (A10)", () => {
@@ -25,12 +25,12 @@ export const updateVerb: VerbSpec<
25
25
  > = {
26
26
  path: ["sources", "update"],
27
27
  summary: "Resolve configured packs and build the local store from them.",
28
- doc: "Resolves each configured pack (git/file/npm) and builds one content-addressed pack, which every later boot reads with no network access. Pin a revision by putting a commit SHA in the pack's source ref.",
28
+ doc: "Resolves each configured pack (git, file, or npm) and builds one local pack from them, which every later run reads without touching the network. Put a commit SHA in a pack source ref to pin it to that revision.",
29
29
  params: [
30
30
  {
31
31
  kind: "boolean",
32
32
  name: "skipInvalid",
33
- doc: "Skip sources that fail to parse (warning about each) and build from the rest, instead of failing the whole update.",
33
+ doc: "Build from the sources that parse, warning about each one that does not, instead of failing the whole update.",
34
34
  },
35
35
  ],
36
36
  output: { formatters: updateFormatters },
@@ -38,7 +38,7 @@ export const updateVerb: VerbSpec<
38
38
  { cmd: `${BIN_NAME} sources update`, note: "resolve and build" },
39
39
  {
40
40
  cmd: `${BIN_NAME} sources update --skip-invalid`,
41
- note: "build from the parseable sources, warning about any dropped",
41
+ note: "build from the sources that parse, and name the ones dropped",
42
42
  },
43
43
  ],
44
44
  capability: {
@@ -257,8 +257,8 @@ describe("surface conformance — capabilities ⊆ covenant (PROTECTED)", () =>
257
257
  it("emits setup as a mixed self+sub noun (self is a tool, sub-verbs are mcp:false)", () => {
258
258
  // The one covenant noun both directly runnable AND with sub-verbs. The self
259
259
  // verb is the `setup` tool; the four installers are CLI-only (mcp:false).
260
- // The band-aware verbs (setup/mcp/skills) carry the --scope/--global/--local
261
- // flags; completions/lsp are single-band installers with no scope.
260
+ // The scope-aware verbs (setup/mcp/skills) carry the --scope/--global/--local
261
+ // flags; completions/lsp are single-scope installers with no scope.
262
262
  expect(emitted.nouns.setup?.verbs).toEqual([
263
263
  {
264
264
  v: "setup",