@canonical/pragma-cli 0.38.0 → 0.40.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 (330) hide show
  1. package/README.md +1 -1
  2. package/dist/package.json +14 -14
  3. package/dist/pragma.conf.js +273 -176
  4. package/dist/pragma.conf.js.map +1 -1
  5. package/dist/src/capabilities/capabilities/capabilities.verb.js +2 -0
  6. package/dist/src/capabilities/capabilities/capabilities.verb.js.map +1 -1
  7. package/dist/src/capabilities/capabilities/catalog.js +47 -51
  8. package/dist/src/capabilities/capabilities/catalog.js.map +1 -1
  9. package/dist/src/capabilities/capabilities/index.js +2 -0
  10. package/dist/src/capabilities/capabilities/index.js.map +1 -1
  11. package/dist/src/capabilities/capabilities/types.js +5 -5
  12. package/dist/src/capabilities/colophon/colophon.render.js +7 -4
  13. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  14. package/dist/src/capabilities/colophon/colophon.verb.js +1 -0
  15. package/dist/src/capabilities/colophon/colophon.verb.js.map +1 -1
  16. package/dist/src/capabilities/config/get.verb.js +2 -0
  17. package/dist/src/capabilities/config/get.verb.js.map +1 -1
  18. package/dist/src/capabilities/config/runField.js +2 -2
  19. package/dist/src/capabilities/config/runField.js.map +1 -1
  20. package/dist/src/capabilities/config/set.verb.js +2 -0
  21. package/dist/src/capabilities/config/set.verb.js.map +1 -1
  22. package/dist/src/capabilities/config/show.verb.js +1 -0
  23. package/dist/src/capabilities/config/show.verb.js.map +1 -1
  24. package/dist/src/capabilities/config/unset.verb.js +2 -0
  25. package/dist/src/capabilities/config/unset.verb.js.map +1 -1
  26. package/dist/src/capabilities/create/constants.js +2 -2
  27. package/dist/src/capabilities/create/create.verb.js +14 -4
  28. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  29. package/dist/src/capabilities/distribution.js +3 -1
  30. package/dist/src/capabilities/distribution.js.map +1 -1
  31. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +10 -2
  32. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  33. package/dist/src/capabilities/doctor/doctor.verb.js +2 -0
  34. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  35. package/dist/src/capabilities/graph/connect.verb.js +2 -0
  36. package/dist/src/capabilities/graph/connect.verb.js.map +1 -1
  37. package/dist/src/capabilities/graph/inspect.render.js +7 -1
  38. package/dist/src/capabilities/graph/inspect.render.js.map +1 -1
  39. package/dist/src/capabilities/graph/inspect.verb.js +2 -0
  40. package/dist/src/capabilities/graph/inspect.verb.js.map +1 -1
  41. package/dist/src/capabilities/graph/query.render.js +37 -5
  42. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  43. package/dist/src/capabilities/graph/query.verb.js +2 -0
  44. package/dist/src/capabilities/graph/query.verb.js.map +1 -1
  45. package/dist/src/capabilities/graph/queryText.js +28 -0
  46. package/dist/src/capabilities/graph/queryText.js.map +1 -1
  47. package/dist/src/capabilities/graph/runConnect.js +3 -3
  48. package/dist/src/capabilities/graph/runConnect.js.map +1 -1
  49. package/dist/src/capabilities/graph/runQuery.js +8 -6
  50. package/dist/src/capabilities/graph/runQuery.js.map +1 -1
  51. package/dist/src/capabilities/info/info.verb.js +2 -0
  52. package/dist/src/capabilities/info/info.verb.js.map +1 -1
  53. package/dist/src/capabilities/meta/mcp.verb.js +1 -0
  54. package/dist/src/capabilities/meta/mcp.verb.js.map +1 -1
  55. package/dist/src/capabilities/ontology/verbs.js +7 -10
  56. package/dist/src/capabilities/ontology/verbs.js.map +1 -1
  57. package/dist/src/capabilities/prompt/verbs.js +5 -4
  58. package/dist/src/capabilities/prompt/verbs.js.map +1 -1
  59. package/dist/src/capabilities/resources/provider.js +6 -3
  60. package/dist/src/capabilities/resources/provider.js.map +1 -1
  61. package/dist/src/capabilities/setup/setup.verb.js +13 -5
  62. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  63. package/dist/src/capabilities/shared/calls.js +16 -0
  64. package/dist/src/capabilities/shared/calls.js.map +1 -0
  65. package/dist/src/capabilities/skill/render.js +7 -5
  66. package/dist/src/capabilities/skill/render.js.map +1 -1
  67. package/dist/src/capabilities/skill/verbs.js +5 -4
  68. package/dist/src/capabilities/skill/verbs.js.map +1 -1
  69. package/dist/src/capabilities/sources/collectStatus.js +6 -1
  70. package/dist/src/capabilities/sources/collectStatus.js.map +1 -1
  71. package/dist/src/capabilities/sources/index.js +4 -3
  72. package/dist/src/capabilities/sources/index.js.map +1 -1
  73. package/dist/src/capabilities/sources/reset.render.js +37 -0
  74. package/dist/src/capabilities/sources/reset.render.js.map +1 -0
  75. package/dist/src/capabilities/sources/reset.verb.js +51 -0
  76. package/dist/src/capabilities/sources/reset.verb.js.map +1 -0
  77. package/dist/src/capabilities/sources/runReset.js +59 -0
  78. package/dist/src/capabilities/sources/runReset.js.map +1 -0
  79. package/dist/src/capabilities/sources/runUpdate.js +7 -6
  80. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  81. package/dist/src/capabilities/sources/status.render.js +24 -8
  82. package/dist/src/capabilities/sources/status.render.js.map +1 -1
  83. package/dist/src/capabilities/sources/status.verb.js +1 -0
  84. package/dist/src/capabilities/sources/status.verb.js.map +1 -1
  85. package/dist/src/capabilities/sources/update.verb.js +2 -0
  86. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  87. package/dist/src/capabilities/upgrade/upgrade.verb.js +1 -0
  88. package/dist/src/capabilities/upgrade/upgrade.verb.js.map +1 -1
  89. package/dist/src/capabilities/version/version.verb.js +1 -0
  90. package/dist/src/capabilities/version/version.verb.js.map +1 -1
  91. package/dist/src/constants.js +4 -3
  92. package/dist/src/constants.js.map +1 -1
  93. package/dist/src/kernel/error/index.js +1 -1
  94. package/dist/src/kernel/error/index.js.map +1 -1
  95. package/dist/src/kernel/error/recovery.js +24 -42
  96. package/dist/src/kernel/error/recovery.js.map +1 -1
  97. package/dist/src/kernel/packs/collect.js +123 -9
  98. package/dist/src/kernel/packs/collect.js.map +1 -1
  99. package/dist/src/kernel/packs/compile.js +74 -15
  100. package/dist/src/kernel/packs/compile.js.map +1 -1
  101. package/dist/src/kernel/packs/graphql/fetchGraphqlLookup.js +3 -3
  102. package/dist/src/kernel/packs/graphql/fetchGraphqlLookup.js.map +1 -1
  103. package/dist/src/kernel/packs/iri.js +2 -4
  104. package/dist/src/kernel/packs/iri.js.map +1 -1
  105. package/dist/src/kernel/packs/renderPack.js +90 -50
  106. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  107. package/dist/src/kernel/packs/resolveEntity.js +202 -90
  108. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  109. package/dist/src/kernel/packs/runBodies.js +89 -30
  110. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  111. package/dist/src/kernel/packs/schema.js +66 -12
  112. package/dist/src/kernel/packs/schema.js.map +1 -1
  113. package/dist/src/kernel/packs/sparql/buildListQuery.js +80 -13
  114. package/dist/src/kernel/packs/sparql/buildListQuery.js.map +1 -1
  115. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +41 -13
  116. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  117. package/dist/src/kernel/packs/sparql/filterValues.js +32 -23
  118. package/dist/src/kernel/packs/sparql/filterValues.js.map +1 -1
  119. package/dist/src/kernel/packs/sparql/runSelect.js +3 -5
  120. package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -1
  121. package/dist/src/kernel/packs/storyRules.js +31 -4
  122. package/dist/src/kernel/packs/storyRules.js.map +1 -1
  123. package/dist/src/kernel/packs/tierScope.js +3 -3
  124. package/dist/src/kernel/packs/tierScope.js.map +1 -1
  125. package/dist/src/kernel/packs/types.js +4 -0
  126. package/dist/src/kernel/packs/types.js.map +1 -1
  127. package/dist/src/kernel/project/cli/buildProgram.js +4 -0
  128. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  129. package/dist/src/kernel/project/cli/dispatch.js +32 -8
  130. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  131. package/dist/src/kernel/project/cli/suggestNames.js +44 -1
  132. package/dist/src/kernel/project/cli/suggestNames.js.map +1 -1
  133. package/dist/src/kernel/project/cli/verbHelp.js +17 -18
  134. package/dist/src/kernel/project/cli/verbHelp.js.map +1 -1
  135. package/dist/src/kernel/project/mcp/buildServer.js +11 -4
  136. package/dist/src/kernel/project/mcp/buildServer.js.map +1 -1
  137. package/dist/src/kernel/project/mcp/envelope.js +3 -2
  138. package/dist/src/kernel/project/mcp/envelope.js.map +1 -1
  139. package/dist/src/kernel/project/mcp/instructions.js +101 -31
  140. package/dist/src/kernel/project/mcp/instructions.js.map +1 -1
  141. package/dist/src/kernel/project/mcp/registerVerb.js +59 -23
  142. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  143. package/dist/src/kernel/render/renderers.js +3 -3
  144. package/dist/src/kernel/render/turtle.js +21 -4
  145. package/dist/src/kernel/render/turtle.js.map +1 -1
  146. package/dist/src/kernel/runtime/graphpack/build.js +54 -1
  147. package/dist/src/kernel/runtime/graphpack/build.js.map +1 -1
  148. package/dist/src/kernel/runtime/graphpack/manifest.js +14 -0
  149. package/dist/src/kernel/runtime/graphpack/manifest.js.map +1 -1
  150. package/dist/src/kernel/runtime/graphpack/read.js +3 -5
  151. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  152. package/dist/src/kernel/runtime/graphpack/schemas.js +1 -0
  153. package/dist/src/kernel/runtime/graphpack/schemas.js.map +1 -1
  154. package/dist/src/kernel/runtime/loadSession.js +8 -1
  155. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  156. package/dist/src/kernel/runtime/packVersion.js +75 -0
  157. package/dist/src/kernel/runtime/packVersion.js.map +1 -0
  158. package/dist/src/kernel/runtime/readEntity.js +43 -4
  159. package/dist/src/kernel/runtime/readEntity.js.map +1 -1
  160. package/dist/src/kernel/runtime/resolveSources.js +61 -9
  161. package/dist/src/kernel/runtime/resolveSources.js.map +1 -1
  162. package/dist/src/kernel/runtime/storeReadiness.js +3 -2
  163. package/dist/src/kernel/runtime/storeReadiness.js.map +1 -1
  164. package/dist/src/kernel/spec/call.js +167 -0
  165. package/dist/src/kernel/spec/call.js.map +1 -0
  166. package/dist/src/kernel/spec/emitReference.js +9 -13
  167. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  168. package/dist/src/kernel/spec/emitSurface.js +8 -1
  169. package/dist/src/kernel/spec/emitSurface.js.map +1 -1
  170. package/dist/src/kernel/spec/guidance.js +63 -0
  171. package/dist/src/kernel/spec/guidance.js.map +1 -0
  172. package/dist/src/kernel/spec/wireType.js +36 -0
  173. package/dist/src/kernel/spec/wireType.js.map +1 -0
  174. package/package.json +14 -14
  175. package/pragma.conf.ts +294 -177
  176. package/src/capabilities/block.parity.test.ts +1 -1
  177. package/src/capabilities/callRule.test.ts +147 -0
  178. package/src/capabilities/capabilities/capabilities.test.ts +47 -19
  179. package/src/capabilities/capabilities/capabilities.verb.ts +2 -0
  180. package/src/capabilities/capabilities/catalog.ts +60 -55
  181. package/src/capabilities/capabilities/index.ts +2 -0
  182. package/src/capabilities/capabilities/types.ts +16 -19
  183. package/src/capabilities/colophon/colophon.render.ts +9 -5
  184. package/src/capabilities/colophon/colophon.verb.ts +1 -0
  185. package/src/capabilities/config/get.verb.ts +2 -0
  186. package/src/capabilities/config/runField.ts +3 -3
  187. package/src/capabilities/config/set.verb.ts +3 -0
  188. package/src/capabilities/config/show.verb.ts +2 -0
  189. package/src/capabilities/config/unset.verb.ts +2 -0
  190. package/src/capabilities/create/constants.ts +2 -2
  191. package/src/capabilities/create/create.verb.ts +16 -0
  192. package/src/capabilities/create/createGrammar.test.ts +1 -1
  193. package/src/capabilities/distribution.test.ts +6 -3
  194. package/src/capabilities/distribution.ts +6 -0
  195. package/src/capabilities/doctor/checks/checkPackageRefs.ts +14 -2
  196. package/src/capabilities/doctor/doctor.test.ts +38 -1
  197. package/src/capabilities/doctor/doctor.verb.ts +2 -0
  198. package/src/capabilities/emptyRecovery.test.ts +5 -0
  199. package/src/capabilities/graph/connect.verb.ts +2 -0
  200. package/src/capabilities/graph/inspect.render.ts +9 -1
  201. package/src/capabilities/graph/inspect.test.ts +73 -0
  202. package/src/capabilities/graph/inspect.verb.ts +2 -0
  203. package/src/capabilities/graph/query.render.test.ts +35 -0
  204. package/src/capabilities/graph/query.render.ts +45 -7
  205. package/src/capabilities/graph/query.shipped.exec.test.ts +44 -0
  206. package/src/capabilities/graph/query.verb.ts +2 -0
  207. package/src/capabilities/graph/queryText.test.ts +55 -1
  208. package/src/capabilities/graph/queryText.ts +47 -0
  209. package/src/capabilities/graph/runConnect.ts +4 -5
  210. package/src/capabilities/graph/runQuery.ts +23 -9
  211. package/src/capabilities/info/info.verb.ts +2 -0
  212. package/src/capabilities/links.shipped.exec.test.ts +398 -0
  213. package/src/capabilities/listBudget.shipped.exec.test.ts +2 -2
  214. package/src/capabilities/listLookup.shipped.exec.test.ts +122 -13
  215. package/src/capabilities/listQuery.shipped.exec.test.ts +39 -16
  216. package/src/capabilities/lookupBudget.shipped.exec.test.ts +208 -0
  217. package/src/capabilities/meta/mcp.verb.ts +2 -0
  218. package/src/capabilities/ontology/ontology.test.ts +24 -0
  219. package/src/capabilities/ontology/verbs.ts +16 -12
  220. package/src/capabilities/prompt/verbs.ts +10 -4
  221. package/src/capabilities/resources/provider.ts +7 -3
  222. package/src/capabilities/resources/resources.test.ts +2 -1
  223. package/src/capabilities/setup/setup.verb.ts +16 -0
  224. package/src/capabilities/shared/calls.ts +20 -0
  225. package/src/capabilities/skill/render.ts +9 -6
  226. package/src/capabilities/skill/verbs.ts +8 -4
  227. package/src/capabilities/sources/collectStatus.ts +6 -1
  228. package/src/capabilities/sources/index.ts +4 -3
  229. package/src/capabilities/sources/reset.render.test.ts +33 -0
  230. package/src/capabilities/sources/reset.render.ts +43 -0
  231. package/src/capabilities/sources/reset.verb.ts +59 -0
  232. package/src/capabilities/sources/runReset.ts +65 -0
  233. package/src/capabilities/sources/runUpdate.ts +12 -11
  234. package/src/capabilities/sources/sources.test.ts +158 -3
  235. package/src/capabilities/sources/status.render.test.ts +63 -1
  236. package/src/capabilities/sources/status.render.ts +25 -8
  237. package/src/capabilities/sources/status.verb.ts +2 -0
  238. package/src/capabilities/sources/types.ts +26 -0
  239. package/src/capabilities/sources/update.verb.ts +3 -0
  240. package/src/capabilities/surface.test.ts +2 -2
  241. package/src/capabilities/token.parity.test.ts +3 -2
  242. package/src/capabilities/tokenGraph.shipped.exec.test.ts +14 -2
  243. package/src/capabilities/upgrade/upgrade.verb.ts +1 -0
  244. package/src/capabilities/variable.parity.test.ts +2 -1
  245. package/src/capabilities/version/version.verb.ts +1 -0
  246. package/src/capabilities/wireContract.test.ts +110 -0
  247. package/src/constants.ts +4 -3
  248. package/src/kernel/copy.test.ts +1 -1
  249. package/src/kernel/error/PragmaError.test.ts +7 -3
  250. package/src/kernel/error/index.ts +1 -1
  251. package/src/kernel/error/recovery.test.ts +52 -18
  252. package/src/kernel/error/recovery.ts +25 -46
  253. package/src/kernel/error/types.ts +1 -1
  254. package/src/kernel/packs/collect.test.ts +79 -0
  255. package/src/kernel/packs/collect.ts +151 -11
  256. package/src/kernel/packs/compile.test.ts +107 -10
  257. package/src/kernel/packs/compile.ts +100 -11
  258. package/src/kernel/packs/filteredEmpty.test.ts +22 -5
  259. package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +5 -6
  260. package/src/kernel/packs/graphqlSource.test.ts +42 -0
  261. package/src/kernel/packs/iri.ts +5 -4
  262. package/src/kernel/packs/listNotice.test.ts +1 -1
  263. package/src/kernel/packs/nounLink.test.ts +385 -0
  264. package/src/kernel/packs/renderPack.ts +137 -57
  265. package/src/kernel/packs/resolveEntity.test.ts +170 -1
  266. package/src/kernel/packs/resolveEntity.ts +281 -101
  267. package/src/kernel/packs/runBodies.ts +133 -31
  268. package/src/kernel/packs/schema.ts +77 -12
  269. package/src/kernel/packs/sparql/buildListQuery.test.ts +78 -1
  270. package/src/kernel/packs/sparql/buildListQuery.ts +106 -16
  271. package/src/kernel/packs/sparql/buildLookupQuery.ts +44 -13
  272. package/src/kernel/packs/sparql/filterValues.test.ts +36 -4
  273. package/src/kernel/packs/sparql/filterValues.ts +50 -31
  274. package/src/kernel/packs/sparql/runSelect.ts +4 -6
  275. package/src/kernel/packs/storyRules.ts +37 -4
  276. package/src/kernel/packs/tierScope.test.ts +120 -6
  277. package/src/kernel/packs/tierScope.ts +5 -7
  278. package/src/kernel/packs/toolDescription.test.ts +59 -74
  279. package/src/kernel/packs/types.ts +93 -11
  280. package/src/kernel/project/cli/buildProgram.ts +4 -0
  281. package/src/kernel/project/cli/dispatch.ts +34 -8
  282. package/src/kernel/project/cli/suggest.test.ts +77 -0
  283. package/src/kernel/project/cli/suggestNames.ts +47 -1
  284. package/src/kernel/project/cli/verbHelp.ts +16 -19
  285. package/src/kernel/project/mcp/buildServer.ts +15 -4
  286. package/src/kernel/project/mcp/envelope.ts +3 -2
  287. package/src/kernel/project/mcp/instructions.ts +123 -35
  288. package/src/kernel/project/mcp/prompts/prompts.test.ts +136 -20
  289. package/src/kernel/project/mcp/registerVerb.test.ts +28 -0
  290. package/src/kernel/project/mcp/registerVerb.ts +67 -29
  291. package/src/kernel/render/__snapshots__/renderers.test.ts.snap +1 -1
  292. package/src/kernel/render/renderers.test.ts +2 -2
  293. package/src/kernel/render/renderers.ts +3 -3
  294. package/src/kernel/render/turtle.ts +24 -4
  295. package/src/kernel/runtime/graphpack/build.ts +58 -1
  296. package/src/kernel/runtime/graphpack/graphpack.test.ts +20 -0
  297. package/src/kernel/runtime/graphpack/manifest.ts +15 -0
  298. package/src/kernel/runtime/graphpack/read.ts +4 -6
  299. package/src/kernel/runtime/graphpack/schemas.ts +1 -0
  300. package/src/kernel/runtime/graphpack/types.ts +13 -0
  301. package/src/kernel/runtime/loadSession.ts +8 -1
  302. package/src/kernel/runtime/packVersion.test.ts +61 -0
  303. package/src/kernel/runtime/packVersion.ts +84 -0
  304. package/src/kernel/runtime/readEntity.ts +61 -6
  305. package/src/kernel/runtime/resolveSources.test.ts +90 -7
  306. package/src/kernel/runtime/resolveSources.ts +91 -10
  307. package/src/kernel/runtime/storeReadiness.ts +4 -4
  308. package/src/kernel/runtime/types.ts +12 -0
  309. package/src/kernel/spec/call.test.ts +95 -0
  310. package/src/kernel/spec/call.ts +186 -0
  311. package/src/kernel/spec/emitReference.ts +9 -13
  312. package/src/kernel/spec/emitSurface.ts +9 -1
  313. package/src/kernel/spec/guidance.test.ts +49 -0
  314. package/src/kernel/spec/guidance.ts +68 -0
  315. package/src/kernel/spec/index.ts +4 -0
  316. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  317. package/src/kernel/spec/types.ts +64 -7
  318. package/src/kernel/spec/wireType.ts +53 -0
  319. package/src/testing/behavioral/errorMatrix.mcp.test.ts +2 -1
  320. package/src/testing/behavioral/journeys.firstInstall.test.ts +72 -2
  321. package/src/testing/behavioral/parity.test.ts +40 -0
  322. package/src/testing/eval/__snapshots__/eval.test.ts.snap +1 -1
  323. package/src/testing/eval/cases/assembly.ts +4 -3
  324. package/src/testing/eval/cases/stable.ts +2 -3
  325. package/src/testing/helpers/callSchema.ts +40 -0
  326. package/src/testing/helpers/parity.ts +35 -2
  327. package/src/testing/setupCallChecking.ts +30 -0
  328. package/dist/src/capabilities/capabilities/hints.js +0 -223
  329. package/dist/src/capabilities/capabilities/hints.js.map +0 -1
  330. package/src/capabilities/capabilities/hints.ts +0 -266
@@ -3,13 +3,16 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
- import { CONVENTIONS } from "../../../../capabilities/capabilities/catalog.js";
6
+ import {
7
+ BLOCKS_SENTENCE,
8
+ CONVENTIONS,
9
+ } from "../../../../capabilities/capabilities/catalog.js";
7
10
  import { capabilities } from "../../../../capabilities/index.js";
8
11
  import type { McpHarness } from "../../../../testing/helpers/projectMcp.js";
9
12
  import { projectMcp } from "../../../../testing/helpers/projectMcp.js";
10
13
  import type { PragmaRuntime } from "../../../runtime/types.js";
11
- import { emitSurface } from "../../../spec/emitSurface.js";
12
- import type { CapabilityModule } from "../../../spec/types.js";
14
+ import { emitSurface, toolName } from "../../../spec/emitSurface.js";
15
+ import type { CapabilityModule, VerbSpec } from "../../../spec/types.js";
13
16
  import { buildInstructions, INSTRUCTIONS_MAX_CHARS } from "../instructions.js";
14
17
  import { fillTemplate, promptProvider } from "./provider.js";
15
18
  import { readPrompts } from "./source.js";
@@ -31,21 +34,123 @@ afterEach(async () => {
31
34
  });
32
35
 
33
36
  describe("instructions — handshake orientation (PROTECTED)", () => {
34
- it("is present, non-empty, mentions capabilities + the discovery flow", () => {
37
+ // A client may defer tools: the agent sees tool NAMES and this text, and a
38
+ // description it never loads cannot steer it. So the text carries a question →
39
+ // tool index GENERATED from each verb's `useWhen` (it replaced the prose
40
+ // "discovery sequence" this case used to look for).
41
+ const reads = capabilities
42
+ .flatMap((module) => module.verbs)
43
+ .filter(
44
+ (verb) =>
45
+ !verb.hidden && verb.capability.mcp.expose && !verb.capability.mutates,
46
+ );
47
+ const isTrio = (verb: VerbSpec): boolean =>
48
+ ["list", "lookup", "sample"].includes(verb.path[1] ?? "");
49
+
50
+ it("indexes every read tool outside a list/lookup/sample trio by the question it answers", () => {
51
+ const text = buildInstructions(capabilities);
52
+ const standalone = reads.filter((verb) => !isTrio(verb));
53
+ expect(standalone.length).toBeGreaterThan(10);
54
+ for (const verb of standalone) {
55
+ const clause = (verb.useWhen as string).replace(/^when asked /, "");
56
+ expect(text).toContain(`${toolName(verb.path)} — ${clause}`);
57
+ }
58
+ // The story that failed: asked which components use a token.
59
+ expect(text).toMatch(/token_consumers — which components use a token/);
60
+ });
61
+
62
+ it("explains the trio once, promising no noun a verb it lacks", () => {
35
63
  const text = buildInstructions(capabilities);
36
- expect(text.length).toBeGreaterThan(0);
37
- expect(text).toContain("capabilities");
38
- expect(text.toLowerCase()).toContain("discovery sequence");
64
+ expect(text.match(/<noun>_list/g)).toHaveLength(1);
65
+ expect(text).toMatch(/block\*/); // has a sample
66
+ expect(text).toMatch(/\btier,/); // list + lookup, no sample
67
+ expect(text).toMatch(/implementation†/); // list only
68
+ });
69
+
70
+ it("is generated: a new read verb adds its own line, a new write joins the plan-first list", () => {
71
+ const base = capabilities.find((m) => m.name === "info")
72
+ ?.verbs[0] as VerbSpec;
73
+ const added: CapabilityModule = {
74
+ name: "weather",
75
+ verbs: [
76
+ {
77
+ ...base,
78
+ path: ["weather", "today"],
79
+ useWhen: "when asked if it rains",
80
+ },
81
+ {
82
+ ...base,
83
+ path: ["weather", "seed"],
84
+ capability: { ...base.capability, mutates: true },
85
+ },
86
+ ],
87
+ };
88
+ const text = buildInstructions([...capabilities, added]);
89
+ expect(text).toMatch(/^weather_today — if it rains$/m);
90
+ expect(text).toMatch(/plan-first[^\n]*weather_seed/);
91
+ expect(buildInstructions([added])).toBe(""); // no module declares an orientation
92
+ });
93
+
94
+ it("fits the HARD ceiling clients cut server instructions at", () => {
95
+ // Unlike the catalogue budget this is not raised on measurement: text past
96
+ // about 2 KB is text no agent reads. Tighten a sentence instead.
97
+ expect(INSTRUCTIONS_MAX_CHARS).toBe(2000);
98
+ const dropped: number[] = [];
99
+ const text = buildInstructions(capabilities, (count) =>
100
+ dropped.push(count),
101
+ );
102
+ expect(text.length).toBeLessThanOrEqual(INSTRUCTIONS_MAX_CHARS);
103
+ // Fitting is the safety net for a project's packs, not for the
104
+ // distribution: its own registry must fit WHOLE.
105
+ expect(
106
+ dropped,
107
+ "the distribution's own index no longer fits — tighten a useWhen sentence; the 2,000 ceiling is what clients keep",
108
+ ).toEqual([]);
39
109
  });
40
110
 
41
- it("stays under the length ceiling (cannot bloat)", () => {
42
- expect(buildInstructions(capabilities).length).toBeLessThanOrEqual(
43
- INSTRUCTIONS_MAX_CHARS,
111
+ it("when a project's packs overflow it, drops index lines from the end and says so", () => {
112
+ const base = capabilities.find((m) => m.name === "info")
113
+ ?.verbs[0] as VerbSpec;
114
+ const crowd: CapabilityModule = {
115
+ name: "crowd",
116
+ verbs: Array.from({ length: 12 }, (_, n) => ({
117
+ ...base,
118
+ path: ["crowd", `probe${n}`] as [string, string],
119
+ category: undefined,
120
+ useWhen: `when asked a long question number ${n} about the crowd`,
121
+ })),
122
+ };
123
+ const dropped: number[] = [];
124
+ const text = buildInstructions([...capabilities, crowd], (count) =>
125
+ dropped.push(count),
126
+ );
127
+ expect(text.length).toBeLessThanOrEqual(INSTRUCTIONS_MAX_CHARS);
128
+ expect(dropped).toHaveLength(1);
129
+ expect(text.endsWith(`… and ${dropped[0]} more: call capabilities.`)).toBe(
130
+ true,
44
131
  );
132
+ // What is one line whatever the registry holds is never what gets cut …
133
+ expect(text).toContain(BLOCKS_SENTENCE);
134
+ expect(text).toMatch(/plan-first/);
135
+ expect(text).toMatch(/<noun>_list/);
136
+ // … and the verbs that read the store lead the index, so they survive.
137
+ expect(text).toMatch(/^token_consumers — /m);
138
+ });
139
+
140
+ it("emits no heading for an empty group, and skips a verb declaring no useWhen", () => {
141
+ const base = capabilities.find(
142
+ (m) => m.name === "capabilities",
143
+ ) as CapabilityModule;
144
+ const { useWhen: _useWhen, ...silent } = base.verbs[0] as VerbSpec;
145
+ const text = buildInstructions([{ ...base, verbs: [silent as VerbSpec] }]);
146
+ expect(text).not.toContain("When asked:");
147
+ expect(text).not.toContain("Also:");
148
+ expect(text).not.toContain("undefined");
45
149
  });
46
150
 
47
151
  it("opens with the shared catalog's conventions, verbatim and once", () => {
48
- // The conventions are authored once (capabilities/catalog.ts); instructions
152
+ // The conventions are authored once (capabilities/catalog.ts) and handed to
153
+ // the kernel as the module's `mcpOrientation`; instructions
49
154
  // must OPEN with them, so a second hand-written preamble cannot creep back
50
155
  // in and drift from the `capabilities` tool the same handshake carries.
51
156
  expect(buildInstructions(capabilities).startsWith(CONVENTIONS.system)).toBe(
@@ -53,6 +158,17 @@ describe("instructions — handshake orientation (PROTECTED)", () => {
53
158
  );
54
159
  });
55
160
 
161
+ it("says which tools read components, patterns, layouts and subcomponents", () => {
162
+ // People ask about "components"; the tools are named for blocks. An agent
163
+ // that went looking for a component tool found none and fell to raw SPARQL.
164
+ const text = buildInstructions(capabilities);
165
+ expect(text).toContain(BLOCKS_SENTENCE);
166
+ expect(BLOCKS_SENTENCE).toMatch(/components.*block_list/i);
167
+ // Said in the handshake and in `block_list`'s own description — not a third
168
+ // time in the catalogue's conventions.
169
+ expect(Object.values(CONVENTIONS)).not.toContain(BLOCKS_SENTENCE);
170
+ });
171
+
56
172
  it("quotes the resource templates the MCP surface actually advertises", () => {
57
173
  // The `pragma:` scheme is covenant-frozen protocol identity
58
174
  // (surface.v2.json). The orientation DERIVES it from the emitted surface
@@ -71,16 +187,16 @@ describe("instructions — handshake orientation (PROTECTED)", () => {
71
187
  expect(text).toContain("confirm: true");
72
188
  });
73
189
 
74
- it("orients a cold agent to check/build the store before any store read", () => {
75
- // Store-blind guard: the handshake must point a cold agent at the store
76
- // pre-check (sources_status → sources_update) BEFORE the sample/query steps,
77
- // or it walks straight into STORE_UNAVAILABLE.
190
+ it("tells an agent which tool explains empty or stale answers, and which rebuilds", () => {
191
+ // This case used to pin POSITION (`sources_status` before any `_sample`),
192
+ // from when a cold store failed every read and the text was a numbered
193
+ // sequence. A fresh install now answers from the shipped snapshot, and the
194
+ // text is an index whose fixed lines come first so an overflow can only
195
+ // cut the index. What must still hold: the store check is indexed by the
196
+ // situation that calls for it, and the rebuild is among the writes.
78
197
  const text = buildInstructions(capabilities);
79
- const storeCheck = text.indexOf("sources_status");
80
- const sample = text.indexOf("_sample");
81
- expect(storeCheck).toBeGreaterThanOrEqual(0);
82
- expect(text).toContain("sources_update");
83
- expect(storeCheck).toBeLessThan(sample);
198
+ expect(text).toMatch(/^sources_status — when results look empty or stale/m);
199
+ expect(text).toMatch(/plan-first[^\n]*sources_update/);
84
200
  });
85
201
  });
86
202
 
@@ -74,6 +74,34 @@ describe("buildZodSchema (params → zod)", () => {
74
74
  ).toBe(true);
75
75
  });
76
76
 
77
+ it("a list param takes one bare value, and coercion does not skip the item type", () => {
78
+ const lists = z.object(
79
+ buildZodSchema([
80
+ { kind: "string[]", name: "name", doc: "n", required: true },
81
+ {
82
+ kind: "enum",
83
+ name: "kind",
84
+ doc: "k",
85
+ values: ["a", "b"],
86
+ repeatable: true,
87
+ },
88
+ ]),
89
+ );
90
+ expect(lists.parse({ name: "x", kind: "a" })).toEqual({
91
+ name: ["x"],
92
+ kind: ["a"],
93
+ });
94
+ expect(lists.parse({ name: ["x", "y"] })).toEqual({ name: ["x", "y"] });
95
+ expect(lists.safeParse({}).success).toBe(false);
96
+ expect(lists.safeParse({ name: "x", kind: "z" }).success).toBe(false);
97
+ expect(lists.safeParse({ name: "x", kind: ["a", 7] }).success).toBe(false);
98
+ // Only a STRING is coerced, so a wrong type is refused at the param itself.
99
+ for (const name of [5, { a: 1 }]) {
100
+ const refused = lists.safeParse({ name });
101
+ expect(refused.error?.issues.at(0)?.path).toEqual(["name"]);
102
+ }
103
+ });
104
+
77
105
  it("applies a declared default when the field is omitted (CLI parity)", () => {
78
106
  const withDefault = z.object(
79
107
  buildZodSchema([
@@ -22,8 +22,10 @@ import { z } from "zod";
22
22
  import { asPragmaError } from "../../error/fromTaskError.js";
23
23
  import { PragmaError } from "../../error/index.js";
24
24
  import type { InteractionRuntime, PragmaRuntime } from "../../runtime/types.js";
25
+ import { describeTool } from "../../spec/guidance.js";
25
26
  import type { McpAnnotations, ParamSpec, VerbSpec } from "../../spec/index.js";
26
27
  import { toolName } from "../../spec/index.js";
28
+ import { type WireItem, wireType } from "../../spec/wireType.js";
27
29
  import { toolError, toolSuccess } from "./envelope.js";
28
30
 
29
31
  /**
@@ -57,9 +59,9 @@ function resolveEffectiveCwd(rawCwd: unknown, fallback: string): string {
57
59
  return rawCwd;
58
60
  }
59
61
 
60
- /** The base zod type for a param, before `.describe()`/`.optional()`. */
61
- function zodForParam(param: ParamSpec): z.ZodTypeAny {
62
- switch (param.kind) {
62
+ /** The zod type of ONE value of a param. */
63
+ function zodForItem(item: WireItem): z.ZodTypeAny {
64
+ switch (item.kind) {
63
65
  case "string":
64
66
  return z.string();
65
67
  case "number":
@@ -67,12 +69,30 @@ function zodForParam(param: ParamSpec): z.ZodTypeAny {
67
69
  case "boolean":
68
70
  return z.boolean();
69
71
  case "enum":
70
- return z.enum(param.values as unknown as [string, ...string[]]);
71
- case "string[]":
72
- return z.array(z.string());
72
+ return z.enum(item.values as unknown as [string, ...string[]]);
73
73
  }
74
74
  }
75
75
 
76
+ /**
77
+ * The base zod type for a param, before `.describe()`/`.optional()`, built
78
+ * from the param's {@link wireType} — the statement the reference prints too.
79
+ *
80
+ * A list is ADVERTISED as a plain array and a bare value is COERCED into one
81
+ * before validation. A preprocess rather than a `string | string[]` union on
82
+ * purpose: the emitted JSON Schema stays `type: "array"`, where a union emits
83
+ * `anyOf`, which the weaker models that most need this read badly. The run
84
+ * bodies already take one value or many, so nothing downstream changes.
85
+ */
86
+ function zodForParam(param: ParamSpec): z.ZodTypeAny {
87
+ const wire = wireType(param);
88
+ const item = zodForItem(wire.item);
89
+ if (!wire.list) return item;
90
+ return z.preprocess(
91
+ (value) => (typeof value === "string" ? [value] : value),
92
+ z.array(item),
93
+ );
94
+ }
95
+
76
96
  /**
77
97
  * Build the zod input shape for a verb's params.
78
98
  *
@@ -152,10 +172,11 @@ function withDetail(
152
172
  * `{"ok":true,"data":[],"meta":{}}` — and, later, could not tell an unambiguous
153
173
  * lookup hit from one of three blocks sharing a name. Riding in `meta` keeps
154
174
  * `data` its uniform shape while making both of those ≠ silence, and the CLI's
155
- * `--format json` carries the same key so the two surfaces stay byte-equal.
175
+ * `--format json` carries the same key. The sentence is the ONE place the two
176
+ * envelopes may differ: a next step inside it is spelled for its surface.
156
177
  */
157
178
  function noticeMeta(verb: VerbSpec, data: unknown): Record<string, unknown> {
158
- const notice = verb.output.formatters.notice?.(data as never);
179
+ const notice = verb.output.formatters.notice?.(data as never, "mcp");
159
180
  // The seam's machine half, merged here so an agent gets the tier scope as
160
181
  // DATA (`meta.scope`) and not only as the sentence `notice` carries. The CLI
161
182
  // merges the same seam into the same keys (`cli/dispatch.ts#renderData`).
@@ -167,15 +188,22 @@ function noticeMeta(verb: VerbSpec, data: unknown): Record<string, unknown> {
167
188
  function readHandler(verb: VerbSpec, runtime: PragmaRuntime) {
168
189
  return async (args: Record<string, unknown>): Promise<CallToolResult> => {
169
190
  try {
170
- if (verb.capability.needsStore) await runtime.store.get();
191
+ const session = verb.capability.needsStore
192
+ ? await runtime.store.get()
193
+ : undefined;
171
194
  const params = paramsFromArgs(verb, args);
172
195
  const result = await Promise.resolve(
173
196
  verb.run(params, withDetail(verb, runtime, args)) as Promise<unknown>,
174
197
  );
175
- return toolSuccess(
176
- JSON.parse(verb.output.formatters.json(result)),
177
- noticeMeta(verb, result),
178
- );
198
+ return toolSuccess(JSON.parse(verb.output.formatters.json(result)), {
199
+ // The store fact this read was answered under, from the session that
200
+ // answered it: a pack this project built that the boot passed over
201
+ // because an older CLI built it. The CLI's `--format json` merges the
202
+ // same key from the same place (`cli/dispatch.ts#storeMeta`), so the
203
+ // two machine surfaces carry the same fact under the same key.
204
+ ...(session?.ignoredPack ? { ignoredPack: session.ignoredPack } : {}),
205
+ ...noticeMeta(verb, result),
206
+ });
179
207
  } catch (error) {
180
208
  return toolError(asPragmaError(error));
181
209
  }
@@ -297,18 +325,11 @@ function mutateHandler(verb: VerbSpec, runtime: PragmaRuntime) {
297
325
  }
298
326
 
299
327
  /**
300
- * Register one exposed verb as an MCP tool on the server.
301
- *
302
- * @param server - The MCP server to register onto.
303
- * @param verb - The verb to expose (caller ensures `mcp.expose === true`).
304
- * @param runtime - The runtime handed to the verb's `run`.
305
- * @note Impure — mutates the server's tool registry.
328
+ * The whole input shape a verb's tool accepts: its declared params plus the
329
+ * arguments the projector injects. Exported so the call rule validates every
330
+ * example and recovery against the schema an agent's call actually meets.
306
331
  */
307
- export function registerVerb(
308
- server: McpServer,
309
- verb: VerbSpec,
310
- runtime: PragmaRuntime,
311
- ): void {
332
+ export function buildToolShape(verb: VerbSpec): z.ZodRawShape {
312
333
  const shape = buildZodSchema(verb.params);
313
334
  // A verb with progressive disclosure gains a `detail` enum param derived from
314
335
  // its DisclosureSpec (Risk2 — NO new VerbSpec field). The handler seeds
@@ -337,17 +358,34 @@ export function registerVerb(
337
358
  "Absolute project directory to write into; defaults to the server's working directory.",
338
359
  );
339
360
  }
361
+ return shape;
362
+ }
363
+
364
+ /**
365
+ * Register one exposed verb as an MCP tool on the server.
366
+ *
367
+ * @param server - The MCP server to register onto.
368
+ * @param verb - The verb to expose (caller ensures `mcp.expose === true`).
369
+ * @param runtime - The runtime handed to the verb's `run`.
370
+ * @note Impure — mutates the server's tool registry.
371
+ */
372
+ export function registerVerb(
373
+ server: McpServer,
374
+ verb: VerbSpec,
375
+ runtime: PragmaRuntime,
376
+ ): void {
377
+ const shape = buildToolShape(verb);
340
378
 
341
379
  const config: {
342
380
  description: string;
343
381
  inputSchema?: z.ZodRawShape;
344
382
  annotations: McpAnnotations;
345
383
  } = {
346
- // The agent-facing tool description is the verb's richer `doc` when present
347
- // (pack `toolDescription`s compile into it; hand-written verbs author it
348
- // directly), falling back to the one-line `summary`. Tool descriptions are
349
- // NOT part of the frozen surface, so this stays covenant-safe.
350
- description: verb.doc ?? verb.summary,
384
+ // Generated, never typed: the question the verb answers, its richer `doc`
385
+ // (pack `toolDescription`s compile into it) or one-line `summary`, and one
386
+ // example call. Tool descriptions are NOT part of the frozen surface, so
387
+ // this stays covenant-safe.
388
+ description: describeTool(verb),
351
389
  annotations: annotationsFor(verb),
352
390
  };
353
391
  if (Object.keys(shape).length > 0) config.inputSchema = shape;
@@ -17,7 +17,7 @@ Active filters:
17
17
  Valid options: stable, beta",
18
18
  },
19
19
  "notFound": {
20
- "json": "{"ok":false,"error":{"code":"ENTITY_NOT_FOUND","message":"block \\"Buton\\" not found.","suggestions":["Button"],"recovery":{"message":"List available blocks.","cli":"pragma block list"}}}",
20
+ "json": "{"ok":false,"error":{"code":"ENTITY_NOT_FOUND","message":"block \\"Buton\\" not found.","suggestions":["Button"],"recovery":{"message":"List available blocks.","cli":"pragma block list","mcp":{"tool":"block_list","params":{}}}}}",
21
21
  "llm": "## Error: ENTITY_NOT_FOUND
22
22
  block "Buton" not found.
23
23
  Suggestions: Button
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { PragmaError } from "../error/PragmaError.js";
3
- import { cliRecovery } from "../error/recovery.js";
3
+ import { callRecovery } from "../error/recovery.js";
4
4
  import {
5
5
  renderErrorJson,
6
6
  renderErrorLlm,
@@ -293,7 +293,7 @@ describe("error render matrix (× plain/llm/json)", () => {
293
293
  it("renders each error across the three formats", () => {
294
294
  const notFound = PragmaError.notFound("block", "Buton", {
295
295
  suggestions: ["Button"],
296
- recovery: cliRecovery("block list", "List available blocks."),
296
+ recovery: callRecovery({ verb: "block list" }, "List available blocks."),
297
297
  });
298
298
  const empty = PragmaError.emptyResults("token", {
299
299
  filters: { channel: "stable", tier: "core" },
@@ -201,9 +201,9 @@ function renderHeaderRow<T>(
201
201
  *
202
202
  * The SCOPE is in the heading for the same reason, and it is the more serious
203
203
  * of the two omissions: a truncated page at least ends where it ends, while a
204
- * scoped read looks exactly like a complete one. `## Block (175, tier scope:
205
- * global, apps, documentation, sites, stores)` says which part of the design
206
- * system the 175 are, in the words `--tier` accepts.
204
+ * scoped read looks exactly like a complete one. `## Block (176, tier scope:
205
+ * global 121, apps 39, …)` says which part of the design system the 176 are,
206
+ * in the words `--tier` accepts. The caller composes that text.
207
207
  *
208
208
  * @param items - The rows on this page.
209
209
  * @param options - Columns and empty-state copy.
@@ -30,6 +30,7 @@ import type {
30
30
  InboundGroup,
31
31
  InspectResult,
32
32
  NestedRecord,
33
+ PredicateGroup,
33
34
  ReadTerm,
34
35
  } from "../runtime/readEntity.js";
35
36
 
@@ -120,6 +121,13 @@ function truncationNote(predicate: string, term: ReadTerm): string | undefined {
120
121
  return ` # ${predicate} — ${total} chars, showing ${term.value.length}`;
121
122
  }
122
123
 
124
+ /** `# ds:hasTokenBinding — 24 objects, showing 10` — a cut group, stated as one. */
125
+ function cutNote(group: PredicateGroup): string | undefined {
126
+ if (!group.truncated || group.count === undefined) return undefined;
127
+ const total = group.count.toLocaleString("en-US");
128
+ return ` # ${renderTerm(group.predicate)} — ${total} objects, showing ${group.objects.length}`;
129
+ }
130
+
123
131
  /** Render one inlined blank node as a Turtle `[ … ]` record. */
124
132
  function renderNested(record: NestedRecord): string {
125
133
  const fields = Object.entries(record).map(([field, value]) => {
@@ -188,21 +196,33 @@ export function toTurtle(
188
196
  // Blank objects are written as their inlined records instead, so they are
189
197
  // skipped here — emitting both would assert the same edge twice.
190
198
  const objects = group.objects.filter((o) => o.termType !== "BlankNode");
199
+ // A group of blank objects is written further down, as its records — and
200
+ // says there that it was cut.
191
201
  if (objects.length === 0) continue;
192
202
  const keyword = predicate === "rdf:type" ? "a" : predicate;
193
203
  // Notes are folded INTO the element, above its statement, so the `;` the
194
204
  // join appends always lands on the statement line. Pushed as siblings they
195
205
  // would each take a separator of their own — into a comment, which runs to
196
206
  // end of line and eats it.
197
- const notes = objects
198
- .map((o) => truncationNote(keyword, o))
199
- .filter((n): n is string => n !== undefined);
207
+ const notes = [
208
+ cutNote(group),
209
+ ...objects.map((o) => truncationNote(keyword, o)),
210
+ ].filter((n): n is string => n !== undefined);
200
211
  const rendered = objects.map(renderTerm).join(", ");
201
212
  predicates.push([...notes, ` ${keyword} ${rendered}`].join("\n"));
202
213
  }
203
214
  for (const [via, records] of Object.entries(result.nested)) {
215
+ const group = result.groups.find(
216
+ (g) => (g.predicate.prefixed ?? g.predicate.value) === via,
217
+ );
218
+ const hasNamed = group?.objects.some((o) => o.termType !== "BlankNode");
219
+ // Said once per predicate: a group with named objects too said it above.
220
+ const note = group && !hasNamed ? cutNote(group) : undefined;
204
221
  predicates.push(
205
- ` ${via}\n ${records.map(renderNested).join(",\n ")}`,
222
+ [
223
+ ...(note ? [note] : []),
224
+ ` ${via}\n ${records.map(renderNested).join(",\n ")}`,
225
+ ].join("\n"),
206
226
  );
207
227
  }
208
228
  if (predicates.length > 0) {
@@ -29,6 +29,7 @@ import {
29
29
  createStoreQueryFn,
30
30
  serializeExtraction,
31
31
  } from "@canonical/ke-graphql";
32
+ import { packIsOlderThanCli } from "../packVersion.js";
32
33
  import { packDir, packsCacheDir } from "../paths.js";
33
34
  import { buildIndex } from "./buildIndex.js";
34
35
  import { contentHash, hashSources } from "./hash.js";
@@ -53,6 +54,7 @@ export interface BuildPackInput {
53
54
  /** Provenance and options for a pack build. */
54
55
  export interface BuildPackOptions {
55
56
  readonly name: string;
57
+ /** The building CLI's version, recorded in the manifest (see `Manifest`). */
56
58
  readonly version: string;
57
59
  /** The config `packs` ref (verbatim) or a label. */
58
60
  readonly sourceRef: string;
@@ -104,7 +106,12 @@ export async function buildPack(
104
106
  if (packIsComplete(dir)) {
105
107
  const cached = readManifest(dir);
106
108
  if (cached)
107
- return { dir, contentHash: hash, manifest: cached, reused: true };
109
+ return {
110
+ dir,
111
+ contentHash: hash,
112
+ manifest: stampBuilderVersion(dir, cached, options.version),
113
+ reused: true,
114
+ };
108
115
  }
109
116
 
110
117
  mkdirSync(packsCacheDir(), { recursive: true });
@@ -185,6 +192,56 @@ export async function buildPack(
185
192
  }
186
193
  }
187
194
 
195
+ /**
196
+ * Refresh a REUSED pack's recorded builder version to the CLI doing the reuse.
197
+ *
198
+ * Without this, `sources update` could not get a project out of the state the
199
+ * boot decision's upgrade row describes. A pack is content-addressed, so an
200
+ * update whose sources have not moved resolves to the same hash and reuses the
201
+ * directory untouched — manifest and all. The manifest would therefore keep
202
+ * naming the OLD CLI as its builder, the boot would go on passing the pack over,
203
+ * and the `pragma sources update` the notice recommends would change nothing:
204
+ * a loop with no way out but `sources reset`.
205
+ *
206
+ * Only the version moves. `createdAt` and `sourceRef` describe the build that
207
+ * produced these bytes and stay as they were — a reuse is not a new build, and
208
+ * `sources status` reporting today's date for a pack from last month would be
209
+ * the same kind of lie in the other direction. What the stamp claims is exactly
210
+ * what happened: THIS CLI resolved these sources and found this pack already
211
+ * holding what it would have built.
212
+ *
213
+ * Best-effort, and deliberately so: the write is a provenance refresh, not part
214
+ * of the build, so a read-only or racing cache returns the manifest as found
215
+ * rather than failing an update that otherwise succeeded (the user keeps the
216
+ * notice, which is recoverable, instead of losing the update, which is worse).
217
+ *
218
+ * @param dir - The reused pack directory.
219
+ * @param cached - Its manifest, as read.
220
+ * @param version - The running CLI's version.
221
+ * @returns The manifest as it now stands on disk.
222
+ * @note Impure — rewrites `manifest.json` in place (temp file + atomic rename,
223
+ * so the completeness marker is never observed half-written).
224
+ */
225
+ function stampBuilderVersion(
226
+ dir: string,
227
+ cached: Manifest,
228
+ version: string,
229
+ ): Manifest {
230
+ // Upward only: the cache directory is shared by every project built from the
231
+ // same sources, so an older (or dev) CLI reusing it must not make a newer
232
+ // CLI start passing the pack over.
233
+ if (!packIsOlderThanCli(cached.version, version)) return cached;
234
+ const stamped: Manifest = { ...cached, version };
235
+ try {
236
+ const temp = join(dir, `.manifest-${process.pid}.json`);
237
+ writeFileSync(temp, JSON.stringify(stamped));
238
+ renameSync(temp, join(dir, MANIFEST_FILE));
239
+ return stamped;
240
+ } catch {
241
+ return cached;
242
+ }
243
+ }
244
+
188
245
  /**
189
246
  * Count a store's triples (a cheap aggregate over the union graph).
190
247
  *
@@ -445,3 +445,23 @@ describe("graphpack read — incomplete pack recovery (CLI + MCP)", () => {
445
445
  }
446
446
  });
447
447
  });
448
+
449
+ describe("the builder version recorded on reuse", () => {
450
+ const stampedBy = async (version: string) =>
451
+ (
452
+ await buildPack([{ path: "stamp.ttl", content: `${TTL}\n# stamp` }], {
453
+ name: "test-pack",
454
+ version,
455
+ sourceRef: "test:inline",
456
+ prefixes: PREFIXES,
457
+ })
458
+ ).manifest.version;
459
+
460
+ it("moves up to a newer CLI and never down to an older one", async () => {
461
+ expect(await stampedBy("2.2.0")).toBe("2.2.0");
462
+ // The cache directory is shared between projects, so an older CLI reusing
463
+ // it must leave the record alone.
464
+ expect(await stampedBy("2.1.0")).toBe("2.2.0");
465
+ expect(await stampedBy("2.3.0")).toBe("2.3.0");
466
+ });
467
+ });
@@ -108,6 +108,21 @@ export function readManifest(dir: string): Manifest | undefined {
108
108
  * — see {@link storiesAreReadable} for why it, and only it, is parsed here. */
109
109
  export function packIsComplete(dir: string): boolean {
110
110
  if (readManifest(dir) === undefined) return false;
111
+ return packArtifactsPresent(dir);
112
+ }
113
+
114
+ /**
115
+ * The second half of {@link packIsComplete}: every non-manifest artifact,
116
+ * present and usable. Exported so a caller that ALREADY holds the manifest can
117
+ * finish the completeness check without reading the file twice —
118
+ * {@link resolveSources} is one, because it now reads the manifest's recorded
119
+ * builder version as part of the boot decision.
120
+ *
121
+ * @param dir - The pack directory.
122
+ * @returns Whether the four content artifacts are present and readable.
123
+ * @note Impure — stats (and, for stories, parses) the pack's files.
124
+ */
125
+ export function packArtifactsPresent(dir: string): boolean {
111
126
  for (const file of [DATA_FILE, SCHEMA_FILE, INDEX_FILE, STORIES_FILE]) {
112
127
  try {
113
128
  if (statSync(join(dir, file)).size <= 0) return false;
@@ -16,7 +16,8 @@ import { readFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { createStore } from "@canonical/ke";
18
18
  import { compileFromExtraction } from "@canonical/ke-graphql";
19
- import { cliRecovery, PragmaError } from "../../error/index.js";
19
+ import { callRecovery, PragmaError } from "../../error/index.js";
20
+ import { BUILD_STORE_CALL } from "../../spec/call.js";
20
21
  import type { StoreSession } from "../types.js";
21
22
  import { readManifest } from "./manifest.js";
22
23
  import { packIndexSchema } from "./schemas.js";
@@ -25,12 +26,9 @@ import { DATA_FILE, INDEX_FILE, SCHEMA_FILE } from "./types.js";
25
26
  /** STORE_UNAVAILABLE with the canonical `pragma sources update` recovery (CLI + MCP). */
26
27
  function packUnavailable(reason: string): PragmaError {
27
28
  return PragmaError.storeUnavailable(reason, {
28
- recovery: cliRecovery(
29
- "sources update",
29
+ recovery: callRecovery(
30
+ BUILD_STORE_CALL,
30
31
  "Rebuild the local store from the configured packs.",
31
- // An agent recovers by calling the tool, then retrying (PR9 C1 cold-store
32
- // retry makes the post-update retry succeed).
33
- { tool: "sources_update" },
34
32
  ),
35
33
  });
36
34
  }