@canonical/pragma-cli 0.32.0 → 0.33.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 (140) hide show
  1. package/README.md +72 -0
  2. package/dist/pragma +0 -0
  3. package/package.json +14 -13
  4. package/src/bin.ts +2 -2
  5. package/src/capabilities/block/blockList.test.ts +79 -0
  6. package/src/capabilities/block/blockList.verb.ts +18 -5
  7. package/src/capabilities/block/pack.ts +12 -1
  8. package/src/capabilities/block/parity.test.ts +9 -2
  9. package/src/capabilities/capabilities/capabilities.test.ts +10 -1
  10. package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
  11. package/src/capabilities/capabilities/catalog.ts +7 -3
  12. package/src/capabilities/capabilities/hints.ts +8 -17
  13. package/src/capabilities/capabilities/types.ts +2 -0
  14. package/src/capabilities/colophon/colophon.render.test.ts +31 -4
  15. package/src/capabilities/colophon/colophon.render.ts +13 -4
  16. package/src/capabilities/colophon/colophon.test.ts +3 -3
  17. package/src/capabilities/colophon/colophon.verb.ts +4 -1
  18. package/src/capabilities/colophon/markdownTerminal.ts +33 -14
  19. package/src/capabilities/colophon/pragmaColophon.ts +4 -4
  20. package/src/capabilities/colophon/types.ts +1 -1
  21. package/src/capabilities/config/fields.ts +21 -92
  22. package/src/capabilities/config/set.test.ts +6 -6
  23. package/src/capabilities/config/set.verb.ts +5 -5
  24. package/src/capabilities/config/show.render.test.ts +65 -0
  25. package/src/capabilities/config/show.render.ts +56 -10
  26. package/src/capabilities/config/show.verb.ts +5 -5
  27. package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
  28. package/src/capabilities/create/byteEquality.test.ts +7 -4
  29. package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
  30. package/src/capabilities/create/create.test.ts +32 -3
  31. package/src/capabilities/create/create.verb.ts +69 -11
  32. package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
  33. package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
  34. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
  35. package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
  36. package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
  37. package/src/capabilities/doctor/doctor.render.test.ts +170 -0
  38. package/src/capabilities/doctor/doctor.render.ts +116 -34
  39. package/src/capabilities/doctor/doctor.test.ts +31 -1
  40. package/src/capabilities/doctor/runChecks.ts +28 -4
  41. package/src/capabilities/doctor/types.ts +15 -0
  42. package/src/capabilities/ontology/index.ts +10 -3
  43. package/src/capabilities/ontology/ontology.test.ts +157 -5
  44. package/src/capabilities/ontology/verbs.ts +192 -93
  45. package/src/capabilities/prompt/prompt.render.test.ts +94 -0
  46. package/src/capabilities/prompt/prompt.render.ts +23 -6
  47. package/src/capabilities/prompt/prompt.test.ts +61 -0
  48. package/src/capabilities/reference.test.ts +32 -0
  49. package/src/capabilities/resources/provider.ts +28 -14
  50. package/src/capabilities/resources/resources.test.ts +99 -0
  51. package/src/capabilities/scopeTypeSync.test.ts +37 -0
  52. package/src/capabilities/setup/index.ts +7 -1
  53. package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
  54. package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
  55. package/src/capabilities/setup/operations/setupLsp.ts +56 -12
  56. package/src/capabilities/setup/operations/setupMcp.ts +216 -39
  57. package/src/capabilities/setup/operations/setupSkills.ts +7 -1
  58. package/src/capabilities/setup/setup.render.test.ts +109 -0
  59. package/src/capabilities/setup/setup.render.ts +61 -14
  60. package/src/capabilities/setup/setup.test.ts +352 -1
  61. package/src/capabilities/setup/setup.verb.ts +57 -5
  62. package/src/capabilities/setup/types.ts +59 -2
  63. package/src/capabilities/shared/bands.ts +16 -0
  64. package/src/capabilities/sources/collectStatus.ts +19 -4
  65. package/src/capabilities/sources/runUpdate.ts +58 -1
  66. package/src/capabilities/sources/sources.test.ts +187 -1
  67. package/src/capabilities/sources/status.render.test.ts +65 -0
  68. package/src/capabilities/sources/status.render.ts +50 -23
  69. package/src/capabilities/surface.test.ts +34 -9
  70. package/src/capabilities/tier/lookup.render.test.ts +72 -0
  71. package/src/capabilities/tier/lookup.render.ts +33 -8
  72. package/src/capabilities/tier/lookup.test.ts +15 -0
  73. package/src/capabilities/tier/runLookup.ts +28 -0
  74. package/src/constants.test.ts +1 -1
  75. package/src/constants.ts +2 -2
  76. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
  77. package/src/kernel/completion/bashDrive.test.ts +3 -3
  78. package/src/kernel/completion/complete.test.ts +3 -5
  79. package/src/kernel/completion/entitySource.ts +14 -6
  80. package/src/kernel/completion/model.test.ts +2 -2
  81. package/src/kernel/completion/model.ts +0 -1
  82. package/src/kernel/completion/parse.test.ts +2 -4
  83. package/src/kernel/error/PragmaError.test.ts +4 -4
  84. package/src/kernel/error/renderError.ts +1 -1
  85. package/src/kernel/packs/compile.ts +1 -1
  86. package/src/kernel/project/cli/buildProgram.test.ts +51 -7
  87. package/src/kernel/project/cli/buildProgram.ts +11 -0
  88. package/src/kernel/project/cli/globalFlags.test.ts +12 -4
  89. package/src/kernel/project/cli/globalFlags.ts +14 -14
  90. package/src/kernel/project/cli/rootHelp.ts +4 -2
  91. package/src/kernel/project/mcp/instructions.ts +1 -1
  92. package/src/kernel/project/mcp/mcpError.ts +52 -0
  93. package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
  94. package/src/kernel/project/mcp/prompts/provider.ts +36 -6
  95. package/src/kernel/render/formatters.ts +5 -4
  96. package/src/kernel/render/renderers.test.ts +34 -0
  97. package/src/kernel/render/renderers.ts +25 -7
  98. package/src/kernel/render/style.test.ts +31 -0
  99. package/src/kernel/render/style.ts +74 -0
  100. package/src/kernel/runtime/graphpack/build.ts +28 -0
  101. package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
  102. package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
  103. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
  104. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
  105. package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
  106. package/src/kernel/runtime/graphpack/read.ts +28 -13
  107. package/src/kernel/runtime/graphpack/types.ts +14 -0
  108. package/src/kernel/runtime/loadSession.ts +4 -12
  109. package/src/kernel/runtime/refs/resolve.ts +63 -3
  110. package/src/kernel/runtime/storeReadiness.ts +61 -0
  111. package/src/kernel/runtime/types.ts +1 -1
  112. package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
  113. package/src/kernel/spec/emitReference.test.ts +196 -0
  114. package/src/kernel/spec/emitReference.ts +491 -0
  115. package/src/kernel/spec/emitSurface.ts +4 -2
  116. package/src/kernel/spec/index.ts +2 -0
  117. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  118. package/src/testing/PARITY_GAPS.ts +1 -1
  119. package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
  120. package/src/testing/behavioral/completion.test.ts +2 -2
  121. package/src/testing/behavioral/disclosure.test.ts +12 -0
  122. package/src/testing/behavioral/docExamples.test.ts +283 -0
  123. package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
  124. package/src/testing/behavioral/journeys.cli.test.ts +3 -1
  125. package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
  126. package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
  127. package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
  128. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
  129. package/src/testing/behavioral/mcpSurface.test.ts +1 -1
  130. package/src/testing/behavioral/staleCommands.test.ts +76 -0
  131. package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
  132. package/src/testing/eval/cases/assembly.ts +3 -3
  133. package/src/testing/eval/cases/effectful.ts +6 -3
  134. package/src/testing/eval/cases/readNouns.ts +4 -1
  135. package/src/testing/fixtures/fixtureCapability.ts +156 -0
  136. package/src/testing/fixtures/graph/defaultPack.ts +178 -0
  137. package/src/testing/fixtures/graph/owlExport.ts +95 -0
  138. package/src/testing/helpers/extractSnippets.ts +29 -0
  139. package/src/testing/helpers/golden.ts +1 -1
  140. package/src/capabilities/config/field.test.ts +0 -188
@@ -137,6 +137,8 @@ ex:one a ex:Widget ; rdfs:label "One" .
137
137
  };
138
138
  expect(envelope.data.lockPresent).toBe(true);
139
139
  expect(envelope.data.cached).toBe(true);
140
- expect(envelope.data.entityCount).toBe(2);
140
+ // Distinct abox subjects: the one individual (ex:one). The ex:Widget class
141
+ // is tbox schema, not an entity — the raw entity count would double this (A1).
142
+ expect(envelope.data.entityCount).toBe(1);
141
143
  });
142
144
  });
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ fixtureModule,
4
+ fixtureReferenceModule,
5
+ } from "../../testing/fixtures/fixtureCapability.js";
6
+ import { ERROR_CODES } from "../error/constants.js";
7
+ import { emitReference } from "./emitReference.js";
8
+
9
+ /** The four pages every reference doc set carries. */
10
+ const PAGES = ["index.md", "commands.md", "tools.md", "errors.md"];
11
+
12
+ describe("emitReference", () => {
13
+ const docs = emitReference([fixtureReferenceModule]);
14
+ const index = docs.get("index.md") ?? "";
15
+ const commands = docs.get("commands.md") ?? "";
16
+ const tools = docs.get("tools.md") ?? "";
17
+ const errors = docs.get("errors.md") ?? "";
18
+
19
+ it("emits exactly the four reference pages", () => {
20
+ expect([...docs.keys()].sort()).toEqual([...PAGES].sort());
21
+ });
22
+
23
+ it("ends every page in exactly one trailing newline", () => {
24
+ for (const content of docs.values()) {
25
+ expect(content.endsWith("\n")).toBe(true);
26
+ expect(content.endsWith("\n\n")).toBe(false);
27
+ }
28
+ });
29
+
30
+ it("is deterministic — the same catalog yields byte-identical pages", () => {
31
+ const again = emitReference([fixtureReferenceModule]);
32
+ for (const page of PAGES) {
33
+ expect(again.get(page)).toBe(docs.get(page));
34
+ }
35
+ });
36
+
37
+ describe("index.md", () => {
38
+ it("reports counts derived from the live grammar", () => {
39
+ // gizmo + ping = 2 nouns; local/scan/tidy/wipe/ping = 5 commands (hidden
40
+ // dropped); scan/tidy/wipe/ping = 4 tools (local withheld); 1 resource
41
+ // template (the module's `mcpResources` surface).
42
+ expect(index).toContain("**2** command nouns");
43
+ expect(index).toContain("**5** CLI commands");
44
+ expect(index).toContain("**4** MCP tools");
45
+ expect(index).toContain("**1** resource template(s)");
46
+ });
47
+
48
+ it("links the three sibling pages with relative paths", () => {
49
+ expect(index).toContain("(./commands.md)");
50
+ expect(index).toContain("(./tools.md)");
51
+ expect(index).toContain("(./errors.md)");
52
+ });
53
+ });
54
+
55
+ describe("commands.md", () => {
56
+ it("groups verbs under noun headings, sorted, hidden excluded", () => {
57
+ expect(commands).toContain("## gizmo");
58
+ expect(commands).toContain("## ping");
59
+ expect(commands.indexOf("## gizmo")).toBeLessThan(
60
+ commands.indexOf("## ping"),
61
+ );
62
+ expect(commands).not.toContain("hidden");
63
+ });
64
+
65
+ it("renders a read verb's usage, args, flags, attributes, and examples", () => {
66
+ expect(commands).toContain("### pragma gizmo scan");
67
+ expect(commands).toContain("pragma gizmo scan [targets...] [options]");
68
+ // Optional variadic positional token.
69
+ expect(commands).toContain("| `[targets...]` | no |");
70
+ // Enum flag: escaped pipe token, enum values, and default in the description.
71
+ expect(commands).toContain("| `--mode` | `<fast\\|slow>` |");
72
+ expect(commands).toContain("(one of: fast, slow) (default: fast)");
73
+ // Each flag kind renders its value placeholder.
74
+ expect(commands).toContain("| `--limit` | `<number>` |");
75
+ expect(commands).toContain("| `--label` | `<string>` |");
76
+ expect(commands).toContain("| `--include` | `<value...>` |");
77
+ // A bare boolean switch has no value placeholder.
78
+ expect(commands).toContain("| `--deep` | — |");
79
+ expect(commands).toContain("- Store: storeless.");
80
+ expect(commands).toContain("- MCP: exposed as the `gizmo_scan` tool.");
81
+ // A note becomes a trailing comment; a noteless example stays bare.
82
+ expect(commands).toContain("pragma gizmo scan # scan everything");
83
+ expect(commands).toContain("pragma gizmo scan alpha --mode slow");
84
+ });
85
+
86
+ it("marks a store-backed destructive mutation's CLI attributes", () => {
87
+ expect(commands).toContain("### pragma gizmo wipe");
88
+ expect(commands).toContain("pragma gizmo wipe <path>");
89
+ expect(commands).toContain(
90
+ "- Store: reads the local store (`pragma sources update` builds it).",
91
+ );
92
+ expect(commands).toContain("- Mutation: plan-first");
93
+ });
94
+
95
+ it("marks a visible verb withheld from MCP", () => {
96
+ expect(commands).toContain("### pragma gizmo local");
97
+ expect(commands).toContain("- MCP: not exposed (CLI-only).");
98
+ });
99
+
100
+ it("renders a self-verb with no sub-command in its invocation", () => {
101
+ expect(commands).toContain("### pragma ping");
102
+ expect(commands).toContain("```\npragma ping\n```");
103
+ });
104
+
105
+ it("falls back to the summary when a verb has no doc", () => {
106
+ // `ping` has no `doc`; its summary is the section body.
107
+ const pingSection = commands.slice(commands.indexOf("### pragma ping"));
108
+ expect(pingSection).toContain("Ping the gizmo host.");
109
+ });
110
+ });
111
+
112
+ describe("tools.md", () => {
113
+ it("adds the disclosure `detail` param and comma-joined enum types", () => {
114
+ expect(tools).toContain("### gizmo_scan");
115
+ expect(tools).toContain("| `mode` | enum(fast, slow) | no |");
116
+ expect(tools).toContain("| `include` | string[] | no |");
117
+ expect(tools).toContain(
118
+ "| `detail` | enum(summary, detailed) | no | Progressive-disclosure level (default summary). |",
119
+ );
120
+ });
121
+
122
+ it("adds confirm + cwd to a mutating tool and flags it destructive", () => {
123
+ const wipe = tools.slice(tools.indexOf("### gizmo_wipe"));
124
+ expect(wipe).toContain("| `confirm` | boolean | no |");
125
+ expect(wipe).toContain("| `cwd` | string | no |");
126
+ expect(tools).toContain(
127
+ "Mutation — plan-first (set `confirm: true` to apply). Marked destructive.",
128
+ );
129
+ });
130
+
131
+ it("marks a non-destructive mutating tool as such", () => {
132
+ const tidy = tools.slice(tools.indexOf("### gizmo_tidy"));
133
+ expect(tidy).toContain("| `confirm` | boolean | no |");
134
+ // The `destructive: false` branch of `formatToolAnnotations`.
135
+ expect(tools).toContain(
136
+ "Mutation — plan-first (set `confirm: true` to apply). Non-destructive.",
137
+ );
138
+ });
139
+
140
+ it("omits verbs withheld from MCP", () => {
141
+ expect(tools).not.toContain("gizmo_local");
142
+ });
143
+
144
+ it("marks a no-param tool as taking no input", () => {
145
+ const ping = tools.slice(tools.indexOf("### ping"));
146
+ expect(ping.slice(0, 200)).toContain("_No input parameters._");
147
+ });
148
+
149
+ it("renders every non-tool surface bullet the module declares", () => {
150
+ expect(tools).toContain("## Non-tool surface");
151
+ // The module declares an `mcpResources` template surface (its `surface.
152
+ // templates` id) and an `mcpPrompts` native surface, so all three bullets
153
+ // render — exercising both optional branches of `renderNonToolSurface`.
154
+ expect(tools).toContain("- **Resources**: `gizmo:{+uri}`");
155
+ expect(tools).toContain("- **Prompts**:");
156
+ expect(tools).toContain("- **Instructions**:");
157
+ });
158
+ });
159
+
160
+ describe("errors.md", () => {
161
+ it("renders the exit-code table and the full error catalog", () => {
162
+ expect(errors).toContain("| `0` | success |");
163
+ expect(errors).toContain("| `3` | store unavailable |");
164
+ expect(errors).toContain("| `ENTITY_NOT_FOUND` |");
165
+ expect(errors).toContain("| `UNSUPPORTED` |");
166
+ });
167
+
168
+ it("lists every code in the closed ERROR_CODES tuple (exhaustive)", () => {
169
+ // Non-circular exhaustiveness: iterate the source-of-truth tuple and
170
+ // assert each code's `catalog` row is present, so a code added to the
171
+ // kernel without a catalog entry fails here (not just the 2 spot-checks).
172
+ for (const code of ERROR_CODES) {
173
+ expect(errors, `errors.md is missing \`${code}\``).toContain(
174
+ `\`${code}\``,
175
+ );
176
+ }
177
+ });
178
+
179
+ it("embeds the response envelope as a JSON block", () => {
180
+ expect(errors).toContain("```json");
181
+ expect(errors).toContain('"ok": true');
182
+ expect(errors).toContain('"code": "<ErrorCode>"');
183
+ });
184
+ });
185
+ });
186
+
187
+ describe("emitReference — MCP annotation branches", () => {
188
+ it("renders a destructive fixture tool and a native-prompt-free surface", () => {
189
+ // `fixtureModule.make` is exposed, mutating, and destructive — the same
190
+ // `Marked destructive.` branch, driven from the emitSurface fixture.
191
+ const tools = emitReference([fixtureModule]).get("tools.md") ?? "";
192
+ expect(tools).toContain("### widget_make");
193
+ expect(tools).toContain("Marked destructive.");
194
+ expect(tools).not.toContain("- **Prompts**:");
195
+ });
196
+ });
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Reference emitter: project the live capability grammar into the committed
3
+ * Markdown reference under `docs/reference/`.
4
+ *
5
+ * A sibling of `emitSurface` — same input (the `CapabilityModule[]` catalog),
6
+ * same discipline: pure, zod-free, and reads ONLY the grammar (never a run
7
+ * body). Where `emitSurface` freezes the machine covenant, this renders the
8
+ * human/agent-facing pages the build hook writes back to disk and the
9
+ * drift-guard (`capabilities/reference.test.ts`) pins byte-for-byte.
10
+ *
11
+ * DETERMINISM is the contract: stable sort, params in declared order, no
12
+ * version, no dates, no `Date.now()` — the same catalog always yields the same
13
+ * bytes, so a rebuild is a no-op and the drift-guard stays green. `doc` strings
14
+ * are kept verbatim (the project-layer `stripToolCallExample` is deliberately
15
+ * NOT imported — this is a kernel-layer emitter).
16
+ */
17
+
18
+ import { ERROR_CODES } from "../error/constants.js";
19
+ import {
20
+ FIXED_SURFACE,
21
+ kebabCase,
22
+ toolName,
23
+ verbLabel,
24
+ } from "./emitSurface.js";
25
+ import type {
26
+ CapabilityModule,
27
+ Example,
28
+ ParamSpec,
29
+ VerbSpec,
30
+ } from "./types.js";
31
+
32
+ /** The reference doc set: relative path under `docs/reference/` → file content. */
33
+ export type ReferenceDocs = ReadonlyMap<string, string>;
34
+
35
+ /**
36
+ * The closed error-code → one-line meaning map. Typed as an exhaustive
37
+ * `Record` over the `ERROR_CODES` tuple, so a code added to the tuple without a
38
+ * description here is a COMPILE error — the single authoring point stays in
39
+ * lockstep with the error kernel. Colocated (single-use) rather than in
40
+ * `constants.ts`: it is only read by `renderErrorsPage`.
41
+ */
42
+ const ERROR_CODE_DESCRIPTIONS: Record<(typeof ERROR_CODES)[number], string> = {
43
+ ENTITY_NOT_FOUND: "A named entity (block, standard, token, …) was not found.",
44
+ EMPTY_RESULTS:
45
+ "A query or listing resolved to nothing under the active scope.",
46
+ INVALID_INPUT: "An argument was malformed, out of range, or the wrong shape.",
47
+ AMBIGUOUS_INPUT:
48
+ "A name resolved to several entities (reserved; not yet raised).",
49
+ UNKNOWN_VERB: "The command noun or verb is not recognized.",
50
+ STORE_UNAVAILABLE: "The local store could not be reached or is not built.",
51
+ CONFIG_ERROR: "The layered configuration could not be resolved.",
52
+ INTERNAL_ERROR: "An unexpected failure — please report it.",
53
+ UNSUPPORTED: "A capability is unavailable in this build or environment.",
54
+ };
55
+
56
+ /** Escape a value for a Markdown table cell (pipes, then newlines to spaces). */
57
+ function escapeCell(text: string): string {
58
+ return text.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
59
+ }
60
+
61
+ /** Flatten every non-hidden verb, sorted by noun ascending then verb-label ascending. */
62
+ function collectDocVerbs(
63
+ modules: readonly CapabilityModule[],
64
+ ): readonly VerbSpec[] {
65
+ const verbs: VerbSpec[] = [];
66
+ for (const module of modules) {
67
+ for (const verb of module.verbs) {
68
+ if (verb.hidden) continue;
69
+ verbs.push(verb);
70
+ }
71
+ }
72
+ return verbs.sort(compareDocVerbs);
73
+ }
74
+
75
+ /** Order two verbs by noun, then by verb-label — a total, locale-free order. */
76
+ function compareDocVerbs(a: VerbSpec, b: VerbSpec): number {
77
+ const nounA = a.path[0];
78
+ const nounB = b.path[0];
79
+ if (nounA !== nounB) return nounA < nounB ? -1 : 1;
80
+ const labelA = verbLabel(a.path);
81
+ const labelB = verbLabel(b.path);
82
+ /* v8 ignore next -- unreachable: no registered grammar produces a `[noun,
83
+ noun]` path (a verb equal to its own noun), so within one noun every
84
+ verb-label is unique and this equal tie-break never fires. */
85
+ if (labelA === labelB) return 0;
86
+ return labelA < labelB ? -1 : 1;
87
+ }
88
+
89
+ /** The full CLI invocation head for a verb (`pragma <noun>` or `pragma <noun> <verb>`). */
90
+ function formatInvocation(verb: VerbSpec): string {
91
+ const sub = verb.path[1];
92
+ return sub === undefined
93
+ ? `pragma ${verb.path[0]}`
94
+ : `pragma ${verb.path[0]} ${sub}`;
95
+ }
96
+
97
+ /** Format a positional param as its usage token (`<name>`/`[name]`, `...` when variadic). */
98
+ function formatPositionalToken(param: ParamSpec): string {
99
+ const variadic = param.kind === "string[]" ? "..." : "";
100
+ return param.required
101
+ ? `<${param.name}${variadic}>`
102
+ : `[${param.name}${variadic}]`;
103
+ }
104
+
105
+ /** The value placeholder a flag takes (empty for a bare boolean switch). */
106
+ function formatFlagValue(param: ParamSpec): string {
107
+ switch (param.kind) {
108
+ case "boolean":
109
+ return "";
110
+ case "enum":
111
+ return `<${param.values.join("|")}>`;
112
+ case "number":
113
+ return "<number>";
114
+ case "string":
115
+ return "<string>";
116
+ case "string[]":
117
+ return "<value...>";
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The tool-schema type label a param projects to (mirrors `buildZodSchema`).
123
+ * Enum values are comma-joined (never pipe-joined) so the label is safe inside
124
+ * a Markdown table cell without escaping.
125
+ */
126
+ function formatParamType(param: ParamSpec): string {
127
+ switch (param.kind) {
128
+ case "enum":
129
+ return `enum(${param.values.join(", ")})`;
130
+ case "string[]":
131
+ return "string[]";
132
+ default:
133
+ return param.kind;
134
+ }
135
+ }
136
+
137
+ /** Render a default value for prose (strings verbatim, everything else stringified). */
138
+ function formatDefault(value: unknown): string {
139
+ return typeof value === "string" ? value : String(value);
140
+ }
141
+
142
+ /** A param's description cell: its doc, plus enum values and any default. */
143
+ function describeParam(param: ParamSpec): string {
144
+ let description = escapeCell(param.doc);
145
+ if (param.kind === "enum") {
146
+ description += ` (one of: ${param.values.join(", ")})`;
147
+ }
148
+ if ("default" in param && param.default !== undefined) {
149
+ description += ` (default: ${escapeCell(formatDefault(param.default))})`;
150
+ }
151
+ return description;
152
+ }
153
+
154
+ /** The single-line usage string: invocation + positionals + `[options]`. */
155
+ function formatUsage(verb: VerbSpec): string {
156
+ const positionals = verb.params.filter((p) => p.positional);
157
+ const flags = verb.params.filter((p) => !p.positional);
158
+ const segments = [formatInvocation(verb)];
159
+ for (const positional of positionals) {
160
+ segments.push(formatPositionalToken(positional));
161
+ }
162
+ if (flags.length > 0) segments.push("[options]");
163
+ return segments.join(" ");
164
+ }
165
+
166
+ /** The Args table for a verb's positionals, or `""` when it has none. */
167
+ function formatArgsTable(params: readonly ParamSpec[]): string {
168
+ const positionals = params.filter((p) => p.positional);
169
+ if (positionals.length === 0) return "";
170
+ const rows = ["| Argument | Required | Description |", "| --- | --- | --- |"];
171
+ for (const param of positionals) {
172
+ const required = param.required ? "yes" : "no";
173
+ rows.push(
174
+ `| \`${formatPositionalToken(param)}\` | ${required} | ${describeParam(param)} |`,
175
+ );
176
+ }
177
+ return `**Arguments**\n\n${rows.join("\n")}`;
178
+ }
179
+
180
+ /** The Flags table for a verb's non-positional params, or `""` when it has none. */
181
+ function formatFlagsTable(params: readonly ParamSpec[]): string {
182
+ const flags = params.filter((p) => !p.positional);
183
+ if (flags.length === 0) return "";
184
+ const rows = ["| Flag | Value | Description |", "| --- | --- | --- |"];
185
+ for (const param of flags) {
186
+ // The `<a|b|c>` enum token carries pipes — escape them so the code-span cell
187
+ // does not split the table column.
188
+ const value = formatFlagValue(param);
189
+ const valueCell = value === "" ? "—" : `\`${escapeCell(value)}\``;
190
+ rows.push(
191
+ `| \`--${kebabCase(param.name)}\` | ${valueCell} | ${describeParam(param)} |`,
192
+ );
193
+ }
194
+ return `**Flags**\n\n${rows.join("\n")}`;
195
+ }
196
+
197
+ /** The Examples block for a verb, or `""` when none are declared. */
198
+ function formatExamples(examples: readonly Example[] | undefined): string {
199
+ if (!examples || examples.length === 0) return "";
200
+ const lines = ["```bash"];
201
+ for (const example of examples) {
202
+ lines.push(
203
+ example.note ? `${example.cmd} # ${example.note}` : example.cmd,
204
+ );
205
+ }
206
+ lines.push("```");
207
+ return `**Examples**\n\n${lines.join("\n")}`;
208
+ }
209
+
210
+ /** The Store / Mutation / MCP attribute bullets for one verb. */
211
+ function formatVerbAttributes(verb: VerbSpec): string {
212
+ const bullets = [
213
+ verb.capability.needsStore
214
+ ? "- Store: reads the local store (`pragma sources update` builds it)."
215
+ : "- Store: storeless.",
216
+ ];
217
+ if (verb.capability.mutates) {
218
+ bullets.push(
219
+ "- Mutation: plan-first — preview with `--dry-run`, apply with `--yes`, reverse with `--undo`.",
220
+ );
221
+ }
222
+ bullets.push(
223
+ verb.capability.mcp.expose
224
+ ? `- MCP: exposed as the \`${toolName(verb.path)}\` tool.`
225
+ : "- MCP: not exposed (CLI-only).",
226
+ );
227
+ return bullets.join("\n");
228
+ }
229
+
230
+ /** Join page blocks with blank lines, dropping empties, and end in ONE newline. */
231
+ function assemblePage(blocks: readonly string[]): string {
232
+ return `${blocks.filter((block) => block.length > 0).join("\n\n")}\n`;
233
+ }
234
+
235
+ /** Render one verb's `commands.md` section (heading through examples). */
236
+ function renderCommandSection(verb: VerbSpec): string {
237
+ const blocks = [
238
+ `### ${formatInvocation(verb)}`,
239
+ verb.summary,
240
+ verb.doc ?? "",
241
+ `\`\`\`\n${formatUsage(verb)}\n\`\`\``,
242
+ formatArgsTable(verb.params),
243
+ formatFlagsTable(verb.params),
244
+ formatVerbAttributes(verb),
245
+ formatExamples(verb.examples),
246
+ ];
247
+ return blocks.filter((block) => block.length > 0).join("\n\n");
248
+ }
249
+
250
+ /** Render the CLI command reference, grouped by noun. */
251
+ function renderCommandsPage(verbs: readonly VerbSpec[]): string {
252
+ const blocks = [
253
+ "# CLI command reference",
254
+ "Every `pragma` command, grouped by noun. Generated from the live capability grammar — do not edit by hand.",
255
+ "Global flags apply to every command: `--format <plain|llm|json>` (auto-detected — the llm/condensed-Markdown form turns on when output is piped), `--verbose`, and `--detail <summary|standard|detailed>`.",
256
+ ];
257
+ let currentNoun = "";
258
+ for (const verb of verbs) {
259
+ const noun = verb.path[0];
260
+ if (noun !== currentNoun) {
261
+ currentNoun = noun;
262
+ blocks.push(`## ${noun}`);
263
+ }
264
+ blocks.push(renderCommandSection(verb));
265
+ }
266
+ return assemblePage(blocks);
267
+ }
268
+
269
+ /** One MCP tool input row: name, projected type, requiredness, description. */
270
+ interface ToolParamRow {
271
+ readonly name: string;
272
+ readonly type: string;
273
+ readonly required: boolean;
274
+ readonly description: string;
275
+ }
276
+
277
+ /** Collect a tool's input rows: the verb's params plus the injected MCP-only ones. */
278
+ function collectToolParams(verb: VerbSpec): readonly ToolParamRow[] {
279
+ const rows: ToolParamRow[] = [];
280
+ for (const param of verb.params) {
281
+ rows.push({
282
+ name: param.name,
283
+ type: formatParamType(param),
284
+ required: param.required === true,
285
+ description: describeParam(param),
286
+ });
287
+ }
288
+ if (verb.disclosure) {
289
+ rows.push({
290
+ name: "detail",
291
+ type: `enum(${verb.disclosure.levels.join(", ")})`,
292
+ required: false,
293
+ description: `Progressive-disclosure level (default ${verb.disclosure.default}).`,
294
+ });
295
+ }
296
+ if (verb.capability.mutates) {
297
+ rows.push({
298
+ name: "confirm",
299
+ type: "boolean",
300
+ required: false,
301
+ description:
302
+ "Set true to execute; otherwise a plan is returned (default false).",
303
+ });
304
+ rows.push({
305
+ name: "cwd",
306
+ type: "string",
307
+ required: false,
308
+ description:
309
+ "Absolute project directory to write into; defaults to the server's working directory.",
310
+ });
311
+ }
312
+ return rows;
313
+ }
314
+
315
+ /** The input-params table for a tool, or a storeless-note when it takes none. */
316
+ function formatToolParams(verb: VerbSpec): string {
317
+ const rows = collectToolParams(verb);
318
+ if (rows.length === 0) return "_No input parameters._";
319
+ const table = [
320
+ "| Parameter | Type | Required | Description |",
321
+ "| --- | --- | --- | --- |",
322
+ ];
323
+ for (const row of rows) {
324
+ const required = row.required ? "yes" : "no";
325
+ table.push(
326
+ `| \`${row.name}\` | ${row.type} | ${required} | ${row.description} |`,
327
+ );
328
+ }
329
+ return table.join("\n");
330
+ }
331
+
332
+ /** The read-only / mutation + destructive annotation line for a tool. */
333
+ function formatToolAnnotations(verb: VerbSpec): string {
334
+ if (!verb.capability.mutates) return "Read-only.";
335
+ const destructive =
336
+ verb.capability.destructive === true
337
+ ? " Marked destructive."
338
+ : verb.capability.destructive === false
339
+ ? " Non-destructive."
340
+ : "";
341
+ return `Mutation — plan-first (set \`confirm: true\` to apply).${destructive}`;
342
+ }
343
+
344
+ /** Render one tool's `tools.md` section. */
345
+ function renderToolSection(verb: VerbSpec): string {
346
+ const blocks = [
347
+ `### ${toolName(verb.path)}`,
348
+ verb.doc ?? verb.summary,
349
+ formatToolAnnotations(verb),
350
+ "**Input**",
351
+ formatToolParams(verb),
352
+ ];
353
+ return blocks.join("\n\n");
354
+ }
355
+
356
+ /** The non-tool MCP surface (resources + native prompts) declared by the modules. */
357
+ function collectMcpExtras(modules: readonly CapabilityModule[]): {
358
+ readonly resources: readonly string[];
359
+ readonly prompts: boolean;
360
+ } {
361
+ const resources: string[] = [];
362
+ let prompts = false;
363
+ for (const module of modules) {
364
+ if (module.mcpResources?.surface) {
365
+ resources.push(...module.mcpResources.surface.templates);
366
+ }
367
+ if (module.mcpPrompts) prompts = true;
368
+ }
369
+ // Default lexicographic sort — deterministic, and the same idiom `emitSurface`
370
+ // uses for its `resources`/`tools` lists.
371
+ return { resources: [...resources].sort(), prompts };
372
+ }
373
+
374
+ /** Render the non-tool MCP surface section for `tools.md`. */
375
+ function renderNonToolSurface(modules: readonly CapabilityModule[]): string {
376
+ const { resources, prompts } = collectMcpExtras(modules);
377
+ const bullets: string[] = [];
378
+ if (resources.length > 0) {
379
+ bullets.push(
380
+ `- **Resources**: ${resources.map((r) => `\`${r}\``).join(", ")} — entity reads addressed by URI (listing and autocomplete are storeless over the pack index).`,
381
+ );
382
+ }
383
+ if (prompts) {
384
+ bullets.push(
385
+ "- **Prompts**: the design system's workflow templates are offered natively over `prompts/list` and `prompts/get`, and as the `prompt_list` / `prompt_lookup` content tools.",
386
+ );
387
+ }
388
+ bullets.push(
389
+ "- **Instructions**: the server always sends handshake instructions describing the conventions and the discovery sequence.",
390
+ );
391
+ return `## Non-tool surface\n\n${bullets.join("\n")}`;
392
+ }
393
+
394
+ /** Render the MCP tool reference plus the non-tool surface. */
395
+ function renderToolsPage(
396
+ verbs: readonly VerbSpec[],
397
+ modules: readonly CapabilityModule[],
398
+ ): string {
399
+ const tools = verbs.filter((verb) => verb.capability.mcp.expose);
400
+ const blocks = [
401
+ "# MCP tool reference",
402
+ "Every tool the pragma MCP server exposes, plus its non-tool surface. Generated from the live capability grammar — do not edit by hand.",
403
+ "Mutating tools are plan-first: called without `confirm: true` they return the plan they WOULD apply; called with `confirm: true` they execute. A mutating tool also accepts an optional absolute `cwd`.",
404
+ ];
405
+ for (const verb of tools) {
406
+ blocks.push(renderToolSection(verb));
407
+ }
408
+ blocks.push(renderNonToolSurface(modules));
409
+ return assemblePage(blocks);
410
+ }
411
+
412
+ /** Render the exit-code table, the response envelope, and the error catalog. */
413
+ function renderErrorsPage(): string {
414
+ const exitRows = ["| Exit code | Meaning |", "| --- | --- |"];
415
+ // Integer-like keys ("0".."3") are iterated in ascending numeric order by the
416
+ // ECMAScript own-key ordering rule, so `Object.entries` is already sorted.
417
+ for (const [code, meaning] of Object.entries(FIXED_SURFACE.exitCodes)) {
418
+ exitRows.push(`| \`${code}\` | ${meaning} |`);
419
+ }
420
+
421
+ const catalogRows = ["| Code | Meaning |", "| --- | --- |"];
422
+ for (const code of ERROR_CODES) {
423
+ const meaning = ERROR_CODE_DESCRIPTIONS[code];
424
+ /* v8 ignore next 3 -- exhaustiveness: ERROR_CODE_DESCRIPTIONS is a Record
425
+ over the ERROR_CODES tuple, so every code is present by construction. */
426
+ if (meaning === undefined) {
427
+ throw new Error(`missing error-code description for ${code}`);
428
+ }
429
+ catalogRows.push(`| \`${code}\` | ${meaning} |`);
430
+ }
431
+
432
+ return assemblePage([
433
+ "# Errors & exit codes",
434
+ "Every command returns a `{ ok, ... }` envelope and maps its failure to one of four process exit codes. Generated from the live error kernel — do not edit by hand.",
435
+ "## Exit codes",
436
+ exitRows.join("\n"),
437
+ "## Response envelope",
438
+ `\`\`\`json\n${JSON.stringify(FIXED_SURFACE.envelope, null, 2)}\n\`\`\``,
439
+ "## Error codes",
440
+ "Every `error.code` in a failure envelope is one of the following:",
441
+ catalogRows.join("\n"),
442
+ ]);
443
+ }
444
+
445
+ /** Render the reference index: overview, derived counts, and links. */
446
+ function renderIndexPage(
447
+ verbs: readonly VerbSpec[],
448
+ modules: readonly CapabilityModule[],
449
+ ): string {
450
+ const nouns = new Set(verbs.map((verb) => verb.path[0]));
451
+ const toolCount = verbs.filter((verb) => verb.capability.mcp.expose).length;
452
+ return assemblePage([
453
+ "# pragma reference",
454
+ "Machine-generated reference for the `pragma` CLI and MCP server, projected from the live capability grammar. Every page here is regenerated by the build and pinned by a drift-guard test, so it can never fall out of step with the code.",
455
+ "## At a glance",
456
+ [
457
+ `- **${nouns.size}** command nouns`,
458
+ `- **${verbs.length}** CLI commands`,
459
+ `- **${toolCount}** MCP tools`,
460
+ `- **${collectMcpExtras(modules).resources.length}** resource template(s)`,
461
+ ].join("\n"),
462
+ "## Pages",
463
+ [
464
+ "- [CLI command reference](./commands.md) — every `pragma <noun> <verb>`, its arguments, flags, and examples.",
465
+ "- [MCP tool reference](./tools.md) — every exposed tool, its input schema, and the non-tool surface.",
466
+ "- [Errors & exit codes](./errors.md) — the exit-code table, response envelope, and error catalog.",
467
+ ].join("\n"),
468
+ ]);
469
+ }
470
+
471
+ /**
472
+ * Emit the Markdown reference doc set for a set of capability modules.
473
+ *
474
+ * Pure and deterministic: the same catalog always yields byte-identical pages
475
+ * (stable sort, declared param order, no version or timestamps).
476
+ *
477
+ * @param modules - The capability modules to project (hidden verbs excluded).
478
+ * @returns A map of `docs/reference/`-relative path → Markdown content, each
479
+ * ending in exactly one trailing newline.
480
+ */
481
+ export function emitReference(
482
+ modules: readonly CapabilityModule[],
483
+ ): ReferenceDocs {
484
+ const verbs = collectDocVerbs(modules);
485
+ return new Map<string, string>([
486
+ ["index.md", renderIndexPage(verbs, modules)],
487
+ ["commands.md", renderCommandsPage(verbs)],
488
+ ["tools.md", renderToolsPage(verbs, modules)],
489
+ ["errors.md", renderErrorsPage()],
490
+ ]);
491
+ }