@canonical/pragma-cli 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +16 -15
  13. package/dist/pragma.conf.js +236 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  18. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  19. package/dist/src/capabilities/create/mount.js +4 -3
  20. package/dist/src/capabilities/create/mount.js.map +1 -1
  21. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  22. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  24. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  27. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  28. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  30. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  31. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  32. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  33. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  34. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  35. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  36. package/dist/src/capabilities/graph/query.render.js +2 -2
  37. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  38. package/dist/src/capabilities/info/collectInfo.js +8 -2
  39. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  40. package/dist/src/capabilities/info/info.render.js +6 -2
  41. package/dist/src/capabilities/info/info.render.js.map +1 -1
  42. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  43. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  44. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  45. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  46. package/dist/src/capabilities/setup/mode.js +2 -2
  47. package/dist/src/capabilities/setup/operations/index.js +4 -4
  48. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/setupGenerator.js +92 -45
  50. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupLsp.js +88 -36
  52. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupMcp.js +97 -51
  54. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupSkills.js +325 -47
  56. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  57. package/dist/src/capabilities/setup/plan.js.map +1 -1
  58. package/dist/src/capabilities/setup/plan.render.js +144 -66
  59. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  60. package/dist/src/capabilities/setup/setup.render.js +1 -1
  61. package/dist/src/capabilities/setup/setup.verb.js +86 -33
  62. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  63. package/dist/src/capabilities/setup/targets.js +110 -45
  64. package/dist/src/capabilities/setup/targets.js.map +1 -1
  65. package/dist/src/capabilities/shared/index.js +6 -5
  66. package/dist/src/capabilities/shared/index.js.map +1 -1
  67. package/dist/src/capabilities/shared/packageManager.js +300 -54
  68. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  69. package/dist/src/capabilities/skill/discover.js +97 -11
  70. package/dist/src/capabilities/skill/discover.js.map +1 -1
  71. package/dist/src/capabilities/skill/render.js +2 -2
  72. package/dist/src/capabilities/skill/render.js.map +1 -1
  73. package/dist/src/capabilities/sources/installSkills.js +145 -3
  74. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  75. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  76. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  77. package/dist/src/capabilities/sources/update.verb.js +3 -3
  78. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  79. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  80. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  81. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  82. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  83. package/dist/src/constants.js +6 -1
  84. package/dist/src/constants.js.map +1 -1
  85. package/dist/src/kernel/index.js +11 -2
  86. package/dist/src/kernel/index.js.map +1 -1
  87. package/dist/src/kernel/interactivity.js +68 -0
  88. package/dist/src/kernel/interactivity.js.map +1 -0
  89. package/dist/src/kernel/packs/renderPack.js +3 -3
  90. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  91. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  92. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  93. package/dist/src/kernel/packs/runBodies.js +57 -4
  94. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  95. package/dist/src/kernel/packs/schema.js +54 -2
  96. package/dist/src/kernel/packs/schema.js.map +1 -1
  97. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  98. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +306 -43
  100. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  101. package/dist/src/kernel/packs/types.js.map +1 -1
  102. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  103. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  104. package/dist/src/kernel/project/cli/dispatch.js +23 -23
  105. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  106. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  107. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  108. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  109. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  110. package/dist/src/kernel/project/cli/rootHelp.js +34 -4
  111. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  112. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  113. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  114. package/dist/src/kernel/render/renderers.js +1 -1
  115. package/dist/src/kernel/render/style.js +29 -9
  116. package/dist/src/kernel/render/style.js.map +1 -1
  117. package/dist/src/kernel/render/vocabulary.js +174 -0
  118. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  119. package/dist/src/kernel/spec/emitReference.js +1 -1
  120. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  121. package/package.json +16 -15
  122. package/pragma.conf.ts +241 -34
  123. package/src/bin.ts +8 -1
  124. package/src/capabilities/block.parity.test.ts +25 -11
  125. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  126. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  127. package/src/capabilities/colophon/colophon.render.ts +1 -2
  128. package/src/capabilities/colophon/colophon.test.ts +2 -2
  129. package/src/capabilities/create/createGrammar.test.ts +1 -1
  130. package/src/capabilities/create/mount.ts +3 -3
  131. package/src/capabilities/distribution.parity.test.ts +4 -0
  132. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  133. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  134. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  135. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  136. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  137. package/src/capabilities/doctor/checks/index.ts +6 -6
  138. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  139. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  140. package/src/capabilities/doctor/doctor.render.ts +56 -80
  141. package/src/capabilities/doctor/doctor.test.ts +247 -35
  142. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  143. package/src/capabilities/doctor/runChecks.ts +14 -11
  144. package/src/capabilities/doctor/types.ts +15 -24
  145. package/src/capabilities/emptyRecovery.test.ts +10 -2
  146. package/src/capabilities/graph/query.render.ts +2 -2
  147. package/src/capabilities/info/collectInfo.ts +8 -1
  148. package/src/capabilities/info/info.render.ts +6 -2
  149. package/src/capabilities/info/info.test.ts +37 -1
  150. package/src/capabilities/info/types.ts +10 -2
  151. package/src/capabilities/prompt/prompt.render.ts +2 -2
  152. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  153. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  154. package/src/capabilities/setup/buildPlan.ts +45 -41
  155. package/src/capabilities/setup/index.ts +1 -1
  156. package/src/capabilities/setup/mode.ts +2 -2
  157. package/src/capabilities/setup/operations/index.ts +6 -1
  158. package/src/capabilities/setup/operations/setupGenerator.ts +144 -70
  159. package/src/capabilities/setup/operations/setupLsp.ts +129 -37
  160. package/src/capabilities/setup/operations/setupMcp.ts +132 -61
  161. package/src/capabilities/setup/operations/setupSkills.ts +392 -58
  162. package/src/capabilities/setup/plan.render.ts +171 -76
  163. package/src/capabilities/setup/plan.ts +17 -12
  164. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  165. package/src/capabilities/setup/setup.render.test.ts +50 -36
  166. package/src/capabilities/setup/setup.render.ts +1 -1
  167. package/src/capabilities/setup/setup.test.ts +881 -50
  168. package/src/capabilities/setup/setup.verb.ts +91 -33
  169. package/src/capabilities/setup/targets.ts +120 -52
  170. package/src/capabilities/setup/types.ts +10 -18
  171. package/src/capabilities/shared/index.ts +17 -6
  172. package/src/capabilities/shared/packageManager.test.ts +421 -6
  173. package/src/capabilities/shared/packageManager.ts +394 -59
  174. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  175. package/src/capabilities/skill/discover.ts +100 -11
  176. package/src/capabilities/skill/render.ts +2 -2
  177. package/src/capabilities/skill/skill.test.ts +17 -4
  178. package/src/capabilities/sources/installSkills.ts +181 -3
  179. package/src/capabilities/sources/runUpdate.ts +78 -2
  180. package/src/capabilities/sources/sources.test.ts +228 -0
  181. package/src/capabilities/sources/update.verb.ts +3 -3
  182. package/src/capabilities/surface.test.ts +2 -2
  183. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  184. package/src/capabilities/upgrade/types.ts +10 -2
  185. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  186. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  187. package/src/constants.ts +6 -1
  188. package/src/identity.test.ts +18 -5
  189. package/src/kernel/completion/entitySource.test.ts +15 -2
  190. package/src/kernel/completion/safety.test.ts +16 -9
  191. package/src/kernel/config/defaults.test.ts +2 -1
  192. package/src/kernel/index.ts +11 -2
  193. package/src/kernel/interactivity.test.ts +76 -0
  194. package/src/kernel/interactivity.ts +69 -0
  195. package/src/kernel/packs/compile.test.ts +92 -0
  196. package/src/kernel/packs/renderPack.ts +3 -3
  197. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  198. package/src/kernel/packs/resolveEntity.ts +88 -24
  199. package/src/kernel/packs/runBodies.ts +84 -19
  200. package/src/kernel/packs/schema.ts +60 -4
  201. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  202. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  203. package/src/kernel/packs/sparql/buildLookupQuery.ts +323 -45
  204. package/src/kernel/packs/toolDescription.test.ts +35 -1
  205. package/src/kernel/packs/types.ts +125 -2
  206. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  207. package/src/kernel/project/cli/buildProgram.ts +8 -1
  208. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  209. package/src/kernel/project/cli/dispatch.ts +25 -24
  210. package/src/kernel/project/cli/globalFlags.ts +4 -1
  211. package/src/kernel/project/cli/helpFormat.ts +1 -2
  212. package/src/kernel/project/cli/rootHelp.ts +36 -3
  213. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  214. package/src/kernel/render/renderers.ts +1 -1
  215. package/src/kernel/render/style.ts +32 -9
  216. package/src/kernel/render/vocabulary.ts +276 -0
  217. package/src/kernel/spec/emitReference.ts +1 -1
  218. package/src/kernel/spec/types.ts +35 -6
  219. package/src/testing/PARITY_GAPS.ts +9 -3
  220. package/src/testing/behavioral/docExamples.test.ts +4 -2
  221. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  222. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  223. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  224. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  225. package/src/testing/eval/cases/readNouns.ts +245 -9
  226. package/src/testing/fixtures/blockGraph.ts +28 -13
  227. package/src/testing/fixtures/graph/canonical.ts +95 -12
  228. package/dist/src/capabilities/shared/bands.js +0 -16
  229. package/dist/src/capabilities/shared/bands.js.map +0 -1
  230. package/src/capabilities/shared/bands.ts +0 -16
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The banded doctor rows, derived from the setup target table.
2
+ * The scoped doctor rows, derived from the setup target table.
3
3
  *
4
- * `doctor` and `setup` are the SAME LIST. Every banded row here carries a
4
+ * `doctor` and `setup` are the SAME LIST. Every scoped row here carries a
5
5
  * target id verbatim — `completions`, not "Shell completions"; `mcp`, not "MCP
6
6
  * configured" — because the row name IS the fix command's argument, and a user
7
7
  * who reads `✗ mcp` beside `fix: pragma setup mcp` can see that they are the
@@ -11,11 +11,18 @@
11
11
  *
12
12
  * Every row's detection comes from the target table's own `detect`, so the two
13
13
  * surfaces cannot disagree about what they looked at, and every `fix:` is
14
- * derived from the row's id and band rather than authored per check.
14
+ * derived from the row's id and scope rather than authored per check.
15
15
  *
16
- * Doctor's UNBANDED environment checks (Node version, the CLI's own version,
16
+ * Doctor's UNSCOPED environment checks (Node version, the CLI's own version,
17
17
  * pack refs, the store) diagnose things setup cannot install and stay outside
18
18
  * this list.
19
+ *
20
+ * ONE scoped row is not a target: `harnesses`, the per-scope inventory. It is an
21
+ * inventory of the machine the targets are measured against, not a target that
22
+ * can be set up, so it deliberately carries no `fix:` and can never be `fail`
23
+ * or `available` — see {@link inventoryChecks}. The bijection between the other
24
+ * rows and the setup table is untouched: `harnesses` is not a `TargetId`, and
25
+ * nothing derives a command from it.
19
26
  */
20
27
 
21
28
  import { MCP_SERVER_NAME } from "../../../constants.js";
@@ -36,15 +43,24 @@ import {
36
43
  lspSkipReason,
37
44
  type McpDetection,
38
45
  mcpGroupState,
46
+ mcpWriteState,
39
47
  type SkillsDetection,
40
48
  skillsSkipReason,
49
+ skillsSkipRemedy,
41
50
  } from "../../setup/operations/index.js";
42
51
  import { shortenPath, TARGET_IDS } from "../../setup/plan.js";
43
- import type { CheckItem, CheckResult, ScopeBand } from "../types.js";
52
+ import { MCP_NO_LOCATION } from "../../setup/targets.js";
53
+ import type { CheckItem, CheckResult, Scope } from "../types.js";
44
54
  import { checkShellCompletions } from "./checkShellCompletions.js";
55
+ import {
56
+ harnessInventory,
57
+ type InventoryGroup,
58
+ type InventoryHarness,
59
+ inventoryHealth,
60
+ } from "./harnessInventory.js";
45
61
  import { commandResolves } from "./mcpCommand.js";
46
62
 
47
- /** What a row reports before its name, band and `fix:` line are attached. */
63
+ /** What a row reports before its name, scope and `fix:` line are attached. */
48
64
  interface Health {
49
65
  readonly status: CheckResult["status"];
50
66
  readonly detail: string;
@@ -64,7 +80,8 @@ const configHealth = (
64
80
  roots: { global: string; project: string },
65
81
  ): Health => {
66
82
  const path = shortenPath(d.path, roots);
67
- if (!d.exists) return { status: "available", detail: `${path} not created` };
83
+ if (!d.exists)
84
+ return { status: "available", detail: `${path} does not exist yet` };
68
85
  if (!d.parses) {
69
86
  return { status: "fail", detail: `${path} cannot be parsed as JSON` };
70
87
  }
@@ -78,8 +95,8 @@ const lspHealth = (d: LspDetection): Health => {
78
95
  }
79
96
  const editors = lspEditorNames(d).join(", ");
80
97
  return d.state === "installed"
81
- ? { status: "pass", detail: `installed (${editors})` }
82
- : { status: "available", detail: `not installed (${editors})` };
98
+ ? { status: "pass", detail: `installed in ${editors}` }
99
+ : { status: "available", detail: `not installed in ${editors}` };
83
100
  };
84
101
 
85
102
  /**
@@ -90,22 +107,19 @@ const lspHealth = (d: LspDetection): Health => {
90
107
  */
91
108
  async function mcpHealth(
92
109
  d: McpDetection,
93
- band: ScopeBand,
110
+ scope: Scope,
94
111
  cwd: string,
95
112
  roots: { global: string; project: string },
96
113
  ): Promise<Health> {
97
114
  if (d.groups.length === 0) {
98
- return {
99
- status: "skip",
100
- detail: "no harness config location in this band",
101
- };
115
+ return { status: "skip", detail: MCP_NO_LOCATION[scope] };
102
116
  }
103
117
  const resolves = await commandResolves(MCP_SERVER_NAME, cwd);
104
118
  const items: CheckItem[] = d.groups.map((group) => {
105
119
  const state = mcpGroupState(d, group.path);
106
120
  const label = shortenPath(group.path, roots);
107
121
  if (state === "absent") {
108
- return { label, status: "available", detail: "not configured" };
122
+ return { label, status: "available", detail: "not registered" };
109
123
  }
110
124
  if (state === "drifted") {
111
125
  return { label, status: "fail", detail: "entry differs from current" };
@@ -114,8 +128,8 @@ async function mcpHealth(
114
128
  label,
115
129
  status: resolves ? "pass" : "fail",
116
130
  detail: resolves
117
- ? `entry current, \`${MCP_SERVER_NAME}\` resolves`
118
- : `entry current, \`${MCP_SERVER_NAME}\` is not on PATH`,
131
+ ? `registered, and \`${MCP_SERVER_NAME}\` is on PATH`
132
+ : `registered, but \`${MCP_SERVER_NAME}\` is not on PATH`,
119
133
  };
120
134
  });
121
135
  const configured = items.filter((item) => item.status === "pass").length;
@@ -123,24 +137,29 @@ async function mcpHealth(
123
137
  if (failing > 0) {
124
138
  return {
125
139
  status: "fail",
126
- detail: `${failing} of ${items.length} need attention`,
140
+ detail: `${failing} of ${items.length} config files need attention`,
127
141
  items,
128
142
  };
129
143
  }
130
144
  if (configured === 0) {
131
- // Project-band absence is not a failure: the band is opt-in, so a
132
- // repository with no checked-in MCP config is a healthy repository.
133
- return band === "project"
134
- ? { status: "skip", detail: "not configured for this project (opt-in)" }
145
+ // Absence in the LOCAL PROJECT is not a failure: registering there is
146
+ // opt-in, so a repository with no checked-in MCP config is healthy. The
147
+ // line says so rather than leaving "(opt-in)" to be decoded.
148
+ return scope === "project"
149
+ ? {
150
+ status: "skip",
151
+ detail:
152
+ "not registered for this project — per-project registration is opt-in",
153
+ }
135
154
  : {
136
155
  status: "available",
137
- detail: `${items.length} not configured`,
156
+ detail: `not registered in any of ${items.length} config files`,
138
157
  items,
139
158
  };
140
159
  }
141
160
  return {
142
161
  status: configured === items.length ? "pass" : "available",
143
- detail: `${configured} of ${items.length} current`,
162
+ detail: `registered in ${configured} of ${items.length} config files`,
144
163
  items,
145
164
  };
146
165
  }
@@ -148,13 +167,27 @@ async function mcpHealth(
148
167
  /** The `skills` row: every expected link present, lstat-verified, none stale. */
149
168
  function skillsHealth(
150
169
  d: SkillsDetection,
151
- band: ScopeBand,
170
+ scope: Scope,
152
171
  roots: { global: string; project: string },
153
172
  ): Health {
154
- if (!d.available) {
173
+ const short = shortenPath(d.sourceRoot, roots);
174
+ // Stale links this scope owns are work even when the root holds no skill, so
175
+ // the row is only a skip once there is nothing left to reconcile either.
176
+ const orphans = d.rootExists ? d.orphans.length : 0;
177
+ if (!d.available && orphans === 0) {
178
+ // The skip carries a remedy — `setup`'s row and this row are the same
179
+ // finding, and the dead-end version of it ("no skills installed", no next
180
+ // step) was reported identically by both surfaces.
155
181
  return {
156
182
  status: "skip",
157
- detail: skillsSkipReason(shortenPath(d.sourceRoot, roots), band),
183
+ detail: skillsSkipReason(short, scope, d.rootExists),
184
+ remedy: skillsSkipRemedy(short, scope),
185
+ };
186
+ }
187
+ if (orphans > 0) {
188
+ return {
189
+ status: "available",
190
+ detail: `${orphans} link${orphans === 1 ? "" : "s"} point at a skill that is gone`,
158
191
  };
159
192
  }
160
193
  const stale = d.actions.filter((a) => a.action === "replaced").length;
@@ -169,6 +202,22 @@ function skillsHealth(
169
202
  // and reporting the two the same way made this row say "links current" where
170
203
  // no link exists at all. Setup will never clear such a path — it is not this
171
204
  // command's to delete — so the row carries the only remedy that settles it.
205
+ // A symlink pointing outside every root pragma owns is `skipped` so setup
206
+ // will never delete it — which would make this row claim "links current" over
207
+ // a path where the user's own link shadows a shipped skill. `skipped` also
208
+ // covers an already-correct link (owned) and a real directory (blocked), so
209
+ // the foreign case is what is left.
210
+ const foreign = d.actions.filter(
211
+ (a) => a.action === "skipped" && !a.owned && !a.blocked,
212
+ ).length;
213
+ if (foreign > 0) {
214
+ return {
215
+ status: "available",
216
+ detail: `${foreign} of ${d.actions.length} link paths hold a symlink pragma does not own`,
217
+ remedy:
218
+ "Move or delete the symlink at that path, then link the skills again.",
219
+ };
220
+ }
172
221
  const blocked = d.actions.filter((a) => a.blocked).length;
173
222
  if (blocked > 0) {
174
223
  return {
@@ -198,7 +247,7 @@ async function healthOf(
198
247
  roots: { global: string; project: string },
199
248
  ): Promise<Health> {
200
249
  // A detection that threw is reported as ITS OWN failing row. Letting the
201
- // rejection escape took the whole banded section of the report with it, so a
250
+ // rejection escape took the whole scoped section of the report with it, so a
202
251
  // single unreadable config file left the user with no rows at all — the one
203
252
  // moment the report is most worth having.
204
253
  const failure = detectionFailure(row);
@@ -220,55 +269,176 @@ async function healthOf(
220
269
  case "lsp":
221
270
  return lspHealth(row.detection as LspDetection);
222
271
  case "mcp":
223
- return mcpHealth(row.detection as McpDetection, row.band, rt.cwd, roots);
272
+ return mcpHealth(row.detection as McpDetection, row.scope, rt.cwd, roots);
224
273
  default:
225
- return skillsHealth(row.detection as SkillsDetection, row.band, roots);
274
+ return skillsHealth(row.detection as SkillsDetection, row.scope, roots);
226
275
  }
227
276
  }
228
277
 
278
+ /** The scoped inventory row's name — a LISTING, not a setup target id. */
279
+ export const INVENTORY_CHECK = "harnesses";
280
+
281
+ /**
282
+ * The `harnesses` row for one scope: what this machine has, and where pragma
283
+ * stands in each.
284
+ *
285
+ * It is the one scoped row that is NOT a target, and it earns that by never
286
+ * competing with the ones that are. Its status is only `pass` or `skip`, so it
287
+ * adds no failure and no `available` to the tally, and it derives no `fix:` —
288
+ * the `mcp` and `skills` rows beside it already name every command a harness
289
+ * needs, and repeating them here would be the same finding seen twice.
290
+ *
291
+ * The registry is the only thing detection could not supply: `detectHarnesses`
292
+ * filters to hits, so "Windsurf is not on this machine" was unrepresentable
293
+ * until the universe came from somewhere. Everything else — which harnesses
294
+ * share which file, and whether each one's own entry in it is current — is read
295
+ * straight off the `mcp` detection this report already ran. The projection to
296
+ * {@link InventoryGroup} is deliberate: `McpDetection` holds
297
+ * `writeMcpConfigTargets`/`removeMcpConfigFrom` as live functions, and a
298
+ * `mutates: false` command should be unable to reach a writer, not merely
299
+ * trusted not to.
300
+ *
301
+ * This is also where the harness↔`mcpKey` association is REJOINED, because it
302
+ * is the one place that holds both halves: `TargetGroup` records the names
303
+ * sharing a file and, separately, one write per distinct `mcpKey`, and the
304
+ * registry is what says which key a given harness writes. Without that join the
305
+ * only state available per harness is the file's aggregate — and a
306
+ * `.vscode/mcp.json` where VS Code's `servers` entry is current while Cline's
307
+ * `mcpServers` is absent aggregates to `drifted`, which would report BOTH
308
+ * harnesses as drifted when neither one is.
309
+ *
310
+ * @param rt - The per-invocation runtime (read for `--verbose`).
311
+ * @param rows - Every detected row, both scopes, as `scopedChecks` has them.
312
+ * @param roots - The roots every path renders relative to.
313
+ * @returns One inventory {@link CheckResult} per scope, global then project.
314
+ * @note Impure — dynamically imports the harness registry.
315
+ */
316
+ async function inventoryChecks(
317
+ rt: PragmaRuntime,
318
+ rows: readonly DetectedRow[],
319
+ roots: { global: string; project: string },
320
+ ): Promise<CheckResult[]> {
321
+ const { harnesses, isHarnessInBand } = await import("@canonical/harnesses");
322
+ const verbose = rt.globalFlags.verbose;
323
+ const scopes: readonly Scope[] = ["global", "project"];
324
+ // The key each harness NAME writes under — the half of the association
325
+ // `groupConfigTargets` consumed and did not record. Names are the only
326
+ // identity a group carries, and the registry's are unique.
327
+ const keyByName = new Map(harnesses.map((h) => [h.name, h.mcpKey]));
328
+
329
+ return scopes.map((scope): CheckResult => {
330
+ // `isHarnessInBand(harness.scope, scope, scope)` — the scope asked for as
331
+ // its OWN selection ("band" is the harnesses package's name for it) — is
332
+ // exactly "does this harness have a config location here", which is the
333
+ // question `detectMcp` already answers for that scope. Asking it under the
334
+ // `both` selection would instead answer "who writes here when both scopes
335
+ // run", and report every dual-scope harness as having no global location.
336
+ const registry: InventoryHarness[] = harnesses.map((harness) => ({
337
+ id: harness.id,
338
+ name: harness.name,
339
+ inScope: isHarnessInBand(harness.scope, scope, scope),
340
+ }));
341
+
342
+ const mcpRow = rows.find(
343
+ (row) => row.target.id === "mcp" && row.scope === scope,
344
+ );
345
+ if (mcpRow === undefined || detectionFailure(mcpRow) !== undefined) {
346
+ return {
347
+ name: INVENTORY_CHECK,
348
+ status: "skip",
349
+ detail: "harness detection did not settle — see the `mcp` row",
350
+ scope,
351
+ };
352
+ }
353
+
354
+ const detection = mcpRow.detection as McpDetection;
355
+ const groups: InventoryGroup[] = detection.groups.map((group) => ({
356
+ path: shortenPath(group.path, roots),
357
+ harnesses: group.harnessNames.map((name) => {
358
+ // The harness's OWN write in this file. A name the registry does not
359
+ // know (or a key no write carries — impossible while the group was
360
+ // built from the same registry) falls back to the file's aggregate:
361
+ // strictly no worse than the state before this join existed.
362
+ const write = group.writes.find(
363
+ (w) => w.mcpKey === keyByName.get(name),
364
+ );
365
+ return {
366
+ name,
367
+ state:
368
+ write === undefined
369
+ ? mcpGroupState(detection, group.path)
370
+ : mcpWriteState(detection, write),
371
+ };
372
+ }),
373
+ }));
374
+
375
+ const health = inventoryHealth(
376
+ harnessInventory(registry, groups, scope),
377
+ verbose,
378
+ );
379
+ return {
380
+ name: INVENTORY_CHECK,
381
+ status: health.status,
382
+ detail: health.detail,
383
+ scope,
384
+ ...(health.items.length === 0 ? {} : { items: health.items }),
385
+ };
386
+ });
387
+ }
388
+
229
389
  /**
230
- * The command that repairs a row — derived from its id and band, never
390
+ * The command that repairs a row — derived from its id and scope, never
231
391
  * authored. This is the bijection made mechanical: a row exists because a
232
392
  * target exists, and its fix is that target's own invocation.
233
393
  */
234
- export const fixCommandFor = (
235
- id: string,
236
- band: ScopeBand,
237
- bin: string,
238
- ): string => `${bin} setup ${id}${band === "project" ? " --local" : ""}`;
394
+ export const fixCommandFor = (id: string, scope: Scope, bin: string): string =>
395
+ `${bin} setup ${id}${scope === "project" ? " --local" : ""}`;
239
396
 
240
397
  /**
241
- * Run every banded check: the target table, both bands, in table order.
398
+ * Run every scoped check: the target table, both scopes, in table order, then
399
+ * each scope's harness inventory.
400
+ *
401
+ * The inventory rows come LAST within the array, and the renderer partitions by
402
+ * scope while preserving order, so each scope's section ends with the listing of
403
+ * what that scope actually holds — the targets first, then the machine they were
404
+ * measured against. They ride on the SAME detection pass: no target is probed a
405
+ * second time to produce them.
242
406
  *
243
407
  * @param rt - The per-invocation runtime.
244
408
  * @param bin - The binary name the `fix:` lines are derived from.
245
- * @returns One {@link CheckResult} per (target, band) the machine can hold.
409
+ * @returns One {@link CheckResult} per (target, scope), plus one per scope.
246
410
  * @note Impure — every target's detection reads the real filesystem.
247
411
  */
248
- export async function bandedChecks(
412
+ export async function scopedChecks(
249
413
  rt: PragmaRuntime,
250
414
  bin: string,
251
415
  ): Promise<CheckResult[]> {
252
416
  const roots = await resolveRoots(rt);
253
417
  const detected = await detectTargets(rt, [...TARGET_IDS], "both");
254
- return Promise.all(
255
- detected.map(async (row): Promise<CheckResult> => {
256
- const health = await healthOf(row, rt, roots);
257
- const needsFix =
258
- health.status === "fail" || health.status === "available";
259
- return {
260
- name: row.target.id,
261
- status: health.status,
262
- detail: health.detail,
263
- band: row.band,
264
- ...(health.items === undefined ? {} : { items: health.items }),
265
- ...(needsFix
266
- ? {
267
- remedy:
268
- health.remedy ?? fixCommandFor(row.target.id, row.band, bin),
269
- }
270
- : {}),
271
- };
272
- }),
273
- );
418
+ const [targetRows, inventoryRows] = await Promise.all([
419
+ Promise.all(
420
+ detected.map(async (row): Promise<CheckResult> => {
421
+ const health = await healthOf(row, rt, roots);
422
+ const needsFix =
423
+ health.status === "fail" || health.status === "available";
424
+ // A `skip` gets no DERIVED fix — re-running the target's own setup command
425
+ // would reproduce the skip. But a skip that AUTHORED a remedy has found a
426
+ // real next step on this machine (fill the scope's skill root, say), and
427
+ // dropping it is what made the skip a dead end on both surfaces.
428
+ const remedy = needsFix
429
+ ? (health.remedy ?? fixCommandFor(row.target.id, row.scope, bin))
430
+ : health.remedy;
431
+ return {
432
+ name: row.target.id,
433
+ status: health.status,
434
+ detail: health.detail,
435
+ scope: row.scope,
436
+ ...(health.items === undefined ? {} : { items: health.items }),
437
+ ...(remedy === undefined ? {} : { remedy }),
438
+ };
439
+ }),
440
+ ),
441
+ inventoryChecks(rt, detected, roots),
442
+ ]);
443
+ return [...targetRows, ...inventoryRows];
274
444
  }
@@ -1,14 +1,16 @@
1
1
  /**
2
- * Render goldens for `pragma doctor` — the band-grouped report AND the plain-path
3
- * color gate (F1).
2
+ * Render goldens for `pragma doctor` — the scope-grouped report AND the
3
+ * plain-path color gate (F1).
4
4
  *
5
- * The banded Global/Project grouping (`partitionByBand` → sections) is pinned for
6
- * both the plain and llm formatters, locking the unified Global/Project
7
- * vocabulary (the report no longer says MACHINE/PROJECT). Separately, the plain
5
+ * The Global / Local project grouping (`partitionByScope` → sections) is pinned
6
+ * for both the plain and llm formatters, locking the vocabulary the flags
7
+ * already use — `--global` and `--local`. It is neither MACHINE/PROJECT (the
8
+ * first spelling) nor "band" (the second): "band" was this repository's word for
9
+ * the partition and nobody else's. Separately, the plain
8
10
  * formatter tints ONLY on a color-capable TTY: `supports-color` reports a
9
11
  * non-zero `chalk.level` off a TTY under `GITHUB_ACTIONS` / `FORCE_COLOR`, so the
10
12
  * `isTTY` gate (via the shared style seam) keeps ANSI out of
11
- * `doctor --format plain | tee`. `beforeEach` forces color OFF so the banded
13
+ * `doctor --format plain | tee`. `beforeEach` forces color OFF so the section
12
14
  * goldens stay deterministic; the color tests opt a level back in explicitly.
13
15
  */
14
16
 
@@ -17,28 +19,28 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
17
19
  import { doctorFormatters } from "./doctor.render.js";
18
20
  import type { DoctorData } from "./types.js";
19
21
 
20
- /** A fixture spanning all three groups: environment (no band), global, project. */
21
- const BANDED_DATA: DoctorData = {
22
+ /** A fixture spanning all three groups: environment (no scope), global, project. */
23
+ const SCOPED_DATA: DoctorData = {
22
24
  checks: [
23
25
  { name: "Node version", status: "pass", detail: "v20 detected" },
24
26
  {
25
27
  name: "Shell completions",
26
28
  status: "pass",
27
29
  detail: "zsh up to date and resolving",
28
- band: "global",
30
+ scope: "global",
29
31
  },
30
32
  {
31
33
  name: "MCP configured",
32
34
  status: "available",
33
35
  detail: "not set up for Windsurf",
34
36
  remedy: "pragma setup mcp",
35
- band: "global",
37
+ scope: "global",
36
38
  },
37
39
  {
38
40
  name: "Skills symlinked",
39
41
  status: "skip",
40
42
  detail: "no AI harnesses detected",
41
- band: "project",
43
+ scope: "project",
42
44
  },
43
45
  ],
44
46
  passed: 2,
@@ -98,54 +100,59 @@ afterAll(() => {
98
100
  chalk.level = prevLevel;
99
101
  });
100
102
 
101
- describe("doctor render — banded plain report", () => {
102
- it("groups checks under Global then Project headers (unified labels)", () => {
103
- const out = doctorFormatters.plain(BANDED_DATA);
103
+ describe("doctor render — scoped plain report", () => {
104
+ it("groups checks under Global then Local project headers", () => {
105
+ const out = doctorFormatters.plain(SCOPED_DATA);
104
106
  const lines = out.split("\n");
105
- // The two banded section headers use the unified Global/Project vocabulary.
107
+ // The two section headers use the words the flags use.
106
108
  expect(lines).toContain("Global");
107
- expect(lines).toContain("Project");
108
- // The superseded MACHINE/PROJECT labels are gone.
109
+ expect(lines).toContain("Local project");
110
+ // The superseded MACHINE/PROJECT labels are gone — and so is "band", which
111
+ // no user-facing string in this report may say.
109
112
  expect(out).not.toContain("MACHINE");
110
113
  expect(out).not.toMatch(/^PROJECT$/m);
114
+ expect(out).not.toMatch(/\bbands?\b/);
111
115
  });
112
116
 
113
- it("orders environment → Global → Project, banding each check correctly", () => {
114
- const out = doctorFormatters.plain(BANDED_DATA);
115
- // Environment check leads with no header; the two global-band checks sit
116
- // under Global; the project-band check sits under Project.
117
+ it("orders environment → Global → Local project, placing each check correctly", () => {
118
+ const out = doctorFormatters.plain(SCOPED_DATA);
119
+ // Environment check leads with no header; the two global checks sit under
120
+ // Global; the per-project check sits under Local project.
117
121
  const at = (needle: string): number => out.indexOf(needle);
118
122
  expect(at("Node version")).toBeGreaterThanOrEqual(0);
119
123
  expect(at("Node version")).toBeLessThan(at("Global"));
120
124
  expect(at("Global")).toBeLessThan(at("Shell completions"));
121
- expect(at("MCP configured")).toBeLessThan(at("Project"));
122
- expect(at("Project")).toBeLessThan(at("Skills symlinked"));
123
- // An available banded check keeps its inline setup command under its band.
125
+ expect(at("MCP configured")).toBeLessThan(at("Local project"));
126
+ expect(at("Local project")).toBeLessThan(at("Skills symlinked"));
127
+ // An available scoped check keeps its inline setup command under its scope.
124
128
  expect(out).toContain("fix: pragma setup mcp");
125
129
  // The tally closes the report.
126
130
  expect(out).toContain("2 passed");
127
131
  });
128
132
  });
129
133
 
130
- describe("doctor render — banded llm report", () => {
131
- it("groups checks under ### Global then ### Project headers", () => {
132
- const out = doctorFormatters.llm(BANDED_DATA);
134
+ describe("doctor render — scoped llm report", () => {
135
+ it("groups checks under ### Global then ### Local project headers", () => {
136
+ const out = doctorFormatters.llm(SCOPED_DATA);
133
137
  expect(out).toContain("### Global");
134
- expect(out).toContain("### Project");
138
+ expect(out).toContain("### Local project");
135
139
  expect(out).not.toContain("### Machine");
136
140
  const at = (needle: string): number => out.indexOf(needle);
137
- // Environment leads (no section header), then the two banded sections.
141
+ // Environment leads (no section header), then the two scoped sections.
138
142
  expect(at("Node version")).toBeLessThan(at("### Global"));
139
143
  expect(at("### Global")).toBeLessThan(at("Shell completions"));
140
- expect(at("Shell completions")).toBeLessThan(at("### Project"));
141
- expect(at("### Project")).toBeLessThan(at("Skills symlinked"));
144
+ expect(at("Shell completions")).toBeLessThan(at("### Local project"));
145
+ expect(at("### Local project")).toBeLessThan(at("Skills symlinked"));
142
146
  expect(out).toContain("_2 passed, 0 failed, 1 available, 1 skipped_");
143
147
  });
144
148
  });
145
149
 
146
150
  describe("doctor render — json", () => {
147
151
  it("is the exact DoctorData round-trip", () => {
148
- expect(JSON.parse(doctorFormatters.json(BANDED_DATA))).toEqual(BANDED_DATA);
152
+ expect(JSON.parse(doctorFormatters.json(SCOPED_DATA))).toEqual(SCOPED_DATA);
153
+ // The machine surface uses the scope vocabulary too — the retired word
154
+ // cannot survive as a JSON key.
155
+ expect(doctorFormatters.json(SCOPED_DATA)).not.toMatch(/\bbands?\b/);
149
156
  });
150
157
  });
151
158
 
@@ -179,3 +186,72 @@ describe("doctor render — color ON (attended TTY)", () => {
179
186
  });
180
187
  });
181
188
  });
189
+
190
+ /**
191
+ * The harness inventory renders through the EXISTING check-with-items
192
+ * primitive — a listing needs no new machinery, only a check whose `items` are
193
+ * harnesses. This pins that it reads as an inventory in both formats: named
194
+ * harnesses under their scope, mixed per-item glyphs, and NO `fix:` line (the
195
+ * `mcp`/`skills` rows own every action a harness can need).
196
+ */
197
+ const INVENTORY_DATA: DoctorData = {
198
+ checks: [
199
+ {
200
+ name: "harnesses",
201
+ status: "pass",
202
+ detail: "1 detected · 1 registered · 3 known",
203
+ scope: "global",
204
+ items: [
205
+ {
206
+ label: "Claude Code",
207
+ status: "pass",
208
+ detail: "registered — ~/.claude.json",
209
+ },
210
+ { label: "Cursor", status: "skip", detail: "not detected" },
211
+ {
212
+ label: "VS Code",
213
+ status: "skip",
214
+ detail: "keeps no global config",
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ passed: 1,
220
+ failed: 0,
221
+ available: 0,
222
+ skipped: 0,
223
+ };
224
+
225
+ describe("doctor render — the harness inventory", () => {
226
+ it("renders one aligned sub-item per harness, with no fix line", () => {
227
+ const out = doctorFormatters.plain(INVENTORY_DATA);
228
+ expect(out).toContain("Global");
229
+ expect(out).toContain("harnesses");
230
+ expect(out).toContain("1 detected · 1 registered · 3 known");
231
+ expect(out).toContain("· ✓ Claude Code registered — ~/.claude.json");
232
+ expect(out).toContain("· ○ Cursor not detected");
233
+ expect(out).toContain("· ○ VS Code keeps no global config");
234
+ // A listing proposes nothing — and never renders a failure glyph.
235
+ expect(out).not.toContain("fix:");
236
+ expect(out).not.toContain("✗");
237
+ });
238
+
239
+ it("nests the harnesses under the check in the llm format", () => {
240
+ const out = doctorFormatters.llm(INVENTORY_DATA);
241
+ expect(out).toContain("### Global");
242
+ expect(out).toContain(
243
+ "- ✓ **harnesses**: 1 detected · 1 registered · 3 known",
244
+ );
245
+ expect(out).toContain(" - ✓ Claude Code: registered — ~/.claude.json");
246
+ expect(out).toContain(" - ○ VS Code: keeps no global config");
247
+ expect(out).not.toContain("_fix:_");
248
+ });
249
+
250
+ it("round-trips through json with every row intact", () => {
251
+ const parsed = JSON.parse(
252
+ doctorFormatters.json(INVENTORY_DATA),
253
+ ) as DoctorData;
254
+ expect(parsed).toEqual(INVENTORY_DATA);
255
+ expect(parsed.checks[0]?.items).toHaveLength(3);
256
+ });
257
+ });