@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
@@ -10,58 +10,26 @@
10
10
  * Glyphs are plain constants tinted at render time; nothing is baked at load.
11
11
  */
12
12
 
13
- import chalk from "chalk";
14
13
  import { BIN_NAME } from "../../constants.js";
15
14
  import { defaultStyle, type RenderStyle } from "../../kernel/render/style.js";
15
+ import {
16
+ CHECK_GLYPHS,
17
+ checkRemedyWord,
18
+ FIX_ARROW,
19
+ SCOPE_LABELS,
20
+ SUB_BULLET,
21
+ SUB_INDENT,
22
+ } from "../../kernel/render/vocabulary.js";
16
23
  import type { Formatters } from "../../kernel/spec/index.js";
17
- import { BAND_LABELS } from "../shared/index.js";
18
24
  import type { CheckResult, CheckStatus, DoctorData } from "./types.js";
19
25
 
20
- /**
21
- * Uncolored status glyphs — tinted at render time (never baked at module load).
22
- * `available` gets its own glyph, distinct from ✗: an opt-in integration that
23
- * is not set up is not a fault, and rendering it as one would train users to
24
- * ignore the failure count. The glyph pairs with the word in the tally, so the
25
- * distinction survives with color stripped.
26
- */
27
- const STATUS_GLYPHS: Record<CheckStatus, string> = {
28
- pass: "✓",
29
- fail: "✗",
30
- available: "◇",
31
- skip: "○",
32
- };
33
-
34
- const SUB_BULLET = "·";
35
- const FIX_ARROW = "↳";
36
- const INDENT = " "; // aligns sub-lines under the check name
37
-
38
- /** The TTY styler plus `red` — the one tint {@link RenderStyle} does not carry. */
39
- interface DoctorStyle extends RenderStyle {
40
- red(text: string): string;
41
- }
42
-
43
- /**
44
- * Build the doctor styler for this process's stdout: the shared TTY seam, plus a
45
- * `red` gated on the SAME decision, so a fail tint appears only on a color TTY.
46
- *
47
- * @returns A colorizing styler on a color-capable TTY, else the identity styler.
48
- * @note Impure — {@link defaultStyle} reads `process.stdout.isTTY` + chalk level.
49
- */
50
- function doctorStyle(): DoctorStyle {
51
- const style = defaultStyle();
52
- return {
53
- ...style,
54
- red: style.enabled ? (text) => chalk.red(text) : (text) => text,
55
- };
56
- }
57
-
58
26
  /**
59
27
  * Tint a status glyph by meaning — green pass, red fail, cyan available
60
28
  * (actionable-but-optional, the same tint as the `fix:` arrow it points at),
61
29
  * yellow skip.
62
30
  */
63
- function paintGlyph(status: CheckStatus, style: DoctorStyle): string {
64
- const glyph = STATUS_GLYPHS[status];
31
+ function paintGlyph(status: CheckStatus, style: RenderStyle): string {
32
+ const glyph = CHECK_GLYPHS[status];
65
33
  if (status === "pass") return style.green(glyph);
66
34
  if (status === "fail") return style.red(glyph);
67
35
  if (status === "available") return style.cyan(glyph);
@@ -70,13 +38,13 @@ function paintGlyph(status: CheckStatus, style: DoctorStyle): string {
70
38
 
71
39
  /**
72
40
  * Render one check as terminal lines: a headline row (icon, aligned name,
73
- * detail), an optional indented breakdown of sub-items, and — for failures — an
74
- * inline remedial instruction.
41
+ * detail), an optional indented breakdown of sub-items, and — whenever the
42
+ * check carries one — an inline instruction.
75
43
  */
76
44
  function formatCheckPlain(
77
45
  check: CheckResult,
78
46
  nameWidth: number,
79
- style: DoctorStyle,
47
+ style: RenderStyle,
80
48
  ): string[] {
81
49
  const name = check.name.padEnd(nameWidth);
82
50
  const label = check.status === "fail" ? style.red(name) : style.bold(name);
@@ -96,19 +64,23 @@ function formatCheckPlain(
96
64
  const itemLabel = item.detail ? item.label.padEnd(itemWidth) : item.label;
97
65
  const detail = item.detail ? ` ${style.dim(item.detail)}` : "";
98
66
  lines.push(
99
- `${INDENT}${style.dim(SUB_BULLET)} ${icon}${itemLabel}${detail}`,
67
+ `${SUB_INDENT}${style.dim(SUB_BULLET)} ${icon}${itemLabel}${detail}`,
100
68
  );
101
69
  }
102
70
  }
103
71
 
104
- // Both actionable tiers keep their inline instruction: a fail's remedy is
105
- // the repair, an available's remedy is the setup command that enables it.
106
- if (
107
- (check.status === "fail" || check.status === "available") &&
108
- check.remedy
109
- ) {
72
+ // Every row that HAS an instruction prints it. A fail's remedy is the
73
+ // repair, an available's is the setup command that enables it — and a skip's,
74
+ // when it authored one, is the only thing standing between the reader and a
75
+ // dead end. Skips were dropped here, so `skills` reported "no skills
76
+ // installed" with the next step (`sources update`) computed, carried through
77
+ // the check, published in `--format json`, and then thrown away one line
78
+ // before it reached the person reading. A skip never DERIVES a remedy
79
+ // (`scopedChecks` only passes through an authored one), so this prints
80
+ // nothing that was not deliberately written for this machine.
81
+ if (check.remedy) {
110
82
  lines.push(
111
- `${INDENT}${style.cyan(FIX_ARROW)} ${style.cyan("fix:")} ${check.remedy}`,
83
+ `${SUB_INDENT}${style.cyan(FIX_ARROW)} ${style.cyan(`${checkRemedyWord(check.status)}:`)} ${check.remedy}`,
112
84
  );
113
85
  }
114
86
 
@@ -120,7 +92,7 @@ function formatCheckPlain(
120
92
  * `available` is counted apart from `failed` so a healthy install with
121
93
  * integrations left to opt into never reports failures it does not have.
122
94
  */
123
- function formatSummary(data: DoctorData, style: DoctorStyle): string {
95
+ function formatSummary(data: DoctorData, style: RenderStyle): string {
124
96
  const parts = [style.green(`${data.passed} passed`)];
125
97
  parts.push(
126
98
  data.failed > 0
@@ -141,28 +113,28 @@ function formatSummary(data: DoctorData, style: DoctorStyle): string {
141
113
  }
142
114
 
143
115
  /**
144
- * Partition checks into ordered bands: environment (no band), then the global
145
- * and project bands. Declaration order is preserved within each band, so the
146
- * report stays deterministic.
116
+ * Partition checks into ordered sections: environment (no scope), then the
117
+ * global and local-project scopes. Declaration order is preserved within each,
118
+ * so the report stays deterministic.
147
119
  */
148
- function partitionByBand(checks: readonly CheckResult[]): {
120
+ function partitionByScope(checks: readonly CheckResult[]): {
149
121
  environment: CheckResult[];
150
- machine: CheckResult[];
122
+ global: CheckResult[];
151
123
  project: CheckResult[];
152
124
  } {
153
125
  return {
154
- environment: checks.filter((c) => c.band === undefined),
155
- machine: checks.filter((c) => c.band === "global"),
156
- project: checks.filter((c) => c.band === "project"),
126
+ environment: checks.filter((c) => c.scope === undefined),
127
+ global: checks.filter((c) => c.scope === "global"),
128
+ project: checks.filter((c) => c.scope === "project"),
157
129
  };
158
130
  }
159
131
 
160
132
  export const doctorFormatters: Formatters<DoctorData> = {
161
133
  plain(data) {
162
- const style = doctorStyle();
134
+ const style = defaultStyle();
163
135
  const nameWidth = Math.max(...data.checks.map((c) => c.name.length), 0);
164
136
  const lines: string[] = [style.bold(`${BIN_NAME} doctor`), ""];
165
- const { environment, machine, project } = partitionByBand(data.checks);
137
+ const { environment, global, project } = partitionByScope(data.checks);
166
138
  const section = (heading: string, checks: CheckResult[]): void => {
167
139
  if (checks.length === 0) return;
168
140
  if (heading) lines.push(style.bold(heading));
@@ -170,11 +142,11 @@ export const doctorFormatters: Formatters<DoctorData> = {
170
142
  lines.push(...formatCheckPlain(check, nameWidth, style));
171
143
  lines.push("");
172
144
  };
173
- // Environment checks lead (no header); the global then project bands are the
174
- // two banded sections before the tally.
145
+ // Environment checks lead (no header); Global then Local project are the
146
+ // two scoped sections before the tally.
175
147
  section("", environment);
176
- section(BAND_LABELS.global, machine);
177
- section(BAND_LABELS.project, project);
148
+ section(SCOPE_LABELS.global, global);
149
+ section(SCOPE_LABELS.project, project);
178
150
  lines.push(formatSummary(data, style));
179
151
  return lines.join("\n");
180
152
  },
@@ -182,34 +154,38 @@ export const doctorFormatters: Formatters<DoctorData> = {
182
154
  llm(data) {
183
155
  const renderCheck = (check: CheckResult): string[] => {
184
156
  const out = [
185
- `- ${STATUS_GLYPHS[check.status]} **${check.name}**: ${check.detail}`,
157
+ `- ${CHECK_GLYPHS[check.status]} **${check.name}**: ${check.detail}`,
186
158
  ];
187
159
  for (const item of check.items ?? []) {
188
- const itemIcon = item.status ? `${STATUS_GLYPHS[item.status]} ` : "";
160
+ const itemIcon = item.status ? `${CHECK_GLYPHS[item.status]} ` : "";
189
161
  const detail = item.detail ? `: ${item.detail}` : "";
190
162
  out.push(` - ${itemIcon}${item.label}${detail}`);
191
163
  }
192
- // Same rule as the plain path: fail and available both carry their
193
- // inline instruction (the repair, or the setup command).
194
- if (
195
- (check.status === "fail" || check.status === "available") &&
196
- check.remedy
197
- ) {
198
- out.push(` - _fix:_ \`${check.remedy}\``);
164
+ // Same rule as the plain path: every row that carries an instruction
165
+ // prints it, under the label its status earns. Only a `fix:` is code-set
166
+ // — a derived remedy is a bare command, while a skip's authored `next:`
167
+ // is a sentence that may quote a command of its own, and wrapping that in
168
+ // backticks nests one code span inside another.
169
+ if (check.remedy) {
170
+ out.push(
171
+ check.status === "skip"
172
+ ? ` - _${checkRemedyWord(check.status)}:_ ${check.remedy}`
173
+ : ` - _${checkRemedyWord(check.status)}:_ \`${check.remedy}\``,
174
+ );
199
175
  }
200
176
  return out;
201
177
  };
202
178
 
203
179
  const lines: string[] = ["## Doctor", ""];
204
- const { environment, machine, project } = partitionByBand(data.checks);
180
+ const { environment, global, project } = partitionByScope(data.checks);
205
181
  const section = (heading: string, checks: CheckResult[]): void => {
206
182
  if (checks.length === 0) return;
207
183
  if (heading) lines.push(`### ${heading}`, "");
208
184
  for (const check of checks) lines.push(...renderCheck(check));
209
185
  };
210
186
  section("", environment);
211
- section(BAND_LABELS.global, machine);
212
- section(BAND_LABELS.project, project);
187
+ section(SCOPE_LABELS.global, global);
188
+ section(SCOPE_LABELS.project, project);
213
189
  lines.push(
214
190
  "",
215
191
  `_${data.passed} passed, ${data.failed} failed, ${data.available} available, ${data.skipped} skipped_`,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `pragma doctor` — the environment checks plus the banded target rows.
2
+ * `pragma doctor` — the environment checks plus the scoped target rows.
3
3
  *
4
4
  * Runs against isolated HOME/cwd/XDG so the harness/config/completion checks are
5
5
  * deterministic (no harnesses, no config, no rc files). Covers the shape, a
@@ -34,7 +34,7 @@ import {
34
34
  } from "../../testing/fixtures/graph/canonical.js";
35
35
  import { bootFixtureRuntime } from "../../testing/helpers/fixtureGraph.js";
36
36
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
37
- import { bandedChecks } from "./checks/targetHealth.js";
37
+ import { scopedChecks } from "./checks/targetHealth.js";
38
38
  import { doctorModule } from "./index.js";
39
39
  import { runChecks } from "./runChecks.js";
40
40
  import type { DoctorData } from "./types.js";
@@ -118,12 +118,13 @@ const byName = (data: DoctorData, name: string) =>
118
118
  data.checks.find((c) => c.name === name);
119
119
 
120
120
  describe("doctor — shape & spread", () => {
121
- it("returns the environment checks plus one row per banded target", async () => {
121
+ it("returns the environment checks plus one row per scoped target", async () => {
122
122
  const data = await runChecks(bootRuntime(FLAGS, tmp("pragma-proj-")));
123
- // Four unbanded environment checks, then the target table in both bands:
124
- // all five targets are global, mcp and skills are also project.
125
- expect(data.checks).toHaveLength(11);
126
- expect(data.passed + data.failed + data.available + data.skipped).toBe(11);
123
+ // Four unscoped environment checks, then the target table in both scopes
124
+ // (all five targets are global, mcp and skills are also project) = 11,
125
+ // plus the two `harnesses` inventory rows, one per scope = 13.
126
+ expect(data.checks).toHaveLength(13);
127
+ expect(data.passed + data.failed + data.available + data.skipped).toBe(13);
127
128
  for (const check of data.checks) {
128
129
  expect(["pass", "fail", "available", "skip"]).toContain(check.status);
129
130
  }
@@ -133,14 +134,28 @@ describe("doctor — shape & spread", () => {
133
134
  // snapshot answers reads and the check passes, naming what it is reading.
134
135
  const pkgRefs = byName(data, "pack refs");
135
136
  expect(pkgRefs?.status).toBe("pass");
136
- expect(pkgRefs?.detail).toContain("embedded snapshot @ ");
137
+ expect(pkgRefs?.detail).toContain("shipped with the CLI");
137
138
  expect(pkgRefs?.remedy).toBeUndefined();
139
+ // Provenance is one ITEM PER PACK, not a comma-joined string in the
140
+ // headline: four packs and two forty-character SHAs on one line was
141
+ // unreadable, and every other multi-part check here already uses items.
142
+ // The headline counts them; the items say which revision each one is.
143
+ expect(pkgRefs?.detail).toContain("packs");
144
+ expect((pkgRefs?.items?.length ?? 0) > 1).toBe(true);
145
+ for (const item of pkgRefs?.items ?? []) {
146
+ expect(item.label).not.toContain(",");
147
+ // A git hash is cut to seven — the length every other tool in this
148
+ // workflow prints, and short enough that four rows stay scannable.
149
+ expect(item.detail ?? "").not.toMatch(/[0-9a-f]{40}/);
150
+ }
138
151
 
139
- // The banded rows carry the TARGET IDS verbatim — `mcp`, not "MCP
152
+ // The scoped rows carry the TARGET IDS verbatim — `mcp`, not "MCP
140
153
  // configured" — because the row name IS the fix command's argument. The
141
- // environment checks stay unbanded.
142
- const banded = data.checks.filter((c) => c.band !== undefined);
143
- expect(banded.map((c) => `${c.band}:${c.name}`)).toEqual([
154
+ // environment checks stay unscoped. `harnesses` is the ONE scoped row that
155
+ // is not a target: an inventory of the machine, not something to set up, so
156
+ // it sits last in each scope and derives no `fix:`.
157
+ const scoped = data.checks.filter((c) => c.scope !== undefined);
158
+ expect(scoped.map((c) => `${c.scope}:${c.name}`)).toEqual([
144
159
  "global:config",
145
160
  "global:completions",
146
161
  "global:lsp",
@@ -148,18 +163,27 @@ describe("doctor — shape & spread", () => {
148
163
  "global:skills",
149
164
  "project:mcp",
150
165
  "project:skills",
166
+ "global:harnesses",
167
+ "project:harnesses",
151
168
  ]);
152
169
  // Every row that wants action names the exact command that repairs it,
153
- // band included — the bijection, derived rather than authored.
154
- for (const check of banded) {
170
+ // scope included — the bijection, derived rather than authored.
171
+ for (const check of scoped) {
155
172
  if (check.status === "fail" || check.status === "available") {
156
173
  expect(check.remedy).toBeDefined();
157
174
  }
158
175
  }
176
+ // The inventory never inflates the failure or the action count: it is only
177
+ // ever `pass` (this scope holds harnesses) or `skip` (it holds none), and it
178
+ // proposes nothing — the `mcp`/`skills` rows own every harness action.
179
+ for (const check of scoped.filter((c) => c.name === "harnesses")) {
180
+ expect(["pass", "skip"]).toContain(check.status);
181
+ expect(check.remedy).toBeUndefined();
182
+ }
159
183
  // No harnesses in an empty HOME/cwd, so both mcp rows skip; the project
160
- // band is opt-in, so its absence is never a fault.
184
+ // scope is opt-in, so its absence is never a fault.
161
185
  expect(
162
- banded.find((c) => c.band === "project" && c.name === "mcp")?.status,
186
+ scoped.find((c) => c.scope === "project" && c.name === "mcp")?.status,
163
187
  ).toBe("skip");
164
188
  // No global config in the isolated XDG — an opt-in that is not set up yet.
165
189
  expect(byName(data, "config")?.status).toBe("available");
@@ -190,8 +214,12 @@ describe("doctor — the pack-refs check", () => {
190
214
  const data = await runChecks(fixture.runtime);
191
215
  const pkgRefs = byName(data, "pack refs");
192
216
  expect(pkgRefs?.status).toBe("pass");
193
- // The fixture pack's own provenance label, not the embedded snapshot's.
194
- expect(pkgRefs?.detail).toContain("fixture");
217
+ // The fixture pack's own provenance label, not the embedded snapshot's —
218
+ // now carried as an item rather than inline, and passed through WHOLE
219
+ // because it parses as no scheme. An unreadable provenance is still
220
+ // provenance; dropping it would be the one failure this check exists to
221
+ // prevent.
222
+ expect(pkgRefs?.items?.map((item) => item.label)).toContain("fixture");
195
223
  expect(pkgRefs?.detail).not.toContain("embedded snapshot");
196
224
  await fixture.dispose();
197
225
  });
@@ -200,7 +228,7 @@ describe("doctor — the pack-refs check", () => {
200
228
  describe("doctor — the store check", () => {
201
229
  it("a store that fails to boot is an attributable fail, not a crash", async () => {
202
230
  const data = await runChecks(throwingStoreRuntime(tmp("pragma-proj-")));
203
- expect(data.checks).toHaveLength(11);
231
+ expect(data.checks).toHaveLength(13);
204
232
  const store = byName(data, "store");
205
233
  expect(store?.status).toBe("fail");
206
234
  expect(store?.remedy).toBeTruthy();
@@ -242,23 +270,23 @@ describe("doctor — dispatch & MCP", () => {
242
270
  const envelope = await mcp.callTool("doctor");
243
271
  await mcp.cleanup();
244
272
  expect(envelope.ok).toBe(true);
245
- expect((envelope.data as DoctorData).checks).toHaveLength(11);
273
+ expect((envelope.data as DoctorData).checks).toHaveLength(13);
246
274
  });
247
275
  });
248
276
 
249
- describe("doctor — the banded rows can see the GLOBAL band", () => {
250
- it("reports a configured global-band entry as configured, not missing", async () => {
277
+ describe("doctor — the scoped rows can see the GLOBAL scope", () => {
278
+ it("reports a configured global-scope entry as configured, not missing", async () => {
251
279
  // The regression this closes: detection recorded only each harness's
252
- // DEFAULT band, so a server registered by `setup mcp --global` was invisible
280
+ // DEFAULT scope, so a server registered by `setup mcp --global` was invisible
253
281
  // and doctor reported it not-configured forever. The rows now come from the
254
- // same target table `setup` writes through, enumerated in BOTH bands, so the
282
+ // same target table `setup` writes through, enumerated in BOTH scopes, so the
255
283
  // two surfaces cannot disagree about what they looked at.
256
284
  const cwd = tmp("pragma-doctor-proj-");
257
285
  mkdirSync(join(cwd, ".windsurf"), { recursive: true }); // ⇒ Windsurf detected
258
286
  const home = process.env.HOME ?? "";
259
287
  const wsDir = join(home, ".codeium", "windsurf");
260
288
  mkdirSync(wsDir, { recursive: true });
261
- // Exactly what a global-band `setup mcp` writes: no `cwd`, so the entry is
289
+ // Exactly what a global-scope `setup mcp` writes: no `cwd`, so the entry is
262
290
  // not pinned to whatever directory the registration ran from.
263
291
  writeFileSync(
264
292
  join(wsDir, "mcp_config.json"),
@@ -272,18 +300,175 @@ describe("doctor — the banded rows can see the GLOBAL band", () => {
272
300
  writeFileSync(join(binDir, "pragma"), "#!/bin/sh\nexit 0\n");
273
301
  chmodSync(join(binDir, "pragma"), 0o755);
274
302
 
275
- const rows = await bandedChecks(bootRuntime(FLAGS, cwd), "pragma");
276
- const global = rows.find((r) => r.name === "mcp" && r.band === "global");
303
+ const rows = await scopedChecks(bootRuntime(FLAGS, cwd), "pragma");
304
+ const global = rows.find((r) => r.name === "mcp" && r.scope === "global");
277
305
  expect(global?.status).toBe("pass");
278
306
  expect(
279
307
  global?.items?.some((i) => i.label.includes("mcp_config.json")),
280
308
  ).toBe(true);
281
- // ...and the project band stays an opt-in skip, never a fault.
282
- const project = rows.find((r) => r.name === "mcp" && r.band === "project");
309
+ // ...and the project scope stays an opt-in skip, never a fault.
310
+ const project = rows.find((r) => r.name === "mcp" && r.scope === "project");
283
311
  expect(project?.status).toBe("skip");
284
312
  });
285
313
  });
286
314
 
315
+ describe("doctor — the harness inventory", () => {
316
+ const VERBOSE: GlobalFlags = { ...FLAGS, verbose: true };
317
+ const inventory = (rows: Awaited<ReturnType<typeof scopedChecks>>) => ({
318
+ global: rows.find((r) => r.name === "harnesses" && r.scope === "global"),
319
+ project: rows.find((r) => r.name === "harnesses" && r.scope === "project"),
320
+ });
321
+
322
+ it("lists ONLY detected harnesses by default, by name and with their location", async () => {
323
+ const cwd = tmp("pragma-doctor-proj-");
324
+ mkdirSync(join(cwd, ".vscode"), { recursive: true }); // ⇒ VS Code detected
325
+
326
+ const rows = await scopedChecks(bootRuntime(FLAGS, cwd), "pragma");
327
+ const { global, project } = inventory(rows);
328
+
329
+ // The project scope holds the hit — by NAME, with the file it resolves to.
330
+ expect(project?.status).toBe("pass");
331
+ expect(project?.detail).toBe("1 detected · 0 registered");
332
+ expect(project?.items?.map((i) => i.label)).toEqual(["VS Code"]);
333
+ expect(project?.items?.[0]?.status).toBe("available");
334
+ expect(project?.items?.[0]?.detail).toContain("detected, not registered");
335
+ expect(project?.items?.[0]?.detail).toContain(".vscode/mcp.json");
336
+
337
+ // Nothing detected in the global scope, and that is a skip — not a fault.
338
+ expect(global?.status).toBe("skip");
339
+ expect(global?.detail).toBe("no harnesses detected");
340
+ expect(global?.items).toBeUndefined();
341
+ });
342
+
343
+ it("--verbose lists every registry harness, undetected ones as a NON-failing skip", async () => {
344
+ const cwd = tmp("pragma-doctor-proj-");
345
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
346
+
347
+ const rows = await scopedChecks(bootRuntime(VERBOSE, cwd), "pragma");
348
+ const { global, project } = inventory(rows);
349
+
350
+ // Every harness the registry knows, in both scopes.
351
+ expect(project?.items).toHaveLength(13);
352
+ expect(global?.items).toHaveLength(13);
353
+ expect(project?.detail).toBe("1 detected · 0 registered · 13 known");
354
+
355
+ // `types.ts` forbids inflating the failure count: a machine that simply
356
+ // does not have Cursor is not a broken machine.
357
+ for (const check of [global, project]) {
358
+ expect(["pass", "skip"]).toContain(check?.status);
359
+ for (const item of check?.items ?? []) {
360
+ expect(item.status).not.toBe("fail");
361
+ expect(["pass", "available", "skip"]).toContain(item.status);
362
+ }
363
+ }
364
+
365
+ const cursor = project?.items?.find((i) => i.label === "Cursor");
366
+ expect(cursor?.status).toBe("skip");
367
+ expect(cursor?.detail).toBe("not detected");
368
+ });
369
+
370
+ it("says where a harness DOES keep its config, rather than omitting it", async () => {
371
+ // `vscode` is `scope: "project"`, so it can NEVER carry a global entry. A
372
+ // verbose global listing that silently dropped it would read as a bug in
373
+ // the listing; the row says why instead. The mirror case is Windsurf, which
374
+ // is `scope: "global"` and keeps nothing per project.
375
+ //
376
+ // The sentence states the FACT, not the partition: `no Global band` named
377
+ // an internal word for the answer instead of giving it.
378
+ const rows = await scopedChecks(
379
+ bootRuntime(VERBOSE, tmp("pragma-doctor-proj-")),
380
+ "pragma",
381
+ );
382
+ const { global, project } = inventory(rows);
383
+
384
+ const vscodeGlobal = global?.items?.find((i) => i.label === "VS Code");
385
+ expect(vscodeGlobal?.detail).toBe(
386
+ "keeps no global config — it is per-project only",
387
+ );
388
+ expect(vscodeGlobal?.status).toBe("skip");
389
+
390
+ const windsurfProject = project?.items?.find((i) => i.label === "Windsurf");
391
+ expect(windsurfProject?.detail).toBe(
392
+ "keeps no per-project config — it is global only",
393
+ );
394
+ expect(windsurfProject?.status).toBe("skip");
395
+ });
396
+
397
+ it("reports a harness whose pragma entry is current as registered", async () => {
398
+ const cwd = tmp("pragma-doctor-proj-");
399
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
400
+ // Byte-for-byte what a project-scope `setup mcp` writes for VS Code: its
401
+ // `servers` key, and the `cwd` a project registration pins.
402
+ writeFileSync(
403
+ join(cwd, ".vscode", "mcp.json"),
404
+ JSON.stringify({
405
+ servers: { pragma: { command: "pragma", args: ["mcp", "serve"], cwd } },
406
+ }),
407
+ );
408
+
409
+ const rows = await scopedChecks(bootRuntime(FLAGS, cwd), "pragma");
410
+ const { project } = inventory(rows);
411
+ expect(project?.detail).toBe("1 detected · 1 registered");
412
+ const vscode = project?.items?.find((i) => i.label === "VS Code");
413
+ expect(vscode?.status).toBe("pass");
414
+ expect(vscode?.detail).toContain("registered");
415
+ expect(vscode?.detail).not.toContain("not registered");
416
+ });
417
+
418
+ it("reports two harnesses sharing ONE file by their own keys, not by the file", async () => {
419
+ // The co-detection case. `.vscode/mcp.json` is written under TWO keys —
420
+ // VS Code's `servers` and Cline's `mcpServers` — and registering pragma
421
+ // for VS Code alone leaves the FILE half-configured, which `detectMcp`
422
+ // aggregates to `drifted` (correctly: a write is still owed). That
423
+ // aggregate is not either harness's standing, and reporting it per harness
424
+ // said both had a stale entry when one was current and the other had none.
425
+ const cwd = tmp("pragma-doctor-proj-");
426
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
427
+ // Cline is detected ONLY by its installed extension, in the same directory
428
+ // that also proves VS Code is installed — so the two co-detect.
429
+ mkdirSync(
430
+ join(
431
+ process.env.HOME as string,
432
+ ".vscode",
433
+ "extensions",
434
+ "saoudrizwan.claude-dev-3.20.0",
435
+ ),
436
+ { recursive: true },
437
+ );
438
+ writeFileSync(
439
+ join(
440
+ process.env.HOME as string,
441
+ ".vscode",
442
+ "extensions",
443
+ "saoudrizwan.claude-dev-3.20.0",
444
+ "package.json",
445
+ ),
446
+ "{}",
447
+ );
448
+ // Only VS Code's key is registered; Cline's `mcpServers` is absent.
449
+ writeFileSync(
450
+ join(cwd, ".vscode", "mcp.json"),
451
+ JSON.stringify({
452
+ servers: { pragma: { command: "pragma", args: ["mcp", "serve"], cwd } },
453
+ }),
454
+ );
455
+
456
+ const rows = await scopedChecks(bootRuntime(FLAGS, cwd), "pragma");
457
+ const { project } = inventory(rows);
458
+ expect(project?.detail).toBe("2 detected · 1 registered");
459
+
460
+ const vscode = project?.items?.find((i) => i.label === "VS Code");
461
+ expect(vscode?.status).toBe("pass");
462
+ expect(vscode?.detail).toContain("registered — ");
463
+ expect(vscode?.detail).not.toContain("differs");
464
+
465
+ const cline = project?.items?.find((i) => i.label === "Cline");
466
+ expect(cline?.status).toBe("available");
467
+ expect(cline?.detail).toContain("detected, not registered");
468
+ expect(cline?.detail).not.toContain("differs");
469
+ });
470
+ });
471
+
287
472
  describe("doctor — a blocked skill link path is never reported as current", () => {
288
473
  it("distinguishes a hand-placed real directory from a link that is correct", async () => {
289
474
  // Detection marks both `skipped` — there is nothing to do to either — so
@@ -299,8 +484,8 @@ describe("doctor — a blocked skill link path is never reported as current", ()
299
484
  // A hand-placed directory sitting exactly where the link would go.
300
485
  mkdirSync(join(cwd, ".agents", "skills", "my-skill"), { recursive: true });
301
486
 
302
- const rows = await bandedChecks(bootRuntime(FLAGS, cwd), "pragma");
303
- const row = rows.find((r) => r.name === "skills" && r.band === "project");
487
+ const rows = await scopedChecks(bootRuntime(FLAGS, cwd), "pragma");
488
+ const row = rows.find((r) => r.name === "skills" && r.scope === "project");
304
489
  expect(row?.status).not.toBe("pass");
305
490
  expect(row?.detail).toContain("real directory");
306
491
  // And the remedy is the one that settles it — rerunning setup skips it.
@@ -308,6 +493,33 @@ describe("doctor — a blocked skill link path is never reported as current", ()
308
493
  });
309
494
  });
310
495
 
496
+ describe("doctor — an EMPTY skills root is not diagnosed as an ABSENT one", () => {
497
+ it("reports an existing but empty project skills root as holding none", async () => {
498
+ // doctor publishes a DIAGNOSIS, so a wrong one is worse than a vague one:
499
+ // the shared skip line told the user `.pragma/skills` "is absent" while
500
+ // they were looking at the directory. Both surfaces read the same
501
+ // `rootExists` the detection already carries.
502
+ const cwd = tmp("pragma-doctor-proj-");
503
+ mkdirSync(join(cwd, ".pragma", "skills"), { recursive: true });
504
+
505
+ const rows = await scopedChecks(bootRuntime(FLAGS, cwd), "pragma");
506
+ const row = rows.find((r) => r.name === "skills" && r.scope === "project");
507
+ expect(row?.status).toBe("skip"); // still nothing to reconcile...
508
+ expect(row?.detail).toContain("is empty"); // ...for the true reason
509
+ expect(row?.detail).not.toContain("does not exist");
510
+ });
511
+
512
+ it("still reports a missing project skills root as absent", async () => {
513
+ const rows = await scopedChecks(
514
+ bootRuntime(FLAGS, tmp("pragma-doctor-proj-")),
515
+ "pragma",
516
+ );
517
+ const row = rows.find((r) => r.name === "skills" && r.scope === "project");
518
+ expect(row?.status).toBe("skip");
519
+ expect(row?.detail).toContain("does not exist");
520
+ });
521
+ });
522
+
311
523
  describe("doctor — an unconfigured opt-in integration is available, not a fault", () => {
312
524
  it("a detected harness with no MCP entry reports available with the setup command", async () => {
313
525
  // Windsurf is detected (project signal) but nothing is configured. A fresh
@@ -317,11 +529,11 @@ describe("doctor — an unconfigured opt-in integration is available, not a faul
317
529
  const cwd = tmp("pragma-doctor-proj-");
318
530
  mkdirSync(join(cwd, ".windsurf"), { recursive: true }); // ⇒ Windsurf detected
319
531
 
320
- const rows = await bandedChecks(bootRuntime(FLAGS, cwd), "pragma");
321
- const check = rows.find((r) => r.name === "mcp" && r.band === "global");
532
+ const rows = await scopedChecks(bootRuntime(FLAGS, cwd), "pragma");
533
+ const check = rows.find((r) => r.name === "mcp" && r.scope === "global");
322
534
  expect(check?.status).toBe("available");
323
- expect(check?.detail).toContain("not configured");
324
- // The fix is the target's own invocation, derived from the row's id + band.
535
+ expect(check?.detail).toContain("not registered");
536
+ // The fix is the target's own invocation, derived from the row's id + scope.
325
537
  expect(check?.remedy).toBe("pragma setup mcp");
326
538
  });
327
539
  });
@@ -20,8 +20,13 @@ import type { DoctorData } from "./types.js";
20
20
 
21
21
  const doctorVerb: VerbSpec<Record<string, unknown>, DoctorData> = {
22
22
  path: ["doctor"],
23
- summary: "Check environment health and every setup target, in both bands.",
24
- doc: "Reports the environment checks and then one row per setup target in each band, as pass, fail, available (an opt-in integration not yet set up), or skip, with inline remedies. Every banded row is named after the setup target that repairs it. Storeless by default; the store check boots lazily and never fails the run.",
23
+ summary:
24
+ "Check your environment and every setup target, globally and in this project.",
25
+ // Two house rules constrain this string and both are PROTECTED: it may not
26
+ // name the distribution (`identity.test.ts`) and it may not spell a CLI flag
27
+ // (`toolDescriptions.test.ts`), so the inventory's widening is described as
28
+ // "the verbose global flag" rather than by its spelling.
29
+ doc: "Reports the environment checks first, then one row per setup target — once for your home directory, once for this project. Each row is pass, fail, available (an optional integration you have not set up yet), or skip (nothing to do here, and the row says why), with the next step printed inline. Every row is named after the setup target that repairs it, except `harnesses`: a listing, per scope, of the AI harnesses found on this machine and whether this CLI's MCP server is registered in each — the ones actually found, or every harness it knows about under the verbose global flag. Needs no store; the store check boots lazily and never fails the run.",
25
30
  params: [],
26
31
  output: { formatters: doctorFormatters },
27
32
  examples: [