@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
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Filters, search and pagination compiled into the query, EXECUTED against the
3
+ * shipped pack, for every list-shaped body the distribution declares
4
+ * (PROTECTED).
5
+ *
6
+ * The kernel change these assertions guard moved a story's declared filters from
7
+ * a predicate over returned rows into the generated SPARQL, and gave every
8
+ * list-shaped verb a page applied inside that same query. Both halves are
9
+ * semantics claims about a corpus, so a fixture cannot falsify them: a fixture
10
+ * obligingly contains whatever the assertion expects, while the shipped pack
11
+ * contains 252 blocks, 147 standards with a `GROUP_CONCAT`-ed category set, and
12
+ * an ordering with 25 tied names.
13
+ *
14
+ * Four claims, each on the whole corpus rather than a sample:
15
+ *
16
+ * - PAGES PARTITION. Walking a body in small pages returns exactly the rows the
17
+ * unpaginated read returns, in exactly that order, with exactly those keys.
18
+ * This is what makes the page a page OF the answer, and it is the assertion
19
+ * that would catch a store whose sub-select ordering is not stable.
20
+ * - THE QUERY AGREES WITH THE ROW PREDICATE. Every declared filter's admissible
21
+ * values, and a search term, answered by the compiled query, equal what the
22
+ * retired row predicate would have answered over the whole population —
23
+ * computed here, independently, as the oracle. Exhaustively for a
24
+ * choice-sized vocabulary; at a deterministic spread for a catalogue-sized
25
+ * one, because the sweep costs a query per value and two of these
26
+ * vocabularies are catalogues of 745 and 1,156. See {@link sweep}.
27
+ * - A REFUSAL IS STILL A REFUSAL. A value the graph's vocabulary does not admit
28
+ * is INVALID_INPUT naming the admissible values.
29
+ * - AN EMPTY ANSWER IS STILL CALM. A value the vocabulary admits that no row
30
+ * carries is an empty page at exit 0, and so is a cursor past the last row.
31
+ */
32
+
33
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
34
+ import { MAX_LISTED_OPTIONS } from "../kernel/error/validOptions.js";
35
+ import { compileStoryModule } from "../kernel/packs/compile.js";
36
+ import { MAX_LIST_WINDOW } from "../kernel/packs/paging.js";
37
+ import {
38
+ distributionSource,
39
+ type PackFilter,
40
+ type PackList,
41
+ type PackPage,
42
+ type PackRow,
43
+ } from "../kernel/packs/types.js";
44
+ import { verbKey } from "../kernel/packs/uniqueness.js";
45
+ import { bootRuntime } from "../kernel/runtime/boot.js";
46
+ import type { PragmaRuntime } from "../kernel/runtime/types.js";
47
+ import { kebabCase } from "../kernel/spec/emitSurface.js";
48
+ import type { VerbSpec } from "../kernel/spec/types.js";
49
+ import { TEST_FLAGS } from "../testing/helpers/projectCli.js";
50
+ import { declaredStories } from "./distribution.js";
51
+
52
+ const SOURCE = distributionSource("pragma.conf.ts");
53
+
54
+ /** One list-shaped body of a declared story, with the verb that runs it. */
55
+ interface Body {
56
+ readonly label: string;
57
+ readonly shape: PackList;
58
+ readonly verb: VerbSpec;
59
+ }
60
+
61
+ /**
62
+ * Every list-shaped body the distribution declares — `list` and each extra
63
+ * verb — DERIVED from the config rather than listed, so a story added tomorrow
64
+ * is covered by the author who declares it and without their help.
65
+ */
66
+ const BODIES: readonly Body[] = [...declaredStories].flatMap(
67
+ ([noun, story]) => {
68
+ const module = compileStoryModule(story, SOURCE, {});
69
+ const find = (verb: string): VerbSpec => {
70
+ const found = module.verbs.find(
71
+ (v) => verbKey(v.path) === `${noun} ${verb}`,
72
+ );
73
+ if (!found) throw new Error(`no compiled "${noun} ${verb}" verb`);
74
+ return found;
75
+ };
76
+ const bodies: Body[] = [];
77
+ if (story.list) {
78
+ bodies.push({
79
+ label: `${noun} list`,
80
+ shape: story.list,
81
+ verb: find("list"),
82
+ });
83
+ }
84
+ for (const verb of story.verbs ?? []) {
85
+ bodies.push({
86
+ label: `${noun} ${verb.verb}`,
87
+ shape: verb,
88
+ verb: find(verb.verb),
89
+ });
90
+ }
91
+ return bodies;
92
+ },
93
+ );
94
+
95
+ /** Bodies whose story declares at least one filter or a search. */
96
+ const NARROWABLE = BODIES.filter(
97
+ (body) => body.shape.filters !== undefined || body.shape.search !== undefined,
98
+ );
99
+
100
+ let rt: PragmaRuntime;
101
+ beforeAll(async () => {
102
+ rt = bootRuntime(TEST_FLAGS);
103
+ await rt.store.get();
104
+ }, 60_000);
105
+
106
+ afterAll(async () => {
107
+ (await rt.store.get()).store.dispose();
108
+ });
109
+
110
+ /** Run one body and return its page. */
111
+ const page = (body: Body, params: Record<string, unknown> = {}) =>
112
+ body.verb.run(params, rt) as Promise<PackPage>;
113
+
114
+ /** Every row of a body, in one call — the population the oracles read. */
115
+ async function population(body: Body): Promise<readonly PackRow[]> {
116
+ // The kernel ceiling, which is far above every declared story's row count —
117
+ // so this is the whole answer rather than a page of it, asked for the way a
118
+ // caller who wants everything has to ask.
119
+ return (await page(body, { limit: MAX_LIST_WINDOW })).rows;
120
+ }
121
+
122
+ /** The values a value-free filter's declared vocabulary admits. */
123
+ async function vocabulary(filter: PackFilter): Promise<readonly string[]> {
124
+ if (filter.values) return filter.values;
125
+ const declared = filter.vocabulary;
126
+ if (!declared) throw new Error(`filter --${filter.param} declares neither`);
127
+ const result = await rt.query.sparql(declared.query);
128
+ if (result.type !== "select") throw new Error("vocabulary must be a SELECT");
129
+ const variable = declared.variable ?? filter.variable;
130
+ return result.bindings
131
+ .map((row) => row[variable] ?? "")
132
+ .filter((value) => value !== "");
133
+ }
134
+
135
+ /**
136
+ * The values this suite actually sweeps for one filter.
137
+ *
138
+ * The promise is per-value, so a sweep costs one query per admissible value.
139
+ * Every filter that predates the token graph had a vocabulary of at most 36 —
140
+ * a set of choices — and exhausting it cost seconds. Two of the token graph's
141
+ * filters have vocabularies that are catalogues instead: 745 symbols and 1,156
142
+ * variables, measured at 1.25s and 0.36s per filtered query. Exhausting those
143
+ * two took 22 minutes between them, and the suite could not finish at all.
144
+ *
145
+ * So a choice-sized vocabulary is still swept exhaustively, and a
146
+ * catalogue-sized one is swept at a deterministic spread of that same size —
147
+ * first, last, and evenly spaced between — which stays reproducible across
148
+ * runs and still exercises every shape of value the filter admits. The
149
+ * boundary is {@link MAX_LISTED_OPTIONS}, the constant that also decides when
150
+ * an error stops naming values and starts counting them, because it is the
151
+ * same distinction: a choice is read, a catalogue is listed.
152
+ *
153
+ * @param admissible - Every value the filter's declared vocabulary admits.
154
+ * @returns The values to sweep, in the vocabulary's own order.
155
+ */
156
+ function sweep(admissible: readonly string[]): readonly string[] {
157
+ if (admissible.length <= MAX_LISTED_OPTIONS) return admissible;
158
+ const step = (admissible.length - 1) / (MAX_LISTED_OPTIONS - 1);
159
+ const picked: string[] = [];
160
+ for (let index = 0; index < MAX_LISTED_OPTIONS; index += 1) {
161
+ const value = admissible[Math.round(index * step)];
162
+ if (value !== undefined) picked.push(value);
163
+ }
164
+ return [...new Set(picked)];
165
+ }
166
+
167
+ /**
168
+ * The RETIRED row predicate, reimplemented here as the oracle the compiled
169
+ * query is held to: whole-cell equality for `exact`, membership of a
170
+ * whitespace-separated set for `set`, case-insensitive either way, and a cell
171
+ * that is missing or empty never matches.
172
+ */
173
+ function rowPredicate(
174
+ rows: readonly PackRow[],
175
+ filter: PackFilter,
176
+ value: string,
177
+ ): PackRow[] {
178
+ const wanted = value.normalize("NFC").toLowerCase();
179
+ return rows.filter((row) => {
180
+ const cell = row[filter.variable];
181
+ if (cell === undefined || cell === "") return false;
182
+ const normalized = cell.normalize("NFC").toLowerCase();
183
+ return filter.match === "set"
184
+ ? normalized.split(/\s+/).includes(wanted)
185
+ : normalized === wanted;
186
+ });
187
+ }
188
+
189
+ /** The retired search predicate, likewise. */
190
+ function searchPredicate(
191
+ rows: readonly PackRow[],
192
+ variables: readonly string[],
193
+ term: string,
194
+ ): PackRow[] {
195
+ const wanted = term.normalize("NFC").toLowerCase();
196
+ return rows.filter((row) =>
197
+ variables.some((variable) =>
198
+ row[variable]?.normalize("NFC").toLowerCase().includes(wanted),
199
+ ),
200
+ );
201
+ }
202
+
203
+ describe("pages partition the answer, whole corpus (PROTECTED)", () => {
204
+ it("derives every list-shaped body from the config", () => {
205
+ // A guard, not a no-op: an empty derivation would make every sweep below
206
+ // vacuously green.
207
+ expect(BODIES.map((body) => body.label)).toContain("standard list");
208
+ expect(BODIES.length).toBeGreaterThanOrEqual(9);
209
+ });
210
+
211
+ it.each(BODIES.map((body) => [body.label, body] as const))(
212
+ "%s: a paged walk is the unpaginated answer, in order",
213
+ async (_label, body) => {
214
+ const whole = await population(body);
215
+ const walked: PackRow[] = [];
216
+ let after: string | undefined;
217
+ // Deliberately not a divisor of any story's row count, so the last page
218
+ // is a partial one and the walk has to notice it is the last.
219
+ const limit = 7;
220
+ for (let guard = 0; guard < 500; guard += 1) {
221
+ const current: PackPage = await page(body, {
222
+ limit,
223
+ ...(after ? { after } : {}),
224
+ });
225
+ expect(current.rows.length).toBeLessThanOrEqual(limit);
226
+ walked.push(...current.rows);
227
+ if (current.nextAfter === undefined) break;
228
+ after = current.nextAfter;
229
+ }
230
+ // Byte-identical, keys included: a row's shape is part of the answer, and
231
+ // the generated wrapper projects the author's own variables in the
232
+ // author's own order precisely so it stays so.
233
+ expect(JSON.stringify(walked)).toBe(JSON.stringify(whole));
234
+ },
235
+ 60_000,
236
+ );
237
+
238
+ it("standard list: a cursor past the last row is a calm empty page", async () => {
239
+ const body = BODIES.find((b) => b.label === "standard list");
240
+ if (!body) throw new Error("no standard list");
241
+ const whole = await population(body);
242
+ const limit = whole.length - 1;
243
+ const first = await page(body, { limit });
244
+ expect(first.nextAfter).toBeTypeOf("string");
245
+ const last = await page(body, { limit, after: first.nextAfter });
246
+ // The tail, then nothing — and nothing is a success, not an error.
247
+ expect(last.rows).toHaveLength(1);
248
+ expect(last.nextAfter).toBeUndefined();
249
+ }, 60_000);
250
+ });
251
+
252
+ describe("the compiled query agrees with the retired row predicate (PROTECTED)", () => {
253
+ it("has a narrowable body to sweep", () => {
254
+ expect([...NARROWABLE.map((body) => body.label)].sort()).toEqual([
255
+ "concept list",
256
+ "implementation list",
257
+ "standard list",
258
+ "token consumers",
259
+ "token list",
260
+ "token values",
261
+ "variable chain",
262
+ "variable list",
263
+ ]);
264
+ });
265
+
266
+ it.each(NARROWABLE.map((body) => [body.label, body] as const))(
267
+ "%s: every admissible filter value answers what the row predicate would",
268
+ async (_label, body) => {
269
+ const whole = await population(body);
270
+ for (const filter of body.shape.filters ?? []) {
271
+ const admissible = await vocabulary(filter);
272
+ expect(admissible.length).toBeGreaterThan(0);
273
+ for (const value of sweep(admissible)) {
274
+ const answered = await page(body, {
275
+ [filter.param]: value,
276
+ limit: MAX_LIST_WINDOW,
277
+ });
278
+ expect(JSON.stringify(answered.rows)).toBe(
279
+ JSON.stringify(rowPredicate(whole, filter, value)),
280
+ );
281
+ }
282
+ }
283
+ },
284
+ 120_000,
285
+ );
286
+
287
+ it.each(NARROWABLE.map((body) => [body.label, body] as const))(
288
+ "%s: a search term answers what the row predicate would",
289
+ async (_label, body) => {
290
+ const search = body.shape.search;
291
+ if (!search) return;
292
+ const whole = await population(body);
293
+ for (const term of ["a", "Button", "TEST"]) {
294
+ const answered = await page(body, {
295
+ search: term,
296
+ limit: MAX_LIST_WINDOW,
297
+ });
298
+ expect(JSON.stringify(answered.rows)).toBe(
299
+ JSON.stringify(searchPredicate(whole, search.variables, term)),
300
+ );
301
+ }
302
+ },
303
+ 120_000,
304
+ );
305
+
306
+ it("standard list: a repeated filter is a union, without duplicating a row", async () => {
307
+ const body = BODIES.find((b) => b.label === "standard list");
308
+ if (!body) throw new Error("no standard list");
309
+ const whole = await population(body);
310
+ const filter = body.shape.filters?.[0];
311
+ if (!filter) throw new Error("no category filter");
312
+ const answered = await page(body, {
313
+ [filter.param]: ["testing", "react"],
314
+ limit: MAX_LIST_WINDOW,
315
+ });
316
+ const union = whole.filter(
317
+ (row) =>
318
+ rowPredicate([row], filter, "testing").length > 0 ||
319
+ rowPredicate([row], filter, "react").length > 0,
320
+ );
321
+ expect(JSON.stringify(answered.rows)).toBe(JSON.stringify(union));
322
+ // The case a `VALUES` block joined into the group would get wrong: a
323
+ // standard filed under a `testing` descendant AND under `react` would come
324
+ // back twice.
325
+ expect(new Set(answered.rows.map((row) => row.uri)).size).toBe(
326
+ answered.rows.length,
327
+ );
328
+ }, 60_000);
329
+
330
+ it("standard list: two filters are a conjunction, narrowing each other", async () => {
331
+ const body = BODIES.find((b) => b.label === "implementation list");
332
+ if (!body) throw new Error("no implementation list");
333
+ const whole = await population(body);
334
+ const [first, second] = body.shape.filters ?? [];
335
+ if (!first || !second) throw new Error("expected two filters");
336
+ const a = (await vocabulary(first))[0] as string;
337
+ const b = (await vocabulary(second))[0] as string;
338
+ const answered = await page(body, {
339
+ [first.param]: a,
340
+ [second.param]: b,
341
+ limit: MAX_LIST_WINDOW,
342
+ });
343
+ expect(JSON.stringify(answered.rows)).toBe(
344
+ JSON.stringify(rowPredicate(rowPredicate(whole, first, a), second, b)),
345
+ );
346
+ }, 60_000);
347
+ });
348
+
349
+ describe("a refusal and a calm empty answer stay distinguishable (PROTECTED)", () => {
350
+ it.each(NARROWABLE.map((body) => [body.label, body] as const))(
351
+ "%s: a value the vocabulary does not admit is INVALID_INPUT, naming the ones it does",
352
+ async (_label, body) => {
353
+ for (const filter of body.shape.filters ?? []) {
354
+ const admissible = await vocabulary(filter);
355
+ await expect(
356
+ page(body, { [filter.param]: "zzz-definitely-not-a-value" }),
357
+ ).rejects.toMatchObject({
358
+ code: "INVALID_INPUT",
359
+ validOptions: [...admissible].sort(),
360
+ });
361
+ }
362
+ },
363
+ 60_000,
364
+ );
365
+
366
+ it("a value the vocabulary admits that no row carries is an empty page, not an error", async () => {
367
+ // The case that separates "the graph is the vocabulary" from "the returned
368
+ // rows are the vocabulary", found in the corpus rather than named: today it
369
+ // is a `ds:ConceptType` the ontology declares and no concept uses. Under
370
+ // the retired rows-as-vocabulary fallback such a value was INVALID_INPUT;
371
+ // under a PAGE it would have been INVALID_INPUT for merely sorting outside
372
+ // the window. It is neither — it is the empty answer the graph actually has.
373
+ const unpopulated: { body: Body; param: string; value: string }[] = [];
374
+ for (const body of NARROWABLE) {
375
+ const whole = await population(body);
376
+ for (const filter of body.shape.filters ?? []) {
377
+ for (const value of sweep(await vocabulary(filter))) {
378
+ if (rowPredicate(whole, filter, value).length === 0) {
379
+ unpopulated.push({ body, param: filter.param, value });
380
+ }
381
+ }
382
+ }
383
+ }
384
+ expect(unpopulated.length).toBeGreaterThan(0);
385
+ for (const { body, param, value } of unpopulated) {
386
+ const answered = await page(body, { [param]: value });
387
+ expect(answered.rows).toEqual([]);
388
+ expect(answered.nextAfter).toBeUndefined();
389
+ // And the calm sentence rides the same seam it always did — naming the
390
+ // filter that narrowed to nothing, since a filtered empty answer is a
391
+ // statement about the filter and not about the population.
392
+ expect(body.verb.output.formatters.notice?.(answered as never)).toContain(
393
+ // In the flag's own spelling: a story's `channelOf` param is typed
394
+ // `--channel-of`, and an answer that named the param would be naming
395
+ // something no caller can pass.
396
+ `matches \`--${kebabCase(param)} ${value}\`.`,
397
+ );
398
+ }
399
+ }, 120_000);
400
+ });
@@ -61,8 +61,31 @@ const EVERYTHING: McpListable = { sources: [{ as: "entities" }] };
61
61
  * only. Payload SIZE is the property that actually regressed (712 entries /
62
62
  * ~155 KB on every connect), and it is the one an agent pays for twice: once in
63
63
  * transfer, once in the context window it can no longer spend on the task.
64
+ *
65
+ * **Raised from 60,000 to 100,000 on 2026-09-01**, when the
66
+ * `@canonical/token-ontology` pack took the payload to 65,119 bytes. The
67
+ * listing was kept whole rather than trimmed to fit: the per-class counts are
68
+ * how an agent gets the shape of the graph before spending a single lookup,
69
+ * and that is worth more than the bytes.
70
+ *
71
+ * **Cost scales with the number of T-Box terms and their metadata, not with
72
+ * the number of packs.** The `ontology` noun emits one resource per matching
73
+ * T-Box entity (`provider.ts`), so a pack costs what its own vocabulary
74
+ * costs — this one happened to add ~23 KB, which is a fact about this
75
+ * ontology and not a rate. The headroom here does not convert into "one more
76
+ * pack" of any size.
77
+ *
78
+ * It is also the only listing that grows this way: every other noun lists
79
+ * collections with their counts, so those grow with the number of nouns. When
80
+ * this ceiling is next reached, the fix is to scope the T-Box slice or
81
+ * paginate it — not to raise the number again, which would turn a gate into a
82
+ * formality.
83
+ *
84
+ * It is deliberately not removed. The regression it was written for is still
85
+ * worth catching, and a ceiling with headroom catches it while a deleted one
86
+ * catches nothing.
64
87
  */
65
- const LISTING_BUDGET_BYTES = 60_000;
88
+ const LISTING_BUDGET_BYTES = 100_000;
66
89
 
67
90
  describe("resource listing (storeless, over the pack index)", () => {
68
91
  it("degrades to a recovery entry on a missing or legacy index", () => {
@@ -322,7 +345,20 @@ describe("resource surface over the server (embedded pack)", () => {
322
345
  let harness: Awaited<ReturnType<typeof projectMcp>>;
323
346
  beforeAll(async () => {
324
347
  harness = await projectMcp(capabilities);
325
- });
348
+ // Boot the store HERE, not in whichever test happens to read first.
349
+ //
350
+ // `createLazyStore` memoises the boot PROMISE, and vitest's per-test
351
+ // timeout aborts the test without aborting the work — so a cold boot that
352
+ // overruns is charged partly to the test that triggered it and partly to
353
+ // the next one, which merely awaits the same promise. That reads as a
354
+ // sudden per-read regression and is nothing of the kind; it cost a real
355
+ // investigation to find that out.
356
+ //
357
+ // Paying it in the hook attributes it where it belongs, keeps every `it`
358
+ // in this file measuring what it claims to, and bounds it by hookTimeout
359
+ // rather than by a 5 s default that pack growth will keep outgrowing.
360
+ await harness.readResource(BUTTON_URI);
361
+ }, 60_000);
326
362
  afterAll(async () => {
327
363
  await harness.cleanup();
328
364
  });
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { PragmaRuntime } from "../../kernel/runtime/index.js";
14
+ import type { FsProbe } from "./operations/writability.js";
14
15
  import {
15
16
  defaultSelected,
16
17
  type PlanRow,
@@ -88,6 +89,10 @@ const messageOf = (reason: unknown): string =>
88
89
  * @param rt - The per-invocation runtime.
89
90
  * @param ids - The targets to plan (all five for the run-all).
90
91
  * @param scope - The resolved scope selection.
92
+ * @param probe - The writability filesystem seam, handed to every row's
93
+ * `detect`. Defaults to the real one; a test injects a fixture to drive the
94
+ * two arms no CI host has — a `/nix/store` path, and (as root) a directory
95
+ * that refuses `W_OK` — without opening a second way in to the row bodies.
91
96
  * @returns One entry per (target, scope) the scope actually runs.
92
97
  * @note Impure — every target's `detect` reads the real filesystem.
93
98
  */
@@ -95,6 +100,7 @@ export async function detectTargets(
95
100
  rt: PragmaRuntime,
96
101
  ids: readonly TargetId[],
97
102
  scope: ScopeSelection,
103
+ probe?: FsProbe,
98
104
  ): Promise<DetectedRow[]> {
99
105
  const wanted = TARGETS.filter((target) => ids.includes(target.id));
100
106
  const pairs = scopesForSelection(scope).flatMap((scope) =>
@@ -106,7 +112,7 @@ export async function detectTargets(
106
112
  })),
107
113
  );
108
114
  const settled = await Promise.allSettled(
109
- pairs.map(({ target, scope }) => target.detect(rt, scope)),
115
+ pairs.map(({ target, scope }) => target.detect(rt, scope, probe)),
110
116
  );
111
117
  return pairs.map(({ target, scope }, index) => {
112
118
  const outcome = settled[index] as PromiseSettledResult<unknown>;
@@ -26,6 +26,11 @@
26
26
  * path — a consumer that routes it through this barrel statically would undo
27
27
  * that, and the setup guard test would say so.
28
28
  *
29
+ * Every line here has an importer that goes THROUGH it. The modules also
30
+ * import each other directly (`targets.ts` reaches `setupLsp.js`, and its
31
+ * siblings reach `writability.js`), and re-exporting those edges as well would
32
+ * make this file a second, wider surface that nothing asked for.
33
+ *
29
34
  * Deliberately internal: the config seed literal, the per-target message
30
35
  * builders, and the MCP entry writer. They are inputs to the composers above,
31
36
  * not operations a caller performs.
@@ -49,8 +54,12 @@ export type { LspDetection } from "./setupLsp.js";
49
54
  export {
50
55
  composeLsp,
51
56
  composeLspRemoval,
57
+ describeEditorSource,
52
58
  detectLsp,
59
+ firstLspBlock,
60
+ installableEditors,
53
61
  LSP_SKIP_REMEDY,
62
+ lspBlockReason,
54
63
  lspEditorNames,
55
64
  lspSkipReason,
56
65
  lspUninstallRemedy,
@@ -58,9 +67,13 @@ export {
58
67
  } from "./setupLsp.js";
59
68
  export type { McpDetection } from "./setupMcp.js";
60
69
  export {
70
+ blockedMcpGroups,
61
71
  composeMcp,
62
72
  composeMcpRemoval,
63
73
  detectMcp,
74
+ firstMcpBlock,
75
+ mcpBlockReason,
76
+ mcpGroupBlock,
64
77
  mcpGroupState,
65
78
  mcpWriteState,
66
79
  ownedMcpGroups,
@@ -77,3 +90,4 @@ export {
77
90
  staleSkillLinks,
78
91
  withinRoot,
79
92
  } from "./setupSkills.js";
93
+ export type { FsProbe } from "./writability.js";
@@ -204,11 +204,18 @@ export function composeCompletions(d: CompletionsDetection): Task<void> {
204
204
  * machine that never installed one reverses zero steps and says so.
205
205
  *
206
206
  * @param d - The detection gathered up front.
207
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
208
+ * their undo outcomes so the caller can read results back per row.
207
209
  * @returns A Task whose undo deletes the installed script.
208
210
  */
209
- export function composeCompletionsRemoval(d: CompletionsDetection): Task<void> {
211
+ export function composeCompletionsRemoval(
212
+ d: CompletionsDetection,
213
+ undoKey?: string,
214
+ ): Task<void> {
210
215
  if (d.path === null || d.script === null || d.state === "absent") {
211
216
  return sequence_([]);
212
217
  }
213
- return sequence_([writeFile(d.path, d.script, { undo: deleteFile(d.path) })]);
218
+ return sequence_([
219
+ writeFile(d.path, d.script, { undo: deleteFile(d.path), undoKey }),
220
+ ]);
214
221
  }
@@ -110,12 +110,17 @@ export function composeConfigFile(d: ConfigDetection): Task<void> {
110
110
  * delete as its `undo`, which is what the undo interpreter executes.
111
111
  *
112
112
  * @param d - The detection gathered up front.
113
+ * @param undoKey - Correlation key stamped on the reversal(s), echoed on
114
+ * their undo outcomes so the caller can read results back per row.
113
115
  * @returns A Task whose undo removes a seed-only config, or an empty Task.
114
116
  */
115
- export function composeConfigRemoval(d: ConfigDetection): Task<void> {
117
+ export function composeConfigRemoval(
118
+ d: ConfigDetection,
119
+ undoKey?: string,
120
+ ): Task<void> {
116
121
  if (!d.exists || !d.isSeed) return sequence_([]);
117
122
  return sequence_([
118
- writeFile(d.path, SEED_CONFIG, { undo: deleteFile(d.path) }),
123
+ writeFile(d.path, SEED_CONFIG, { undo: deleteFile(d.path), undoKey }),
119
124
  ]);
120
125
  }
121
126