@canonical/pragma-cli 0.36.0 → 0.38.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 (312) hide show
  1. package/README.md +3 -2
  2. package/bundled-skills/adoption-a1-styles/SKILL.md +24 -0
  3. package/bundled-skills/adoption-a2-components/SKILL.md +22 -6
  4. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +1397 -327
  5. package/bundled-skills/anatomy-author/SKILL.md +840 -137
  6. package/bundled-skills/design-auditor/SKILL.md +133 -9
  7. package/bundled-skills/implement-component/SKILL.md +192 -23
  8. package/bundled-skills/specify-component/SKILL.md +124 -12
  9. package/bundled-skills/specify-pattern/SKILL.md +47 -8
  10. package/dist/package.json +23 -20
  11. package/dist/pragma.conf.js +963 -40
  12. package/dist/pragma.conf.js.map +1 -1
  13. package/dist/src/capabilities/capabilities/catalog.js +14 -5
  14. package/dist/src/capabilities/capabilities/catalog.js.map +1 -1
  15. package/dist/src/capabilities/capabilities/hints.js +33 -5
  16. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  17. package/dist/src/capabilities/config/show.render.js +30 -2
  18. package/dist/src/capabilities/config/show.render.js.map +1 -1
  19. package/dist/src/capabilities/create/create.verb.js +2 -1
  20. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  21. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  22. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  24. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +1 -1
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  28. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js +104 -14
  30. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  31. package/dist/src/capabilities/graph/connect.render.js +101 -0
  32. package/dist/src/capabilities/graph/connect.render.js.map +1 -0
  33. package/dist/src/capabilities/graph/connect.types.js +21 -0
  34. package/dist/src/capabilities/graph/connect.types.js.map +1 -0
  35. package/dist/src/capabilities/graph/connect.verb.js +147 -0
  36. package/dist/src/capabilities/graph/connect.verb.js.map +1 -0
  37. package/dist/src/capabilities/graph/index.js +5 -3
  38. package/dist/src/capabilities/graph/index.js.map +1 -1
  39. package/dist/src/capabilities/graph/queryText.js +206 -0
  40. package/dist/src/capabilities/graph/queryText.js.map +1 -0
  41. package/dist/src/capabilities/graph/relationIndex.js +302 -0
  42. package/dist/src/capabilities/graph/relationIndex.js.map +1 -0
  43. package/dist/src/capabilities/graph/runConnect.js +456 -0
  44. package/dist/src/capabilities/graph/runConnect.js.map +1 -0
  45. package/dist/src/capabilities/graph/runQuery.js +21 -4
  46. package/dist/src/capabilities/graph/runQuery.js.map +1 -1
  47. package/dist/src/capabilities/setup/buildPlan.js +6 -2
  48. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/index.js +7 -2
  50. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  52. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  54. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupGenerator.js +72 -14
  56. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  57. package/dist/src/capabilities/setup/operations/setupLsp.js +372 -62
  58. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupMcp.js +150 -5
  60. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/writability.js +114 -0
  64. package/dist/src/capabilities/setup/operations/writability.js.map +1 -0
  65. package/dist/src/capabilities/setup/plan.js.map +1 -1
  66. package/dist/src/capabilities/setup/setup.verb.js +80 -31
  67. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  68. package/dist/src/capabilities/setup/targets.js +124 -42
  69. package/dist/src/capabilities/setup/targets.js.map +1 -1
  70. package/dist/src/capabilities/shared/index.js +1 -1
  71. package/dist/src/capabilities/shared/index.js.map +1 -1
  72. package/dist/src/constants.js +7 -1
  73. package/dist/src/constants.js.map +1 -1
  74. package/dist/src/kernel/error/renderError.js +3 -2
  75. package/dist/src/kernel/error/renderError.js.map +1 -1
  76. package/dist/src/kernel/error/validOptions.js +41 -0
  77. package/dist/src/kernel/error/validOptions.js.map +1 -0
  78. package/dist/src/kernel/packs/compile.js +90 -6
  79. package/dist/src/kernel/packs/compile.js.map +1 -1
  80. package/dist/src/kernel/packs/cursor.js +135 -0
  81. package/dist/src/kernel/packs/cursor.js.map +1 -0
  82. package/dist/src/kernel/packs/graphql/buildLookupDocument.js +9 -2
  83. package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -1
  84. package/dist/src/kernel/packs/paging.js +108 -0
  85. package/dist/src/kernel/packs/paging.js.map +1 -0
  86. package/dist/src/kernel/packs/renderPack.js +154 -5
  87. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  88. package/dist/src/kernel/packs/resolveEntity.js +118 -19
  89. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  90. package/dist/src/kernel/packs/runBodies.js +156 -18
  91. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  92. package/dist/src/kernel/packs/schema.js +160 -12
  93. package/dist/src/kernel/packs/schema.js.map +1 -1
  94. package/dist/src/kernel/packs/sparql/authorQuery.js +227 -0
  95. package/dist/src/kernel/packs/sparql/authorQuery.js.map +1 -0
  96. package/dist/src/kernel/packs/sparql/buildListQuery.js +251 -0
  97. package/dist/src/kernel/packs/sparql/buildListQuery.js.map +1 -0
  98. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +125 -22
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  100. package/dist/src/kernel/packs/sparql/filterValues.js +145 -0
  101. package/dist/src/kernel/packs/sparql/filterValues.js.map +1 -0
  102. package/dist/src/kernel/packs/sparql/index.js +24 -14
  103. package/dist/src/kernel/packs/sparql/index.js.map +1 -1
  104. package/dist/src/kernel/packs/sparql/runSelect.js +10 -4
  105. package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -1
  106. package/dist/src/kernel/packs/sparql/searchTerm.js +25 -0
  107. package/dist/src/kernel/packs/sparql/searchTerm.js.map +1 -0
  108. package/dist/src/kernel/packs/storyRules.js +151 -0
  109. package/dist/src/kernel/packs/storyRules.js.map +1 -0
  110. package/dist/src/kernel/packs/tierScope.js +310 -0
  111. package/dist/src/kernel/packs/tierScope.js.map +1 -0
  112. package/dist/src/kernel/packs/types.js +80 -0
  113. package/dist/src/kernel/packs/types.js.map +1 -1
  114. package/dist/src/kernel/project/cli/buildProgram.js +10 -1
  115. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  116. package/dist/src/kernel/project/cli/dispatch.js +104 -3
  117. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  118. package/dist/src/kernel/project/cli/rootHelp.js +8 -5
  119. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  120. package/dist/src/kernel/project/cli/suggestNames.js +31 -7
  121. package/dist/src/kernel/project/cli/suggestNames.js.map +1 -1
  122. package/dist/src/kernel/project/mcp/registerVerb.js +5 -1
  123. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  124. package/dist/src/kernel/render/renderers.js +34 -2
  125. package/dist/src/kernel/render/renderers.js.map +1 -1
  126. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  127. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  128. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  129. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  130. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  131. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  132. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  133. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  134. package/dist/src/kernel/runtime/graphpack/read.js +17 -10
  135. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  136. package/dist/src/kernel/runtime/loadSession.js +3 -1
  137. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  138. package/dist/src/kernel/runtime/probe.js +1 -1
  139. package/dist/src/kernel/runtime/probe.js.map +1 -1
  140. package/dist/src/kernel/runtime/readEntity.js +19 -4
  141. package/dist/src/kernel/runtime/readEntity.js.map +1 -1
  142. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  143. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  144. package/dist/src/kernel/runtime/refs/resolve.js +38 -5
  145. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  146. package/dist/src/kernel/spec/emitReference.js +1 -1
  147. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  148. package/package.json +23 -20
  149. package/pragma.conf.ts +983 -40
  150. package/src/capabilities/block.parity.test.ts +207 -27
  151. package/src/capabilities/block.shipped.exec.test.ts +156 -14
  152. package/src/capabilities/block.tierRank.exec.test.ts +46 -9
  153. package/src/capabilities/capabilities/catalog.ts +14 -5
  154. package/src/capabilities/capabilities/hints.ts +40 -5
  155. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  156. package/src/capabilities/config/set.test.ts +24 -25
  157. package/src/capabilities/config/show.render.test.ts +9 -4
  158. package/src/capabilities/config/show.render.ts +30 -2
  159. package/src/capabilities/create/byteEquality.test.ts +28 -1
  160. package/src/capabilities/create/create.test.ts +1 -0
  161. package/src/capabilities/create/create.verb.ts +2 -1
  162. package/src/capabilities/create/createGrammar.test.ts +4 -1
  163. package/src/capabilities/create/createSurface.generated.ts +23 -1
  164. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  165. package/src/capabilities/distribution.parity.test.ts +12 -11
  166. package/src/capabilities/distribution.test.ts +77 -7
  167. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  168. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  169. package/src/capabilities/doctor/checks/harnessInventory.test.ts +13 -6
  170. package/src/capabilities/doctor/checks/harnessInventory.ts +8 -4
  171. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  172. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  173. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  174. package/src/capabilities/doctor/checks/targetHealth.ts +114 -13
  175. package/src/capabilities/doctor/doctor.test.ts +327 -36
  176. package/src/capabilities/emptyRecovery.test.ts +84 -28
  177. package/src/capabilities/graph/connect.render.ts +141 -0
  178. package/src/capabilities/graph/connect.shipped.exec.test.ts +370 -0
  179. package/src/capabilities/graph/connect.test.ts +361 -0
  180. package/src/capabilities/graph/connect.types.ts +136 -0
  181. package/src/capabilities/graph/connect.verb.ts +165 -0
  182. package/src/capabilities/graph/index.ts +5 -3
  183. package/src/capabilities/graph/query.shipped.exec.test.ts +66 -0
  184. package/src/capabilities/graph/query.test.ts +17 -0
  185. package/src/capabilities/graph/queryText.test.ts +121 -0
  186. package/src/capabilities/graph/queryText.ts +240 -0
  187. package/src/capabilities/graph/relationIndex.ts +372 -0
  188. package/src/capabilities/graph/runConnect.ts +577 -0
  189. package/src/capabilities/graph/runQuery.ts +21 -6
  190. package/src/capabilities/lazy.test.ts +32 -2
  191. package/src/capabilities/listBudget.shipped.exec.test.ts +239 -0
  192. package/src/capabilities/listLookup.shipped.exec.test.ts +387 -0
  193. package/src/capabilities/listQuery.shipped.exec.test.ts +400 -0
  194. package/src/capabilities/resources/resources.test.ts +38 -2
  195. package/src/capabilities/setup/buildPlan.ts +7 -1
  196. package/src/capabilities/setup/operations/index.ts +14 -0
  197. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  198. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  199. package/src/capabilities/setup/operations/setupGenerator.ts +93 -15
  200. package/src/capabilities/setup/operations/setupLsp.ts +544 -85
  201. package/src/capabilities/setup/operations/setupMcp.ts +212 -4
  202. package/src/capabilities/setup/operations/setupSkills.ts +7 -2
  203. package/src/capabilities/setup/operations/writability.test.ts +151 -0
  204. package/src/capabilities/setup/operations/writability.ts +135 -0
  205. package/src/capabilities/setup/plan.ts +11 -0
  206. package/src/capabilities/setup/setup.test.ts +992 -16
  207. package/src/capabilities/setup/setup.verb.ts +84 -36
  208. package/src/capabilities/setup/targets.ts +152 -48
  209. package/src/capabilities/setup/types.ts +41 -5
  210. package/src/capabilities/shared/index.ts +1 -0
  211. package/src/capabilities/sources/sources.test.ts +55 -0
  212. package/src/capabilities/surface.test.ts +59 -11
  213. package/src/capabilities/tier.parity.test.ts +2 -2
  214. package/src/capabilities/token.parity.test.ts +784 -0
  215. package/src/capabilities/tokenGraph.cli.test.ts +279 -0
  216. package/src/capabilities/tokenGraph.shipped.exec.test.ts +653 -0
  217. package/src/capabilities/variable.parity.test.ts +448 -0
  218. package/src/constants.ts +8 -0
  219. package/src/kernel/completion/shellDrive.test.ts +32 -30
  220. package/src/kernel/config/defaults.test.ts +21 -2
  221. package/src/kernel/config/readConfig.test.ts +1 -1
  222. package/src/kernel/error/renderError.ts +3 -2
  223. package/src/kernel/error/validOptions.test.ts +57 -0
  224. package/src/kernel/error/validOptions.ts +45 -0
  225. package/src/kernel/packs/collect.test.ts +2 -0
  226. package/src/kernel/packs/compile.test.ts +188 -15
  227. package/src/kernel/packs/compile.ts +109 -14
  228. package/src/kernel/packs/cursor.ts +135 -0
  229. package/src/kernel/packs/filteredEmpty.test.ts +213 -0
  230. package/src/kernel/packs/graphql/buildLookupDocument.ts +16 -9
  231. package/src/kernel/packs/graphqlSource.test.ts +152 -0
  232. package/src/kernel/packs/listNotice.test.ts +156 -0
  233. package/src/kernel/packs/paging.ts +110 -0
  234. package/src/kernel/packs/renderPack.ts +186 -13
  235. package/src/kernel/packs/resolveEntity.test.ts +62 -3
  236. package/src/kernel/packs/resolveEntity.ts +186 -26
  237. package/src/kernel/packs/runBodies.ts +196 -26
  238. package/src/kernel/packs/schema.ts +180 -13
  239. package/src/kernel/packs/sparql/authorQuery.test.ts +179 -0
  240. package/src/kernel/packs/sparql/authorQuery.ts +257 -0
  241. package/src/kernel/packs/sparql/buildListQuery.test.ts +344 -0
  242. package/src/kernel/packs/sparql/buildListQuery.ts +344 -0
  243. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +138 -0
  244. package/src/kernel/packs/sparql/buildLookupQuery.ts +140 -20
  245. package/src/kernel/packs/sparql/filterValues.test.ts +86 -0
  246. package/src/kernel/packs/sparql/filterValues.ts +182 -0
  247. package/src/kernel/packs/sparql/index.ts +30 -14
  248. package/src/kernel/packs/sparql/runSelect.test.ts +63 -0
  249. package/src/kernel/packs/sparql/runSelect.ts +42 -2
  250. package/src/kernel/packs/sparql/searchTerm.ts +29 -0
  251. package/src/kernel/packs/storyRules.ts +180 -0
  252. package/src/kernel/packs/tierScope.test.ts +487 -0
  253. package/src/kernel/packs/tierScope.ts +409 -0
  254. package/src/kernel/packs/toolDescription.test.ts +13 -10
  255. package/src/kernel/packs/types.ts +311 -9
  256. package/src/kernel/project/cli/buildProgram.ts +17 -1
  257. package/src/kernel/project/cli/dispatch.test.ts +87 -0
  258. package/src/kernel/project/cli/dispatch.ts +114 -4
  259. package/src/kernel/project/cli/rootHelp.ts +8 -5
  260. package/src/kernel/project/cli/suggest.test.ts +35 -0
  261. package/src/kernel/project/cli/suggestNames.ts +34 -7
  262. package/src/kernel/project/mcp/registerVerb.ts +5 -1
  263. package/src/kernel/render/contracts.ts +2 -0
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.test.ts +43 -0
  266. package/src/kernel/render/renderers.ts +34 -1
  267. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  268. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  269. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  270. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  271. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  272. package/src/kernel/runtime/graphpack/read.ts +17 -13
  273. package/src/kernel/runtime/loadSession.ts +3 -1
  274. package/src/kernel/runtime/probe.ts +1 -1
  275. package/src/kernel/runtime/readEntity.ts +19 -4
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +39 -5
  279. package/src/kernel/runtime/types.ts +30 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  282. package/src/kernel/spec/types.ts +17 -0
  283. package/src/testing/PARITY_GAPS.ts +4 -4
  284. package/src/testing/behavioral/agentSession.mcp.test.ts +43 -39
  285. package/src/testing/behavioral/completion.test.ts +7 -6
  286. package/src/testing/behavioral/disclosure.test.ts +39 -37
  287. package/src/testing/behavioral/errorMatrix.mcp.test.ts +82 -64
  288. package/src/testing/behavioral/errorMatrix.storeless.test.ts +11 -10
  289. package/src/testing/behavioral/journeys.cli.test.ts +71 -32
  290. package/src/testing/behavioral/journeys.defaultPack.test.ts +6 -4
  291. package/src/testing/behavioral/journeys.firstInstall.test.ts +70 -5
  292. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +4 -3
  293. package/src/testing/behavioral/journeys.packageStories.test.ts +2 -1
  294. package/src/testing/behavioral/mcpSurface.test.ts +28 -25
  295. package/src/testing/behavioral/parity.test.ts +25 -23
  296. package/src/testing/eval/__snapshots__/eval.test.ts.snap +20 -0
  297. package/src/testing/eval/cases/effectful.ts +7 -1
  298. package/src/testing/eval/cases/readNouns.ts +100 -11
  299. package/src/testing/fixtures/blockGraph.ts +89 -5
  300. package/src/testing/fixtures/connectGraph.ts +159 -0
  301. package/src/testing/fixtures/graph/canonical.ts +128 -32
  302. package/src/testing/helpers/fsProbe.ts +88 -0
  303. package/src/testing/helpers/runCli.test.ts +44 -0
  304. package/src/testing/helpers/runCli.ts +27 -3
  305. package/src/testing/perf/budgets.test.ts +3 -1
  306. package/src/testing/perf/budgets.ts +138 -54
  307. package/dist/src/kernel/packs/sparql/applyFilters.js +0 -167
  308. package/dist/src/kernel/packs/sparql/applyFilters.js.map +0 -1
  309. package/dist/src/kernel/packs/sparql/applySearch.js +0 -25
  310. package/dist/src/kernel/packs/sparql/applySearch.js.map +0 -1
  311. package/src/kernel/packs/sparql/applyFilters.ts +0 -203
  312. package/src/kernel/packs/sparql/applySearch.ts +0 -33
@@ -19,6 +19,80 @@
19
19
  * of the canonical set.
20
20
  */
21
21
 
22
+ /**
23
+ * The parameter names the KERNEL puts on a compiled verb, which a story's own
24
+ * filter may therefore not claim.
25
+ *
26
+ * `search` is a story's declared free-text flag; `detail` the disclosure
27
+ * selector; `name` and `count` a lookup's and a sample's positionals; `limit`
28
+ * and `after` the page every list-shaped verb carries. A filter claiming one
29
+ * would collide with a flag the CLI has already registered — a Commander
30
+ * failure outside every error envelope the CLI owns. Declared here, in the
31
+ * zod-free grammar, so the validator that refuses them and any reader that
32
+ * needs to tell a story's OWN params from the kernel's read one list.
33
+ */
34
+ export const RESERVED_STORY_PARAMS: readonly string[] = [
35
+ "search",
36
+ "detail",
37
+ "name",
38
+ "count",
39
+ "limit",
40
+ "after",
41
+ ];
42
+
43
+ /**
44
+ * The SPARQL VARIABLE prefix the kernel reserves, which a story's own query may
45
+ * therefore not use.
46
+ *
47
+ * The generated filter and search clauses bind a caller's values to variables
48
+ * under this prefix (`?__pragmaFilter0`, `?__pragmaSearch`). A story query using
49
+ * it would have its own variable shadowed, and the symptom would be a filter
50
+ * matching nothing with nothing raised anywhere. Beside the reserved PARAMS for
51
+ * the same reason: the grammar that refuses it and the builder that mints it
52
+ * must read one name, and this file is the zod-free place both can reach — the
53
+ * builder lives behind the dynamically imported run bodies, so nothing on the
54
+ * `--help` fast path may import it.
55
+ */
56
+ export const RESERVED_VARIABLE_PREFIX = "__pragma";
57
+
58
+ /**
59
+ * The `--tier` value that turns the tier scope OFF — every tier, the way every
60
+ * read answered before the scope existed.
61
+ *
62
+ * A word rather than a second flag (this distribution retired an `--all-tiers`
63
+ * once already): `--tier all` answers the same question the flag already asks,
64
+ * so there is one argument to learn and one to document.
65
+ *
66
+ * Declared HERE, in the zod-free grammar, for the reason
67
+ * {@link RESERVED_VARIABLE_PREFIX} is: the compiler that puts the flag on a
68
+ * verb runs on the storeless `--help` fast path, and the run body that reads it
69
+ * lives behind a dynamic import. Both must read one spelling.
70
+ */
71
+ export const EVERY_TIER = "all";
72
+
73
+ /**
74
+ * The parameter name the kernel puts on a TIER-SCOPED noun's reads (`--tier`,
75
+ * `tier` over MCP).
76
+ *
77
+ * Not in {@link RESERVED_STORY_PARAMS}, because it is reserved CONDITIONALLY:
78
+ * an unscoped noun may still declare a `tier` filter of its own, and one does —
79
+ * `variable list --tier` filters the token graph's `dt:tier`, a different
80
+ * predicate in a different namespace from the `ds:tier` this scope reads. The
81
+ * rule that a SCOPED story may not claim it lives in {@link ./storyRules}.
82
+ */
83
+ export const TIER_PARAM = "tier";
84
+
85
+ /**
86
+ * The SELECT variable a list-shaped story publishes its entity IRI in.
87
+ *
88
+ * A convention this package already depends on twice — the condensed renderer
89
+ * titles a row from `uri`/`name`, and every shipped list's IRI column is `uri`
90
+ * — stated here because the tier scope is compiled in as a constraint on that
91
+ * variable, and because both the storeless declaration rules and the query
92
+ * builder behind the dynamic import must read one spelling of it.
93
+ */
94
+ export const ENTITY_VARIABLE = "uri";
95
+
22
96
  /** A list column: a SELECT variable to display. */
23
97
  export interface PackColumn {
24
98
  /** SELECT variable name (without `?`). */
@@ -53,6 +127,17 @@ export interface PackField {
53
127
  export interface PackSection extends PackField {
54
128
  /** Rendering kind (inline field or fenced code block). */
55
129
  readonly kind?: "field" | "code";
130
+ /**
131
+ * One sentence rendered under the section's heading, above its body: how to
132
+ * READ what follows, when the body's own notation does not say.
133
+ *
134
+ * For a section whose body is a document the graph authored, the label is
135
+ * enough. For one whose body is a NOTATION — a block's anatomy is a YAML
136
+ * document in which `background.color: [a, b]` is a fallback chain, first
137
+ * value that resolves winning — the reader has to be told, and the place to
138
+ * tell them is where they are looking.
139
+ */
140
+ readonly note?: string;
56
141
  }
57
142
 
58
143
  /** A field read from each child node of a {@link PackExpand}. */
@@ -65,6 +150,22 @@ export interface PackExpandField {
65
150
  readonly label?: string;
66
151
  /** Explicit GraphQL field name (escape hatch). Ignored on the SPARQL path. */
67
152
  readonly graphqlField?: string;
153
+ /**
154
+ * Leave the cell BLANK when its value repeats the looked-up entity's own
155
+ * `by` value — for a relation an entity can point back at ITSELF along.
156
+ *
157
+ * The standing case is a block's token bindings: `ds:viaBlock` names the
158
+ * anatomy tree a binding was reached through, and the ontology defines it as
159
+ * present on every record and EQUAL to the block on the block's own tree. So
160
+ * a Button lookup printed "Button" in the via column of all 24 of its rows —
161
+ * a cell that is only ever news when it differs. Blank says "this block's
162
+ * own", which is the reading, and leaves the column carrying only the
163
+ * inherited trees.
164
+ *
165
+ * SPARQL lane only: the generated sub-SELECT can bind the entity's own name
166
+ * and filter against it, and a GraphQL document cannot.
167
+ */
168
+ readonly blankWhenSelf?: true;
68
169
  }
69
170
 
70
171
  /**
@@ -119,6 +220,72 @@ export interface PackExpand {
119
220
  readonly showWhenEmpty?: boolean;
120
221
  /** Minimum canonical disclosure level at which this expand is fetched. */
121
222
  readonly level?: string;
223
+ /**
224
+ * One sentence rendered under the section's heading, above the rows: how to
225
+ * READ them, when the columns do not say it themselves.
226
+ *
227
+ * The same seam a {@link PackSection} carries, for the same reason and
228
+ * through the same renderer field — a section's note was declarable only for
229
+ * a section whose body is one literal, and a TABLE can be the notation that
230
+ * needs reading just as much: a `rank` column is a position in a fallback
231
+ * chain, and nothing in the word "rank" says which chain or that first wins.
232
+ */
233
+ readonly note?: string;
234
+ /**
235
+ * Child field names to ORDER BY in the generated sub-SELECT, in order.
236
+ *
237
+ * Without one an expand's rows arrive in the store's own scan order, which
238
+ * SPARQL does not define and which no reader can predict. That is tolerable
239
+ * for a short list of names and wrong for a TABLE whose rows differ in one
240
+ * column: Button's 24 token bindings came back with `spacing.internal.inline.start`
241
+ * and `…end` adjacent, in no stated order, and a reader comparing two rows
242
+ * had no way to know whether the pair was two facts or one repeated.
243
+ *
244
+ * Names must be fields the `select` declares (the grammar refuses others),
245
+ * so the clause is composed only from validated identifiers. SPARQL lane
246
+ * only — a GraphQL connection's order is the schema's, not the story's.
247
+ */
248
+ readonly orderBy?: readonly string[];
249
+ /**
250
+ * Resolve THIS expand through the SPARQL lane even when the lookup is
251
+ * `source: "graphql"`.
252
+ *
253
+ * The lanes are not interchangeable, and a lookup is one or the other: the
254
+ * GraphQL lane can project a grandchild through a MULTI-valued relation
255
+ * (a nested expand), which the single-hop SPARQL sub-SELECT cannot, and the
256
+ * SPARQL lane can read property paths and any property the ontology defines,
257
+ * which the GraphQL lane cannot — a derived field name that the compiled
258
+ * schema does not carry drops out of the document in silence.
259
+ *
260
+ * The block lookup needs both at once, which is why this exists rather than
261
+ * a second lookup. Its modifier families are a nested expand and so must
262
+ * stay on the GraphQL lane; its token bindings must not, because
263
+ * `anatomy:styleKey` and `anatomy:styleState` — two of the six columns that
264
+ * IDENTIFY a binding — carry no `rdfs:domain`, so ke-graphql generates no
265
+ * field for them on `TokenBinding` and the two columns vanished without a
266
+ * word, leaving rows that differed only in a dropped column looking like
267
+ * duplicates.
268
+ *
269
+ * Only `"sparql"` is admissible: the SPARQL lane is every lookup's default,
270
+ * so an expand opting INTO GraphQL would be asking for a document the lookup
271
+ * never builds.
272
+ */
273
+ readonly source?: "sparql";
274
+ }
275
+
276
+ /**
277
+ * Which lane resolves one expand: its own declared {@link PackExpand.source},
278
+ * else the lookup's, else SPARQL.
279
+ *
280
+ * Read by BOTH lanes — the GraphQL document generator skips what it does not
281
+ * own, and the entity fetcher runs it — so the question is answered in one
282
+ * place rather than asked twice with two spellings.
283
+ */
284
+ export function expandIsSparql(
285
+ lookup: Pick<PackLookup, "source">,
286
+ expand: Pick<PackExpand, "source">,
287
+ ): boolean {
288
+ return (expand.source ?? lookup.source ?? "sparql") === "sparql";
122
289
  }
123
290
 
124
291
  /**
@@ -136,8 +303,12 @@ export interface PackDisclosure {
136
303
 
137
304
  /**
138
305
  * A declarative list filter: a CLI/MCP parameter constraining one SELECT
139
- * variable. Filters are row predicates applied AFTER the author query runs — the
140
- * query text is never modified, so filter input cannot inject SPARQL.
306
+ * variable. Filters compile INTO the generated query, as predicates over the
307
+ * author query read as a sub-select — the author's text is never modified, and
308
+ * a caller's value is bound through a `VALUES` block rather than spliced into
309
+ * query text, so filter input still cannot inject SPARQL. Compiling them in is
310
+ * what puts filtering on the right side of a page: a predicate over returned
311
+ * rows would decide over whatever the page happened to contain.
141
312
  */
142
313
  export interface PackFilter {
143
314
  /** Parameter name — a single lowercase word (`--param` / MCP key). */
@@ -161,11 +332,16 @@ export interface PackFilter {
161
332
  * the hierarchy, also a `testing` standard, and a filter that compared only
162
333
  * the leaf answered `--category testing` with 1 of 8 — a silently wrong
163
334
  * answer, exit 0.
335
+ *
336
+ * A `"set"` cell is computed by the author's own `GROUP BY`, so its predicate
337
+ * can only run AFTER aggregation. That is why a filtered list wraps the
338
+ * author query in a sub-select rather than appending to it, and why a
339
+ * filterable story must project its variables by name.
164
340
  */
165
341
  readonly match?: "exact" | "set";
166
342
  /**
167
- * Where the dimension's VOCABULARY lives in the graph, for a filter with no
168
- * declared {@link values}.
343
+ * Where the dimension's VOCABULARY lives in the graph. REQUIRED for a filter
344
+ * with no declared {@link values} — a filter declaring neither is refused.
169
345
  *
170
346
  * A value-free filter rejects a value the data does not carry. The question is
171
347
  * what "the data" means. The rows a list just returned are the wrong answer:
@@ -180,9 +356,13 @@ export interface PackFilter {
180
356
  * this honours is "the graph is the vocabulary, don't hard-code the slugs" —
181
357
  * rows were never the graph, just the part of it that answered.
182
358
  *
183
- * Without it a value-free filter falls back to the observed rows, which is the
184
- * only evidence available; that is a NARROWER vocabulary than the truth, and a
185
- * story whose dimension has an authoritative source should declare it.
359
+ * A value-free filter USED to fall back to the observed rows — knowingly
360
+ * narrower than the truth, but the only evidence available. Now that a list
361
+ * answers with a PAGE that fallback is not merely narrow, it is wrong: a real
362
+ * value outside the window would be refused as a bad argument, with a
363
+ * truncated list of alternatives. So the fallback is gone and the declaration
364
+ * takes its place — a filter names `values` or a vocabulary, and one that
365
+ * names neither is refused where it is declared.
186
366
  */
187
367
  readonly vocabulary?: PackFilterVocabulary;
188
368
  /** Help text (defaults to a generated description). */
@@ -202,8 +382,9 @@ export interface PackFilterVocabulary {
202
382
 
203
383
  /**
204
384
  * Free-text search over list rows: a `--search` string keeping a row when ANY
205
- * named SELECT variable contains the term (case-insensitive substring). Applied
206
- * after the author query, so input never touches the query text.
385
+ * named SELECT variable contains the term (case-insensitive substring).
386
+ * Compiled into the generated query alongside the filters, with the term bound
387
+ * through a `VALUES` block rather than spliced into query text.
207
388
  */
208
389
  export interface PackSearch {
209
390
  /** SELECT variables searched (without `?`). */
@@ -426,6 +607,73 @@ export interface PackLookup {
426
607
  /** A pack list row / flat lookup base: variable name → string value. */
427
608
  export type PackRow = Record<string, string>;
428
609
 
610
+ /**
611
+ * What a list-shaped verb answers with: one page of rows, and whether more
612
+ * follow.
613
+ *
614
+ * The ROWS are still the payload — `formatters.json` projects this to
615
+ * {@link rows} alone, so `--format json` and the MCP tool result stay the bare
616
+ * array they always were and nothing downstream had to learn a new shape. The
617
+ * page's own facts ride the `notice` seam into the envelope's `meta`, the same
618
+ * channel a calm empty result already uses. That is deliberate: a caller
619
+ * reading the data reads data, and a caller who needs to know there is more is
620
+ * told in the one place this surface already puts things the data cannot say
621
+ * about itself.
622
+ */
623
+ export interface PackPage {
624
+ /** This page's rows, in the story's own order. */
625
+ readonly rows: readonly PackRow[];
626
+ /**
627
+ * The filters this read was narrowed by, as the caller spelled them — absent
628
+ * when the read was unfiltered.
629
+ *
630
+ * Carried because a zero-row answer has something to say that its rows
631
+ * cannot: which arguments narrowed it. A reader who typed `--kind input` and
632
+ * got nothing has to see that it was applied and came back empty, so the
633
+ * empty-state sentence names the filters in force. The story's
634
+ * `emptyRecovery` — which speaks about the population, and is worded to hold
635
+ * whether or not a filter is in force — prints under that sentence, the same
636
+ * as it prints on its own when nothing narrowed the read. The renderer cannot
637
+ * re-derive this: it sees a page, not the arguments, so the run body records
638
+ * what it narrowed by.
639
+ */
640
+ readonly filters?: readonly PackAppliedFilter[];
641
+ /**
642
+ * The cursor that asks for the rows after this page, absent when this page is
643
+ * the last. Its presence IS the "more rows exist" answer.
644
+ */
645
+ readonly nextAfter?: string;
646
+ /** The limit this page was cut to, for the notice that reports it. */
647
+ readonly limit: number;
648
+ /**
649
+ * The tier scope this read was narrowed to, absent when the story is not
650
+ * tiered or the caller asked for every tier.
651
+ *
652
+ * Carried for the same reason {@link filters} is: the rows cannot say that a
653
+ * whole part of the population was never considered. A reader who sees 175
654
+ * blocks under a heading that says `(175)` reads it as the design system; the
655
+ * scope is how the answer admits it is one tier chain of it.
656
+ */
657
+ readonly scope?: PageTierScope;
658
+ }
659
+
660
+ /** The tier scope a page was read under, as a caller can read it back. */
661
+ export interface PageTierScope {
662
+ /**
663
+ * The in-scope tiers, in the spelling the `--tier` flag and the `Tier` column
664
+ * use (the tier IRI's local name), base first and then shallowest first.
665
+ */
666
+ readonly tiers: readonly string[];
667
+ }
668
+
669
+ /** One filter a list read was narrowed by, as the caller spelled it. */
670
+ export interface PackAppliedFilter {
671
+ /** The parameter name (its CLI flag is `--<param>`). */
672
+ readonly param: string;
673
+ /** The value(s) supplied, already joined for display. */
674
+ readonly value: string;
675
+ }
676
+
429
677
  /**
430
678
  * A child record under an expand. Scalar values come from child fields; a nested
431
679
  * expand contributes either a collapsed string array (single-field select) or
@@ -467,6 +715,60 @@ export interface PackDefinition {
467
715
  * modules) by `pragma colophon`, after pragma's own built-in colophon.
468
716
  */
469
717
  readonly colophon?: string;
718
+ /**
719
+ * The tier hierarchy this noun's entities carry, which SCOPES every read of
720
+ * them. Absent means unscoped — the read answers from every tier, as every
721
+ * read did before the scope existed.
722
+ */
723
+ readonly tierScope?: PackTierScope;
724
+ }
725
+
726
+ /**
727
+ * The tier hierarchy this noun's entities belong to, and the scope every read
728
+ * of them is narrowed by.
729
+ *
730
+ * DECLARED PER NOUN, because being tiered is a property of the entities: the
731
+ * design system's blocks, modifier families and concepts carry a `ds:tier`, and
732
+ * its token symbols, variables and code standards carry nothing of the kind. A
733
+ * noun that declares this gets the scope compiled into every list it declares
734
+ * and applied to its lookup; a noun that does not is read exactly as before.
735
+ *
736
+ * WHY THE HIERARCHY IS A NAME AND NOT AN EDGE. The shipped graph asserts no
737
+ * parent predicate on a tier — `ds:apps_lxd` is `a ds:Tier` with a
738
+ * `ds:name "Apps/LXD"`, and that slash IS the hierarchy: `Apps/LXD` sits under
739
+ * `Apps`, which sits under nothing. So the parent of a tier is the tier whose
740
+ * name is its own name minus the last `/` segment, exactly the derivation
741
+ * {@link PackScopeWeight} already ranks by ("the depth is the `/` count in the
742
+ * tier's OWN `ds:name`, not in its IRI"). Reading the hierarchy from the same
743
+ * place twice is what keeps the ranking and the scope from disagreeing about
744
+ * which tier is above which. The day a parent edge is asserted upstream, `by`
745
+ * gives way to it and every reader here changes in one place.
746
+ */
747
+ export interface PackTierScope {
748
+ /** The class whose instances ARE the tiers (e.g. `ds:Tier`). */
749
+ readonly type: string;
750
+ /** The entity → tier edge (e.g. `ds:tier`), same term as `scopeWeight.via`. */
751
+ readonly via: string;
752
+ /**
753
+ * The tier property holding its path-shaped name (e.g. `ds:name`), whose
754
+ * `/`-separated segments ARE the hierarchy: `Apps/LXD` is a child of `Apps`,
755
+ * and a name with no `/` is top-level.
756
+ */
757
+ readonly by: string;
758
+ /**
759
+ * The tier every other tier builds on, in scope whenever a scope is in force
760
+ * (e.g. `ds:global`).
761
+ *
762
+ * DECLARED, not inferred: nothing in the graph says which of the top-level
763
+ * tiers is the base — the design system's own colophon does ("`global` >
764
+ * `apps` > `apps/lxd`: a lower tier inherits and overrides the blocks of its
765
+ * ancestors"), and that is editorial judgement the ontology has not made yet,
766
+ * so it belongs in the declaration layer beside `weights` and `scopeWeight`.
767
+ * Without it a narrowed read would hide the very blocks the narrow tier
768
+ * inherits: `--tier apps_lxd` would answer with LXD's nine and not the 119
769
+ * global ones every LXD screen is actually built from.
770
+ */
771
+ readonly base?: string;
470
772
  }
471
773
 
472
774
  /** A validated pack definition paired with where it was declared. */
@@ -175,7 +175,23 @@ function attachVerb(
175
175
 
176
176
  command.action(async (...actionArgs: unknown[]) => {
177
177
  const { positionals: positionalArgs, opts } = splitActionArgs(actionArgs);
178
- await dispatch(verb, positionalArgs, opts, globalFlags);
178
+ // `command.args` is every operand this leaf received; the action arguments
179
+ // are only the DECLARED ones, so a verb that declares none is handed
180
+ // nothing and a stray word vanishes between the two. Dispatch reads the
181
+ // longer of the two lists and refuses what the verb cannot take
182
+ // (`dispatch.ts#refuseExcessPositionals`) — Commander's own excess check
183
+ // cannot: a sub-verb-only noun opts into excess operands to keep its
184
+ // "unknown command" suggestion, and `copyInheritedSettings` hands that
185
+ // opt-in down to every verb attached beneath it.
186
+ const operands = command.args.filter(
187
+ (arg): arg is string => typeof arg === "string",
188
+ );
189
+ await dispatch(
190
+ verb,
191
+ operands.length > positionalArgs.length ? operands : positionalArgs,
192
+ opts,
193
+ globalFlags,
194
+ );
179
195
  });
180
196
  }
181
197
 
@@ -379,6 +379,93 @@ describe("dispatch — errors", () => {
379
379
  });
380
380
  });
381
381
 
382
+ describe("a positional the spec cannot hold is refused, not dropped", () => {
383
+ const savedExit = process.exitCode;
384
+ afterEach(() => {
385
+ process.exitCode = savedExit;
386
+ });
387
+
388
+ /** Run `verb` with `positionals` and collect what stderr was told. */
389
+ async function stderrOf(
390
+ verb: VerbSpec,
391
+ positionals: string[],
392
+ ): Promise<string> {
393
+ const errs: string[] = [];
394
+ const spy = vi
395
+ .spyOn(process.stderr, "write")
396
+ .mockImplementation((chunk: string | Uint8Array) => {
397
+ errs.push(String(chunk));
398
+ return true;
399
+ });
400
+ // The verb that is NOT refused runs and prints; keep it out of the report.
401
+ const out = vi
402
+ .spyOn(process.stdout, "write")
403
+ .mockImplementation(() => true);
404
+ await dispatch(verb, positionals, {}, PLAIN);
405
+ spy.mockRestore();
406
+ out.mockRestore();
407
+ return errs.join("");
408
+ }
409
+
410
+ /** A verb addressed by a flag, the shape `variable chain` has. */
411
+ const chain: VerbSpec = {
412
+ path: ["gizmo", "chain"],
413
+ summary: "Walk a gizmo.",
414
+ params: [
415
+ { kind: "string", name: "gizmo", doc: "The gizmo." },
416
+ { kind: "number", name: "limit", doc: "Rows." },
417
+ ],
418
+ output: { formatters: passthroughFormatters },
419
+ capability: { needsStore: false, mutates: false, mcp: { expose: true } },
420
+ run: async () => ({ walked: true }),
421
+ };
422
+
423
+ it("names the flag the value belonged to, and exits 2", async () => {
424
+ const out = await stderrOf(chain, ["color-text"]);
425
+
426
+ expect(out).toContain(
427
+ '`pragma gizmo chain` takes no positional argument, but received "color-text".',
428
+ );
429
+ expect(out).toContain("Did you mean `--gizmo color-text`?");
430
+ expect(process.exitCode).toBe(2);
431
+ });
432
+
433
+ it("points at --help when no flag could have held it", async () => {
434
+ const bare: VerbSpec = { ...chain, params: [] };
435
+
436
+ const out = await stderrOf(bare, ["color-text"]);
437
+
438
+ expect(out).toContain("takes no positional argument");
439
+ expect(out).toContain("run `pragma gizmo chain --help`");
440
+ });
441
+
442
+ it("refuses one MORE than the spec declares", async () => {
443
+ const out = await stderrOf(echo, ["hello", "spare"]);
444
+
445
+ expect(out).toContain(
446
+ '`pragma probe echo` takes 1 positional argument, but received an extra "spare".',
447
+ );
448
+ });
449
+
450
+ it("leaves a variadic verb alone — its tail is the point", async () => {
451
+ const many: VerbSpec = {
452
+ ...echo,
453
+ params: [
454
+ {
455
+ kind: "string[]",
456
+ name: "name",
457
+ doc: "Names.",
458
+ positional: true,
459
+ required: true,
460
+ },
461
+ ],
462
+ run: async (p) => (p as { name: string[] }).name,
463
+ };
464
+
465
+ expect(await stderrOf(many, ["one", "two", "three"])).toBe("");
466
+ });
467
+ });
468
+
382
469
  describe("--dry-run is honest (PRA-104)", () => {
383
470
  const savedExit = process.exitCode;
384
471
  afterEach(() => {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { describeEffect, type Effect, type Task } from "@canonical/task";
15
15
  import { runPreview, runTask, runUndo } from "@canonical/task/node";
16
+ import { BIN_NAME } from "../../../constants.js";
16
17
  import {
17
18
  asPragmaError,
18
19
  CANCELLED_MESSAGE,
@@ -34,7 +35,7 @@ import type {
34
35
  InteractionRuntime,
35
36
  PragmaRuntime,
36
37
  } from "../../runtime/types.js";
37
- import type { ParamSpec, VerbSpec } from "../../spec/index.js";
38
+ import { kebabCase, type ParamSpec, type VerbSpec } from "../../spec/index.js";
38
39
  import { EXIT, mapExitCode } from "./exitCodes.js";
39
40
 
40
41
  /** The CLI-only mutation flags auto-injected onto every mutating verb. */
@@ -177,9 +178,17 @@ function renderData(
177
178
  // (`mcp/registerVerb.ts#noticeMeta`) — the two machine surfaces stay
178
179
  // byte-equal.
179
180
  const notice = verb.output.formatters.notice?.(data);
181
+ // The machine half of the same seam: facts the read wants to hand an agent
182
+ // as data rather than prose (`meta.scope`). MCP merges the same seam into
183
+ // the same keys (`mcp/registerVerb.ts#readMeta`).
184
+ const extra = verb.output.formatters.meta?.(data);
180
185
  return {
181
186
  stdout: `${JSON.stringify(
182
- successEnvelope(projection, notice ? { ...meta, notice } : meta),
187
+ successEnvelope(projection, {
188
+ ...meta,
189
+ ...(extra ?? {}),
190
+ ...(notice ? { notice } : {}),
191
+ }),
183
192
  )}\n`,
184
193
  exitCode: 0,
185
194
  };
@@ -411,7 +420,29 @@ export async function executeVerb(
411
420
  }
412
421
  }
413
422
  if (mutation.undo) {
414
- const { undoCount } = await runUndo(task, { onLog: logSink(flags) });
423
+ // The truth seam. `runUndo`'s collection phase drove the verb's task
424
+ // with every effect mocked, so anything the task body reported was a
425
+ // narration of the mocked walk, not of the reversals — which have only
426
+ // JUST run, each isolated, each with its own outcome. Those outcomes
427
+ // are the one honest record of what the undo did: a verb that reports
428
+ // per-target results sets `undoReport` (like `exec`/`planData`) and
429
+ // projects them onto its own rows; a throw from it renders as the
430
+ // run's error, exactly like a failed real run. Verbs without a
431
+ // projector of their own still may not exit 0 over a failed reversal,
432
+ // so the outcomes are re-checked here as the backstop.
433
+ const { undoCount, outcomes } = await runUndo(task, {
434
+ onLog: logSink(flags),
435
+ });
436
+ await mutationRuntime.undoReport?.(outcomes);
437
+ const failed = outcomes.filter((o) => o.status === "failed");
438
+ if (failed.length > 0) {
439
+ throw new PragmaError({
440
+ code: "UNSUPPORTED",
441
+ message: `${failed.length} of ${outcomes.length} undo step(s) did not complete: ${failed
442
+ .map((o) => o.error.message)
443
+ .join("; ")}`,
444
+ });
445
+ }
415
446
  return renderUndo(flags, undoCount);
416
447
  }
417
448
  // Real execution: spread the verb's runner options into the node
@@ -508,12 +539,91 @@ export async function dispatch(
508
539
  };
509
540
  await runPrepared(
510
541
  verb,
511
- () => extractParams(verb.params, positionals, opts),
542
+ () => {
543
+ refuseExcessPositionals(verb, positionals);
544
+ return extractParams(verb.params, positionals, opts);
545
+ },
512
546
  mutation,
513
547
  globalFlags,
514
548
  );
515
549
  }
516
550
 
551
+ /**
552
+ * Refuse a positional the verb's spec has nowhere to put.
553
+ *
554
+ * `extractParams` maps positionals onto DECLARED positional params and drops
555
+ * the rest, so `pragma variable chain color-text` — whose story takes its
556
+ * subject as `--variable` — ran the unfiltered chain and printed 300 unrelated
557
+ * rows, having silently discarded the one word the user cared about. A wrong
558
+ * answer with no diagnostic is worse than a refusal.
559
+ *
560
+ * The refusal names the flag the value most likely belongs to: the verb's own
561
+ * noun as a flag (`variable chain` → `--variable`), else its first
562
+ * value-taking flag. That is the identity filter for every story shaped like
563
+ * this one, and where it is not, the flag list is one `--help` away.
564
+ *
565
+ * @param verb - The verb the user invoked.
566
+ * @param positionals - The operands Commander collected for it.
567
+ * @throws PragmaError INVALID_INPUT when more operands arrived than the spec
568
+ * declares (a variadic positional absorbs the tail, so it never trips).
569
+ */
570
+ function refuseExcessPositionals(
571
+ verb: VerbSpec,
572
+ positionals: readonly string[],
573
+ ): void {
574
+ const declared = verb.params.filter((param) => param.positional);
575
+ if (declared.some((param) => param.kind === "string[]")) return;
576
+ const excess = positionals[declared.length];
577
+ if (excess === undefined) return;
578
+ const command = `${BIN_NAME} ${verb.path.join(" ")}`;
579
+ const flag = suggestedFlag(verb);
580
+ throw new PragmaError({
581
+ code: "INVALID_INPUT",
582
+ message:
583
+ declared.length === 0
584
+ ? `\`${command}\` takes no positional argument, but received "${excess}".`
585
+ : `\`${command}\` takes ${declared.length} positional argument${
586
+ declared.length === 1 ? "" : "s"
587
+ }, but received an extra "${excess}".`,
588
+ recovery: {
589
+ message: flag
590
+ ? `Did you mean \`--${flag} ${excess}\`?`
591
+ : `Pass values as flags — run \`${command} --help\` to see them.`,
592
+ },
593
+ });
594
+ }
595
+
596
+ /**
597
+ * The flag a stray positional most likely belonged to.
598
+ *
599
+ * The verb's NOUN first (`variable chain` declares `--variable`, which is the
600
+ * identity filter every such story is addressed by), then the first flag that
601
+ * takes a value at all. Paging and disclosure flags are never the answer — a
602
+ * bare word is not a cursor.
603
+ *
604
+ * @param verb - The verb the user invoked.
605
+ * @returns The flag name (without dashes), or undefined when none fits.
606
+ */
607
+ function suggestedFlag(verb: VerbSpec): string | undefined {
608
+ const candidates = verb.params.filter(
609
+ (param) =>
610
+ !param.positional &&
611
+ param.kind !== "boolean" &&
612
+ !GENERIC_FLAGS.has(param.name),
613
+ );
614
+ const identity = candidates.find((param) => param.name === verb.path[0]);
615
+ const flag = identity ?? candidates[0];
616
+ return flag ? kebabCase(flag.name) : undefined;
617
+ }
618
+
619
+ /** Flags that answer no question a stray word could be the answer to. */
620
+ const GENERIC_FLAGS: ReadonlySet<string> = new Set([
621
+ "limit",
622
+ "after",
623
+ "detail",
624
+ "count",
625
+ ]);
626
+
517
627
  /**
518
628
  * Dispatch a verb whose params were ALREADY extracted by the caller — the
519
629
  * seam a mounted subtree drives: its leaf specs carry no Commander defaults