@canonical/pragma-cli 0.35.0 → 0.37.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 (295) 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 +24 -20
  13. package/dist/pragma.conf.js +266 -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/capabilities/hints.js +1 -1
  18. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  19. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  20. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  21. package/dist/src/capabilities/create/create.verb.js +2 -1
  22. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  23. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  24. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  25. package/dist/src/capabilities/create/mount.js +4 -3
  26. package/dist/src/capabilities/create/mount.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  28. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  29. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  30. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  31. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  32. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  33. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  34. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  35. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  36. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  37. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  38. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  39. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  40. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  41. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  42. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  43. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  44. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  45. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  46. package/dist/src/capabilities/graph/query.render.js +2 -2
  47. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  48. package/dist/src/capabilities/info/collectInfo.js +8 -2
  49. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  50. package/dist/src/capabilities/info/info.render.js +6 -2
  51. package/dist/src/capabilities/info/info.render.js.map +1 -1
  52. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  53. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  54. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  55. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  56. package/dist/src/capabilities/setup/mode.js +2 -2
  57. package/dist/src/capabilities/setup/operations/index.js +4 -4
  58. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  60. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/setupGenerator.js +156 -58
  64. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  65. package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
  66. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  67. package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
  68. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  69. package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
  70. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  71. package/dist/src/capabilities/setup/plan.js.map +1 -1
  72. package/dist/src/capabilities/setup/plan.render.js +144 -66
  73. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  74. package/dist/src/capabilities/setup/setup.render.js +1 -1
  75. package/dist/src/capabilities/setup/setup.verb.js +165 -63
  76. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  77. package/dist/src/capabilities/setup/targets.js +122 -51
  78. package/dist/src/capabilities/setup/targets.js.map +1 -1
  79. package/dist/src/capabilities/shared/index.js +7 -6
  80. package/dist/src/capabilities/shared/index.js.map +1 -1
  81. package/dist/src/capabilities/shared/packageManager.js +300 -54
  82. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  83. package/dist/src/capabilities/skill/discover.js +97 -11
  84. package/dist/src/capabilities/skill/discover.js.map +1 -1
  85. package/dist/src/capabilities/skill/render.js +2 -2
  86. package/dist/src/capabilities/skill/render.js.map +1 -1
  87. package/dist/src/capabilities/sources/installSkills.js +145 -3
  88. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  89. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  90. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  91. package/dist/src/capabilities/sources/update.verb.js +3 -3
  92. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  93. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  94. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  95. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  96. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  97. package/dist/src/constants.js +13 -2
  98. package/dist/src/constants.js.map +1 -1
  99. package/dist/src/kernel/index.js +11 -2
  100. package/dist/src/kernel/index.js.map +1 -1
  101. package/dist/src/kernel/interactivity.js +68 -0
  102. package/dist/src/kernel/interactivity.js.map +1 -0
  103. package/dist/src/kernel/packs/renderPack.js +3 -3
  104. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  105. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  106. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  107. package/dist/src/kernel/packs/runBodies.js +57 -4
  108. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  109. package/dist/src/kernel/packs/schema.js +54 -2
  110. package/dist/src/kernel/packs/schema.js.map +1 -1
  111. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  112. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  113. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +310 -43
  114. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  115. package/dist/src/kernel/packs/types.js.map +1 -1
  116. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  117. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  118. package/dist/src/kernel/project/cli/dispatch.js +46 -24
  119. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  120. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  121. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  122. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  123. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  124. package/dist/src/kernel/project/cli/rootHelp.js +42 -9
  125. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  126. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  127. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  128. package/dist/src/kernel/render/renderers.js +1 -1
  129. package/dist/src/kernel/render/style.js +29 -9
  130. package/dist/src/kernel/render/style.js.map +1 -1
  131. package/dist/src/kernel/render/vocabulary.js +174 -0
  132. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  133. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  134. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  135. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  136. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  137. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  138. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  139. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  140. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  141. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  142. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  143. package/dist/src/kernel/runtime/loadSession.js +3 -1
  144. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  145. package/dist/src/kernel/runtime/probe.js +1 -1
  146. package/dist/src/kernel/runtime/probe.js.map +1 -1
  147. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  148. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  149. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  150. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  151. package/dist/src/kernel/spec/emitReference.js +1 -1
  152. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  153. package/package.json +24 -20
  154. package/pragma.conf.ts +272 -34
  155. package/src/bin.ts +8 -1
  156. package/src/capabilities/block.parity.test.ts +25 -11
  157. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  158. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  159. package/src/capabilities/capabilities/hints.ts +1 -1
  160. package/src/capabilities/colophon/colophon.render.ts +1 -2
  161. package/src/capabilities/colophon/colophon.test.ts +2 -2
  162. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  163. package/src/capabilities/create/byteEquality.test.ts +28 -1
  164. package/src/capabilities/create/create.test.ts +1 -0
  165. package/src/capabilities/create/create.verb.ts +2 -1
  166. package/src/capabilities/create/createGrammar.test.ts +5 -2
  167. package/src/capabilities/create/createSurface.generated.ts +23 -1
  168. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  169. package/src/capabilities/create/mount.ts +3 -3
  170. package/src/capabilities/distribution.parity.test.ts +4 -0
  171. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  172. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  173. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  174. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  175. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  176. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  177. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  178. package/src/capabilities/doctor/checks/index.ts +6 -6
  179. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  180. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  181. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  182. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  183. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  184. package/src/capabilities/doctor/doctor.render.ts +56 -80
  185. package/src/capabilities/doctor/doctor.test.ts +247 -35
  186. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  187. package/src/capabilities/doctor/runChecks.ts +14 -11
  188. package/src/capabilities/doctor/types.ts +15 -24
  189. package/src/capabilities/emptyRecovery.test.ts +10 -2
  190. package/src/capabilities/graph/query.render.ts +2 -2
  191. package/src/capabilities/info/collectInfo.ts +8 -1
  192. package/src/capabilities/info/info.render.ts +6 -2
  193. package/src/capabilities/info/info.test.ts +37 -1
  194. package/src/capabilities/info/types.ts +10 -2
  195. package/src/capabilities/lazy.test.ts +26 -2
  196. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  197. package/src/capabilities/prompt/prompt.render.ts +2 -2
  198. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  199. package/src/capabilities/resources/resources.test.ts +38 -2
  200. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  201. package/src/capabilities/setup/buildPlan.ts +45 -41
  202. package/src/capabilities/setup/index.ts +1 -1
  203. package/src/capabilities/setup/mode.ts +2 -2
  204. package/src/capabilities/setup/operations/index.ts +6 -1
  205. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  206. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  207. package/src/capabilities/setup/operations/setupGenerator.ts +227 -84
  208. package/src/capabilities/setup/operations/setupLsp.ts +203 -38
  209. package/src/capabilities/setup/operations/setupMcp.ts +139 -62
  210. package/src/capabilities/setup/operations/setupSkills.ts +399 -60
  211. package/src/capabilities/setup/plan.render.ts +171 -76
  212. package/src/capabilities/setup/plan.ts +17 -12
  213. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  214. package/src/capabilities/setup/setup.render.test.ts +50 -36
  215. package/src/capabilities/setup/setup.render.ts +1 -1
  216. package/src/capabilities/setup/setup.test.ts +1126 -50
  217. package/src/capabilities/setup/setup.verb.ts +174 -68
  218. package/src/capabilities/setup/targets.ts +138 -60
  219. package/src/capabilities/setup/types.ts +10 -18
  220. package/src/capabilities/shared/index.ts +18 -6
  221. package/src/capabilities/shared/packageManager.test.ts +421 -6
  222. package/src/capabilities/shared/packageManager.ts +394 -59
  223. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  224. package/src/capabilities/skill/discover.ts +100 -11
  225. package/src/capabilities/skill/render.ts +2 -2
  226. package/src/capabilities/skill/skill.test.ts +17 -4
  227. package/src/capabilities/sources/installSkills.ts +181 -3
  228. package/src/capabilities/sources/runUpdate.ts +78 -2
  229. package/src/capabilities/sources/sources.test.ts +228 -0
  230. package/src/capabilities/sources/update.verb.ts +3 -3
  231. package/src/capabilities/surface.test.ts +14 -6
  232. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  233. package/src/capabilities/upgrade/types.ts +10 -2
  234. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  235. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  236. package/src/constants.ts +14 -1
  237. package/src/identity.test.ts +18 -5
  238. package/src/kernel/completion/entitySource.test.ts +15 -2
  239. package/src/kernel/completion/safety.test.ts +16 -9
  240. package/src/kernel/config/defaults.test.ts +19 -3
  241. package/src/kernel/config/readConfig.test.ts +1 -1
  242. package/src/kernel/index.ts +11 -2
  243. package/src/kernel/interactivity.test.ts +76 -0
  244. package/src/kernel/interactivity.ts +69 -0
  245. package/src/kernel/packs/compile.test.ts +92 -0
  246. package/src/kernel/packs/renderPack.ts +3 -3
  247. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  248. package/src/kernel/packs/resolveEntity.ts +88 -24
  249. package/src/kernel/packs/runBodies.ts +84 -19
  250. package/src/kernel/packs/schema.ts +60 -4
  251. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  252. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  253. package/src/kernel/packs/sparql/buildLookupQuery.ts +327 -45
  254. package/src/kernel/packs/toolDescription.test.ts +35 -1
  255. package/src/kernel/packs/types.ts +125 -2
  256. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  257. package/src/kernel/project/cli/buildProgram.ts +8 -1
  258. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  259. package/src/kernel/project/cli/dispatch.ts +48 -25
  260. package/src/kernel/project/cli/globalFlags.ts +4 -1
  261. package/src/kernel/project/cli/helpFormat.ts +1 -2
  262. package/src/kernel/project/cli/rootHelp.ts +44 -8
  263. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.ts +1 -1
  266. package/src/kernel/render/style.ts +32 -9
  267. package/src/kernel/render/vocabulary.ts +276 -0
  268. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  269. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  270. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  271. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  272. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  273. package/src/kernel/runtime/graphpack/read.ts +14 -12
  274. package/src/kernel/runtime/loadSession.ts +3 -1
  275. package/src/kernel/runtime/probe.ts +1 -1
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +9 -3
  279. package/src/kernel/runtime/types.ts +17 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/types.ts +35 -6
  282. package/src/testing/PARITY_GAPS.ts +9 -3
  283. package/src/testing/behavioral/docExamples.test.ts +4 -2
  284. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  285. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  286. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  287. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  288. package/src/testing/eval/cases/readNouns.ts +245 -9
  289. package/src/testing/fixtures/blockGraph.ts +28 -13
  290. package/src/testing/fixtures/graph/canonical.ts +95 -12
  291. package/src/testing/helpers/runCli.test.ts +44 -0
  292. package/src/testing/helpers/runCli.ts +27 -3
  293. package/dist/src/capabilities/shared/bands.js +0 -16
  294. package/dist/src/capabilities/shared/bands.js.map +0 -1
  295. package/src/capabilities/shared/bands.ts +0 -16
@@ -26,9 +26,9 @@ import {
26
26
  writeFileSync,
27
27
  } from "node:fs";
28
28
  import { tmpdir } from "node:os";
29
- import { join, relative } from "node:path";
29
+ import { join, relative, sep } from "node:path";
30
30
  import { execute } from "@canonical/summon-core";
31
- import { dryRun, type Effect, type Task } from "@canonical/task";
31
+ import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
32
32
  import { runTask, runUndo } from "@canonical/task/node";
33
33
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
34
  import { BIN_NAME } from "../../constants.js";
@@ -42,13 +42,16 @@ import { projectCli } from "../../testing/helpers/projectCli.js";
42
42
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
43
43
  import { capabilities } from "../index.js";
44
44
  import { detectCompletions } from "./operations/setupCompletions.js";
45
- import { buildSetupRun } from "./operations/setupGenerator.js";
45
+ import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
46
46
  import {
47
47
  composeSkills,
48
48
  composeSkillsRemoval,
49
49
  detectSkills,
50
50
  ownedSkillLinks,
51
+ skillsSkipReason,
52
+ skillsSkipRemedy,
51
53
  } from "./operations/setupSkills.js";
54
+ import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
52
55
  import { setupModule } from "./setup.verb.js";
53
56
  import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
54
57
 
@@ -98,6 +101,14 @@ const stubPath = (): string => {
98
101
  return dir;
99
102
  };
100
103
 
104
+ /**
105
+ * The machine's real PATH, captured before the suite isolates it. A stub
106
+ * editor script that has to run a real utility (`rm`) restores this INSIDE
107
+ * itself — hardcoding `/bin:/usr/bin` breaks on hosts that keep coreutils
108
+ * elsewhere, and the isolated PATH the tests run under has no `rm`.
109
+ */
110
+ const HOST_PATH = process.env.PATH ?? "";
111
+
101
112
  let prevHome: string | undefined;
102
113
  let prevPath: string | undefined;
103
114
  beforeEach(() => {
@@ -204,7 +215,7 @@ describe("setup completions", () => {
204
215
  // The dry-run renders the PLAN through the verb's formatPlan seam, not the
205
216
  // kernel's raw effect dump: one row, the shell it detected, and the script
206
217
  // path rendered against the header's roots instead of repeated absolutely.
207
- expect(outcome.stdout).toContain("Setup plan — global band");
218
+ expect(outcome.stdout).toContain("Setup plan — global");
208
219
  expect(outcome.stdout).toMatch(
209
220
  new RegExp(`completions\\s+install\\s+${SHELL} →`),
210
221
  );
@@ -249,7 +260,7 @@ describe("setup mcp — recap gate", () => {
249
260
  mkdirSync(join(cwd, ".cursor"), { recursive: true }); // makes Cursor detected
250
261
  const configPath = join(cwd, ".cursor", "mcp.json");
251
262
 
252
- // `--local`: the project band is opt-in now, so a bare `setup mcp` would
263
+ // `--local`: the project scope is opt-in now, so a bare `setup mcp` would
253
264
  // write the HOME config and leave this repository untouched.
254
265
  const outcome = await executeVerb(
255
266
  verbOf("mcp"),
@@ -405,7 +416,7 @@ describe("setup mcp — scope & dedup", () => {
405
416
  // A per-user server must not be pinned to whatever directory `setup mcp
406
417
  // --global` happened to run from (a registration made from ~/Downloads
407
418
  // used to serve ~/Downloads forever, and re-running from repo B flipped
408
- // the machine band to repo B).
419
+ // the machine scope to repo B).
409
420
  const home = process.env.HOME ?? "";
410
421
  const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
411
422
  const register = async (cwd: string): Promise<string> => {
@@ -461,9 +472,9 @@ describe("setup mcp — scope & dedup", () => {
461
472
  expect(
462
473
  JSON.parse(readFileSync(configPath, "utf-8")).mcpServers.pragma,
463
474
  ).toEqual({ command: "pragma", args: ["mcp", "serve"] });
464
- // ...and it stays `configured` afterwards (no churn on every run).
475
+ // ...and it stays `registered` afterwards (no churn on every run).
465
476
  const after = await detectMcp(bootRuntime(FLAGS, cwd), "global");
466
- expect(mcpGroupState(after, configPath)).toBe("configured");
477
+ expect(mcpGroupState(after, configPath)).toBe("registered");
467
478
  });
468
479
 
469
480
  it("a PROJECT entry still records the project root as cwd", async () => {
@@ -482,25 +493,32 @@ describe("setup mcp — scope & dedup", () => {
482
493
  });
483
494
  });
484
495
 
485
- describe("setup mcp — customize opt-in gate (Item 6)", () => {
486
- it("gates the per-file multiselect behind an explicit customize=true", async () => {
496
+ describe("setup mcp — the per-file question, asked directly", () => {
497
+ it("asks the per-file multiselect with NO `customize` meta-question in front", async () => {
498
+ // `answers.customize` was read in exactly one place in the repo — this
499
+ // prompt's `when` — and by nothing else: not `generate`, not `applied`, not
500
+ // any plan, recap, renderer or JSON projection. Answering no just meant the
501
+ // child prompt went unasked, its key was absent, and the compose bodies
502
+ // read that as "all" — so everything detected was configured anyway.
487
503
  const cwd = tmp("pragma-setup-proj-");
488
- mkdirSync(join(cwd, ".cursor"), { recursive: true }); // ⇒ one MCP target group
504
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
505
+ mkdirSync(join(cwd, ".gemini"), { recursive: true }); // 2 children ⇒ narrowable
489
506
  const { generator } = await buildSetupRun(
490
507
  bootRuntime(FLAGS, cwd),
491
508
  "mcp",
492
- "both",
509
+ "project",
493
510
  );
494
- const customize = generator.prompts.find((p) => p.name === "customize");
511
+ expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
495
512
  const targets = generator.prompts.find((p) => p.name === "mcpTargets");
496
- // The gate is an opt-in confirm that defaults to NOT customizing.
497
- expect(customize?.type).toBe("confirm");
498
- expect(customize?.default).toBe(false);
499
- // The per-file multiselect only surfaces after an explicit yes — so the
500
- // "all" default configures every deduped file without an extra question.
501
- expect(targets?.when?.({})).toBe(false);
502
- expect(targets?.when?.({ customize: false })).toBe(false);
503
- expect(targets?.when?.({ customize: true })).toBe(true);
513
+ expect(targets).toBeDefined();
514
+ // It is asked whenever its row is in the run — the row's own selection is
515
+ // the only gate, and the choices carry the detected state per file.
516
+ expect(targets?.when?.({})).toBe(true);
517
+ expect(
518
+ (targets?.choices as { label: string }[]).every((c) =>
519
+ /— (add|update|unchanged)$/.test(c.label),
520
+ ),
521
+ ).toBe(true);
504
522
  });
505
523
 
506
524
  it("recaps the files the run KEPT, not the ones it offered", async () => {
@@ -517,7 +535,6 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
517
535
 
518
536
  const applied = run.applied({
519
537
  targets: ["project:mcp"],
520
- customize: true,
521
538
  mcpTargets: [kept],
522
539
  });
523
540
  expect(applied.rows.find((r) => r.target === "mcp")?.outcome?.note).toBe(
@@ -526,6 +543,67 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
526
543
  });
527
544
  });
528
545
 
546
+ describe("setup — live row events (the wizard's step feed)", () => {
547
+ it("brackets each row with start/done, in the recap's own sentence", async () => {
548
+ // The wizard's live progress renders the PLAN's rows, not the effect
549
+ // transcript — one `start` as a row's body is entered, one `done` carrying
550
+ // the same `target detail — note` sentence the recap will print, so the
551
+ // watcher and the recap reader see one dialect.
552
+ const cwd = tmp("pragma-setup-proj-");
553
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
554
+ const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
555
+ const events: RowEvent[] = [];
556
+ run.setRowListener((event) => events.push(event));
557
+ await runTask(run.generator.generate({}), { onLog: () => {} });
558
+ expect(events.map((event) => event.status)).toEqual(["start", "done"]);
559
+ expect(events[0]?.key).toBe("project:mcp");
560
+ // The start label is the row without an outcome; the done label appends
561
+ // the SAME note `applied()` puts on the recap row.
562
+ const applied = run.applied({});
563
+ const note = applied.rows.find((r) => r.target === "mcp")?.outcome?.note;
564
+ expect(note).toBe("1 added");
565
+ expect(events[1]?.label).toBe(`${events[0]?.label} — ${note}`);
566
+ });
567
+
568
+ it("no listener registered costs nothing — the run is unchanged", async () => {
569
+ // `--yes`, `--dry-run` and MCP never register one; the bracket emits into
570
+ // the void and the composed effects are identical.
571
+ const cwd = tmp("pragma-setup-proj-");
572
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
573
+ const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
574
+ await runTask(run.generator.generate({}), { onLog: () => {} });
575
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
576
+ });
577
+
578
+ it("a failing row reports `failed` with its cause, and its siblings still bracket", async () => {
579
+ // Same arrangement as the S1-1 isolation test: an editor on PATH with no
580
+ // `bun`, so the LSP row's compose fails. The event stream must name the
581
+ // failure — the wizard paints that row ✗ live — while the run continues.
582
+ const prevPath = process.env.PATH;
583
+ const prevData = process.env.XDG_DATA_HOME;
584
+ process.env.XDG_DATA_HOME = tmp("pragma-rowevents-data-");
585
+ const dir = stubPath();
586
+ writeFileSync(join(dir, "code"), "");
587
+ process.env.PATH = dir;
588
+ try {
589
+ const run = await buildSetupRun(
590
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
591
+ "lsp",
592
+ "global",
593
+ );
594
+ const events: RowEvent[] = [];
595
+ run.setRowListener((event) => events.push(event));
596
+ await runTask(run.generator.generate({}), { onLog: () => {} });
597
+ expect(events.map((event) => event.status)).toEqual(["start", "failed"]);
598
+ expect(events[1]?.label).toContain("bun");
599
+ } finally {
600
+ process.env.PATH = prevPath;
601
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
602
+ else process.env.XDG_DATA_HOME = prevData;
603
+ }
604
+ });
605
+ });
606
+
529
607
  describe("setup lsp — per-editor multiselect (child rows)", () => {
530
608
  let prevPath: string | undefined;
531
609
  let stubDir = "";
@@ -573,20 +651,18 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
573
651
  expect(children?.find((c) => c.key === "codium")?.action).toBe("add");
574
652
  });
575
653
 
576
- it("gates the per-editor multiselect behind the same customize confirm", async () => {
654
+ it("asks the per-editor multiselect directly, with both forks pre-selected", async () => {
577
655
  const { generator } = await buildSetupRun(
578
656
  bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
579
657
  "lsp",
580
658
  "global",
581
659
  );
582
- const customize = generator.prompts.find((p) => p.name === "customize");
660
+ expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
583
661
  const editors = generator.prompts.find((p) => p.name === "lspEditors");
584
- expect(customize?.default).toBe(false);
585
- // Both pending editors are pre-selected, so the default path installs into
586
- // every fork without an extra question.
662
+ // Both pending editors are pre-selected, so pressing enter installs into
663
+ // every fork — the same default the meta-question used to stand for.
587
664
  expect(editors?.default).toEqual(["code", "codium"]);
588
- expect(editors?.when?.({ customize: false })).toBe(false);
589
- expect(editors?.when?.({ customize: true })).toBe(true);
665
+ expect(editors?.when?.({})).toBe(true);
590
666
  });
591
667
 
592
668
  it("sideloads only into the editors the user kept", async () => {
@@ -608,6 +684,405 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
608
684
  const { effects } = dryRun(composeLsp(detected, []));
609
685
  expect(effects.some((e) => e._tag === "Exec")).toBe(false);
610
686
  });
687
+
688
+ it("the removal is `none` when no editor carries the extension", async () => {
689
+ // Both forks are on PATH, neither has a copy — there is nothing this
690
+ // command owns, so a removal has honestly nothing to do.
691
+ const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
692
+ "./operations/setupLsp.js"
693
+ );
694
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
695
+ expect(ownedLspEditors(detected)).toEqual([]);
696
+ expect(collectUndos(composeLspRemoval(detected))).toEqual([]);
697
+
698
+ const { plan } = await buildSetupRun(
699
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
700
+ "lsp",
701
+ "global",
702
+ true,
703
+ );
704
+ const row = plan.rows.find((r) => r.target === "lsp");
705
+ expect(row?.action).toBe("none");
706
+ expect(row?.detail).toBe("no editor carries the extension");
707
+ });
708
+ });
709
+
710
+ describe("setup lsp — the removal contract the other four rows implement", () => {
711
+ let prevPath: string | undefined;
712
+ let stubDir = "";
713
+ beforeEach(() => {
714
+ prevPath = process.env.PATH;
715
+ stubDir = stubPath();
716
+ writeFileSync(join(stubDir, "code"), "");
717
+ writeFileSync(join(stubDir, "codium"), "");
718
+ process.env.PATH = stubDir;
719
+ });
720
+ afterEach(() => {
721
+ process.env.PATH = prevPath;
722
+ });
723
+
724
+ /** Seed a versioned extension copy in an editor's extensions dir. */
725
+ const seedExtension = (dir: string, version: string): void => {
726
+ mkdirSync(
727
+ join(
728
+ process.env.HOME ?? "",
729
+ dir,
730
+ "extensions",
731
+ `canonical.terrazzo-lsp-extension-${version}`,
732
+ ),
733
+ { recursive: true },
734
+ );
735
+ };
736
+
737
+ it("carries one `--uninstall-extension` exec per OWNED editor as its undo", async () => {
738
+ // The row hardcoded a skip behind a docblock claiming "an `exec` carries no
739
+ // reversal" — factually wrong: `Exec` has an `undo` slot, and the sibling
740
+ // `composeMcpRemoval` already uses this exact carrier shape.
741
+ seedExtension(".vscode", "1.2.3");
742
+ const { detectLsp, composeLspRemoval } = await import(
743
+ "./operations/setupLsp.js"
744
+ );
745
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
746
+
747
+ // The FORWARD side must be walkable with every effect mocked: it is a
748
+ // `mkdir` of the editor's own extensions dir, which reads nothing.
749
+ const forward = dryRun(composeLspRemoval(detected));
750
+ expect(forward.effects.map((e) => e._tag)).toEqual(["MakeDir"]);
751
+ expect(forward.effects.some((e) => e._tag === "Exec")).toBe(false);
752
+
753
+ const undos = collectUndos(composeLspRemoval(detected));
754
+ expect(undos).toHaveLength(1);
755
+ const execs = undos
756
+ .flatMap((u) => dryRun(u).effects)
757
+ .filter((e) => e._tag === "Exec") as (Effect & {
758
+ _tag: "Exec";
759
+ command: string;
760
+ args: string[];
761
+ })[];
762
+ expect(execs.map((e) => e.command)).toEqual(["code"]);
763
+ expect(execs[0].args).toEqual([
764
+ "--uninstall-extension",
765
+ "canonical.terrazzo-lsp-extension",
766
+ ]);
767
+ });
768
+
769
+ it("REGRESSION: a pre-0.8.3 copy still plans `install` forward but `remove` back", async () => {
770
+ // The conflation that would have caused a silent wrong-skip.
771
+ // `extensionInstalled` was version-gated, so an editor carrying a dead
772
+ // pre-0.8.3 copy — one this command itself installed — reported
773
+ // `installed: false`, and a removal keyed on it would have walked past the
774
+ // dead extension while reporting success.
775
+ seedExtension(".vscode", "0.8.1");
776
+ const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
777
+ "./operations/setupLsp.js"
778
+ );
779
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
780
+ const code = detected.editors.find((e) => e.editor.cli === "code");
781
+ // Present, but not current — the two facts stay separate.
782
+ expect(code?.present).toBe(true);
783
+ expect(code?.installed).toBe(false);
784
+
785
+ // Forward is UNCHANGED by this fix: the dead copy is still "not installed".
786
+ const forward = await buildSetupRun(
787
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
788
+ "lsp",
789
+ "global",
790
+ );
791
+ expect(forward.plan.rows.find((r) => r.target === "lsp")?.action).toBe(
792
+ "install",
793
+ );
794
+
795
+ // Removal keys on ownership, so the dead copy IS removed.
796
+ expect(ownedLspEditors(detected).map((e) => e.editor.cli)).toEqual([
797
+ "code",
798
+ ]);
799
+ const { plan } = await buildSetupRun(
800
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
801
+ "lsp",
802
+ "global",
803
+ true,
804
+ );
805
+ const row = plan.rows.find((r) => r.target === "lsp");
806
+ expect(row?.action).toBe("remove");
807
+ expect(row?.children?.map((c) => c.key)).toEqual(["code"]);
808
+ expect(
809
+ collectUndos(composeLspRemoval(detected))
810
+ .flatMap((u) => dryRun(u).effects)
811
+ .filter((e) => e._tag === "Exec"),
812
+ ).toHaveLength(1);
813
+ });
814
+
815
+ it("the remedy names an OWNING editor, not the first one on PATH", async () => {
816
+ // With Cursor (or here: `code`) installed and the extension only in
817
+ // VSCodium, the printed remedy named the wrong editor — a command that
818
+ // would report success having uninstalled nothing.
819
+ seedExtension(".vscode-oss", "1.2.3"); // codium's extensions dir
820
+ const { detectLsp, lspUninstallRemedy } = await import(
821
+ "./operations/setupLsp.js"
822
+ );
823
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
824
+ expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
825
+ expect(lspUninstallRemedy(detected)).toBe(
826
+ "codium --uninstall-extension canonical.terrazzo-lsp-extension",
827
+ );
828
+ });
829
+
830
+ it("still SKIPS with the named reason when no editor CLI is on PATH", async () => {
831
+ process.env.PATH = stubPath(); // no editor CLI at all
832
+ const { plan } = await buildSetupRun(
833
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
834
+ "lsp",
835
+ "global",
836
+ true,
837
+ );
838
+ const row = plan.rows.find((r) => r.target === "lsp");
839
+ expect(row?.action).toBe("skip");
840
+ expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
841
+ });
842
+
843
+ it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
844
+ // The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
845
+ // marker-writing script — never a real editor: an undo test must not be
846
+ // able to mutate the machine running it. It DOES delete the seeded
847
+ // extension entry (inside this test's temp HOME), because the removal now
848
+ // asserts its own goal against the extensions directory: a stub that
849
+ // exits 0 leaving the entry behind is the LYING-editor case, pinned as a
850
+ // failure below.
851
+ const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
852
+ const dir = stubPath();
853
+ writeFileSync(
854
+ join(dir, "code"),
855
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
856
+ { mode: 0o755 },
857
+ );
858
+ process.env.PATH = dir;
859
+ seedExtension(".vscode", "1.2.3");
860
+
861
+ const outcome = await executeVerb(
862
+ verbOf("lsp"),
863
+ {},
864
+ UNDO,
865
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
866
+ );
867
+ expect(outcome.exitCode).toBe(0);
868
+ expect(outcome.stdout).toBe("Undid 1 step(s).\n");
869
+ expect(readFileSync(marker, "utf-8")).toBe(
870
+ "--uninstall-extension\ncanonical.terrazzo-lsp-extension\n",
871
+ );
872
+ // NON-GOAL guard: the staging dir is a cache, not this removal's business.
873
+ // Nothing here deletes it, and nothing here touches the VSIX.
874
+ const { detectLsp, composeLspRemoval } = await import(
875
+ "./operations/setupLsp.js"
876
+ );
877
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
878
+ const all = [
879
+ ...dryRun(composeLspRemoval(detected)).effects,
880
+ ...collectUndos(composeLspRemoval(detected)).flatMap(
881
+ (u) => dryRun(u).effects,
882
+ ),
883
+ ];
884
+ expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
885
+ expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
886
+ });
887
+
888
+ /** Drive `setup lsp --undo` capturing stderr (where the row lines land). */
889
+ const undoLspCapturing = async (): Promise<{
890
+ chunks: string[];
891
+ outcome?: { stdout?: string; stderr?: string; exitCode: number };
892
+ thrown?: unknown;
893
+ }> => {
894
+ const chunks: string[] = [];
895
+ const spy = vi
896
+ .spyOn(process.stderr, "write")
897
+ .mockImplementation((chunk: unknown): boolean => {
898
+ chunks.push(String(chunk));
899
+ return true;
900
+ });
901
+ try {
902
+ const outcome = await executeVerb(
903
+ verbOf("lsp"),
904
+ {},
905
+ UNDO,
906
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
907
+ );
908
+ return { chunks, outcome };
909
+ } catch (thrown) {
910
+ return { chunks, thrown };
911
+ } finally {
912
+ spy.mockRestore();
913
+ }
914
+ };
915
+
916
+ it("HEADLINE: an uninstall that exits 0 leaving the extension on disk reports FAILED, never `✓ removed`", async () => {
917
+ // The observed field defect: VS Code-family CLIs can exit 0 while
918
+ // deferring or skipping the deletion. The reversal's success criterion
919
+ // was that exit code; detection's criterion is the extensions DIRECTORY.
920
+ // The two disagreed permanently and the run printed `✓ … removed` over an
921
+ // extension that was still installed. The removal now asserts its own
922
+ // goal in-task — probe the owned entries after the uninstall — and the
923
+ // run reports the survival as a failure with a manual remedy.
924
+ const dir = stubPath();
925
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
926
+ process.env.PATH = dir;
927
+ seedExtension(".vscode", "1.2.3");
928
+ const survivor = join(
929
+ process.env.HOME ?? "",
930
+ ".vscode",
931
+ "extensions",
932
+ "canonical.terrazzo-lsp-extension-1.2.3",
933
+ );
934
+
935
+ const { chunks, outcome, thrown } = await undoLspCapturing();
936
+ const stderrText = chunks.join("");
937
+
938
+ // The world: the artifact survived, and the run must not claim otherwise.
939
+ expect(existsSync(survivor)).toBe(true);
940
+ expect(stderrText).not.toContain("— removed");
941
+ expect(outcome).toBeUndefined();
942
+ expect(thrown).toBeDefined();
943
+ const err = asPragmaError(thrown);
944
+ expect(err.code).toBe("UNSUPPORTED");
945
+ expect(err.message).toContain("lsp");
946
+ expect(err.message).toContain("still present after the undo ran");
947
+ // The remedy names the surviving path as DATA for a manual deletion —
948
+ // not the CLI command that has just demonstrably done nothing while
949
+ // reporting success, and not a synthesized shell line (the entry names
950
+ // come off the filesystem, so no quoting is trustworthy, and one command
951
+ // cannot be right for every supported platform).
952
+ expect(err.recovery?.message ?? "").toContain(survivor);
953
+ expect(err.recovery?.message ?? "").not.toContain("rm -rf");
954
+ expect(err.recovery?.message ?? "").not.toContain("--uninstall-extension");
955
+ // The truthful row is reported (stderr), naming the survival.
956
+ expect(stderrText).toContain("still present after the undo ran");
957
+ });
958
+
959
+ it("a DANGLING matching symlink still counts as a survivor — the probe uses detection's criterion", async () => {
960
+ // Detection calls an entry present when `readdirSync` lists it. A probe
961
+ // built on default `exists` (`fs.access`) FOLLOWS symlinks, so a dangling
962
+ // matching symlink would be "installed" to detection and "absent" to the
963
+ // probe: an editor that exits 0 without removing it would report
964
+ // `undone`, and the next detection would find it again — the exact
965
+ // "reports success it did not achieve" this fix exists to close, rebuilt
966
+ // inside the fix. The probe therefore asks about the entry itself
967
+ // (lstat semantics), the same question the directory listing answers.
968
+ const dir = stubPath();
969
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
970
+ process.env.PATH = dir;
971
+ const extensionsDir = join(process.env.HOME ?? "", ".vscode", "extensions");
972
+ mkdirSync(extensionsDir, { recursive: true });
973
+ const entry = join(extensionsDir, "canonical.terrazzo-lsp-extension-1.2.3");
974
+ symlinkSync(join(extensionsDir, "no-such-target"), entry);
975
+
976
+ const { chunks, outcome, thrown } = await undoLspCapturing();
977
+
978
+ expect(lstatSync(entry).isSymbolicLink()).toBe(true); // still there
979
+ expect(chunks.join("")).not.toContain("— removed");
980
+ expect(outcome).toBeUndefined();
981
+ const err = asPragmaError(thrown);
982
+ expect(err.message).toContain("still present after the undo ran");
983
+ expect(err.recovery?.message ?? "").toContain(entry);
984
+ });
985
+
986
+ it("a truthful uninstall reports its `removed` row from the executed reversal", async () => {
987
+ // The positive half of the headline: when the editor really deletes the
988
+ // entry, the in-task postcondition passes, the reversal's outcome comes
989
+ // back `undone`, and the row reports `removed` — same sentence as before
990
+ // the fix, now derived from what ran instead of from the plan.
991
+ const dir = stubPath();
992
+ writeFileSync(
993
+ join(dir, "code"),
994
+ `#!/bin/sh\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
995
+ { mode: 0o755 },
996
+ );
997
+ process.env.PATH = dir;
998
+ seedExtension(".vscode", "1.2.3");
999
+
1000
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1001
+ expect(thrown).toBeUndefined();
1002
+ expect(outcome?.exitCode).toBe(0);
1003
+ expect(outcome?.stdout).toBe("Undid 1 step(s).\n");
1004
+ expect(chunks.join("")).toContain("— removed");
1005
+ });
1006
+
1007
+ it("the `✓ … removed` rows cannot be produced by the collection walk alone", async () => {
1008
+ // The collection walk mocks every exec to exit 0 and every prompt to its
1009
+ // default — under the old code that was enough to print the whole green
1010
+ // recap before one real process had spawned. With an uninstall that
1011
+ // GENUINELY fails (exit 1), the executed reversal fails; if any `removed`
1012
+ // row still appeared on stderr, it could only have come from the mocked
1013
+ // walk.
1014
+ const dir = stubPath();
1015
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
1016
+ process.env.PATH = dir;
1017
+ seedExtension(".vscode", "1.2.3");
1018
+
1019
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1020
+ expect(chunks.join("")).not.toContain("— removed");
1021
+ expect(outcome).toBeUndefined();
1022
+ expect(thrown).toBeDefined();
1023
+ const err = asPragmaError(thrown);
1024
+ expect(err.message).toContain("--uninstall-extension");
1025
+ expect(err.message).toContain("exited with code 1");
1026
+ });
1027
+
1028
+ it("appliedUndo grades rows from the outcomes — one model, both directions", async () => {
1029
+ // The projection unit: the sink the FORWARD run fills is fed by the undo
1030
+ // interpreter's outcomes instead, and the same `outcomeFor` reads it
1031
+ // back. A failed outcome keyed to the row fails the row (carrying the
1032
+ // reversal's own remedy); an actionable row that no reversal reported
1033
+ // back for cannot claim `removed`; a run with clean outcomes reports
1034
+ // `removed`.
1035
+ seedExtension(".vscode", "1.2.3");
1036
+ const run = await buildSetupRun(
1037
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1038
+ "lsp",
1039
+ "global",
1040
+ true,
1041
+ );
1042
+
1043
+ const clean = run.appliedUndo([{ key: "global:lsp", status: "undone" }]);
1044
+ expect(clean.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1045
+ status: "removed",
1046
+ note: "removed",
1047
+ });
1048
+
1049
+ const failed = run.appliedUndo([
1050
+ {
1051
+ key: "global:lsp",
1052
+ status: "failed",
1053
+ error: { code: "UNSUPPORTED", message: "still there" },
1054
+ },
1055
+ ]);
1056
+ expect(failed.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1057
+ status: "failed",
1058
+ note: "still there",
1059
+ });
1060
+
1061
+ // No reversal reported back at all for an actionable row: never `removed`.
1062
+ const silent = run.appliedUndo([]);
1063
+ expect(silent.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1064
+ status: "failed",
1065
+ note: "no reversal was executed for this target",
1066
+ });
1067
+ });
1068
+
1069
+ it("a removal row with nothing owned reports `kept`, not a green noop", async () => {
1070
+ // The `kept` status was declared in the vocabulary and counted by
1071
+ // `planTally` but no producer ever emitted it. On a removal, a `none`
1072
+ // row IS the kept case: the run owned nothing here and deliberately left
1073
+ // the machine's state standing — standing aside (○), not work done (✓).
1074
+ const run = await buildSetupRun(
1075
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1076
+ "lsp",
1077
+ "global",
1078
+ true,
1079
+ );
1080
+ expect(run.plan.rows.find((r) => r.target === "lsp")?.action).toBe("none");
1081
+ const kept = run.appliedUndo([]);
1082
+ expect(kept.rows.find((r) => r.target === "lsp")?.outcome).toEqual({
1083
+ status: "kept",
1084
+ });
1085
+ });
611
1086
  });
612
1087
 
613
1088
  describe("setup (run-all wizard) — scope threading", () => {
@@ -630,7 +1105,7 @@ describe("setup (run-all wizard) — scope threading", () => {
630
1105
  writeFileSync(join(editorPath, "code"), "");
631
1106
  };
632
1107
 
633
- /** Seed a discoverable skill so the project-band skills step is offerable. */
1108
+ /** Seed a discoverable skill so the project-scope skills step is offerable. */
634
1109
  const seedSkill = (cwd: string): void => {
635
1110
  const dir = join(cwd, ".pragma", "skills", "s");
636
1111
  mkdirSync(dir, { recursive: true });
@@ -640,10 +1115,10 @@ describe("setup (run-all wizard) — scope threading", () => {
640
1115
  );
641
1116
  };
642
1117
 
643
- it("--local omits the global-band completions + lsp steps, keeping project steps", async () => {
1118
+ it("--local omits the global-scope completions + lsp steps, keeping project steps", async () => {
644
1119
  const cwd = tmp("pragma-setup-proj-");
645
- mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-band MCP target
646
- seedSkill(cwd); // project-band skills step
1120
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-scope MCP target
1121
+ seedSkill(cwd); // project-scope skills step
647
1122
  const outcome = await executeVerb(
648
1123
  setupSelfVerb,
649
1124
  { local: true },
@@ -652,17 +1127,19 @@ describe("setup (run-all wizard) — scope threading", () => {
652
1127
  );
653
1128
  expect(outcome.exitCode).toBe(0);
654
1129
  const plan = outcome.stdout ?? "";
655
- // A band FILTERS the run-all: the out-of-band targets are still named, as
656
- // rows saying why they are not in this run, so nothing is silently absent.
657
- expect(plan).toContain("completions skip");
658
- expect(plan).toContain("user-level only — it has no project band");
659
- // The project-band steps remain: MCP into .cursor, skills into .agents/skills.
1130
+ // A scope FILTERS the run-all: the out-of-scope targets are still named, as
1131
+ // rows saying why they are not in this run, so nothing is silently absent —
1132
+ // and the reason names the flag that WOULD reach them, so the row is not a
1133
+ // dead end either.
1134
+ expect(plan).toContain("completions nothing to do");
1135
+ expect(plan).toContain("this one is global only — run it with `--global`");
1136
+ // The per-project steps remain: MCP into .cursor, skills into .agents/skills.
660
1137
  expect(plan).toContain("mcp.json");
661
1138
  expect(plan).toContain(".agents/skills");
662
1139
  });
663
1140
 
664
1141
  withShell(
665
- "--global omits the project-band skills step, keeping global steps",
1142
+ "--global omits the project-scope skills step, keeping global steps",
666
1143
  async () => {
667
1144
  const cwd = tmp("pragma-setup-proj-");
668
1145
  seedSkill(cwd); // WOULD be offered under the default `both`
@@ -675,13 +1152,20 @@ describe("setup (run-all wizard) — scope threading", () => {
675
1152
  );
676
1153
  expect(outcome.exitCode).toBe(0);
677
1154
  const plan = outcome.stdout ?? "";
678
- // The global-band steps are present under --global. Paths render against
1155
+ // The global-scope steps are present under --global. Paths render against
679
1156
  // the header's roots, so the row shows `~/.zfunc/_pragma`, not an absolute
680
1157
  // prefix repeated on every line.
681
1158
  expect(plan).toMatch(new RegExp(`completions\\s+install\\s+${SHELL} →`));
682
1159
  expect(plan).toContain("lsp");
683
- // The project-band skills step is gone (the bug: it used to run under --global).
684
- expect(plan).not.toContain(".agents/skills");
1160
+ // The project-scope skills step is gone (the bug: it used to run under
1161
+ // --global). `shortenPath` renders a project path as `./…` and a global
1162
+ // one as `~/…`, and the two scopes' cross-client directories differ only
1163
+ // by that marker — so the covenant is asserted on the PROJECT spelling.
1164
+ // A bare "not .agents/skills" no longer separates them: the global scope
1165
+ // legitimately links its own `~/.agents/skills` now that the bundled
1166
+ // snapshot gives it skills on a machine that has run nothing.
1167
+ expect(plan).not.toContain(`.${sep}.agents${sep}skills`);
1168
+ expect(plan).toContain(`~${sep}.agents${sep}skills`);
685
1169
  },
686
1170
  );
687
1171
  });
@@ -701,7 +1185,101 @@ describe("setup skills", () => {
701
1185
  );
702
1186
  expect(outcome.exitCode).toBe(0);
703
1187
  expect(outcome.stdout).toContain("skills");
704
- expect(outcome.stdout).toContain("no project skills");
1188
+ expect(outcome.stdout).toContain("this project holds no skills");
1189
+ // The skip is no longer a dead end: it names the action that settles it on
1190
+ // THIS machine. The reason does NOT invert — the row still skips.
1191
+ expect(outcome.stdout).toContain("SKILL.md, then run this again");
1192
+ });
1193
+
1194
+ it("an EXISTING but empty project skills root is not reported as absent", async () => {
1195
+ // `available` is false for an absent root and for an empty one alike, so
1196
+ // the shared reason line said "is absent" about a directory the user is
1197
+ // looking at. `rootExists` is the flag that already separates those two
1198
+ // states everywhere else in the module; the wording now honours it.
1199
+ const cwd = tmp("pragma-setup-proj-");
1200
+ mkdirSync(join(cwd, ".pragma", "skills"), { recursive: true });
1201
+
1202
+ const { plan } = await buildSetupRun(
1203
+ bootRuntime(FLAGS, cwd),
1204
+ "skills",
1205
+ "project",
1206
+ );
1207
+ const row = plan.rows.find((r) => r.target === "skills");
1208
+ expect(row?.action).toBe("skip"); // still nothing to do...
1209
+ expect(row?.reason).toContain("is empty"); // ...for the honest reason
1210
+ expect(row?.reason).not.toContain("does not exist");
1211
+ });
1212
+
1213
+ it("a genuinely MISSING project skills root still reports absent", async () => {
1214
+ // The other half of the distinction: the empty-root wording must not have
1215
+ // been bought by making every skip vague.
1216
+ const { plan } = await buildSetupRun(
1217
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1218
+ "skills",
1219
+ "project",
1220
+ );
1221
+ const row = plan.rows.find((r) => r.target === "skills");
1222
+ expect(row?.action).toBe("skip");
1223
+ expect(row?.reason).toContain("does not exist");
1224
+ });
1225
+
1226
+ it("the GLOBAL scope OFFERS skills on a machine that has run nothing", async () => {
1227
+ // THE BEHAVIOUR CHANGE, asserted at the scope that used to be empty. The
1228
+ // global scope's only source was the installed root, so an empty
1229
+ // `XDG_DATA_HOME` — a fresh install — planned `skip` and told the user to
1230
+ // go and run `sources update` first. The bundled snapshot is the scope's
1231
+ // second root, so the row is actionable with no network and no prior
1232
+ // command. The empty-root WORDING this replaces is still asserted, on the
1233
+ // pure reason/remedy pair, in the cell below.
1234
+ const prevData = process.env.XDG_DATA_HOME;
1235
+ process.env.XDG_DATA_HOME = tmp("pragma-skills-data-");
1236
+ try {
1237
+ const { plan } = await buildSetupRun(
1238
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1239
+ "skills",
1240
+ "global",
1241
+ );
1242
+ const row = plan.rows.find((r) => r.target === "skills");
1243
+ expect(row?.action).toBe("link");
1244
+ expect(row?.detail).toMatch(/\d+ skills? →/);
1245
+
1246
+ // End to end, inside the same jail (an ambient data root that happened to
1247
+ // hold a skill would stop this exercising the fresh-machine condition).
1248
+ const outcome = await executeVerb(
1249
+ verbOf("skills"),
1250
+ {},
1251
+ YES,
1252
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1253
+ );
1254
+ expect(outcome.exitCode).toBe(0);
1255
+ expect(outcome.stdout).not.toContain("no skills installed");
1256
+ } finally {
1257
+ // `process.env.X = undefined` STORES THE STRING "undefined"; it does not
1258
+ // unset. The variable is always set here (`setupXdgIsolation.ts` gives
1259
+ // every test file its own XDG root), so this restores rather than
1260
+ // deletes in practice — but a test must not leave a poisoned path behind
1261
+ // for the files that run after it if that ever stops being true.
1262
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
1263
+ else process.env.XDG_DATA_HOME = prevData;
1264
+ }
1265
+ });
1266
+
1267
+ it("the GLOBAL skip still names `sources update` when it does happen", async () => {
1268
+ // The remedy the cell above used to assert end-to-end. That state is no
1269
+ // longer reachable with a snapshot in the package — it takes a build whose
1270
+ // `bundled-skills/` is missing AND an empty installed root — so it is
1271
+ // asserted where it actually lives: on the pure pair that both the setup row
1272
+ // and the doctor row read, which is what stops the two surfaces wording one
1273
+ // finding differently. A skip with no next step is the defect.
1274
+ expect(
1275
+ skillsSkipReason("~/.local/share/pragma/skills", "global", true),
1276
+ ).toBe(
1277
+ "nothing to link — no skills installed yet; they arrive with the packs " +
1278
+ `\`${BIN_NAME} sources update\` builds`,
1279
+ );
1280
+ expect(
1281
+ skillsSkipRemedy("~/.local/share/pragma/skills", "global"),
1282
+ ).toContain(`${BIN_NAME} sources update`);
705
1283
  });
706
1284
 
707
1285
  it("detects a created action and composes a symlink carrying an undo", async () => {
@@ -727,6 +1305,77 @@ describe("setup skills", () => {
727
1305
  expect(existsSync(join(cwd, ".agents", "skills", "my-skill"))).toBe(false);
728
1306
  });
729
1307
 
1308
+ it("NEVER deletes a user's own symlink that collides with a shipped skill (REGRESSION)", async () => {
1309
+ // Bundled skills ship on every install, so a folder-name collision with a
1310
+ // user's own link is reachable with NO user action — where previously an
1311
+ // empty skill set meant no action was ever composed for that path.
1312
+ // `composeSkills` composes every non-skipped row, so classifying a
1313
+ // resolving foreign link as `replaced` deletes the user's link.
1314
+ const cwd = tmp("pragma-setup-proj-");
1315
+ const skillDir = join(cwd, ".pragma", "skills", "my-skill");
1316
+ mkdirSync(skillDir, { recursive: true });
1317
+ writeFileSync(
1318
+ join(skillDir, "SKILL.md"),
1319
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1320
+ );
1321
+
1322
+ // Somewhere entirely outside every pragma root, and it RESOLVES.
1323
+ const mine = join(cwd, "my-own-work", "my-skill");
1324
+ mkdirSync(mine, { recursive: true });
1325
+ writeFileSync(join(mine, "SKILL.md"), "---\nname: mine\n---\n");
1326
+
1327
+ const linkDir = join(cwd, ".agents", "skills");
1328
+ mkdirSync(linkDir, { recursive: true });
1329
+ const linkPath = join(linkDir, "my-skill");
1330
+ symlinkSync(mine, linkPath);
1331
+
1332
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1333
+ const action = detected.actions.find((a) => a.linkPath === linkPath);
1334
+ expect(action?.action).toBe("skipped");
1335
+ expect(action?.owned).toBe(false);
1336
+
1337
+ // Neither the preview nor the real run may touch it.
1338
+ const { effects } = dryRun(composeSkills(detected));
1339
+ expect(
1340
+ effects.filter(
1341
+ (e) =>
1342
+ e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
1343
+ ),
1344
+ ).toHaveLength(0);
1345
+
1346
+ await runTask(composeSkills(detected));
1347
+ expect(readlinkSync(linkPath)).toBe(mine);
1348
+ expect(existsSync(join(linkPath, "SKILL.md"))).toBe(true);
1349
+ });
1350
+
1351
+ it("sweeps a link into a REMOVED package directory once its skill is gone (REGRESSION)", async () => {
1352
+ // The upgrade shape under a version-stamped layout (pnpm, npx, volta): the
1353
+ // link points into `…@0.34.0/bundled-skills/<name>`, a directory the
1354
+ // upgrade replaced. Ownership by residence in a CURRENT root cannot see it,
1355
+ // and once the release drops that skill no per-skill action covers the path
1356
+ // either — so the dangling link would survive every reconcile, invisible to
1357
+ // setup and to doctor alike.
1358
+ const cwd = tmp("pragma-setup-proj-");
1359
+ const skillDir = join(cwd, ".pragma", "skills", "still-here");
1360
+ mkdirSync(skillDir, { recursive: true });
1361
+ writeFileSync(
1362
+ join(skillDir, "SKILL.md"),
1363
+ "---\nname: still-here\ndescription: A test skill.\n---\n",
1364
+ );
1365
+
1366
+ const linkDir = join(cwd, ".agents", "skills");
1367
+ mkdirSync(linkDir, { recursive: true });
1368
+ // Never created: this is the point — the old package directory is gone.
1369
+ const retired = join(cwd, "pragma-cli@0.34.0", "bundled-skills", "retired");
1370
+ const linkPath = join(linkDir, "retired");
1371
+ symlinkSync(retired, linkPath);
1372
+
1373
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1374
+ const orphan = detected.orphans.find((o) => o.linkPath === linkPath);
1375
+ expect(orphan).toBeDefined();
1376
+ expect(orphan?.owned).toBe(true);
1377
+ });
1378
+
730
1379
  it("classifies a DANGLING symlink as replaced and repairs it (S1-2)", async () => {
731
1380
  // `existsSync` FOLLOWS a symlink, so a dangling link used to read as
732
1381
  // "absent" → plan `created` → the real symlink() crashed EEXIST labeled
@@ -858,6 +1507,40 @@ describe("setup skills", () => {
858
1507
  expect(() => lstatSync(linkPath)).toThrow();
859
1508
  });
860
1509
 
1510
+ it("the removal plan's children are the FOLDERS the links live in, deduped", async () => {
1511
+ // `dirs` mapped `a.linkPath` — one path per LINK — so the `new Set(…)`
1512
+ // deduped nothing and the removal preview printed an N-path wall where
1513
+ // the forward plan says `N skills → M folders`. The folders are the
1514
+ // links' parent directories.
1515
+ const cwd = tmp("pragma-setup-proj-");
1516
+ const linkDir = join(cwd, ".agents", "skills");
1517
+ mkdirSync(linkDir, { recursive: true });
1518
+ for (const name of ["s1", "s2"]) {
1519
+ const skillDir = join(cwd, ".pragma", "skills", name);
1520
+ mkdirSync(skillDir, { recursive: true });
1521
+ writeFileSync(
1522
+ join(skillDir, "SKILL.md"),
1523
+ `---\nname: ${name}\ndescription: A test skill.\n---\n`,
1524
+ );
1525
+ symlinkSync(skillDir, join(linkDir, name));
1526
+ }
1527
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1528
+ expect(ownedSkillLinks(detected)).toHaveLength(2);
1529
+
1530
+ const { findTarget } = await import("./targets.js");
1531
+ const draft = findTarget("skills")?.removalPlan(
1532
+ detected as never,
1533
+ "project",
1534
+ { global: process.env.HOME ?? "", project: cwd },
1535
+ );
1536
+ expect(draft?.action).toBe("remove");
1537
+ expect(draft?.detail).toBe("2 links");
1538
+ // TWO links, ONE folder: the children name the folder once, not each link.
1539
+ expect(draft?.children?.map((c) => c.label)).toEqual([
1540
+ `.${sep}${join(".agents", "skills")}`,
1541
+ ]);
1542
+ });
1543
+
861
1544
  it("owns a RELATIVE link into the skill root, resolved against the link dir", async () => {
862
1545
  // `readlink` reports the raw target, which may be relative — resolving it
863
1546
  // against the link's own directory is what makes the containment test
@@ -877,17 +1560,168 @@ describe("setup skills", () => {
877
1560
  const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
878
1561
  const action = detected.actions.find((a) => a.linkPath === linkPath);
879
1562
  expect(action?.owned).toBe(true);
1563
+ // A relative link that RESOLVES to the skill is already correct. Comparing
1564
+ // the raw `readlink` string to an absolute path classified it `replaced`,
1565
+ // so it churned on every run and doctor called it "points elsewhere".
1566
+ expect(action?.action).toBe("skipped");
1567
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
880
1568
 
881
1569
  await runUndo(composeSkillsRemoval(detected));
882
1570
  expect(existsSync(linkPath)).toBe(false);
883
1571
  });
884
1572
  });
885
1573
 
1574
+ describe("setup skills — the forward pass is a RECONCILE", () => {
1575
+ /** Seed one project skill and return its source dir. */
1576
+ const seed = (cwd: string, name: string): string => {
1577
+ const dir = join(cwd, ".pragma", "skills", name);
1578
+ mkdirSync(dir, { recursive: true });
1579
+ writeFileSync(
1580
+ join(dir, "SKILL.md"),
1581
+ `---\nname: ${name}\ndescription: A skill.\n---\n`,
1582
+ );
1583
+ return dir;
1584
+ };
1585
+
1586
+ it("removes the link a dropped skill left behind, without --undo", async () => {
1587
+ // The reported bug. `d.orphans` was computed and read ONLY by `--undo`,
1588
+ // which removes every owned link rather than the stale ones — so no command
1589
+ // reconciled, and a skill dropped upstream kept its link forever.
1590
+ const cwd = tmp("pragma-setup-proj-");
1591
+ const kept = seed(cwd, "kept");
1592
+ const dropped = seed(cwd, "dropped");
1593
+ const linkDir = join(cwd, ".agents", "skills");
1594
+ mkdirSync(linkDir, { recursive: true });
1595
+ symlinkSync(kept, join(linkDir, "kept"));
1596
+ symlinkSync(dropped, join(linkDir, "dropped"));
1597
+ rmSync(dropped, { recursive: true, force: true }); // retired upstream
1598
+
1599
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1600
+ expect(detected.orphans.map((o) => o.skillName)).toEqual(["dropped"]);
1601
+
1602
+ await runTask(composeSkills(detected));
1603
+ // `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
1604
+ expect(() => lstatSync(join(linkDir, "dropped"))).toThrow();
1605
+ expect(readlinkSync(join(linkDir, "kept"))).toBe(kept);
1606
+ });
1607
+
1608
+ it("plans the sweep as an ACTIONABLE row, not a deselected `none`", async () => {
1609
+ // An orphan-only tree planned `none` → `defaultSelected(none)` is false →
1610
+ // the row was never composed, so even the fixed reconcile would not run.
1611
+ const cwd = tmp("pragma-setup-proj-");
1612
+ const dropped = seed(cwd, "dropped");
1613
+ const linkDir = join(cwd, ".agents", "skills");
1614
+ mkdirSync(linkDir, { recursive: true });
1615
+ symlinkSync(dropped, join(linkDir, "dropped"));
1616
+ rmSync(dropped, { recursive: true, force: true });
1617
+
1618
+ const { plan } = await buildSetupRun(
1619
+ bootRuntime(FLAGS, cwd),
1620
+ "skills",
1621
+ "project",
1622
+ );
1623
+ const row = plan.rows.find((r) => r.target === "skills");
1624
+ expect(row?.action).toBe("update");
1625
+ expect(row?.selected).toBe(true);
1626
+ expect(row?.detail).toContain("1 stale link to remove");
1627
+ });
1628
+
1629
+ it("the sweep is reversible — undo puts the stale link back", async () => {
1630
+ const cwd = tmp("pragma-setup-proj-");
1631
+ const dropped = seed(cwd, "dropped");
1632
+ const linkDir = join(cwd, ".agents", "skills");
1633
+ mkdirSync(linkDir, { recursive: true });
1634
+ const linkPath = join(linkDir, "dropped");
1635
+ symlinkSync(dropped, linkPath);
1636
+ rmSync(dropped, { recursive: true, force: true });
1637
+
1638
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1639
+ const { undoCount } = await runUndo(composeSkills(detected));
1640
+ expect(undoCount).toBeGreaterThan(0);
1641
+ expect(readlinkSync(linkPath)).toBe(dropped);
1642
+ });
1643
+
1644
+ it("is idempotent — a second pass composes zero effects", async () => {
1645
+ const cwd = tmp("pragma-setup-proj-");
1646
+ const dropped = seed(cwd, "dropped");
1647
+ const linkDir = join(cwd, ".agents", "skills");
1648
+ mkdirSync(linkDir, { recursive: true });
1649
+ symlinkSync(dropped, join(linkDir, "dropped"));
1650
+ rmSync(dropped, { recursive: true, force: true });
1651
+
1652
+ await runTask(
1653
+ composeSkills(await detectSkills(bootRuntime(FLAGS, cwd), "project")),
1654
+ );
1655
+ const second = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1656
+ expect(dryRun(composeSkills(second)).effects).toEqual([]);
1657
+ });
1658
+
1659
+ it("SAFETY: an ABSENT source root means no opinion — nothing is swept", async () => {
1660
+ // `available` conflated "the root is empty" with "the root does not exist".
1661
+ // Sweeping on that alone would wipe every pragma link out of the harness
1662
+ // dirs on any machine where XDG_DATA_HOME moved or sources update never ran.
1663
+ const cwd = tmp("pragma-setup-proj-");
1664
+ const skillDir = seed(cwd, "gone");
1665
+ const linkDir = join(cwd, ".agents", "skills");
1666
+ mkdirSync(linkDir, { recursive: true });
1667
+ const linkPath = join(linkDir, "gone");
1668
+ symlinkSync(skillDir, linkPath);
1669
+ // The whole source ROOT disappears, not just one skill.
1670
+ rmSync(join(cwd, ".pragma", "skills"), { recursive: true, force: true });
1671
+
1672
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1673
+ expect(detected.rootExists).toBe(false);
1674
+ expect(detected.available).toBe(false);
1675
+ expect(detected.orphans).toEqual([]);
1676
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
1677
+
1678
+ await runTask(composeSkills(detected));
1679
+ expect(readlinkSync(linkPath)).toBe(skillDir);
1680
+ });
1681
+
1682
+ it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
1683
+ // The three shapes the sweep must never touch, in one tree: a hand-placed
1684
+ // directory, a link into someone's own checkout, and a link into a SIBLING
1685
+ // whose name merely EXTENDS the root's (the old string-prefix bug).
1686
+ const cwd = tmp("pragma-setup-proj-");
1687
+ const root = join(cwd, ".pragma", "skills");
1688
+ mkdirSync(root, { recursive: true }); // exists, but holds no skill
1689
+ const backup = join(cwd, ".pragma", "skills-backup", "sibling");
1690
+ mkdirSync(backup, { recursive: true });
1691
+ writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
1692
+ const elsewhere = join(cwd, "elsewhere", "mine");
1693
+ mkdirSync(elsewhere, { recursive: true });
1694
+
1695
+ const linkDir = join(cwd, ".agents", "skills");
1696
+ mkdirSync(linkDir, { recursive: true });
1697
+ const realDir = join(linkDir, "hand-placed");
1698
+ mkdirSync(realDir, { recursive: true });
1699
+ writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
1700
+ symlinkSync(elsewhere, join(linkDir, "mine"));
1701
+ symlinkSync(backup, join(linkDir, "sibling"));
1702
+
1703
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1704
+ expect(detected.rootExists).toBe(true); // the sweep IS entitled to act
1705
+ expect(detected.orphans).toEqual([]); // ...and finds nothing of its own
1706
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
1707
+
1708
+ await runTask(composeSkills(detected));
1709
+ expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
1710
+ "hand-placed\n",
1711
+ );
1712
+ expect(readlinkSync(join(linkDir, "mine"))).toBe(elsewhere);
1713
+ expect(readlinkSync(join(linkDir, "sibling"))).toBe(backup);
1714
+ expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
1715
+ "the user's own copy\n",
1716
+ );
1717
+ });
1718
+ });
1719
+
886
1720
  describe("setup — detections settle independently", () => {
887
1721
  it("gives a rejecting detection its own failed row, leaving the rest planned", async () => {
888
1722
  // The targets are independent, and one `Promise.all` made them share a
889
1723
  // fate: a single rejecting detection prevented a run being built at all,
890
- // so NONE of the other targets ran and doctor rendered no banded rows.
1724
+ // so NONE of the other targets ran and doctor rendered no scoped rows.
891
1725
  const { TARGETS } = await import("./targets.js");
892
1726
  const lsp = TARGETS.find((t) => t.id === "lsp") as (typeof TARGETS)[number];
893
1727
  const spy = vi
@@ -1364,8 +2198,8 @@ describe("setup (run-all wizard)", () => {
1364
2198
  let thrown: unknown;
1365
2199
  try {
1366
2200
  // `--scope both`: the failing target (lsp) is global-only, and the two
1367
- // that must survive it write the project band, so the run has to cover
1368
- // both. A bare run-all is the GLOBAL band now and would not touch this
2201
+ // that must survive it write the project scope, so the run has to cover
2202
+ // both. A bare run-all is the GLOBAL scope now and would not touch this
1369
2203
  // repository at all.
1370
2204
  await executeVerb(
1371
2205
  setupSelfVerb,
@@ -1392,11 +2226,17 @@ describe("setup (run-all wizard)", () => {
1392
2226
  });
1393
2227
 
1394
2228
  it("omits skills gracefully when none are discovered (no mid-wizard EMPTY_RESULTS)", async () => {
1395
- // A run-all in a project with no skills must NOT throw — it just doesn't
1396
- // offer the skills step. Reaching a clean plan proves the graceful degrade.
2229
+ // A run-all in a scope with no skills must NOT throw — it just doesn't offer
2230
+ // the skills step. Reaching a clean plan proves the graceful degrade.
2231
+ //
2232
+ // POINTED AT THE PROJECT BAND, which is where an empty skills root is still
2233
+ // an ordinary condition: the global scope now always has the bundled
2234
+ // snapshot behind it, so a run-all there can no longer produce the empty
2235
+ // row this cell exists to survive. The condition is the same one; only the
2236
+ // scope that can still reach it has changed.
1397
2237
  const outcome = await executeVerb(
1398
2238
  setupSelfVerb,
1399
- {},
2239
+ { local: true },
1400
2240
  DRY,
1401
2241
  bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1402
2242
  );
@@ -1404,9 +2244,245 @@ describe("setup (run-all wizard)", () => {
1404
2244
  // The skills row is PRESENT and says why it cannot act, rather than being
1405
2245
  // dropped from a plan that then claims completeness.
1406
2246
  expect(outcome.stdout).toContain("skills");
1407
- expect(outcome.stdout).toContain("no skills installed");
2247
+ expect(outcome.stdout).toContain("this project holds no skills");
1408
2248
  expect(outcome.stdout).toContain("lsp");
1409
2249
  });
2250
+
2251
+ it("the GLOBAL run-all offers the skills row on a fresh machine", async () => {
2252
+ // The other side of the same graceful-degrade question, and the one a fresh
2253
+ // install actually meets: a bare run-all is the global scope, whose skills
2254
+ // row is now offerable from the bundled snapshot rather than skipped with a
2255
+ // remedy the user has to go away and run.
2256
+ const prevData = process.env.XDG_DATA_HOME;
2257
+ process.env.XDG_DATA_HOME = tmp("pragma-runall-data-");
2258
+ try {
2259
+ const outcome = await executeVerb(
2260
+ setupSelfVerb,
2261
+ {},
2262
+ DRY,
2263
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2264
+ );
2265
+ expect(outcome.exitCode).toBe(0);
2266
+ expect(outcome.stdout).toContain("skills");
2267
+ expect(outcome.stdout).not.toContain("no skills installed");
2268
+ } finally {
2269
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
2270
+ else process.env.XDG_DATA_HOME = prevData;
2271
+ }
2272
+ });
2273
+ });
2274
+
2275
+ describe("setup — a converged machine is reported, never asked", () => {
2276
+ // A converged run composes ZERO effects, and the confirm gate then mounted
2277
+ // Ink to render summon-core's "No operations planned." above "Proceed?".
2278
+ // On a sub-verb, whose prompt list is empty, that contentless gate was the
2279
+ // first and only thing the user ever saw.
2280
+ let prevPath: string | undefined;
2281
+ beforeEach(() => {
2282
+ prevPath = process.env.PATH;
2283
+ process.env.PATH = stubPath(); // no editor CLI ⇒ lsp skips
2284
+ });
2285
+ afterEach(() => {
2286
+ process.env.PATH = prevPath;
2287
+ });
2288
+
2289
+ const NO = { dryRun: false, undo: false, yes: false };
2290
+
2291
+ it("non-interactive + no --yes: the recap, NOT the `run again with --yes` hint", async () => {
2292
+ // The compounding defect: a converged plan printed "Nothing was applied.
2293
+ // Run again with --yes to apply." over a machine where there was nothing
2294
+ // to apply in the first place.
2295
+ const outcome = await executeVerb(
2296
+ verbOf("lsp"),
2297
+ {},
2298
+ NO,
2299
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2300
+ );
2301
+ expect(outcome.exitCode).toBe(0);
2302
+ expect(outcome.stdout).not.toContain("Run again with --yes");
2303
+ expect(outcome.stdout).not.toContain("Setup plan");
2304
+ // It says what was detected and what happened to it — doctor's house shape.
2305
+ expect(outcome.stdout).toContain("skipped");
2306
+ expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
2307
+ });
2308
+
2309
+ it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
2310
+ // The acceptance case. Faking a terminal on both streams is what makes
2311
+ // `cliIsTTY()` true, which is the ONLY gate between this run and the wizard.
2312
+ const prevIn = process.stdin.isTTY;
2313
+ const prevErr = process.stderr.isTTY;
2314
+ (process.stdin as { isTTY?: boolean }).isTTY = true;
2315
+ (process.stderr as { isTTY?: boolean }).isTTY = true;
2316
+ let outcome: Awaited<ReturnType<typeof executeVerb>>;
2317
+ try {
2318
+ outcome = await executeVerb(
2319
+ verbOf("lsp"),
2320
+ {},
2321
+ NO,
2322
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2323
+ );
2324
+ } finally {
2325
+ (process.stdin as { isTTY?: boolean }).isTTY = prevIn;
2326
+ (process.stderr as { isTTY?: boolean }).isTTY = prevErr;
2327
+ }
2328
+ expect(outcome.exitCode).toBe(0);
2329
+ expect(outcome.stdout).not.toContain("Proceed");
2330
+ expect(outcome.stdout).toContain("skipped");
2331
+ // The same lazy-React probe the PROTECTED guard below uses: had the
2332
+ // contentless gate mounted, Ink would be in the module cache.
2333
+ const isReactPkg = (k: string) =>
2334
+ /[\\/](react|react-dom|ink)@\d/.test(k) ||
2335
+ /[\\/]node_modules[\\/](react|react-dom|ink)[\\/]/.test(k);
2336
+ expect(Object.keys(require.cache ?? {}).filter(isReactPkg)).toEqual([]);
2337
+ });
2338
+
2339
+ it("--dry-run is untouched — it still ends with the dry-run hint", async () => {
2340
+ // The guard sits AFTER the `previewing` computation precisely so a preview
2341
+ // keeps saying it was a preview.
2342
+ const outcome = await executeVerb(
2343
+ verbOf("lsp"),
2344
+ {},
2345
+ DRY,
2346
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2347
+ );
2348
+ expect(outcome.exitCode).toBe(0);
2349
+ expect(outcome.stdout).toContain("Setup plan");
2350
+ expect(outcome.stdout).toContain(DRY_RUN_HINT);
2351
+ });
2352
+
2353
+ it("a PARTIALLY converged plan is NOT short-circuited", async () => {
2354
+ // One actionable row is enough: the run goes through `execute` as before,
2355
+ // so the wizard is still reachable on a machine with something to do.
2356
+ const cwd = tmp("pragma-setup-proj-");
2357
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2358
+ const outcome = await executeVerb(
2359
+ verbOf("mcp"),
2360
+ { local: true },
2361
+ NO,
2362
+ bootRuntime(FLAGS, cwd),
2363
+ );
2364
+ expect(outcome.exitCode).toBe(0);
2365
+ // Non-interactive without --yes ⇒ still the preview, hint and all.
2366
+ expect(outcome.stdout).toContain("Setup plan");
2367
+ expect(outcome.stdout).toContain(PREVIEW_HINT);
2368
+ });
2369
+
2370
+ it("the run-all on a fully converged machine recaps every row at exit 0", async () => {
2371
+ const cwd = tmp("pragma-setup-proj-");
2372
+ // First pass configures whatever this machine can hold...
2373
+ await executeVerb(setupSelfVerb, {}, YES, bootRuntime(FLAGS, cwd));
2374
+ // ...the second has nothing left to do.
2375
+ const outcome = await executeVerb(
2376
+ setupSelfVerb,
2377
+ {},
2378
+ NO,
2379
+ bootRuntime(FLAGS, cwd),
2380
+ );
2381
+ expect(outcome.exitCode).toBe(0);
2382
+ expect(outcome.stdout).not.toContain("Run again with --yes");
2383
+ expect(outcome.stdout).toContain("targets configured");
2384
+ });
2385
+ });
2386
+
2387
+ describe("setup — the detection summary", () => {
2388
+ let prevPath: string | undefined;
2389
+ beforeEach(() => {
2390
+ prevPath = process.env.PATH;
2391
+ process.env.PATH = stubPath();
2392
+ });
2393
+ afterEach(() => {
2394
+ process.env.PATH = prevPath;
2395
+ });
2396
+
2397
+ /**
2398
+ * Drive a run, capturing STDERR — which is where the report seam writes, and
2399
+ * the same stream the Ink frame would use. The dispatcher builds its own
2400
+ * runtime for a mutation, so an `rt.report` set on the boot runtime is
2401
+ * replaced; capturing the stream is what actually pins the behaviour.
2402
+ */
2403
+ const onStderr = async (
2404
+ verb: VerbSpec,
2405
+ params: Record<string, unknown>,
2406
+ flags: GlobalFlags,
2407
+ cwd: string,
2408
+ mutation = YES,
2409
+ ): Promise<string[]> => {
2410
+ const chunks: string[] = [];
2411
+ const spy = vi
2412
+ .spyOn(process.stderr, "write")
2413
+ .mockImplementation((chunk: unknown): boolean => {
2414
+ chunks.push(String(chunk));
2415
+ return true;
2416
+ });
2417
+ try {
2418
+ await executeVerb(verb, params, mutation, bootRuntime(flags, cwd));
2419
+ } finally {
2420
+ spy.mockRestore();
2421
+ }
2422
+ return chunks;
2423
+ };
2424
+
2425
+ /** The one write the report seam made for the summary block, if any. */
2426
+ const summaryOf = (chunks: readonly string[]): string =>
2427
+ chunks.find((c) => c.startsWith("Detected —")) ?? "";
2428
+
2429
+ it("names what was detected BEFORE anything is asked, detected rows only", async () => {
2430
+ const cwd = tmp("pragma-setup-proj-");
2431
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2432
+ const chunks = await onStderr(setupSelfVerb, { local: true }, FLAGS, cwd);
2433
+ // The report seam writes the block as ONE write, before the generator is
2434
+ // driven — so it is also the first thing on the stream.
2435
+ expect(chunks[0]?.startsWith("Detected —")).toBe(true);
2436
+ const summary = summaryOf(chunks);
2437
+ // FIRST — the Ink frame and this line both go to stderr, so only a line
2438
+ // written before `execute` lands above the frame in scrollback.
2439
+ expect(summary).toContain("Detected —");
2440
+ expect(summary).toContain("mcp");
2441
+ // A skip is "nothing detected here", so it is out of the default view...
2442
+ expect(summary).not.toContain("user-level only");
2443
+ expect(summary).toContain("--verbose lists them");
2444
+ });
2445
+
2446
+ it("--verbose widens it to every row, with no `not detected` footer", async () => {
2447
+ const cwd = tmp("pragma-setup-proj-");
2448
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2449
+ const summary = summaryOf(
2450
+ await onStderr(
2451
+ setupSelfVerb,
2452
+ { local: true },
2453
+ { ...FLAGS, verbose: true },
2454
+ cwd,
2455
+ ),
2456
+ );
2457
+ // ...and back in under `--verbose`, which consumes the EXISTING global flag
2458
+ // rather than adding a signal.
2459
+ expect(summary).toContain("this one is global only");
2460
+ expect(summary).not.toContain("--verbose lists them");
2461
+ });
2462
+
2463
+ it("says nothing on a --dry-run — the plan table IS that run's output", async () => {
2464
+ const cwd = tmp("pragma-setup-proj-");
2465
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2466
+ const chunks = await onStderr(
2467
+ setupSelfVerb,
2468
+ { local: true },
2469
+ FLAGS,
2470
+ cwd,
2471
+ DRY,
2472
+ );
2473
+ expect(summaryOf(chunks)).toBe("");
2474
+ });
2475
+
2476
+ it("is silent over MCP — `rt.report` is a no-op there, so no Ink and no noise", async () => {
2477
+ // The seam is the point: a plain-string report, not an Ink mount, is what
2478
+ // keeps this off the React graph AND out of the MCP data stream.
2479
+ const cwd = tmp("pragma-setup-proj-");
2480
+ const mcp = await projectMcp([setupModule], cwd);
2481
+ const result = await mcp.callTool("setup", { confirm: true });
2482
+ await mcp.cleanup();
2483
+ expect(result.ok).toBe(true);
2484
+ expect(JSON.stringify(result.data)).not.toContain("Detected —");
2485
+ });
1410
2486
  });
1411
2487
 
1412
2488
  describe("lazy-React discipline (PROTECTED)", () => {