@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
@@ -12,9 +12,10 @@
12
12
  * VSCodium/Cursor/Windsurf/Antigravity machines unable to install at all.
13
13
  *
14
14
  * `detectLsp` probes FOR REAL up front, and spawns NOTHING: an editor is
15
- * "present" when its CLI resolves on PATH (the `editorClis` registry names
16
- * them), and "installed" when its extensions dir holds a
17
- * `canonical.terrazzo-lsp-extension-<version>/` entry. Running
15
+ * detected when its CLI resolves on PATH (the `editorClis` registry names
16
+ * them), carries the extension when its extensions dir holds a
17
+ * `canonical.terrazzo-lsp-extension-<version>/` entry, and is "installed" when
18
+ * that entry is also new enough to work. Running
18
19
  * `--list-extensions` instead would be the natural probe, but Cursor's Linux
19
20
  * launcher OPENS THE EDITOR on that flag — a detection step must never launch
20
21
  * an app, so the fs is the source of truth. The full extension id is matched
@@ -35,13 +36,21 @@ import type { EditorCliDefinition, PlatformEnv } from "@canonical/harnesses";
35
36
  import {
36
37
  type ExecResult,
37
38
  exec,
39
+ exists,
38
40
  flatMap,
39
41
  mkdir,
42
+ pure,
43
+ sequence,
40
44
  sequence_,
41
45
  type Task,
42
46
  } from "@canonical/task";
43
47
  import { BIN_NAME } from "../../../constants.js";
44
- import { checkExecOk, guardMissingBinary } from "../../shared/index.js";
48
+ import { PragmaError } from "../../../kernel/error/index.js";
49
+ import {
50
+ checkExecOk,
51
+ failPragma,
52
+ guardMissingBinary,
53
+ } from "../../shared/index.js";
45
54
  import type { LspState } from "../types.js";
46
55
 
47
56
  /** The full extension id, as the editor's extensions dir spells it. */
@@ -50,10 +59,40 @@ const EXTENSION_ID = "canonical.terrazzo-lsp-extension";
50
59
  /** The npm package whose tarball bundles the VSIX. */
51
60
  const LSP_PACKAGE = "@canonical/terrazzo-lsp-extension";
52
61
 
53
- /** One detected editor: its registry row + whether the extension is present. */
62
+ /**
63
+ * One detected editor: its registry row, its extensions directory, and the two
64
+ * DIFFERENT questions about the extension.
65
+ *
66
+ * `present` and `installed` are deliberately separate, and collapsing them is a
67
+ * silent wrong-removal. `installed` is version-gated — an entry older than
68
+ * {@link MIN_EXTENSION_VERSION} reports false, because the forward plan and
69
+ * doctor must treat a copy that cannot start as not installed. But a REMOVAL
70
+ * keyed on that same boolean would walk past a dead pre-0.8.3 copy that pragma
71
+ * itself put there, report "nothing to remove", and leave it behind forever.
72
+ * So removal keys on `present` (any matching entry at all) and everything else
73
+ * keys on `installed` (present AND new enough to work).
74
+ */
54
75
  export interface DetectedEditor {
55
76
  readonly editor: EditorCliDefinition;
77
+ /** Any `canonical.terrazzo-lsp-extension-*` entry, whatever its version. */
78
+ readonly present: boolean;
79
+ /** Present AND at least {@link MIN_EXTENSION_VERSION} — i.e. it works. */
56
80
  readonly installed: boolean;
81
+ /**
82
+ * The editor's extensions directory. Kept because the removal's forward
83
+ * carrier needs a path that DEMONSTRABLY already exists (see
84
+ * {@link composeLspRemoval}); when `present` is true, this is that path.
85
+ */
86
+ readonly extensionsDir: string;
87
+ /**
88
+ * The matching entry NAMES under {@link extensionsDir} — the artifacts
89
+ * `present` is true about. The removal's postcondition probes exactly
90
+ * these after the uninstall (see {@link composeLspRemoval}): the editor
91
+ * CLI's exit code and the directory listing are DIFFERENT success
92
+ * criteria, and only the directory is the one detection (and doctor)
93
+ * believe.
94
+ */
95
+ readonly presentEntries: readonly string[];
57
96
  }
58
97
 
59
98
  /**
@@ -144,37 +183,45 @@ const compareVersions = (left: number[], right: number[]): number => {
144
183
  };
145
184
 
146
185
  /**
147
- * Whether an editor's extensions dir holds a copy new enough to WORK.
186
+ * The two facts an editor's extensions dir answers: is a copy THERE, and is it
187
+ * new enough to WORK.
148
188
  *
149
- * Version-aware on purpose. Matching any `canonical.terrazzo-lsp-extension-*`
150
- * entry meant a machine carrying a build whose server cannot start reported
151
- * `installed`, so `setup lsp` skipped it and `doctor` called it healthy —
152
- * a wrong skip that reads as correct, which is worse than a wrong failure and
153
- * is exactly the trap this module's docblock warns about elsewhere. Everyone
154
- * who ran `setup lsp` before {@link MIN_EXTENSION_VERSION} would have kept a
155
- * dead language server forever, with nothing telling them why.
189
+ * `current` is version-aware on purpose. Matching any
190
+ * `canonical.terrazzo-lsp-extension-*` entry meant a machine carrying a build
191
+ * whose server cannot start reported `installed`, so `setup lsp` skipped it and
192
+ * `doctor` called it healthy — a wrong skip that reads as correct, which is
193
+ * worse than a wrong failure. Everyone who ran `setup lsp` before
194
+ * {@link MIN_EXTENSION_VERSION} would have kept a dead language server forever,
195
+ * with nothing telling them why. An unparseable suffix counts as too old: a
196
+ * directory this function cannot read the version of is one it cannot vouch for.
156
197
  *
157
- * An unparseable suffix counts as too old: a directory this function cannot
158
- * read the version of is one it cannot vouch for.
198
+ * `present` is the same probe WITHOUT the version gate, and it exists because
199
+ * removal asks a different question. A pre-0.8.3 copy is not `current`, but it
200
+ * is unquestionably there, and it is a copy this command installed — so a
201
+ * removal keyed on `current` would leave the dead extension on disk while
202
+ * reporting success. See {@link DetectedEditor}.
159
203
  */
160
- const extensionInstalled = (
204
+ const extensionState = (
161
205
  editor: EditorCliDefinition,
162
206
  platform: PlatformEnv,
163
- ): boolean => {
207
+ ): { present: boolean; current: boolean; entries: string[] } => {
164
208
  let entries: string[];
165
209
  try {
166
210
  entries = readdirSync(editor.extensionsDir(platform));
167
211
  } catch {
168
- return false; // No extensions dir — nothing installed.
212
+ // No dir — nothing installed.
213
+ return { present: false, current: false, entries: [] };
169
214
  }
170
215
  const prefix = `${EXTENSION_ID}-`;
171
- return entries.some((entry) => {
172
- const lower = entry.toLowerCase();
173
- if (!lower.startsWith(prefix)) return false;
174
- const version = parseVersion(lower.slice(prefix.length));
216
+ const matching = entries.filter((entry) =>
217
+ entry.toLowerCase().startsWith(prefix),
218
+ );
219
+ const current = matching.some((entry) => {
220
+ const version = parseVersion(entry.toLowerCase().slice(prefix.length));
175
221
  if (version === undefined) return false; // Unreadable: cannot vouch for it.
176
222
  return compareVersions(version, MINIMUM) >= 0;
177
223
  });
224
+ return { present: matching.length > 0, current, entries: matching };
178
225
  };
179
226
 
180
227
  /**
@@ -191,10 +238,16 @@ export async function detectLsp(_cwd: string): Promise<LspDetection> {
191
238
  const platform = readPlatformEnv();
192
239
  const editors: DetectedEditor[] = editorClis
193
240
  .filter((editor) => cliOnPath(executableCandidates(editor.cli, platform)))
194
- .map((editor) => ({
195
- editor,
196
- installed: extensionInstalled(editor, platform),
197
- }));
241
+ .map((editor) => {
242
+ const { present, current, entries } = extensionState(editor, platform);
243
+ return {
244
+ editor,
245
+ present,
246
+ installed: current,
247
+ extensionsDir: editor.extensionsDir(platform),
248
+ presentEntries: entries,
249
+ };
250
+ });
198
251
  const state: LspState =
199
252
  editors.length === 0
200
253
  ? "unknown"
@@ -327,29 +380,141 @@ export function composeLsp(
327
380
  }
328
381
 
329
382
  /**
330
- * Compose the removal — deliberately empty. An extension install is an `exec`,
331
- * and an `exec` carries no reversal: pretending otherwise is what made
332
- * `setup lsp --undo` report a step count for work it never did. The honest
333
- * answer is the plan row's, which names the editor CLI that CAN uninstall it
334
- * (see {@link lspUninstallRemedy}) or states that this machine has none.
383
+ * The editors this command OWNS an extension copy in right now — every detected
384
+ * editor whose extensions dir holds a `canonical.terrazzo-lsp-extension-*`
385
+ * entry, version-gate deliberately NOT applied (see {@link DetectedEditor}).
386
+ *
387
+ * Named and shaped like `ownedMcpGroups` and `ownedSkillLinks`, and for the
388
+ * same reason those exist: a reversal is composed from what detection says this
389
+ * command owns, never from what a fresh forward plan would create.
335
390
  *
336
- * @param _d - The detection (unused; the shape is the table's).
337
- * @returns An empty Task.
391
+ * @param d - The detection gathered up front.
392
+ * @returns The editors carrying a copy.
338
393
  */
339
- export function composeLspRemoval(_d: LspDetection): Task<void> {
340
- return sequence_([]);
394
+ export const ownedLspEditors = (d: LspDetection): readonly DetectedEditor[] =>
395
+ d.editors.filter((e) => e.present);
396
+
397
+ /**
398
+ * Compose the removal: one `<editor cli> --uninstall-extension <id>` per owned
399
+ * editor, carried as the `undo` of a forward no-op — and each uninstall
400
+ * asserts its own GOAL, not just its exit code.
401
+ *
402
+ * The docblock this replaces claimed "an `exec` carries no reversal". That was
403
+ * factually wrong about the effect model — `Exec` has an `undo` slot and `exec`
404
+ * takes `UndoOptions`. What is true is that `exec` has no DEFAULT undo; the
405
+ * caller supplies one, exactly as `composeMcpRemoval` does for its `mkdir`.
406
+ *
407
+ * The carrier is copied from `composeMcpRemoval`, and its trap is the reason
408
+ * for the shape: `runUndo` collects undos by walking the FORWARD task with
409
+ * effects MOCKED, so the forward side must be walkable without reading or
410
+ * mutating anything. A `mkdir` of the editor's own extensions directory — which
411
+ * `present` proves already exists — reads nothing and is a genuine no-op. Phase
412
+ * two then runs the uninstall against the real editor.
413
+ *
414
+ * THE POSTCONDITION is the removal's real success criterion. The exec's exit
415
+ * code and the extensions DIRECTORY are two different judges — detection
416
+ * deliberately reads the directory (probing via the CLI can launch an
417
+ * editor), and VS Code-family CLIs are known to exit 0 while deferring or
418
+ * skipping the deletion — so after `checkExecOk` the undo probes the exact
419
+ * entries detection said this command owns, and FAILS (with the manual
420
+ * deletion as the remedy — never the command that just claimed success) when
421
+ * any survived. An in-task failure here is safe precisely because
422
+ * `runCollectedUndos` executes each undo isolated: it used to abort every
423
+ * reversal still pending, which is why no removal could afford to assert its
424
+ * own goal.
425
+ *
426
+ * @param d - The detection gathered up front.
427
+ * @param undoKey - Correlation key stamped on each reversal, echoed on its
428
+ * `UndoOutcome` so the caller can read the outcomes back per row.
429
+ * @returns A Task whose undo uninstalls the extension from each owned editor
430
+ * and verifies the copy is gone.
431
+ */
432
+ export function composeLspRemoval(
433
+ d: LspDetection,
434
+ undoKey?: string,
435
+ ): Task<void> {
436
+ const owned = ownedLspEditors(d);
437
+ // Nothing on this machine carries a copy — there is no honest work to model,
438
+ // and the plan row says so. Same shape as the other rows' empty removals.
439
+ if (owned.length === 0) return sequence_([]);
440
+ return sequence_(
441
+ owned.map(({ editor, extensionsDir, presentEntries }) => {
442
+ const command = `${editor.cli} --uninstall-extension ${EXTENSION_ID}`;
443
+ const ownedPaths = presentEntries.map((entry) =>
444
+ join(extensionsDir, entry),
445
+ );
446
+ const uninstall = flatMap(
447
+ exec(
448
+ editor.cli,
449
+ ["--uninstall-extension", EXTENSION_ID],
450
+ extensionsDir,
451
+ ),
452
+ (result) =>
453
+ checkExecOk(command, result as ExecResult, {
454
+ message:
455
+ `${editor.name} refused the uninstall (its output is above). ` +
456
+ `Remove it by hand with \`${command}\`.`,
457
+ }),
458
+ );
459
+ // The probe must apply DETECTION'S presence criterion, or the check can
460
+ // quietly stop checking: `extensionState` calls an entry present when
461
+ // `readdirSync` lists it, and a default `exists` (`fs.access`) follows
462
+ // symlinks — so a dangling matching symlink would be "installed" to
463
+ // detection and "absent" here, and an editor that exits 0 without
464
+ // removing it would report `undone` while the next detection finds it
465
+ // again. `followSymlinks: false` is `lstat` semantics: the entry
466
+ // itself, exactly what the directory listing answers.
467
+ const verified = flatMap(uninstall, () =>
468
+ flatMap(
469
+ sequence(ownedPaths.map((p) => exists(p, { followSymlinks: false }))),
470
+ (stillThere) => {
471
+ const survivors = ownedPaths.filter((_, i) => stillThere[i]);
472
+ if (survivors.length === 0) return pure(undefined);
473
+ // The remedy presents the surviving paths as DATA, never as a
474
+ // synthesized shell line: the names come from `readdirSync`, so
475
+ // no quoting is trustworthy, and one command cannot be right for
476
+ // every platform the editor registry supports.
477
+ return failPragma(
478
+ new PragmaError({
479
+ code: "UNSUPPORTED",
480
+ message: `\`${command}\` exited 0, but the extension is still present after the undo ran.`,
481
+ recovery: {
482
+ message: `Delete ${survivors.length === 1 ? "this entry" : "these entries"} from ${editor.name}'s extensions folder by hand, then restart it: ${survivors.join(" · ")}`,
483
+ },
484
+ }),
485
+ );
486
+ },
487
+ ),
488
+ );
489
+ return mkdir(extensionsDir, true, {
490
+ undo: guardMissingBinary(
491
+ editor.cli,
492
+ {
493
+ message: `The \`${editor.cli}\` CLI disappeared from PATH mid-run — restore it, then run \`${BIN_NAME} setup lsp --undo\` again.`,
494
+ },
495
+ verified,
496
+ ),
497
+ undoKey,
498
+ });
499
+ }),
500
+ );
341
501
  }
342
502
 
343
503
  /**
344
504
  * The uninstall instruction for a detection — a command that runs on THIS
345
- * machine, or `undefined` when no editor CLI was found and there is therefore
346
- * nothing honest to print.
505
+ * machine, or `undefined` when nothing here carries the extension and there is
506
+ * therefore nothing honest to print.
507
+ *
508
+ * It names an OWNING editor. Reading `d.editors.at(0)` named the first editor
509
+ * on PATH instead, so a machine with Cursor installed and the extension only in
510
+ * VSCodium printed a command against the wrong editor — one that would report
511
+ * success having uninstalled nothing.
347
512
  *
348
513
  * @param d - The detection gathered up front.
349
514
  * @returns The uninstall command, or undefined.
350
515
  */
351
516
  export function lspUninstallRemedy(d: LspDetection): string | undefined {
352
- const cli = d.editors.at(0)?.editor.cli;
517
+ const cli = ownedLspEditors(d).at(0)?.editor.cli;
353
518
  return cli === undefined
354
519
  ? undefined
355
520
  : `${cli} --uninstall-extension ${EXTENSION_ID}`;
@@ -11,9 +11,9 @@
11
11
  *
12
12
  * Detection ALSO reads each group's existing config (via `readMcpConfigFrom`,
13
13
  * FOR REAL up front) and classifies every group as `absent` (no pragma entry
14
- * yet), `configured` (the pragma entry already matches what we'd write), or
14
+ * yet), `registered` (the pragma entry already matches what we'd write), or
15
15
  * `drifted` (a pragma entry exists but differs). The wizard shows that prior
16
- * state in each row and DEFAULT-DESELECTS the already-`configured` files; the
16
+ * state in each row and DEFAULT-DESELECTS the already-`registered` files; the
17
17
  * write itself stays idempotent (a re-merge of the identical entry is
18
18
  * byte-for-byte), so a re-run is a no-op the recap reports as "unchanged" — the
19
19
  * same state-awareness `setup skills` has always had.
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { dirname } from "node:path";
23
23
  import type {
24
+ ConfigTarget,
24
25
  McpServerConfig,
25
26
  PlatformEnv,
26
27
  TargetGroup,
@@ -28,7 +29,7 @@ import type {
28
29
  import { mkdir, sequence_, type Task } from "@canonical/task";
29
30
  import { MCP_SERVER_NAME } from "../../../constants.js";
30
31
  import type { PragmaRuntime } from "../../../kernel/runtime/index.js";
31
- import type { McpTargetState, ScopeBand } from "../types.js";
32
+ import type { McpTargetState, Scope } from "../types.js";
32
33
 
33
34
  /** The `writeMcpConfigTargets` builder, captured from the dynamic harness import. */
34
35
  type WriteMcpConfigTargets =
@@ -41,15 +42,25 @@ type RemoveMcpConfigFrom =
41
42
  /**
42
43
  * The detected MCP state: the per-file target groups (already scoped to the
43
44
  * requested `--scope`), a by-path map of each group's prior
44
- * {@link McpTargetState} (read up front), the project root, and the
45
- * (dynamically imported) target-based writer the pure `composeMcp` needs
46
- * synchronously. Keeping `groups` a plain {@link TargetGroup}[] leaves every
47
- * existing `.groups` consumer (path/harnessNames/scope) unchanged; the state
48
- * rides alongside, keyed by the group's `path`.
45
+ * {@link McpTargetState} (read up front), the same states at PER-WRITE
46
+ * resolution, the project root, and the (dynamically imported) target-based
47
+ * writer the pure `composeMcp` needs synchronously. Keeping `groups` a plain
48
+ * {@link TargetGroup}[] leaves every existing `.groups` consumer
49
+ * (path/harnessNames/scope) unchanged; the state rides alongside, keyed by the
50
+ * group's `path`.
51
+ *
52
+ * `stateByPath` is what the WRITE side needs — a file is written unless every
53
+ * key in it is already current — and it is the aggregate of `stateByWrite`
54
+ * (see {@link aggregateMcpStates}), so the two can never disagree.
55
+ * `stateByWrite` is the finer grain a per-HARNESS report needs: two harnesses
56
+ * sharing one file own two different keys in it, and the file's aggregate is
57
+ * not either harness's standing.
49
58
  */
50
59
  export interface McpDetection {
51
60
  readonly groups: readonly TargetGroup[];
52
61
  readonly stateByPath: ReadonlyMap<string, McpTargetState>;
62
+ /** Each write's own state, keyed by {@link mcpWriteKey} — see the docblock. */
63
+ readonly stateByWrite: ReadonlyMap<string, McpTargetState>;
53
64
  readonly cwd: string;
54
65
  readonly platform: PlatformEnv;
55
66
  readonly writeMcpConfigTargets: WriteMcpConfigTargets;
@@ -74,49 +85,54 @@ const MCP_SERVE_ARGV = ["mcp", "serve"] as const;
74
85
  /**
75
86
  * The pragma MCP server entry we register — the SINGLE source of truth both the
76
87
  * classifier (does the existing entry match this?) and the writer (`composeMcp`)
77
- * consume, so "already configured" means byte-for-byte what a write would emit.
88
+ * consume, so "already registered" means byte-for-byte what a write would emit.
78
89
  *
79
- * The entry is BAND-shaped: a project-band entry records the project root as
90
+ * The entry is SCOPE-shaped: a project-scope entry records the project root as
80
91
  * `cwd` (that binding is the point of a per-repo registration), while a
81
- * global-band entry OMITS `cwd` entirely — a per-user server must not be
92
+ * global-scope entry OMITS `cwd` entirely — a per-user server must not be
82
93
  * pinned to whatever directory `setup mcp --global` happened to run from
83
94
  * (observed: a global registration from `~/Downloads` permanently served
84
95
  * `~/Downloads`, and re-running from repo B "repaired" it to repo B,
85
- * ping-ponging the machine band between projects). The per-harness
96
+ * ping-ponging the machine scope between projects). The per-harness
86
97
  * serializers already omit an undefined `cwd`, and the matcher treats an
87
98
  * omitted controlled field as must-be-absent, so a stale global entry still
88
99
  * carrying a `cwd` classifies as `drifted` and converges on the next write.
89
100
  *
90
- * @param cwd - The project root (recorded only on project-band entries).
91
- * @param band - The config band the entry is written to.
101
+ * @param cwd - The project root (recorded only on project-scope entries).
102
+ * @param scope - The config scope the entry is written to.
92
103
  * @returns The pragma {@link McpServerConfig}.
93
104
  */
94
- export function pragmaMcpEntry(cwd: string, band: ScopeBand): McpServerConfig {
95
- return band === "project"
105
+ export function pragmaMcpEntry(cwd: string, scope: Scope): McpServerConfig {
106
+ return scope === "project"
96
107
  ? { command: MCP_SERVER_NAME, args: [...MCP_SERVE_ARGV], cwd }
97
108
  : { command: MCP_SERVER_NAME, args: [...MCP_SERVE_ARGV] };
98
109
  }
99
110
 
100
111
  /**
101
- * Classify one target group against its on-disk config: `absent` when no pragma
102
- * entry exists in ANY of the group's writes, `configured` when EVERY write
103
- * already carries a matching pragma entry, `drifted` otherwise (present in at
104
- * least one write but not matching everywhere). Reads each write's file for real
105
- * via `readMcpConfigFrom`, and matches each raw entry against what THAT write's
106
- * per-harness serializer would emit for the group's BAND (`mcpEntryMatches`
112
+ * Classify ONE write against its on-disk config: `absent` when the file carries
113
+ * no pragma entry under this write's `mcpKey`, `registered` when the entry
114
+ * already matches what this write would emit, `drifted` when one is there and
115
+ * differs. Reads the file for real via `readMcpConfigFrom` (which reads only
116
+ * this write's key), and matches the raw entry against what THAT write's
117
+ * per-harness serializer would emit for the group's SCOPE (`mcpEntryMatches`
107
118
  * compares the serializer's controlled fields — so a global entry still
108
119
  * pinning a `cwd` reads as drifted — and ignores extra keys a harness or user
109
120
  * added, so we never churn a file we did not author).
110
121
  *
111
- * @param group - The target group whose writes to inspect.
112
- * @param want - The canonical pragma entry for the group's band.
122
+ * The write, not the file, is the unit that can be classified honestly: a
123
+ * shared `.vscode/mcp.json` holds VS Code's `servers` and Cline's `mcpServers`
124
+ * as two independent entries, and "the file" has no single state when one is
125
+ * current and the other is missing.
126
+ *
127
+ * @param write - The resolved config target to inspect.
128
+ * @param want - The canonical pragma entry for the group's scope.
113
129
  * @param harnessesApi - The dynamically imported harnesses module.
114
130
  * @param runTask - The node Task interpreter.
115
- * @returns The group's {@link McpTargetState}.
116
- * @note Impure — reads each write's config file.
131
+ * @returns This write's {@link McpTargetState}.
132
+ * @note Impure — reads the write's config file.
117
133
  */
118
- async function classifyGroup(
119
- group: TargetGroup,
134
+ async function classifyWrite(
135
+ write: ConfigTarget,
120
136
  want: McpServerConfig,
121
137
  harnessesApi: Pick<
122
138
  typeof import("@canonical/harnesses"),
@@ -124,37 +140,60 @@ async function classifyGroup(
124
140
  >,
125
141
  runTask: typeof import("@canonical/task/node").runTask,
126
142
  ): Promise<McpTargetState> {
127
- let present = 0;
128
- let matching = 0;
129
- for (const write of group.writes) {
130
- const servers = await runTask(harnessesApi.readMcpConfigFrom(write));
131
- const existing = servers[MCP_SERVER_NAME];
132
- if (existing === undefined) continue;
133
- present += 1;
134
- if (harnessesApi.mcpEntryMatches(existing, want, write.serializeEntry)) {
135
- matching += 1;
136
- }
137
- }
138
- if (present === 0) return "absent";
139
- if (matching === group.writes.length) return "configured";
140
- return "drifted";
143
+ const servers = await runTask(harnessesApi.readMcpConfigFrom(write));
144
+ const existing = servers[MCP_SERVER_NAME];
145
+ if (existing === undefined) return "absent";
146
+ return harnessesApi.mcpEntryMatches(existing, want, write.serializeEntry)
147
+ ? "registered"
148
+ : "drifted";
141
149
  }
142
150
 
151
+ /**
152
+ * The state of a whole FILE, aggregated from its writes: `absent` when no write
153
+ * carries the entry, `registered` when every write already carries a matching
154
+ * one, `drifted` otherwise. This is the rule the write side needs — a file is
155
+ * rewritten unless it is `registered` everywhere — and it is deliberately the
156
+ * only place the per-write states are collapsed, so nothing else re-derives it.
157
+ *
158
+ * @param states - Each write's own state, in the group's write order.
159
+ * @returns The group's {@link McpTargetState}.
160
+ */
161
+ const aggregateMcpStates = (
162
+ states: readonly McpTargetState[],
163
+ ): McpTargetState => {
164
+ if (states.every((state) => state === "absent")) return "absent";
165
+ if (states.every((state) => state === "registered")) return "registered";
166
+ return "drifted";
167
+ };
168
+
169
+ /**
170
+ * The key one write's state is recorded under: the file it lands in AND the
171
+ * `mcpKey` it owns there. `(path, mcpKey)` is exactly the write-dedup key
172
+ * `groupConfigTargets` builds its `writes` from, so the map has one entry per
173
+ * write and no two harnesses sharing a file can collide. The separator is NUL
174
+ * because a config path may legally contain everything else.
175
+ *
176
+ * @param write - The resolved config target.
177
+ * @returns Its lookup key in {@link McpDetection.stateByWrite}.
178
+ */
179
+ const mcpWriteKey = (write: ConfigTarget): string =>
180
+ `${write.path}\u0000${write.mcpKey}`;
181
+
143
182
  /**
144
183
  * Detect the AI harnesses present in the project (real reads, up front),
145
184
  * resolve+dedup their config targets for the chosen scope, AND read each
146
- * group's existing config to classify it `absent`/`configured`/`drifted` — so
147
- * the wizard can show prior state and default-deselect already-`configured`
185
+ * group's existing config to classify it `absent`/`registered`/`drifted` — so
186
+ * the wizard can show prior state and default-deselect already-`registered`
148
187
  * files.
149
188
  *
150
189
  * @param rt - The per-invocation runtime.
151
- * @param band - The band being planned (global or project).
152
- * @returns The band's target groups, their prior states, + the config writer.
190
+ * @param scope - The scope being planned (global or project).
191
+ * @returns The scope's target groups, their prior states, + the config writer.
153
192
  * @note Impure — reads the filesystem via `detectHarnesses` + `readMcpConfigFrom`.
154
193
  */
155
194
  export async function detectMcp(
156
195
  rt: PragmaRuntime,
157
- band: ScopeBand,
196
+ scope: Scope,
158
197
  ): Promise<McpDetection> {
159
198
  const cwd = rt.cwd;
160
199
  const [
@@ -174,29 +213,45 @@ export async function detectMcp(
174
213
  ]);
175
214
  const platform = readPlatformEnv();
176
215
  const detected = await runTask(detectHarnesses(cwd, platform));
177
- const groups = groupTargetsForScope(detected, cwd, band, platform);
216
+ const groups = groupTargetsForScope(detected, cwd, scope, platform);
178
217
 
179
218
  // Read each group's existing config (for real, up front) so the recap/preview
180
219
  // and the default selection reflect true prior state — the same discipline
181
220
  // `detectSkills` uses for its per-link create/skip/replace decision. The
182
- // wanted entry is BAND-shaped (a global entry omits `cwd`), so it is built
183
- // per group, not once.
221
+ // wanted entry is SCOPE-shaped (a global entry omits `cwd`), so it is built
222
+ // per group, not once. Each WRITE is classified and kept; the file's state is
223
+ // their aggregate, so the coarse view is derived from the fine one rather
224
+ // than measured separately.
184
225
  const stateByPath = new Map<string, McpTargetState>();
226
+ const stateByWrite = new Map<string, McpTargetState>();
185
227
  await Promise.all(
186
228
  groups.map(async (group) => {
187
- const state = await classifyGroup(
188
- group,
189
- pragmaMcpEntry(cwd, group.scope),
190
- { readMcpConfigFrom, mcpEntryMatches },
191
- runTask,
229
+ const want = pragmaMcpEntry(cwd, group.scope);
230
+ const classified = await Promise.all(
231
+ group.writes.map(async (write) => ({
232
+ write,
233
+ state: await classifyWrite(
234
+ write,
235
+ want,
236
+ { readMcpConfigFrom, mcpEntryMatches },
237
+ runTask,
238
+ ),
239
+ })),
240
+ );
241
+ for (const { write, state } of classified) {
242
+ stateByWrite.set(mcpWriteKey(write), state);
243
+ }
244
+ stateByPath.set(
245
+ group.path,
246
+ aggregateMcpStates(classified.map((c) => c.state)),
192
247
  );
193
- stateByPath.set(group.path, state);
194
248
  }),
195
249
  );
196
250
 
197
251
  return {
198
252
  groups,
199
253
  stateByPath,
254
+ stateByWrite,
200
255
  cwd,
201
256
  platform,
202
257
  writeMcpConfigTargets,
@@ -212,6 +267,22 @@ export function mcpGroupState(d: McpDetection, path: string): McpTargetState {
212
267
  return d.stateByPath.get(path) ?? "absent";
213
268
  }
214
269
 
270
+ /**
271
+ * The prior state of ONE write — the state of the single harness key it owns,
272
+ * not of the file it shares. Defaults to `absent` for an unknown write, the
273
+ * same way {@link mcpGroupState} does for an unknown path.
274
+ *
275
+ * @param d - The detection gathered up front.
276
+ * @param write - One of a group's {@link ConfigTarget} writes.
277
+ * @returns That write's {@link McpTargetState}.
278
+ */
279
+ export function mcpWriteState(
280
+ d: McpDetection,
281
+ write: ConfigTarget,
282
+ ): McpTargetState {
283
+ return d.stateByWrite.get(mcpWriteKey(write)) ?? "absent";
284
+ }
285
+
215
286
  /** The target groups the user selected (by path), or all when none recorded. */
216
287
  export function selectedGroups(
217
288
  d: McpDetection,
@@ -222,7 +293,7 @@ export function selectedGroups(
222
293
 
223
294
  /**
224
295
  * Compose the per-target config writes for the SELECTED groups (pure —
225
- * re-runnable; builds ABSOLUTE config paths itself). An already-`configured`
296
+ * re-runnable; builds ABSOLUTE config paths itself). An already-`registered`
226
297
  * group composes NOTHING: the entry on disk is byte-for-byte what a write would
227
298
  * emit, so re-writing it would only move an mtime. Removal no longer depends on
228
299
  * the forward plan having written something ({@link composeMcpRemoval}), which
@@ -241,9 +312,9 @@ export function composeMcp(
241
312
  // Re-runnable combinators (NOT a single-use `gen`): `execute` interprets the
242
313
  // task twice (preview + perform). One combined write per file keeps a shared
243
314
  // file (VS Code + Cline) a single read-modify-write — dry-run safe. The
244
- // written entry is BAND-shaped per group (a global entry omits `cwd`).
315
+ // written entry is SCOPE-shaped per group (a global entry omits `cwd`).
245
316
  const pending = groups.filter(
246
- (group) => mcpGroupState(d, group.path) !== "configured",
317
+ (group) => mcpGroupState(d, group.path) !== "registered",
247
318
  );
248
319
  return sequence_(
249
320
  pending.map((group) =>
@@ -257,8 +328,8 @@ export function composeMcp(
257
328
  }
258
329
 
259
330
  /**
260
- * The groups this band OWNS right now — every file where detection classifies a
261
- * pragma entry as present (`configured` or `drifted`). Removal acts on exactly
331
+ * The groups this scope OWNS right now — every file where detection classifies a
332
+ * pragma entry as present (`registered` or `drifted`). Removal acts on exactly
262
333
  * this set, so a file that never carried the entry is never rewritten and a
263
334
  * foreign server in a file that does is never touched.
264
335
  */
@@ -285,9 +356,14 @@ export const ownedMcpGroups = (d: McpDetection): readonly TargetGroup[] =>
285
356
  * same file is never touched.
286
357
  *
287
358
  * @param d - The detection gathered up front.
359
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
360
+ * their undo outcomes so the caller can read results back per row.
288
361
  * @returns A Task whose undo removes the pragma entry from each owned file.
289
362
  */
290
- export function composeMcpRemoval(d: McpDetection): Task<void> {
363
+ export function composeMcpRemoval(
364
+ d: McpDetection,
365
+ undoKey?: string,
366
+ ): Task<void> {
291
367
  return sequence_(
292
368
  ownedMcpGroups(d).map((group) =>
293
369
  mkdir(dirname(group.path), true, {
@@ -296,6 +372,7 @@ export function composeMcpRemoval(d: McpDetection): Task<void> {
296
372
  d.removeMcpConfigFrom(write, MCP_SERVER_NAME),
297
373
  ),
298
374
  ),
375
+ undoKey,
299
376
  }),
300
377
  ),
301
378
  );