@canonical/pragma-cli 0.33.0 → 0.34.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 (250) hide show
  1. package/README.md +7 -7
  2. package/dist/pragma +0 -0
  3. package/package.json +18 -15
  4. package/pragma.conf.ts +664 -0
  5. package/src/bin.ts +13 -4
  6. package/src/capabilities/block.parity.test.ts +250 -0
  7. package/src/capabilities/capabilities/capabilities.render.ts +3 -2
  8. package/src/capabilities/capabilities/capabilities.verb.ts +31 -13
  9. package/src/capabilities/capabilities/catalog.ts +18 -10
  10. package/src/capabilities/capabilities/hints.ts +13 -19
  11. package/src/capabilities/colophon/collectColophon.ts +34 -19
  12. package/src/capabilities/colophon/colophon.verb.ts +8 -4
  13. package/src/capabilities/colophon/types.ts +16 -7
  14. package/src/capabilities/config/collectConfigShow.test.ts +57 -0
  15. package/src/capabilities/config/collectConfigShow.ts +41 -4
  16. package/src/capabilities/config/fields.ts +0 -27
  17. package/src/capabilities/config/set.verb.ts +7 -3
  18. package/src/capabilities/config/show.render.test.ts +77 -5
  19. package/src/capabilities/config/show.render.ts +7 -12
  20. package/src/capabilities/config/show.verb.ts +4 -3
  21. package/src/capabilities/config/types.ts +8 -0
  22. package/src/capabilities/create/byteEquality.test.ts +78 -100
  23. package/src/capabilities/create/compiledCreate.subprocess.test.ts +52 -15
  24. package/src/capabilities/create/constants.ts +75 -0
  25. package/src/capabilities/create/create.mcp.test.ts +2 -2
  26. package/src/capabilities/create/create.test.ts +162 -15
  27. package/src/capabilities/create/create.verb.ts +140 -69
  28. package/src/capabilities/create/index.ts +2 -10
  29. package/src/capabilities/create/pickGenerator.ts +34 -28
  30. package/src/capabilities/create/templates.embedded.generated.ts +4 -29
  31. package/src/capabilities/create/types.ts +10 -6
  32. package/src/capabilities/{standard/parity.test.ts → distribution.parity.test.ts} +24 -14
  33. package/src/capabilities/distribution.test.ts +120 -0
  34. package/src/capabilities/distribution.ts +59 -0
  35. package/src/capabilities/doctor/checks/checkConfigFile.ts +6 -5
  36. package/src/capabilities/doctor/checks/checkKeStore.ts +2 -2
  37. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +7 -6
  38. package/src/capabilities/doctor/checks/checkPackageRefs.ts +79 -45
  39. package/src/capabilities/doctor/checks/checkPragmaVersion.ts +3 -2
  40. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +61 -11
  41. package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -23
  42. package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +3 -2
  43. package/src/capabilities/doctor/doctor.render.test.ts +6 -6
  44. package/src/capabilities/doctor/doctor.render.ts +2 -1
  45. package/src/capabilities/doctor/doctor.test.ts +37 -8
  46. package/src/capabilities/doctor/doctor.verb.ts +6 -2
  47. package/src/capabilities/doctor/runChecks.ts +5 -5
  48. package/src/capabilities/doctor/types.ts +1 -1
  49. package/src/capabilities/emptyRecovery.test.ts +32 -15
  50. package/src/capabilities/graph/inspect.verb.ts +3 -2
  51. package/src/capabilities/graph/query.verb.ts +3 -2
  52. package/src/capabilities/graph/runQuery.ts +8 -5
  53. package/src/capabilities/index.ts +22 -18
  54. package/src/capabilities/info/collectInfo.ts +10 -6
  55. package/src/capabilities/info/info.render.ts +3 -2
  56. package/src/capabilities/info/info.verb.ts +3 -2
  57. package/src/capabilities/lazy.test.ts +93 -2
  58. package/src/capabilities/ontology/verbs.ts +21 -21
  59. package/src/capabilities/prompt/prompt.test.ts +12 -6
  60. package/src/capabilities/prompt/verbs.ts +24 -11
  61. package/src/capabilities/resources/provider.ts +30 -11
  62. package/src/capabilities/resources/resources.test.ts +85 -29
  63. package/src/capabilities/setup/operations/setupGenerator.ts +16 -9
  64. package/src/capabilities/setup/operations/setupMcp.ts +2 -2
  65. package/src/capabilities/setup/operations/setupSkills.ts +3 -3
  66. package/src/capabilities/setup/setup.verb.ts +12 -5
  67. package/src/capabilities/setup/shell.ts +29 -3
  68. package/src/capabilities/skill/discover.ts +11 -5
  69. package/src/capabilities/skill/render.ts +4 -5
  70. package/src/capabilities/skill/verbs.ts +7 -7
  71. package/src/capabilities/sources/collectStatus.ts +55 -50
  72. package/src/capabilities/sources/index.ts +1 -1
  73. package/src/capabilities/sources/installSkills.ts +2 -2
  74. package/src/capabilities/sources/runUpdate.ts +113 -122
  75. package/src/capabilities/sources/sources.test.ts +111 -117
  76. package/src/capabilities/sources/status.render.test.ts +53 -24
  77. package/src/capabilities/sources/status.render.ts +24 -29
  78. package/src/capabilities/sources/status.verb.ts +15 -8
  79. package/src/capabilities/sources/types.ts +18 -25
  80. package/src/capabilities/sources/update.render.ts +9 -2
  81. package/src/capabilities/sources/update.verb.ts +12 -23
  82. package/src/capabilities/surface.test.ts +33 -10
  83. package/src/capabilities/tier.parity.test.ts +144 -0
  84. package/src/capabilities/upgrade/upgrade.verb.ts +7 -3
  85. package/src/constants.test.ts +8 -6
  86. package/src/constants.ts +47 -21
  87. package/src/identity.test.ts +333 -0
  88. package/src/index.ts +2 -2
  89. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +5 -2
  90. package/src/kernel/completion/emitScripts.test.ts +1 -1
  91. package/src/kernel/completion/emitScripts.ts +4 -2
  92. package/src/kernel/completion/entitySource.test.ts +185 -41
  93. package/src/kernel/completion/entitySource.ts +117 -107
  94. package/src/kernel/completion/model.test.ts +1 -1
  95. package/src/kernel/completion/model.ts +11 -0
  96. package/src/kernel/completion/safety.test.ts +82 -36
  97. package/src/kernel/completion/shellDrive.test.ts +592 -0
  98. package/src/kernel/completion/templates/fish.ts +28 -6
  99. package/src/kernel/completion/types.ts +3 -3
  100. package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +1 -1
  101. package/src/kernel/config/defaults.test.ts +85 -0
  102. package/src/kernel/config/defaults.ts +12 -23
  103. package/src/kernel/config/evaluateProjectConfig.ts +2 -3
  104. package/src/kernel/config/findProjectConfig.ts +4 -3
  105. package/src/kernel/config/firstRun.ts +12 -5
  106. package/src/kernel/config/globalConfig.ts +2 -2
  107. package/src/kernel/config/index.ts +4 -5
  108. package/src/kernel/config/paths.ts +17 -17
  109. package/src/kernel/config/readConfig.test.ts +242 -6
  110. package/src/kernel/config/readConfig.ts +8 -8
  111. package/src/kernel/config/schema.test.ts +50 -0
  112. package/src/kernel/config/schema.ts +97 -10
  113. package/src/kernel/config/types.ts +73 -19
  114. package/src/kernel/copy.test.ts +453 -0
  115. package/src/kernel/error/PragmaError.test.ts +1 -1
  116. package/src/kernel/error/constants.ts +3 -2
  117. package/src/kernel/error/index.ts +1 -1
  118. package/src/kernel/error/recovery.test.ts +22 -45
  119. package/src/kernel/error/recovery.ts +22 -27
  120. package/src/kernel/error/types.ts +5 -4
  121. package/src/kernel/packs/collect.test.ts +195 -11
  122. package/src/kernel/packs/collect.ts +213 -58
  123. package/src/kernel/packs/compile.test.ts +163 -7
  124. package/src/kernel/packs/compile.ts +15 -12
  125. package/src/kernel/packs/disclosure.test.ts +5 -2
  126. package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +11 -10
  127. package/src/kernel/packs/graphqlSource.test.ts +6 -3
  128. package/src/kernel/packs/iri.ts +5 -7
  129. package/src/kernel/packs/renderPack.ts +5 -3
  130. package/src/kernel/packs/resolveEntity.ts +21 -17
  131. package/src/kernel/packs/runBodies.ts +15 -14
  132. package/src/kernel/packs/schema.ts +96 -10
  133. package/src/kernel/packs/sparql/runSelect.test.ts +104 -10
  134. package/src/kernel/packs/sparql/runSelect.ts +73 -34
  135. package/src/kernel/packs/toolDescription.test.ts +10 -2
  136. package/src/kernel/packs/types.ts +39 -4
  137. package/src/kernel/packs/uniqueness.ts +4 -4
  138. package/src/kernel/project/cli/buildProgram.test.ts +9 -4
  139. package/src/kernel/project/cli/dispatch.test.ts +67 -0
  140. package/src/kernel/project/cli/dispatch.ts +34 -12
  141. package/src/kernel/project/cli/helpFormat.ts +1 -1
  142. package/src/kernel/project/cli/rootHelp.ts +105 -105
  143. package/src/kernel/project/mcp/buildServer.ts +11 -6
  144. package/src/kernel/project/mcp/instructions.ts +11 -6
  145. package/src/kernel/project/mcp/parity.test.ts +39 -1
  146. package/src/kernel/project/mcp/prompts/prompts.test.ts +120 -5
  147. package/src/kernel/project/mcp/prompts/provider.ts +22 -8
  148. package/src/kernel/project/mcp/prompts/source.ts +68 -67
  149. package/src/kernel/project/mcp/registerVerb.ts +25 -6
  150. package/src/kernel/project/mcp/serve.ts +11 -3
  151. package/src/kernel/project/mcp/toolDescriptions.test.ts +6 -2
  152. package/src/kernel/render/disclosure.ts +4 -2
  153. package/src/kernel/render/prefixes.test.ts +36 -0
  154. package/src/kernel/render/prefixes.ts +40 -13
  155. package/src/kernel/render/renderers.test.ts +1 -1
  156. package/src/kernel/runtime/graphpack/build.ts +29 -3
  157. package/src/kernel/runtime/graphpack/buildIndex.test.ts +46 -0
  158. package/src/kernel/runtime/graphpack/buildIndex.ts +52 -17
  159. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +5 -5
  160. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +3 -3
  161. package/src/kernel/runtime/graphpack/embedded/pack.stories.generated.ts +3 -0
  162. package/src/kernel/runtime/graphpack/embedded.ts +37 -11
  163. package/src/kernel/runtime/graphpack/graphpack.test.ts +205 -5
  164. package/src/kernel/runtime/graphpack/hash.ts +3 -2
  165. package/src/kernel/runtime/graphpack/index.ts +1 -1
  166. package/src/kernel/runtime/graphpack/manifest.ts +114 -12
  167. package/src/kernel/runtime/graphpack/read.ts +4 -9
  168. package/src/kernel/runtime/graphpack/schemas.ts +74 -0
  169. package/src/kernel/runtime/graphpack/stories.ts +84 -0
  170. package/src/kernel/runtime/graphpack/types.ts +61 -48
  171. package/src/kernel/runtime/paths.ts +64 -15
  172. package/src/kernel/runtime/probe.test.ts +8 -4
  173. package/src/kernel/runtime/readEntity.ts +6 -5
  174. package/src/kernel/runtime/refs/gitOps.ts +1 -19
  175. package/src/kernel/runtime/refs/parseRef.ts +7 -7
  176. package/src/kernel/runtime/refs/resolve.ts +78 -62
  177. package/src/kernel/runtime/resolveSources.test.ts +63 -24
  178. package/src/kernel/runtime/resolveSources.ts +36 -25
  179. package/src/kernel/runtime/storeReadiness.ts +9 -8
  180. package/src/kernel/runtime/storeless.test.ts +1 -1
  181. package/src/kernel/runtime/wasmEmbed.test.ts +22 -19
  182. package/src/kernel/spec/emitReference.test.ts +3 -3
  183. package/src/kernel/spec/emitReference.ts +144 -11
  184. package/src/kernel/spec/emitSurface.ts +9 -5
  185. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  186. package/src/kernel/spec/surfaceConformance.ts +8 -0
  187. package/src/kernel/spec/types.ts +29 -9
  188. package/src/kernel/spec/validate.ts +2 -1
  189. package/src/kernel/vocabulary.test.ts +164 -0
  190. package/src/kernel/vocabulary.ts +146 -0
  191. package/src/testing/PARITY_GAPS.ts +8 -2
  192. package/src/testing/behavioral/README.md +3 -3
  193. package/src/testing/behavioral/completion.test.ts +19 -6
  194. package/src/testing/behavioral/errorMatrix.mcp.test.ts +4 -3
  195. package/src/testing/behavioral/errorMatrix.storeless.test.ts +1 -1
  196. package/src/testing/behavioral/exitCodes.e2e.test.ts +6 -5
  197. package/src/testing/behavioral/harness.foundation.test.ts +14 -5
  198. package/src/testing/behavioral/journeys.cli.test.ts +116 -75
  199. package/src/testing/behavioral/journeys.defaultPack.test.ts +82 -176
  200. package/src/testing/behavioral/journeys.firstInstall.test.ts +167 -0
  201. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +9 -6
  202. package/src/testing/behavioral/journeys.packageStories.test.ts +188 -0
  203. package/src/testing/behavioral/liveReadSurface.ts +4 -3
  204. package/src/testing/behavioral/mcpSurface.test.ts +9 -4
  205. package/src/testing/behavioral/staleCommands.test.ts +10 -0
  206. package/src/testing/eval/__snapshots__/eval.test.ts.snap +7 -7
  207. package/src/testing/eval/cases/assembly.ts +13 -6
  208. package/src/testing/eval/cases/effectful.ts +25 -13
  209. package/src/testing/eval/cases/readNouns.ts +22 -20
  210. package/src/testing/eval/cases/stable.ts +5 -1
  211. package/src/testing/eval/eval.test.ts +7 -1
  212. package/src/testing/fixtures/fixtureCapability.ts +45 -1
  213. package/src/testing/fixtures/graph/canonical.ts +6 -3
  214. package/src/testing/fixtures/graph/defaultPack.ts +6 -5
  215. package/src/testing/fixtures/graph/owlExport.ts +7 -5
  216. package/src/testing/helpers/fixtureGraph.ts +33 -16
  217. package/src/testing/helpers/packRuntime.ts +2 -3
  218. package/src/testing/helpers/projectCli.ts +2 -1
  219. package/src/testing/helpers/projectMcp.ts +6 -0
  220. package/src/testing/perf/budgets.test.ts +4 -0
  221. package/src/testing/perf/budgets.ts +22 -7
  222. package/src/testing/perf/globalSetup.ts +121 -6
  223. package/src/types.ts +2 -2
  224. package/src/capabilities/block/blockList.test.ts +0 -79
  225. package/src/capabilities/block/blockList.verb.ts +0 -163
  226. package/src/capabilities/block/index.ts +0 -25
  227. package/src/capabilities/block/pack.ts +0 -166
  228. package/src/capabilities/block/parity.test.ts +0 -187
  229. package/src/capabilities/block/tierChain.ts +0 -78
  230. package/src/capabilities/colophon/pragmaColophon.ts +0 -66
  231. package/src/capabilities/modifier/index.ts +0 -15
  232. package/src/capabilities/modifier/pack.ts +0 -64
  233. package/src/capabilities/standard/index.ts +0 -18
  234. package/src/capabilities/standard/pack.ts +0 -128
  235. package/src/capabilities/tier/index.ts +0 -19
  236. package/src/capabilities/tier/lookup.render.test.ts +0 -72
  237. package/src/capabilities/tier/lookup.render.ts +0 -59
  238. package/src/capabilities/tier/lookup.test.ts +0 -83
  239. package/src/capabilities/tier/lookup.verb.ts +0 -44
  240. package/src/capabilities/tier/pack.ts +0 -28
  241. package/src/capabilities/tier/runLookup.ts +0 -87
  242. package/src/capabilities/token/addConfig.render.ts +0 -27
  243. package/src/capabilities/token/addConfig.test.ts +0 -62
  244. package/src/capabilities/token/addConfig.verb.ts +0 -40
  245. package/src/capabilities/token/index.ts +0 -19
  246. package/src/capabilities/token/pack.ts +0 -58
  247. package/src/capabilities/token/runAddConfig.ts +0 -95
  248. package/src/kernel/completion/bashDrive.test.ts +0 -73
  249. package/src/kernel/runtime/graphpack/embedded/sample.ttl +0 -39
  250. package/src/kernel/runtime/lock.ts +0 -90
@@ -0,0 +1,453 @@
1
+ /**
2
+ * The kernel is generic machinery: it may describe itself, but it may not name
3
+ * the distribution in an authored string. A fork changes `pragma.conf.ts`;
4
+ * anything the kernel hardcodes becomes a lie it cannot fix.
5
+ *
6
+ * What this guard enforces, exactly: no string literal under `src/kernel/**`
7
+ * (plus `constants.ts` and `bin.ts`) contains the distribution's `name` as a
8
+ * word, the domain phrase "design system", or any namespace IRI the
9
+ * distribution declares in `prefixes`. Module specifiers are not copy and are
10
+ * skipped; every remaining site that legitimately carries one is listed in
11
+ * {@link EXEMPT} with its reason.
12
+ *
13
+ * `src/capabilities/**` and `pragma.conf.ts` are OUT of the rule above.
14
+ * `pragma.conf.ts` is the file a fork edits, so it is content by definition;
15
+ * `src/capabilities/**` carries the kernel's own nouns — their prompts,
16
+ * setup copy, doctor findings and other runtime copy no doc publishes — and a
17
+ * guard needing a 65-entry exemption list is a guard that mostly exempts. (The
18
+ * `ds:` residue the exclusion was originally written for is gone: L-OPEN-9
19
+ * left no hand-written data verb behind, and `distribution.test.ts` now proves
20
+ * every data noun is exactly its declared story.) Two narrower rules reach it
21
+ * instead, at the bottom of this
22
+ * file — *a command a user is told to run is never a literal*, stated once as
23
+ * a POSITION ({@link COMMAND_POSITIONS}, read over raw source so it holds
24
+ * whatever the scanner can see) and once as a SHAPE (a backticked command
25
+ * inside any authored literal, read through {@link readCopy} so an
26
+ * interpolated one leaves no matching chunk). Neither rule carries an
27
+ * exemption: the last one — the colophon narrative, this distribution's voice
28
+ * in a capability source — became content the config declares
29
+ * (`pragma.conf.ts#colophon`), which is what made removing it checkable.
30
+ *
31
+ * NOTE for a reader of an older revision: this docblock used to say the
32
+ * `examples[].cmd` sweep and the `docs/reference/*.md` regen "have to move
33
+ * together". They must be CONSISTENT, not simultaneous — the whole sweep was
34
+ * measured to move zero doc bytes, because every literal it replaced was
35
+ * already spelling this distribution's own name.
36
+ */
37
+
38
+ import { readdirSync, readFileSync } from "node:fs";
39
+ import { dirname, join, relative, resolve } from "node:path";
40
+ import { fileURLToPath } from "node:url";
41
+ import { describe, expect, it } from "vitest";
42
+ import conf from "../../pragma.conf.js";
43
+ import { BIN_NAME } from "../constants.js";
44
+
45
+ const here = dirname(fileURLToPath(import.meta.url));
46
+ const root = resolve(here, "..");
47
+
48
+ /**
49
+ * Files whose strings are pinned, frozen, or owned by another lane, so changing
50
+ * them is not a copy edit:
51
+ * - `*.generated.ts` — build artifacts (the embedded pack's graph data).
52
+ * - `config/defaults.ts` — THE distribution seam: it imports the distribution
53
+ * config and names the file it imports. That is its job, not a leak.
54
+ * - `kernel/vocabulary.ts` — the same seam for the vocabulary declaration. Its
55
+ * diagnostics quote the file it imports, and five modules hardcode that
56
+ * specifier, so a fork cannot rename it: deriving the name from `BIN_NAME`
57
+ * here would name a path that does not exist.
58
+ * - `runtime/graphpack/hash.ts` — `<<<pragma-pack:…>>>` are hash domain
59
+ * separators; changing them re-mints every pack content hash. CROSS-LANE.
60
+ */
61
+ const EXEMPT = [
62
+ ".generated.ts",
63
+ "config/defaults.ts",
64
+ "kernel/vocabulary.ts",
65
+ "runtime/graphpack/hash.ts",
66
+ ];
67
+
68
+ /**
69
+ * Every authored `.ts` in the kernel, plus the identity module and the bin.
70
+ *
71
+ * @param dir - Directory to walk.
72
+ * @returns Absolute paths of the non-test TypeScript sources beneath it.
73
+ * @note Impure — reads the source tree.
74
+ */
75
+ function listSources(dir: string): string[] {
76
+ const found: string[] = [];
77
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
78
+ const path = join(dir, entry.name);
79
+ if (entry.isDirectory()) found.push(...listSources(path));
80
+ else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
81
+ found.push(path);
82
+ }
83
+ }
84
+ return found;
85
+ }
86
+
87
+ const files = [
88
+ ...listSources(join(root, "kernel")),
89
+ join(root, "constants.ts"),
90
+ join(root, "bin.ts"),
91
+ ].filter((file) => !EXEMPT.some((suffix) => file.endsWith(suffix)));
92
+
93
+ /**
94
+ * The authored copy in a file: every string literal that is neither a module
95
+ * specifier nor inside a comment.
96
+ *
97
+ * A single left-to-right pass, not a pair of regexes, because the two orderings
98
+ * a regex pair allows are both wrong: strip comments first and any literal
99
+ * containing `//` is truncated, re-pairing the quotes across the deleted tail
100
+ * and hiding whatever followed; match literals first and an apostrophe in prose
101
+ * ("the kernel's") opens a quote that never closes, which is also the shape
102
+ * that makes the literal pattern backtrack catastrophically. Reading the file
103
+ * in source order makes both cases fall out: a `//` inside a string belongs to
104
+ * the string, an apostrophe inside a comment belongs to the comment.
105
+ *
106
+ * A REGEX LITERAL is code, not copy, and it is the third ordering hazard: a `"`
107
+ * inside a regex (`/[<>"{}|\\^`\s]/`) would otherwise open a phantom string that
108
+ * swallows every literal until the next `"` — so the scanner reads regexes as
109
+ * regexes and skips them whole.
110
+ *
111
+ * A TEMPLATE SUBSTITUTION is the fourth, and the one this tree actually writes:
112
+ * `${…}` is code, and the code inside it may open another template. Reading a
113
+ * template as one flat span makes the NESTED backtick close the OUTER literal,
114
+ * and every quote after it in the file pairs off by one — which left the whole
115
+ * tail of `packs/renderPack.ts` invisible to this guard. So a substitution is
116
+ * handed back to the code scanner, and the literal chunks around it are the copy.
117
+ *
118
+ * Specifiers (`./x.js`, `@scope/pkg`) name modules, not users, and must stay
119
+ * literal, so they are not copy.
120
+ *
121
+ * @param source - The file's text.
122
+ * @returns The authored string literals.
123
+ */
124
+ function readCopy(source: string): string[] {
125
+ const copy: string[] = [];
126
+ scanCode(source, 0, copy);
127
+ return copy;
128
+ }
129
+
130
+ /** A `/` opens a regex only where a VALUE may start; after a value it divides. */
131
+ const REGEX_OPENS_AFTER = "=(,:[!&|?{};+-*%~^<>";
132
+
133
+ /**
134
+ * Read code from `from`, pushing every authored literal it quotes onto `copy`.
135
+ *
136
+ * @param source - The file's text.
137
+ * @param from - Index to start reading at.
138
+ * @param copy - Accumulator the authored literals are pushed onto.
139
+ * @param untilBrace - Stop at the `}` closing a template substitution.
140
+ * @returns The index the scan stopped at.
141
+ */
142
+ function scanCode(
143
+ source: string,
144
+ from: number,
145
+ copy: string[],
146
+ untilBrace = false,
147
+ ): number {
148
+ // One character of context decides regex-vs-division — the last significant
149
+ // one — which is all TypeScript sources in this tree need (no `)`/`]`-preceded
150
+ // regex, i.e. no `if (x) /re/.test(y)`, which would read as division).
151
+ let previous = "";
152
+ let braces = 0;
153
+ for (let i = from; i < source.length; i++) {
154
+ const char = source[i];
155
+ if (char === "/" && source[i + 1] === "*") {
156
+ const end = source.indexOf("*/", i + 2);
157
+ if (end === -1) return source.length;
158
+ i = end + 1;
159
+ continue;
160
+ }
161
+ if (char === "/" && source[i + 1] === "/") {
162
+ const end = source.indexOf("\n", i + 2);
163
+ if (end === -1) return source.length;
164
+ i = end;
165
+ continue;
166
+ }
167
+ if (
168
+ char === "/" &&
169
+ previous !== "" &&
170
+ REGEX_OPENS_AFTER.includes(previous)
171
+ ) {
172
+ // Skip the body, honouring escapes and `[…]` classes (where an unescaped
173
+ // `/` is an ordinary character). An unterminated regex cannot span a line,
174
+ // so a newline ends the scan rather than eating the rest of the file.
175
+ let inClass = false;
176
+ i += 1;
177
+ while (i < source.length && source[i] !== "\n") {
178
+ const body = source[i];
179
+ if (body === "\\") i += 1;
180
+ else if (body === "[") inClass = true;
181
+ else if (body === "]") inClass = false;
182
+ else if (body === "/" && !inClass) break;
183
+ i += 1;
184
+ }
185
+ previous = "/";
186
+ continue;
187
+ }
188
+ if (char === "`") {
189
+ i = scanTemplate(source, i + 1, copy);
190
+ previous = char;
191
+ continue;
192
+ }
193
+ if (char !== '"' && char !== "'") {
194
+ if (untilBrace && char === "{") braces += 1;
195
+ if (untilBrace && char === "}") {
196
+ if (braces === 0) return i;
197
+ braces -= 1;
198
+ }
199
+ if (!/\s/.test(char)) previous = char;
200
+ continue;
201
+ }
202
+ const start = ++i;
203
+ while (i < source.length && source[i] !== char) {
204
+ i += source[i] === "\\" ? 2 : 1;
205
+ }
206
+ pushCopy(copy, source.slice(start, i));
207
+ previous = char;
208
+ }
209
+ return source.length;
210
+ }
211
+
212
+ /**
213
+ * Read a template literal's body from `from` — just past the opening backtick —
214
+ * pushing its literal chunks onto `copy` and handing each `${…}` back to
215
+ * {@link scanCode}, which is where a nested template is read as a template.
216
+ *
217
+ * @param source - The file's text.
218
+ * @param from - Index of the first character after the opening backtick.
219
+ * @param copy - Accumulator the authored chunks are pushed onto.
220
+ * @returns The index of the closing backtick.
221
+ */
222
+ function scanTemplate(source: string, from: number, copy: string[]): number {
223
+ let chunk = "";
224
+ let i = from;
225
+ while (i < source.length && source[i] !== "`") {
226
+ if (source[i] === "\\") {
227
+ chunk += source.slice(i, i + 2);
228
+ i += 2;
229
+ continue;
230
+ }
231
+ if (source[i] === "$" && source[i + 1] === "{") {
232
+ i = scanCode(source, i + 2, copy, true) + 1;
233
+ continue;
234
+ }
235
+ chunk += source[i];
236
+ i += 1;
237
+ }
238
+ pushCopy(copy, chunk);
239
+ return i;
240
+ }
241
+
242
+ /** Keep a literal unless it is empty or names a module rather than a user. */
243
+ function pushCopy(copy: string[], literal: string): void {
244
+ if (literal && !literal.startsWith(".") && !literal.startsWith("@")) {
245
+ copy.push(literal);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * `file: literal` for every authored literal matching `pattern`, so a failure
251
+ * names its offenders instead of only counting them.
252
+ *
253
+ * @param pattern - What kernel copy may not contain.
254
+ * @returns One entry per offending literal.
255
+ * @note Impure — reads every kernel source.
256
+ */
257
+ function findOffenders(pattern: RegExp): string[] {
258
+ const found: string[] = [];
259
+ for (const file of files) {
260
+ for (const literal of readCopy(readFileSync(file, "utf-8"))) {
261
+ if (pattern.test(literal)) {
262
+ found.push(`${relative(root, file)}: ${literal}`);
263
+ }
264
+ }
265
+ }
266
+ return found;
267
+ }
268
+
269
+ describe("the copy scanner (PROTECTED)", () => {
270
+ // The guard below is only as good as what the scanner can see, so the scanner
271
+ // is pinned directly on the shape that used to blind it: `src/kernel/packs/
272
+ // iri.ts` declares two regexes that each contain a `"`, and a leak between
273
+ // them was invisible. DERIVED, so the probe leaks whatever this distribution
274
+ // is called.
275
+ const leak = `${BIN_NAME} sources update`;
276
+
277
+ it("reads a literal sitting between two regexes that contain a quote", () => {
278
+ const source = [
279
+ 'const UNSAFE = /[<>"{}|\\\\^`\\s]/;',
280
+ `const LEAK = "${leak}";`,
281
+ 'const EMBEDDABLE = /^[A-Za-z][\\w+.-]*:\\/\\/[^<>"\\s]+$/;',
282
+ ].join("\n");
283
+ expect(readCopy(source)).toEqual([leak]);
284
+ });
285
+
286
+ it("reads the same literal with no regex around it", () => {
287
+ expect(readCopy(`const LEAK = "${leak}";`)).toEqual([leak]);
288
+ });
289
+
290
+ it("reads a dividing slash as division, not as an opening regex", () => {
291
+ // The division and the leak share a LINE: a scanner that read every `/` as
292
+ // a regex opener would skip to the next `/` or the newline, whichever comes
293
+ // first, and swallow the leak. On separate lines the regex skip stops at the
294
+ // newline before it reaches the literal, so both scanners agree and the case
295
+ // asserts nothing.
296
+ const source = `const rate = done / all; const LEAK = "${leak}";`;
297
+ expect(readCopy(source)).toEqual([leak]);
298
+ });
299
+
300
+ it("reads a template substitution as code, not as the end of the literal", () => {
301
+ // `packs/renderPack.ts` writes exactly this: a template whose substitution
302
+ // contains another template, itself containing an ESCAPED backtick. Read
303
+ // flat, the nested backtick closes the outer literal and every quote after
304
+ // it in the file pairs off by one — so the leak below, and the whole tail of
305
+ // any file shaped like this, became invisible.
306
+ const source = [
307
+ "const hint = shape.emptyRecovery",
308
+ " ? `${shape.emptyRecovery.message}${",
309
+ " shape.emptyRecovery.cli",
310
+ " ? ` Run \\`${PREFIX}${shape.emptyRecovery.cli}\\`.`",
311
+ ' : ""',
312
+ " }`",
313
+ " : DEFAULT_HINT;",
314
+ `const LEAK = "${leak}";`,
315
+ ].join("\n");
316
+ expect(readCopy(source)).toContain(leak);
317
+ });
318
+ });
319
+
320
+ describe("kernel copy (PROTECTED)", () => {
321
+ it("no kernel string names the distribution", () => {
322
+ // DERIVED from the shipped `name`, and ESCAPED: this guard hardcodes no
323
+ // name, and a fork called `my.cli` or `c++tool` neither over-matches nor
324
+ // dies compiling its own name as a pattern. The word boundaries keep
325
+ // identifiers like `PragmaError` out while still catching the forms the
326
+ // name actually leaks in — `pragma.config.ts`, `pragma-pack`, `pragma `.
327
+ const name = BIN_NAME.replace(/[-.*+?^${}()|[\]\\]/g, "\\$&");
328
+ expect(findOffenders(new RegExp(`\\b${name}\\b`, "i"))).toEqual([]);
329
+ });
330
+
331
+ it("no kernel string names a domain", () => {
332
+ expect(findOffenders(/design[- ]system/i)).toEqual([]);
333
+ });
334
+
335
+ it("no kernel string hardcodes a namespace the distribution declares", () => {
336
+ // The behavioural half of the same rule. A kernel module that spells out a
337
+ // declared namespace IRI has decided what the domain is, so a fork editing
338
+ // `pragma.conf.ts` changes the store and not the code reading it.
339
+ //
340
+ // DERIVED from the declaration, one pattern per namespace rather than one
341
+ // alternation: a distribution that declares none then yields no offenders,
342
+ // instead of an empty alternation that matches every literal.
343
+ const offenders = Object.values(conf.prefixes ?? {}).flatMap((namespace) =>
344
+ findOffenders(
345
+ new RegExp(namespace.replace(/[-.*+?^${}()|[\]\\]/g, "\\$&")),
346
+ ),
347
+ );
348
+ expect(offenders).toEqual([]);
349
+ });
350
+
351
+ it("no kernel string writes a term in a namespace the distribution declares", () => {
352
+ // The form the kernel actually leaked the domain in. Every coupling this
353
+ // guard exists to keep out — `ds:Prompt`, `ds:Tier`, `ds:name` — was a
354
+ // PREFIXED NAME, which contains no namespace IRI and so passes the rule
355
+ // above. DERIVED from the same declaration, by KEY this time.
356
+ //
357
+ // A leading non-word character keeps `https://…` and `foo.ds:x` out while
358
+ // still catching a bare term, one inside a query template, and one in
359
+ // user-facing copy.
360
+ const offenders = Object.keys(conf.prefixes ?? {}).flatMap((prefix) =>
361
+ findOffenders(
362
+ new RegExp(
363
+ `(^|[^A-Za-z0-9_])${prefix.replace(/[-.*+?^${}()|[\]\\]/g, "\\$&")}:[A-Za-z_]`,
364
+ ),
365
+ ),
366
+ );
367
+ expect(offenders).toEqual([]);
368
+ });
369
+ });
370
+
371
+ /**
372
+ * The grammar positions that quote a command a user is told to RUN: an example
373
+ * (`cmd:`), a recovery hint (`cli:`) and a doctor fix (`remedy:`).
374
+ */
375
+ const COMMAND_POSITIONS = ["cmd", "cli", "remedy"];
376
+
377
+ /**
378
+ * Every authored `.ts` under `src/capabilities/**` — no exemptions. The one
379
+ * file these rules used to exempt, `colophon/pragmaColophon.ts`, carried the
380
+ * distribution's own narrative in a capability source while the owner had not
381
+ * decided whether a fork inherits, rewrites, or declares it. The ruling
382
+ * (PRA-107): the narrative is DECLARED — it lives in `pragma.conf.ts` as
383
+ * `colophon: { markdown, summary }`, the file a fork edits — and the module is
384
+ * deleted. This filter-free list is the machine-checkable proof that the seam
385
+ * exists: a narrative reintroduced as a capability source falls under the two
386
+ * rules below like any other copy.
387
+ */
388
+ const capabilitySources = listSources(join(root, "capabilities"));
389
+
390
+ describe("capability commands (PROTECTED)", () => {
391
+ it("no `cmd:`, `cli:` or `remedy:` value is a bare literal", () => {
392
+ // The rule that reaches `src/capabilities/**` without exempting most of it.
393
+ // The wider "name nothing" rule cannot: that tree legitimately carries the
394
+ // domain in runtime copy and in wire identifiers. But a command string is
395
+ // never content — it is the installed binary's own name plus a grammar
396
+ // path, and both are derived. So this is stated as a POSITION rule, not a
397
+ // file rule, and carries no exemptions.
398
+ //
399
+ // Raw source, not `readCopy`: the position is what makes it an offence, and
400
+ // reading the text directly means this guard holds whatever the scanner can
401
+ // see. DERIVED and ESCAPED from the shipped `name`, as above.
402
+ //
403
+ // A BACKTICK is a quote here. Every swept site is a template literal, so a
404
+ // regression that keeps the backtick and drops the interpolation is the ONE
405
+ // shape this rule has to catch — and it was the one shape the class `["']`
406
+ // could not see. The opening quote is captured and the span runs to it, so
407
+ // a name inside backticks in a double-quoted sentence still counts while
408
+ // the match cannot run past the literal that opened it.
409
+ //
410
+ // The scan is over the whole file, not line by line, because the formatter
411
+ // wraps a long value onto the line after its key: that is how `doctor`'s
412
+ // ke-store remedy sat in a `remedy:` position, naming this distribution,
413
+ // under a guard that reported zero exemptions.
414
+ const name = BIN_NAME.replace(/[-.*+?^${}()|[\]\\]/g, "\\$&");
415
+ const pattern = new RegExp(
416
+ `(?:${COMMAND_POSITIONS.join("|")}):\\s*(["'\`])(?:(?!\\1)[^])*?\\b${name}\\b`,
417
+ "gi",
418
+ );
419
+ const offenders: string[] = [];
420
+ for (const file of capabilitySources) {
421
+ for (const match of readFileSync(file, "utf-8").matchAll(pattern)) {
422
+ offenders.push(`${relative(root, file)}: ${match[0].trim()}`);
423
+ }
424
+ }
425
+ expect(offenders).toEqual([]);
426
+ });
427
+
428
+ it("no command quoted in capability prose is a bare literal", () => {
429
+ // The other half of the same promise, and the half the position rule cannot
430
+ // reach: an empty-state hint, a recovery MESSAGE (the second argument to
431
+ // `cliRecovery`, beside a first argument the position rule already covers),
432
+ // a `sources status` headline, a wizard step's title. They reach the same
433
+ // user with the same instruction, and seven of them named this distribution
434
+ // while the rule above reported zero exemptions.
435
+ //
436
+ // A command in prose is a backticked one: `` run `pragma sources update` ``.
437
+ // `readCopy` reads a template's substitutions as code and its literal chunks
438
+ // as copy, so an interpolated `\`${BIN_NAME} …\`` leaves no chunk that
439
+ // begins with the name — which is exactly the difference this asserts.
440
+ // DERIVED and ESCAPED from the shipped `name`, as above.
441
+ const name = BIN_NAME.replace(/[-.*+?^${}()|[\]\\]/g, "\\$&");
442
+ const pattern = new RegExp(`\`${name}\\b`, "i");
443
+ const offenders: string[] = [];
444
+ for (const file of capabilitySources) {
445
+ for (const literal of readCopy(readFileSync(file, "utf-8"))) {
446
+ if (pattern.test(literal)) {
447
+ offenders.push(`${relative(root, file)}: ${literal}`);
448
+ }
449
+ }
450
+ }
451
+ expect(offenders).toEqual([]);
452
+ });
453
+ });
@@ -73,7 +73,7 @@ describe("serializeError", () => {
73
73
  it("includes suggestions, recovery, validOptions, and filters when present", () => {
74
74
  const error = PragmaError.notFound("block", "Buton", {
75
75
  suggestions: ["Button"],
76
- recovery: cliRecovery("pragma block list", "List blocks."),
76
+ recovery: cliRecovery("block list", "List blocks."),
77
77
  });
78
78
  expect(serializeError(error)).toEqual({
79
79
  code: "ENTITY_NOT_FOUND",
@@ -27,8 +27,9 @@ const ERROR_CODES = [
27
27
  // A runtime condition that is NOT a bug and NOT a usage mistake, so it must
28
28
  // NOT collapse to INTERNAL_ERROR's "please report this issue": a capability
29
29
  // genuinely unavailable in this build/environment (`create package` /
30
- // `create application` in the compiled binary, whose generator assets are not
31
- // embedded — `create component` is embedded and runs), OR an external command
30
+ // `create application` in the compiled binary, whose generators read their
31
+ // templates from disk rather than through the embedded manifest —
32
+ // `create component` reads through it and runs), OR an external command
32
33
  // that RAN and failed for a fixable environment reason (a denied global
33
34
  // `npm i -g`, a network/registry failure — see `shared/assertExecOk`). Both
34
35
  // carry an actionable recovery and map to the generic runtime exit 1.
@@ -8,7 +8,7 @@
8
8
 
9
9
  export { ERROR_CODES } from "./constants.js";
10
10
  export { PragmaError } from "./PragmaError.js";
11
- export { assertRecoveryCli, cliRecovery } from "./recovery.js";
11
+ export { cliRecovery } from "./recovery.js";
12
12
  export {
13
13
  renderErrorJson,
14
14
  renderErrorLlm,
@@ -1,60 +1,37 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { RECOVERY_CLI_PREFIX } from "../../constants.js";
3
- import { PragmaError } from "./PragmaError.js";
4
- import { assertRecoveryCli, cliRecovery } from "./recovery.js";
3
+ import { cliRecovery } from "./recovery.js";
5
4
 
6
5
  describe("recovery.cli invariant (D5)", () => {
7
- it("keeps the recovery prefix literal as `pragma `", () => {
8
- // Recovery hints quote the stable, documented command name via the fixed
9
- // `pragma ` prefix, so they never drift from the published command.
6
+ it("carries the shipped distribution's recovery prefix", () => {
7
+ // The prefix is DERIVED from the distribution's `name` (pragma.conf.ts), so
8
+ // recovery hints quote a command the installed binary actually answers to.
9
+ // This pins the value THIS distribution ships; `src/identity.test.ts` proves
10
+ // the derivation by varying the config.
10
11
  expect(RECOVERY_CLI_PREFIX).toBe("pragma ");
11
12
  });
12
13
 
13
- it("accepts a command carrying the canonical prefix", () => {
14
- expect(() => assertRecoveryCli("pragma info")).not.toThrow();
15
- expect(() => assertRecoveryCli("pragma config show")).not.toThrow();
16
- });
17
-
18
- it("rejects a command missing the prefix", () => {
19
- expect(() => assertRecoveryCli("info")).toThrow(/must start with/);
20
- expect(() => assertRecoveryCli("pragmatic info")).toThrow(
21
- /must start with/,
22
- );
23
- expect(() => assertRecoveryCli("pragmainfo")).toThrow(/must start with/);
24
- });
25
-
26
- it("builds a validated recovery whose cli carries the prefix", () => {
27
- const recovery = cliRecovery(
28
- "pragma config show",
29
- "See the resolved config.",
30
- );
31
- expect(recovery).toEqual({
14
+ it("prepends the distribution's prefix to the command it is given", () => {
15
+ // The caller passes the SUFFIX, so a hint naming the wrong binary is
16
+ // unwritable rather than merely asserted against. What replaced the deleted
17
+ // `assertRecoveryCli` is `kernel/copy.test.ts`'s position rule, which sees
18
+ // the raw `cli:` literals that never reached this function at all — the only
19
+ // place a wrong prefix was ever actually written.
20
+ expect(cliRecovery("config show", "See the resolved config.")).toEqual({
32
21
  cli: "pragma config show",
33
22
  message: "See the resolved config.",
34
23
  });
35
- expect(recovery.cli?.startsWith(RECOVERY_CLI_PREFIX)).toBe(true);
36
24
  });
37
25
 
38
- it("refuses to build a recovery from an unprefixed command", () => {
39
- expect(() => cliRecovery("config show", "nope")).toThrow(/must start with/);
40
- });
41
-
42
- it("every PragmaError.recovery.cli in the kernel carries the prefix", () => {
43
- // The factories that ship a recovery hint must honour the invariant. Only
44
- // internalError seeds one today (message-only, no cli), so assert the
45
- // guard holds for any cli that is present.
46
- const errors = [
47
- PragmaError.internalError("boom"),
48
- PragmaError.notFound("block", "Nope", {
49
- recovery: cliRecovery("pragma block list", "List blocks."),
26
+ it("carries an MCP tool alongside the command when one is given", () => {
27
+ expect(
28
+ cliRecovery("sources update", "Build the store.", {
29
+ tool: "sources_update",
50
30
  }),
51
- ];
52
- for (const error of errors) {
53
- if (error.recovery?.cli) {
54
- expect(() =>
55
- assertRecoveryCli(error.recovery.cli as string),
56
- ).not.toThrow();
57
- }
58
- }
31
+ ).toEqual({
32
+ cli: "pragma sources update",
33
+ message: "Build the store.",
34
+ mcp: { tool: "sources_update" },
35
+ });
59
36
  });
60
37
  });
@@ -1,49 +1,44 @@
1
1
  /**
2
2
  * The recovery-string invariant (D5).
3
3
  *
4
- * Every `recovery.cli` hint quotes the stable, documented command name, so it
5
- * must begin with the literal `pragma ` prefix, matching the installed `pragma`
6
- * binary. Recovery hints are authored through {@link cliRecovery}
7
- * so the prefix can never drift; {@link assertRecoveryCli} is the single guard,
8
- * exercised by the invariant test.
4
+ * A `recovery.cli` hint quotes a command the installed binary answers to, so it
5
+ * must begin with {@link RECOVERY_CLI_PREFIX} — the distribution's own `name`
6
+ * plus a space. {@link cliRecovery} takes the command SUFFIX and prepends the
7
+ * prefix itself, so a hint that names the wrong binary is unwritable rather
8
+ * than merely detected. That is why there is no assertion here: an assertion
9
+ * over a string the caller composed from the prefix cannot fail, and the hints
10
+ * that were actually wrong were raw `cli:` object literals that never reached
11
+ * this function at all. `kernel/copy.test.ts` covers those, at the position.
12
+ *
13
+ * `kernel/packs/schema.ts` holds a user-authored pack's `emptyRecovery.cli` to
14
+ * the same shape from the other side — it must NOT carry a prefix, because the
15
+ * consuming distribution's renderer supplies one.
9
16
  */
10
17
 
11
18
  import { RECOVERY_CLI_PREFIX } from "../../constants.js";
12
19
  import type { Recovery } from "./types.js";
13
20
 
14
21
  /**
15
- * Assert that a `recovery.cli` string starts with the canonical prefix.
16
- *
17
- * @param cli - The candidate CLI recovery command.
18
- * @throws Error when the command does not begin with `pragma `.
19
- */
20
- export function assertRecoveryCli(cli: string): void {
21
- if (!cli.startsWith(RECOVERY_CLI_PREFIX)) {
22
- throw new Error(
23
- `recovery.cli must start with "${RECOVERY_CLI_PREFIX}" (D5); got "${cli}"`,
24
- );
25
- }
26
- }
27
-
28
- /**
29
- * Build a {@link Recovery} whose `cli` command is guaranteed to carry the
30
- * canonical prefix, optionally pairing it with an MCP tool an agent can call.
22
+ * Build a {@link Recovery} whose `cli` command carries the distribution's own
23
+ * prefix, optionally pairing it with an MCP tool an agent can call.
31
24
  *
32
25
  * A recovery may need to speak to BOTH surfaces: the `cli` string guides a human,
33
26
  * while `mcp` names the tool an agent invokes (an agent cannot run a shell
34
27
  * command). The single human `message` is shared by both.
35
28
  *
36
- * @param cli - The full recovery command, including the `pragma ` prefix.
29
+ * @param command - The recovery command WITHOUT the binary name (`sources update`).
37
30
  * @param message - Human-readable guidance shown alongside the command.
38
31
  * @param mcp - The MCP tool (and optional params) an agent calls to recover.
39
- * @returns A validated recovery hint.
40
- * @throws Error when `cli` does not begin with `pragma `.
32
+ * @returns A recovery hint whose `cli` names this distribution's binary.
41
33
  */
42
34
  export function cliRecovery(
43
- cli: string,
35
+ command: string,
44
36
  message: string,
45
37
  mcp?: Recovery["mcp"],
46
38
  ): Recovery {
47
- assertRecoveryCli(cli);
48
- return { message, cli, ...(mcp ? { mcp } : {}) };
39
+ return {
40
+ message,
41
+ cli: `${RECOVERY_CLI_PREFIX}${command}`,
42
+ ...(mcp ? { mcp } : {}),
43
+ };
49
44
  }
@@ -14,14 +14,15 @@ type ErrorCode = (typeof ERROR_CODES)[number];
14
14
  /**
15
15
  * Structured recovery hint attached to a {@link PragmaError}.
16
16
  *
17
- * `cli` — when present — is the exact command the user can run to recover; it
18
- * always begins with the literal `pragma ` prefix (D5), enforced by
19
- * {@link assertRecoveryCli}.
17
+ * `cli` — when present — is the command as a user would type it: this
18
+ * distribution's own bin name (`RECOVERY_CLI_PREFIX`) plus a grammar path (D5).
19
+ * `cliRecovery` takes the path alone and prepends the prefix, so there is no
20
+ * assertion to make — see the docblock on `error/recovery.ts`.
20
21
  */
21
22
  interface Recovery {
22
23
  /** Human-readable recovery guidance. */
23
24
  message: string;
24
- /** CLI command the user can run to recover (starts with `pragma `). */
25
+ /** CLI command the user can run to recover, bin name included. */
25
26
  cli?: string;
26
27
  /** MCP tool invocation an agent can call to recover. */
27
28
  mcp?: { tool: string; params?: Record<string, unknown> };