@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
@@ -0,0 +1,276 @@
1
+ /**
2
+ * The rendering vocabulary — the ONE place a state and the word for it meet.
3
+ *
4
+ * `doctor` reports states, `setup` plans and applies them, and both speak to
5
+ * the same reader about the same machine. For as long as each surface spelled
6
+ * the states itself — three glyph tables, two scope-label maps, two indent
7
+ * constants — the type layer and the copy layer were free to disagree, and a
8
+ * copy pass could rename a scope in every sentence while the model underneath
9
+ * kept the old word. This module ends that freedom: the statuses, their
10
+ * glyphs, the scope names, and the shared layout markers are each defined
11
+ * once, here, and every renderer reads them.
12
+ *
13
+ * The SCOPES are **global** and **local project**, chosen because they are the
14
+ * words the user already typed: `--global` and `--local`. Reading `Local
15
+ * project` above a row whose fix is `pragma setup mcp --local` is the whole
16
+ * point — the report names the flag that repairs it. (Earlier these surfaces
17
+ * said MACHINE/PROJECT, and later "global band"/"project band": a third and a
18
+ * fourth term for the same two things, and "band" is a word nobody outside
19
+ * this repository has ever used for a config scope. The type layer carried
20
+ * "band" longest — `ScopeBand`, `PlanRow.band`, a `bands.ts` — and the render
21
+ * tests now guard against either spelling coming back.)
22
+ *
23
+ * The GLYPHS are one small alphabet with fixed meanings, so the same mark
24
+ * never says two things: `✓` is work done or verified, `✗` is a real fault,
25
+ * `◇` is available-but-not-set-up, `○` is inert (nothing to act on), and `·`
26
+ * is neutral punctuation — a sub-item bullet, or a row that never ran. Doctor
27
+ * and setup map their own status tiers onto that alphabet below, which is what
28
+ * keeps a failed setup row and a failed doctor row — the same finding seen
29
+ * twice — wearing the same mark.
30
+ *
31
+ * This is the RENDERING vocabulary; the graph-domain vocabulary a distribution
32
+ * declares (`kernel/vocabulary.ts`) is a different thing with the same good
33
+ * reason for existing. Like the rest of `kernel/render/`, this module is
34
+ * statically reachable from the `--help`/`__complete` fast paths, so it
35
+ * imports NOTHING — it is inert data plus two pure one-line functions.
36
+ */
37
+
38
+ // =============================================================================
39
+ // Scopes
40
+ // =============================================================================
41
+
42
+ /**
43
+ * One of the two config scopes: the per-user/home `global` scope or the
44
+ * per-repository `project` scope. Mirrors `@canonical/harnesses`' `ScopeBand`
45
+ * STRUCTURALLY (pinned by `scopeTypeSync.test.ts`) but is declared here so the
46
+ * statically-reachable type modules never pull the harnesses runtime onto the
47
+ * fast-path module graph — and under pragma's own name for the concept, the
48
+ * one the `--global`/`--local` flags and every rendered sentence use.
49
+ */
50
+ export type Scope = "project" | "global";
51
+
52
+ /**
53
+ * The resolved scope selection: which scope(s) a run touches. `global` is the
54
+ * DEFAULT — the user/home scope a machine-level installer configures;
55
+ * `project` is the opt-in per-repository scope; `both` is the explicit "run
56
+ * each scope in one invocation". Structurally mirrors the harnesses
57
+ * `ScopeSelection` (same pin).
58
+ */
59
+ export type ScopeSelection = Scope | "both";
60
+
61
+ /** The user-facing display label for each of the two config scopes. */
62
+ export const SCOPE_LABELS: Record<Scope, string> = {
63
+ global: "Global",
64
+ project: "Local project",
65
+ };
66
+
67
+ /**
68
+ * The scope phrase in running text — the lowercase form of
69
+ * {@link SCOPE_LABELS}, plus the word for "both". Authored here rather than
70
+ * lowercased at the call site so a reader grepping for either spelling lands
71
+ * in one file.
72
+ */
73
+ export const SCOPE_PHRASES: Record<ScopeSelection, string> = {
74
+ global: "global",
75
+ project: "local project",
76
+ both: "global and local project",
77
+ };
78
+
79
+ // =============================================================================
80
+ // The glyph alphabet
81
+ // =============================================================================
82
+
83
+ /**
84
+ * The five marks, by meaning. Uncolored — every glyph is tinted at render
85
+ * time, never baked at module load. `summon-core`'s Ink wizard renders the
86
+ * same `success`/`failure` marks for the same meanings; the two packages
87
+ * cannot share this module (the dependency runs the other way, and the
88
+ * fast-path covenants forbid the import), so `renderVocabularySync.test.ts`
89
+ * pins the two alphabets to byte equality instead.
90
+ */
91
+ export const GLYPHS = {
92
+ /** Work done, or a state verified healthy. */
93
+ success: "✓",
94
+ /** A real fault — something set up that no longer works, or a failed row. */
95
+ failure: "✗",
96
+ /** Detected and installable, but not set up — an invitation, not a fault. */
97
+ available: "◇",
98
+ /** Inert: nothing to act on here, and nothing wrong either. */
99
+ inert: "○",
100
+ /** Neutral punctuation — a sub-item bullet, or a row that never ran. */
101
+ neutral: "·",
102
+ } as const;
103
+
104
+ /** The arrow in front of an inline instruction (`↳ fix: …`). */
105
+ export const FIX_ARROW = "↳";
106
+
107
+ /** The bullet a check's indented sub-items carry. */
108
+ export const SUB_BULLET = GLYPHS.neutral;
109
+
110
+ /**
111
+ * The marker for a row that carries NO outcome — one the user deselected, so
112
+ * it was neither done nor skipped-for-cause. It must not be a ✓: a green check
113
+ * against work that never ran is the same lie the old recap told when it
114
+ * reported "Setup complete" over a target it had dropped.
115
+ */
116
+ export const NOT_RUN_GLYPH = GLYPHS.neutral;
117
+
118
+ // =============================================================================
119
+ // Indents
120
+ // =============================================================================
121
+
122
+ /** One indent step — every list body row starts two spaces in. */
123
+ export const INDENT = " ";
124
+
125
+ /**
126
+ * The indent that aligns a check's sub-lines under its name: the row's own
127
+ * {@link INDENT}, plus one glyph column, plus the two-space gap after it.
128
+ */
129
+ export const SUB_INDENT = " ";
130
+
131
+ // =============================================================================
132
+ // Doctor's status tiers
133
+ // =============================================================================
134
+
135
+ /**
136
+ * Status of a doctor check or one of its sub-items.
137
+ *
138
+ * `available` is the tier between fail and skip that keeps the report honest:
139
+ * an opt-in integration (MCP registration, skills symlinks, a completion
140
+ * script) that is detected and installable but that the user has not set up.
141
+ * Nothing is broken — a fresh install is HEALTHY with several availables — so
142
+ * reporting these as `fail` would teach users that the failure count is noise.
143
+ * `fail` stays reserved for a real fault: something set up that no longer
144
+ * works, or an environment the CLI cannot run correctly in. `skip` remains
145
+ * "nothing to check here" (no shell detected, no harnesses present).
146
+ */
147
+ export type CheckStatus = "pass" | "fail" | "available" | "skip";
148
+
149
+ /**
150
+ * Each check status's mark. `available` gets its own glyph, distinct from ✗:
151
+ * an opt-in integration that is not set up is not a fault, and rendering it as
152
+ * one would train users to ignore the failure count. The glyph pairs with the
153
+ * word in the tally, so the distinction survives with color stripped.
154
+ */
155
+ export const CHECK_GLYPHS: Record<CheckStatus, string> = {
156
+ pass: GLYPHS.success,
157
+ fail: GLYPHS.failure,
158
+ available: GLYPHS.available,
159
+ skip: GLYPHS.inert,
160
+ };
161
+
162
+ /**
163
+ * The word in front of a check row's instruction, chosen by what the row IS.
164
+ *
165
+ * A `fail` and an `available` are things to repair or switch on, so they read
166
+ * `fix`. A `skip` is neither — nothing is wrong and nothing is off; the row
167
+ * simply has nothing to act on yet — so labelling its instruction `fix` would
168
+ * be the same "a skip is a fault" reading the `available` glyph exists to
169
+ * prevent. It reads `next`, which is what the line actually is.
170
+ */
171
+ export const checkRemedyWord = (status: CheckStatus): "fix" | "next" =>
172
+ status === "skip" ? "next" : "fix";
173
+
174
+ // =============================================================================
175
+ // Setup's outcome tiers
176
+ // =============================================================================
177
+
178
+ /**
179
+ * How one setup row ended. `skipped` is NOT a failure — see
180
+ * `setup/plan.ts#planExitFailed`, which states the exit rule once.
181
+ */
182
+ export type OutcomeStatus =
183
+ | "done"
184
+ | "noop"
185
+ | "skipped"
186
+ | "failed"
187
+ | "removed"
188
+ | "kept";
189
+
190
+ /**
191
+ * Each outcome's mark. The three ways work can be complete — `done`, `noop`
192
+ * (already current), `removed` — all read ✓; the two ways a row can stand
193
+ * aside — `kept`, `skipped` — read the inert ○; only `failed` earns ✗.
194
+ */
195
+ export const OUTCOME_GLYPHS: Record<OutcomeStatus, string> = {
196
+ done: GLYPHS.success,
197
+ noop: GLYPHS.success,
198
+ removed: GLYPHS.success,
199
+ kept: GLYPHS.inert,
200
+ skipped: GLYPHS.inert,
201
+ failed: GLYPHS.failure,
202
+ };
203
+
204
+ /**
205
+ * The word in front of a row outcome's instruction — `fix` for a row that
206
+ * failed, `next` for one that skipped. Same rule as {@link checkRemedyWord}: a
207
+ * skip is not a fault, and labelling its instruction as a repair is what
208
+ * teaches a reader to treat skips as failures.
209
+ */
210
+ export const outcomeRemedyWord = (status: OutcomeStatus): "fix" | "next" =>
211
+ status === "failed" ? "fix" : "next";
212
+
213
+ // =============================================================================
214
+ // Install-source states
215
+ // =============================================================================
216
+
217
+ /**
218
+ * How the running CLI got onto the machine — the state `info`, `upgrade`, and
219
+ * doctor's `pragma version` row all report, so its words live here with the
220
+ * other shared states. Only a `global` install has a sanctioned update
221
+ * command; the type layer in `capabilities/shared/packageManager.ts` makes the
222
+ * others structurally command-less, and {@link INSTALL_UPDATE_GUIDANCE} is
223
+ * what those states say instead. `linked` in particular must never be handed
224
+ * an install command: `npm i -g` against a linked checkout would overwrite the
225
+ * link to the development tree the user is working in.
226
+ */
227
+ export type InstallKind =
228
+ | "global"
229
+ | "workspace"
230
+ | "linked"
231
+ | "ephemeral"
232
+ | "unknown";
233
+
234
+ /** The install kinds that carry NO update command (everything but `global`). */
235
+ export type NoCommandInstallKind = Exclude<InstallKind, "global">;
236
+
237
+ /**
238
+ * The display label for an install source, e.g. `npm (global)`, `bun link
239
+ * (development checkout)`, `npx (ephemeral)`, `unknown (node runtime)`. The
240
+ * `via` word is the manager/runner/runtime name the detector resolved —
241
+ * absent only for a `workspace`/`linked` install whose manager could not be
242
+ * told, which then reads as the bare state.
243
+ */
244
+ export const installSourceLabel = (kind: InstallKind, via?: string): string => {
245
+ switch (kind) {
246
+ case "global":
247
+ return `${via} (global)`;
248
+ case "workspace":
249
+ return via ? `${via} (local project)` : "local project dependency";
250
+ case "linked":
251
+ return via
252
+ ? `${via} link (development checkout)`
253
+ : "linked (development checkout)";
254
+ case "ephemeral":
255
+ return `${via} (ephemeral)`;
256
+ case "unknown":
257
+ return `unknown (${via} runtime)`;
258
+ }
259
+ };
260
+
261
+ /**
262
+ * What each command-less install state says when a newer release exists —
263
+ * honest guidance in place of a command that would be wrong to run. The
264
+ * `linked` sentence is the load-bearing one: it replaces the `npm i -g` that
265
+ * would clobber the development link.
266
+ */
267
+ export const INSTALL_UPDATE_GUIDANCE: Record<NoCommandInstallKind, string> = {
268
+ workspace:
269
+ "Installed as a project dependency — update it through the project's package.json.",
270
+ linked:
271
+ "Linked development checkout — update it from its source tree, or unlink it before installing a release.",
272
+ ephemeral:
273
+ "Ephemeral run — invoke it again with an explicit @latest version to pick up the newest release.",
274
+ unknown:
275
+ "The install method cannot be determined — update it the same way it was installed.",
276
+ };
@@ -541,7 +541,7 @@ const CONFIG_FIELD_DOCS: Record<keyof RawConfig, ConfigFieldDoc> = {
541
541
  logo: {
542
542
  type: "string[] (optional)",
543
543
  notes:
544
- "Distribution-only — see below. ASCII-art wordmark lines shown above the header on root `--help`, tinted with the terminal palette's orange slot. Declared content, not code: a wordmark spells the distribution's name, so a fork ships its own art or omits the field and gets no wordmark. Omit or leave empty for a bare header.",
544
+ "Distribution-only — see below. ASCII-art wordmark lines shown above the header on root `--help`, tinted with the terminal palette's orange slot. Shown to a HUMAN reader only: the front door omits the art for MACHINE-ORIENTED output — `--format llm` or `--format json`, plus the `llm` a non-interactive stdout auto-infers — so a piped `--help` spends none of an agent's budget on a picture of the name it just typed. The gate is the output SHAPE, not whether stdout is a terminal, and explicit beats inferred in both directions: `--format plain` keeps the art down a pipe (as does `PRAGMA_NO_AUTO_LLM`, which turns the inference off), and `--format llm` drops it on a TTY. Declared content, not code: a wordmark spells the distribution's name, so a fork ships its own art or omits the field and gets no wordmark. Omit or leave empty for a bare header.",
545
545
  },
546
546
  colophon: {
547
547
  type: "object (optional)",
@@ -149,11 +149,40 @@ export type ParamSpec =
149
149
  * list-shaped formatters read it; `llm` (the byte-frozen agent contract) and
150
150
  * `json` (the envelope) never see it.
151
151
  *
152
- * `emptyNotice` is the empty-state seam: when the data amounts to zero
153
- * records, return the calm notice — the dispatcher routes it to STDERR with
154
- * exit 0, keeping the stdout data stream free of human sentences a pipe
155
- * would read as records. Return `undefined` (or omit the member) for data
156
- * that has content; only the plain mode routes it.
152
+ * `notice` is the seam for what the DATA cannot say about itself. Return the
153
+ * calm sentence when the payload would otherwise misrepresent the read; return
154
+ * `undefined` (or omit the member) when it speaks for itself.
155
+ *
156
+ * Two shapes qualify, and they are the same failure. A zero-record result
157
+ * ("empty ≠ silence"): an unbuilt store, a mistyped filter and a genuinely empty
158
+ * result were all `{"ok":true,"data":[],"meta":{}}`. And a result that is one of
159
+ * several it could have been — a `lookup` whose name reached more entities than
160
+ * the one it answers with. A caller shown Launchpad's `Button` has no way to
161
+ * discover the global one from a payload that looks exactly like an unambiguous
162
+ * hit; the notice names the IRIs the answer did not take, which is the only
163
+ * address that reaches them.
164
+ *
165
+ * It was called `emptyNotice` while emptiness was the only case. The channel is
166
+ * unchanged — same key, same three surfaces — so this is one seam covering both,
167
+ * not a second one grown beside it.
168
+ *
169
+ * Three surfaces route it, each in its own register — it is one seam, not a
170
+ * plain-mode courtesy:
171
+ * - `plain`: the dispatcher writes it to STDERR with exit 0, keeping the stdout
172
+ * data stream free of human sentences a pipe would read as records. `--quiet`
173
+ * mutes it (success-path guidance).
174
+ * - `--format json` AND the MCP tool result: it rides the envelope as
175
+ * `meta.notice` (`project/cli/dispatch.ts#renderData`,
176
+ * `project/mcp/registerVerb.ts#emptyMeta` — the same key from the same seam,
177
+ * so the two MACHINE surfaces stay byte-equal). `data` keeps its uniform empty
178
+ * shape; `[]` stays `[]`. Without it an agent could not tell an unbuilt store,
179
+ * a mistyped filter and a genuinely empty result apart — all three were
180
+ * `{"ok":true,"data":[],"meta":{}}`.
181
+ * - `llm` (the byte-frozen agent contract) is the ONE mode that still ignores
182
+ * it: it renders one stream and its bytes are frozen.
183
+ *
184
+ * The formatters themselves never see the notice: `json(d)` renders the data
185
+ * alone, and the ENVELOPE layer carries the notice beside it.
157
186
  */
158
187
  export interface Formatters<T> {
159
188
  readonly plain: (
@@ -162,7 +191,7 @@ export interface Formatters<T> {
162
191
  ) => string;
163
192
  readonly llm: (d: T) => string;
164
193
  readonly json: (d: T) => string;
165
- readonly emptyNotice?: (d: T) => string | undefined;
194
+ readonly notice?: (d: T) => string | undefined;
166
195
  }
167
196
 
168
197
  /** A usage example shown in verb help. */
@@ -42,13 +42,13 @@ export const PARITY_GAPS: readonly ParityGapEntry[] = [
42
42
  id: "read-meta-always-empty",
43
43
  area: "envelope",
44
44
  description:
45
- "`dispatch.ts#executeVerb`'s read branch always renders with `meta: {}` — there is no `meta.count`/`meta.total` field on a list/lookup envelope (verified empirically). An empty (possibly filtered) list is `{ok:true, data:[], meta:{}}`; the only way to know it's empty is `data.length === 0`, not a meta field.",
45
+ "NARROWED: a read envelope still carries no `meta.count`/`meta.total` — the only way to know a list is empty is `data.length === 0`, not a count field. But `meta` is no longer ALWAYS `{}` on a read. A zero-record read now carries the verb's own `notice` as `meta.notice` on BOTH machine surfaces (`project/cli/dispatch.ts#renderData` for `--format json`, `project/mcp/registerVerb.ts#emptyMeta` for MCP — one seam, the same key, so the two stay byte-equal). `data` keeps its uniform empty shape; `[]` is still `[]`. The divergence this entry records is the ABSENCE of counts, not the emptiness of `meta`: without the notice an agent could not tell an unbuilt store, a mistyped filter and a genuinely empty result apart, all three being `{ok:true,data:[],meta:{}}`.",
46
46
  },
47
47
  {
48
48
  id: "no-empty-hook-on-free-filter",
49
49
  area: "pack list / empty-state",
50
50
  description:
51
- "A zero-row pack list — whether narrowed by a declared filter, an unmatched `--search`, or unconditionally empty — returns `ok:true, data:[], meta:{}` at exit 0 with a calm `emptyMessage`/`emptyHint`; pragma v2 never raises a typed EMPTY_RESULTS error for an empty list. (U5 removed the former enum-filter-only `buildListEmptyError` hook, making the empty-state uniform across every list verb.)",
51
+ "STILL TRUE OF EMPTINESS, NARROWED ON BAD ARGUMENTS. A zero-row pack list — narrowed by a declared filter, by an unmatched `--search`, or unconditionally empty — is a SUCCESS: `ok:true, data:[]` at exit 0 with a calm notice (stderr in plain mode, `meta.notice` on the machine surfaces). pragma v2 still never raises EMPTY_RESULTS for an empty list, and U5's removal of the enum-filter-only `buildListEmptyError` hook stands. What is no longer true is that an UNMATCHED free-filter value stays successful: a value-free filter now rejects a value its vocabulary does not admit with INVALID_INPUT and the admissible values as `validOptions`, the same courtesy a declared-`values` filter always extended (`packs/sparql/applyFilters.ts`). The vocabulary is the graph's, not the returned rows' — a filter declares where it lives (`PackFilter.vocabulary`), so a real value that happens to match no row (a category with zero standards, a `ds:ConceptType` no concept uses) is a calm empty list, and only a value the graph does not know is an error.",
52
52
  },
53
53
  {
54
54
  id: "raw-iri-in-data",
@@ -84,7 +84,7 @@ export const PARITY_GAPS: readonly ParityGapEntry[] = [
84
84
  id: "block-lookup-not-tier-scoped",
85
85
  area: "block",
86
86
  description:
87
- "`block lookup` resolves by `ds:name` GLOBALLY, with no tier or channel filtering. A same-named block declared at two tiers cannot be disambiguated by plain name (the old shell's tier-scoped lookup disambiguation has no v2 equivalent) — address it by prefixed IRI instead. Since L-OPEN-9 NOTHING is tier/channel-aware: the hand-written, tier-chain-filtered `block list` was removed with its `tierChain.ts`.",
87
+ "CLOSED, by REVERSAL of the entry it used to be. This gap read: `block lookup` resolves by `ds:name` globally, a same-named block at two tiers cannot be disambiguated by plain name, address it by prefixed IRI instead. That was accepted while the answer was merely arbitrary; it stopped being acceptable once measured — 25 live block names are carried by 2–3 blocks, and EVERY one of them was answered with whichever IRI sorted first and no sign the others existed (`block lookup button` returned Launchpad's Button, because `apps_launchpad…` precedes `global…`). 'Address it by IRI' is not a recovery an agent can reach when nothing tells it there is a second block to address. Two things replace it, and neither is a change of arity — one name still answers with one entity, and a glob is still the declared multi-entity form. (1) The discard is no longer SILENT: the resolve is unlimited, the winner is answered with, and the IRIs it outranked ride the verb's `notice` — the same seam a zero-record read uses (stderr in plain mode, `meta.notice` on `--format json` and MCP), because an ambiguous hit is the same failure as an empty one, a result that misrepresents itself. `LookupOutput.ambiguous` carries them in `data` for the machine surfaces, and is ABSENT (not `[]`) when nothing was ambiguous, so every unambiguous read keeps the payload it had. Note the recovery is not reachable any other way: a glob expands over DISTINCT names, so `block lookup 'Butt*'` returned Launchpad's Button and ButtonLink and the global Button appeared nowhere. (2) The order is a declared, derived RANKING (`PackLookup.scopeWeight` in `pragma.conf.ts`, generated by `packs/sparql/buildLookupQuery.ts#rankingClause`): a tier's depth comes from the `/` count in its OWN `ds:name` (`\"Apps/Launchpad\"` → 2, never from the `_` in `ds:apps_launchpad`), and it MULTIPLIES the existing type weight rather than tiebreaking above it, so a whole component in a nested tier still outranks a global SUBcomponent (`TextInput`). Nothing is enumerated, so a tier added upstream is placed without an edit here; an asserted `ds:tierRank` would take precedence over the derived depth and retire the config declaration with no code change. WHAT REMAINS a gap: `lookup` is still not tier-SCOPED — there is no way to ask for one tier's answer only, the ranking merely decides which comes first — and since L-OPEN-9 nothing is tier/channel-FILTERED (the hand-written `block list` went with its `tierChain.ts`; see `block-list-unfiltered`). Two top-level tiers still cannot be ordered against each other by depth alone, which today leaves `Navigation` (`global` vs `apps`) and `InfoTable` (`global` vs `sites`) decided by the IRI fallback; naming a precedence among the depth-1 tiers is an ontology question, deliberately NOT invented here.",
88
88
  },
89
89
  {
90
90
  id: "block-list-unfiltered",
@@ -182,4 +182,10 @@ export const PARITY_GAPS: readonly ParityGapEntry[] = [
182
182
  description:
183
183
  "The native MCP `prompts/list` (and the `prompt_list`/`prompt_lookup` content tools) project `ds:Prompt` KG entities read from the ACTIVE pack index (`kernel/project/mcp/prompts/source.ts#listPromptSummaries`) — project-supplied data, NOT shipped in the embedded/bundled pack. A bare install (no project-authored `ds:Prompt`) advertises the prompts capability but lists ZERO prompts, never booting the store on the list path. Non-empty listings are exercised only against the seeded fixture graph (`testing/fixtures/graph/canonical.ts`); the storeless empty-by-default case is pinned by `prompts.test.ts` ('lists zero prompts … when no prompt entities exist').",
184
184
  },
185
+ {
186
+ id: "scope-not-band-in-machine-output",
187
+ area: "setup / doctor `--format json`",
188
+ description:
189
+ "A setup plan row and a doctor check carry their config scope under the key `scope` (values `global`/`project`), not `band`. The old shell's type layer said `band` (`ScopeBand`, `PlanRow.band`, `CheckResult.band`) while every user-facing string had already moved to the scope vocabulary the `--global`/`--local` flags use; the type-layer rename (kernel/render/vocabulary.ts) finished the move, and `--format json`/MCP emit the model verbatim, so the machine key moved with it. Any consumer reading `.band` off `setup --format json` or `doctor --format json` reads `.scope` now — `setup.render.test.ts` pins that neither spelling of `band` can return through any render, JSON included.",
190
+ },
185
191
  ] as const;
@@ -164,9 +164,11 @@ const READ_CASES: readonly ReadCase[] = [
164
164
  },
165
165
  { command: "pragma standard list", key: "standard list", params: {} },
166
166
  {
167
- command: "pragma standard list --category react",
167
+ // The doc's headline example, and the one the roll-up is about: a parent
168
+ // category answering for its whole branch.
169
+ command: "pragma standard list --category testing",
168
170
  key: "standard list",
169
- params: { category: ["react"] },
171
+ params: { category: ["testing"] },
170
172
  },
171
173
  {
172
174
  command: "pragma standard categories",
@@ -11,10 +11,16 @@
11
11
  * a total miss; only a PARTIAL batch reports the miss while staying
12
12
  * `ok:true` (that shape is B1's job, `agentSession.mcp.test.ts`). See
13
13
  * PARITY_GAPS `single-lookup-miss-fails-batch-partial-reports`.
14
- * 2. A filtered list narrowed to zero rows is `{ok:true, data:[], meta:{}}` —
15
- * there is no `meta.count` field on any read envelope (`dispatch.ts`
16
- * always renders reads with `meta:{}`). See PARITY_GAPS
17
- * `read-meta-always-empty`.
14
+ * 2. A filtered list narrowed to zero rows is `{ok:true, data:[]}` — there is
15
+ * still no `meta.count` field on any read envelope, which is the divergence
16
+ * `read-meta-always-empty` now records (it no longer claims `meta` is always
17
+ * `{}`: a zero-record read carries the list's own `notice` as
18
+ * `meta.notice` on both machine surfaces, asserted below).
19
+ * 3. A value-free filter given a value its vocabulary does not admit is
20
+ * INVALID_INPUT, not a successful empty list — the narrowing recorded in
21
+ * PARITY_GAPS `no-empty-hook-on-free-filter`. Emptiness itself is still a
22
+ * success: a value the GRAPH admits but no row carries (a category with zero
23
+ * standards) is a calm empty list, asserted below.
18
24
  */
19
25
 
20
26
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
@@ -88,7 +94,22 @@ describe("lookup miss — total miss fails the call (B3, adapted)", () => {
88
94
  });
89
95
  });
90
96
 
91
- describe("list — a narrowing filter to zero rows is a plain empty list (B3, adapted)", () => {
97
+ /**
98
+ * CHANGED DELIBERATELY. This used to assert that ANY filter value narrowed to a
99
+ * plain `{"ok":true,"data":[],"meta":{}}`, which was written when no value-free
100
+ * filter could tell a typo from an empty answer. It could: the rows it is
101
+ * filtering carry the vocabulary. Over MCP the old shape made a mistyped
102
+ * `--category`, an unbuilt store and a genuinely empty category byte-identical —
103
+ * the silence a model has no way to recover from.
104
+ *
105
+ * A value the graph does not know is now INVALID_INPUT with the admissible
106
+ * values as `validOptions`, exactly as a filter declaring `values` has always
107
+ * behaved; the difference is that the vocabulary is READ FROM THE GRAPH rather
108
+ * than copied into the story. Zero rows for a value that DOES exist stays a calm
109
+ * empty list — that is a real answer, not a bad argument — and "exists" means
110
+ * the graph declares it, not that some returned row happened to carry it.
111
+ */
112
+ describe("list — a filter value the graph does not carry is INVALID_INPUT (B3, adapted)", () => {
92
113
  it("has at least one filtered list verb to sweep", () => {
93
114
  // A guard, not a no-op: if this ever goes empty the sweep below silently
94
115
  // covers nothing, which would be a silent coverage loss worth noticing.
@@ -97,16 +118,98 @@ describe("list — a narrowing filter to zero rows is a plain empty list (B3, ad
97
118
 
98
119
  it.each(
99
120
  filteredListVerbs,
100
- )("$tool: an unmatched filter value narrows to [] (no EMPTY_RESULTS)", async ({
121
+ )("$tool: an unobserved filter value is rejected, naming the observed ones", async ({
101
122
  tool,
102
123
  param,
103
124
  }) => {
104
125
  const result = await mcp.callTool(tool, {
105
126
  [param]: "zzz-definitely-not-a-real-value",
106
127
  });
128
+ expect(result.ok).toBe(false);
129
+ const error = result.error as { code: string; validOptions?: string[] };
130
+ expect(error.code).toBe("INVALID_INPUT");
131
+ expect(error.validOptions?.length).toBeGreaterThan(0);
132
+ expect(error.validOptions).not.toContain("zzz-definitely-not-a-real-value");
133
+ });
134
+
135
+ it.each(
136
+ filteredListVerbs,
137
+ )("$tool: every value the graph DOES carry narrows to a calm list", async ({
138
+ tool,
139
+ param,
140
+ }) => {
141
+ const rejected = await mcp.callTool(tool, { [param]: "zzz-nope" });
142
+ const admissible = (rejected.error as { validOptions: string[] })
143
+ .validOptions;
144
+ expect(admissible.length).toBeGreaterThan(0);
145
+ // EVERY admissible value is accepted, and at least one is populated. Not
146
+ // "the first one returns rows": the vocabulary is the graph's, so it can
147
+ // legitimately contain a value no row carries (a category with zero
148
+ // standards) — that value is a calm empty list, not a rejection, and the
149
+ // sweep would otherwise read a real answer as a failure.
150
+ let populated = 0;
151
+ for (const value of admissible) {
152
+ const result = await mcp.callTool(tool, { [param]: value });
153
+ expect(result.ok).toBe(true);
154
+ if ((result.data as unknown[]).length > 0) populated += 1;
155
+ }
156
+ expect(populated).toBeGreaterThan(0);
157
+ });
158
+
159
+ it("standard_list: a declared category with NO standards is a calm empty list", async () => {
160
+ // The case that separates "the graph is the vocabulary" from "the returned
161
+ // rows are the vocabulary". `standard_categories` publishes every declared
162
+ // slug with its count, zeros included, and tells the agent to pick one —
163
+ // so rejecting a zero-count slug as INVALID_INPUT tells it the slug the
164
+ // tool just handed it was a typo. Validity read off the rows did exactly
165
+ // that, because a category with no standards appears in no row.
166
+ const categories = await mcp.callTool("standard_categories");
167
+ const zero = (categories.data as { name: string; count: string }[]).filter(
168
+ (row) => Number(row.count) === 0,
169
+ );
170
+ expect(zero.length).toBeGreaterThan(0);
171
+ for (const row of zero) {
172
+ const result = await mcp.callTool("standard_list", {
173
+ category: row.name,
174
+ });
175
+ expect(result.ok).toBe(true);
176
+ expect(result.data).toEqual([]);
177
+ // And it is a real empty answer, so it carries the calm notice rather
178
+ // than passing in silence.
179
+ expect((result.meta as { notice?: string }).notice ?? "").not.toBe("");
180
+ }
181
+ });
182
+
183
+ it("standard_list: the rejection names the declared slugs, not just the used ones", async () => {
184
+ const result = await mcp.callTool("standard_list", {
185
+ category: "zzz-definitely-not-a-slug",
186
+ });
187
+ expect(result.ok).toBe(false);
188
+ const error = result.error as { code: string; validOptions: string[] };
189
+ expect(error.code).toBe("INVALID_INPUT");
190
+ const categories = await mcp.callTool("standard_categories");
191
+ const declared = (categories.data as { name: string }[]).map((r) => r.name);
192
+ // Same vocabulary, both surfaces: whatever `standard_categories` lists is
193
+ // what `--category` accepts. Read from `cs:Category`/`cs:slug` on both
194
+ // sides, so the two cannot drift.
195
+ expect([...error.validOptions].sort()).toEqual([...declared].sort());
196
+ });
197
+ });
198
+
199
+ describe("empty ≠ silence on the machine surfaces", () => {
200
+ it("carries the list's empty-state guidance in the envelope meta", async () => {
201
+ // `notice` had exactly one consumer — the CLI dispatcher's stderr — so
202
+ // an agent received `{"ok":true,"data":[],"meta":{}}` and nothing else. The
203
+ // search path is the honest way to reach an empty list without a bad
204
+ // argument.
205
+ const result = await mcp.callTool("standard_list", {
206
+ search: "zzz-nothing-matches-this",
207
+ });
107
208
  expect(result.ok).toBe(true);
108
209
  expect(result.data).toEqual([]);
109
- expect(result.meta).toEqual({});
210
+ const notice = (result.meta as { notice?: string }).notice ?? "";
211
+ expect(notice).toContain("No standard entries found.");
212
+ expect(notice).toContain("pragma standard categories");
110
213
  });
111
214
  });
112
215
 
@@ -158,10 +158,15 @@ describe("first install — the surfaces say where the answers come from", () =>
158
158
  expect(status.entityCount).toBeGreaterThan(0);
159
159
  });
160
160
 
161
- it("doctor passes, naming the embedded snapshot rather than the config", async () => {
161
+ it("doctor passes, saying the packs came with the CLI rather than from the config", async () => {
162
162
  const result = await checkPackageRefs(bootRuntime(JSON_FLAGS, emptyCwd()));
163
163
  expect(result.status).toBe("pass");
164
- expect(result.detail).toContain("embedded snapshot @ ");
164
+ // What is pinned is the DISTINCTION — a fresh install reads packs it
165
+ // shipped with, not packs this project configured — and that the row says
166
+ // how to change that. The wording is the reader's ("shipped with the CLI"),
167
+ // not the build's ("embedded snapshot"); the per-pack revisions moved to
168
+ // the row's sub-items when each pack got its own line.
169
+ expect(result.detail).toContain("shipped with the CLI");
165
170
  expect(result.detail).toContain("pragma sources update");
166
171
  });
167
172
  });
@@ -138,12 +138,15 @@ describe("the two recoveries still run with bad stories in the pack", () => {
138
138
  // The pack DOES answer reads, so the check passes…
139
139
  expect(check?.status).toBe("pass");
140
140
  expect(check?.detail).toContain("2 stories ignored");
141
- // …with the unusable stories named underneath it.
142
- expect(check?.items?.map((item) => item.label).sort()).toEqual([
141
+ // …with the unusable stories named underneath it. The items also carry
142
+ // one PASSING row per pack the answer was built from, so this pins the
143
+ // FAILING ones: what a story problem contributes is a failing item, and
144
+ // that claim must not break every time provenance gains a row.
145
+ const failing = (check?.items ?? []).filter((i) => i.status === "fail");
146
+ expect(failing.map((item) => item.label).sort()).toEqual([
143
147
  "fixture/stories/broken.json",
144
148
  "fixture/stories/invalid.json",
145
149
  ]);
146
- for (const item of check?.items ?? []) expect(item.status).toBe("fail");
147
150
  });
148
151
  });
149
152
 
@@ -63,7 +63,12 @@ exports[`eval harness — seed gate > runs the full seed (stable + read-noun) ca
63
63
  "passed": true,
64
64
  },
65
65
  {
66
- "id": "content-standard-list-length-equals-category-count-sum",
66
+ "id": "content-standard-category-counts-match-filtered-list",
67
+ "kind": "content",
68
+ "passed": true,
69
+ },
70
+ {
71
+ "id": "content-standard-category-rollup-keeps-the-direct-member",
67
72
  "kind": "content",
68
73
  "passed": true,
69
74
  },
@@ -172,6 +177,21 @@ exports[`eval harness — seed gate > runs the full seed (stable + read-noun) ca
172
177
  "kind": "tool",
173
178
  "passed": true,
174
179
  },
180
+ {
181
+ "id": "tool-standard-list-empty-category-is-a-calm-empty-list",
182
+ "kind": "tool",
183
+ "passed": true,
184
+ },
185
+ {
186
+ "id": "tool-standard-list-names-are-addressable-by-lookup",
187
+ "kind": "tool",
188
+ "passed": true,
189
+ },
190
+ {
191
+ "id": "tool-standard-lookup-advertised-glob-matches",
192
+ "kind": "tool",
193
+ "passed": true,
194
+ },
175
195
  {
176
196
  "id": "tool-standard-lookup-has-dos-and-donts",
177
197
  "kind": "tool",
@@ -182,6 +202,11 @@ exports[`eval harness — seed gate > runs the full seed (stable + read-noun) ca
182
202
  "kind": "tool",
183
203
  "passed": true,
184
204
  },
205
+ {
206
+ "id": "tool-token-lookup-addresses-only-what-list-publishes",
207
+ "kind": "tool",
208
+ "passed": true,
209
+ },
185
210
  {
186
211
  "id": "tool-token-noun-is-read-only",
187
212
  "kind": "tool",