@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
@@ -1,17 +1,36 @@
1
1
  /**
2
- * `ontology list` and `ontology show <prefix>` — schema (TBox) inspection.
2
+ * `ontology list` and `ontology lookup <prefix>` — schema (TBox) inspection.
3
3
  *
4
- * `list` groups loaded namespaces by prefix with class/property counts; `show`
4
+ * `list` groups loaded namespaces by prefix with class/property counts; `lookup`
5
5
  * renders a namespace's class hierarchy (with per-class instance counts read
6
- * from the pack index) and, with `--properties` or a `--class` focus, its
7
- * properties. `--full-uris` shows absolute IRIs; `--class <name>` narrows to one
8
- * class and the properties whose domain is that class.
6
+ * from the pack index) and, with `--properties`, a `--class` focus, or a
7
+ * `--detail` of `standard`+, its properties. `--full-uris` shows absolute IRIs;
8
+ * `--class <name>` narrows to one class and the properties whose domain is that
9
+ * class.
10
+ *
11
+ * `ontology show` is the DEPRECATED alias of `ontology lookup` (AV-228 B1): the
12
+ * verb was renamed to `lookup` for consistency with block/standard/tier/etc.
13
+ * (the ratified by-name read name), and `show` is kept callable — both share the
14
+ * same params, disclosure, and handler, and both stay blessed in the covenant.
15
+ *
16
+ * Disclosure (B5): rather than reinvent its own scheme, the by-name read declares
17
+ * the canonical disclosure ladder and honours the `--detail`/config `detail` the
18
+ * rest of the CLI reads AND the injected `detail` tool param the MCP projector
19
+ * derives from that declaration — a two-behaviour fold where `summary` is
20
+ * classes-only and `standard`/`detailed` add the properties section. The frozen
21
+ * `--properties` flag stays as an explicit override (it forces the section at
22
+ * any level), so the covenant is untouched.
9
23
  */
10
24
 
11
25
  import { PragmaError } from "../../kernel/error/PragmaError.js";
26
+ import { resolvePackDetail } from "../../kernel/packs/disclosure.js";
12
27
  import type { PragmaRuntime } from "../../kernel/runtime/types.js";
13
28
  import { asVerb } from "../../kernel/spec/asVerb.js";
14
- import type { VerbSpec } from "../../kernel/spec/types.js";
29
+ import type {
30
+ DisclosureSpec,
31
+ ParamSpec,
32
+ VerbSpec,
33
+ } from "../../kernel/spec/types.js";
15
34
  import {
16
35
  listNamespaces,
17
36
  localName,
@@ -73,98 +92,178 @@ const listVerb: VerbSpec<Record<string, unknown>, OntologySummary[]> = {
73
92
  },
74
93
  };
75
94
 
76
- const showVerb: VerbSpec<Record<string, unknown>, OntologyShowData> = {
77
- path: ["ontology", "show"],
78
- summary: "Show a namespace's classes (hierarchy + counts) and properties.",
79
- params: [
80
- {
81
- kind: "string",
82
- name: "prefix",
83
- doc: "The namespace prefix (ds) or full URI.",
84
- positional: true,
85
- required: true,
86
- complete: { kind: "names", source: { from: "prefixes" } },
87
- },
88
- {
89
- kind: "boolean",
90
- name: "properties",
91
- doc: "Include the properties section.",
92
- },
93
- {
94
- kind: "boolean",
95
- name: "fullUris",
96
- doc: "Show full IRIs instead of prefixed.",
97
- },
98
- {
99
- kind: "string",
100
- name: "class",
101
- doc: "Focus on one class and its properties.",
102
- },
103
- ],
104
- output: { formatters: ontologyShowFormatters },
105
- examples: [
106
- { cmd: "pragma ontology show ds" },
107
- { cmd: "pragma ontology show ds --properties" },
108
- { cmd: "pragma ontology show ds --class Component" },
109
- ],
110
- capability: {
111
- needsStore: true,
112
- mutates: false,
113
- mcp: {
114
- expose: true,
115
- annotations: { readOnlyHint: true, openWorldHint: false },
116
- },
95
+ /**
96
+ * The canonical disclosure the by-name read folds onto (B5). Declared on both
97
+ * the primary {@link ontologyLookupVerb} and its deprecated {@link
98
+ * ontologyShowVerb} alias — so the MCP projector injects the `detail` enum tool
99
+ * param and its per-call `withDetail` seeding light up (symmetric with
100
+ * `block`/`standard`, which an inline resolve left dark over MCP) — AND read by
101
+ * `run` to resolve the effective level, so the advertised param and the fetched
102
+ * level share ONE source. The levels are the full canonical ladder; the fold is
103
+ * two-behaviour (`summary` = classes only, `standard`/`detailed` add properties).
104
+ */
105
+ const BY_NAME_DISCLOSURE: DisclosureSpec = {
106
+ levels: ["summary", "standard", "detailed"],
107
+ default: "summary",
108
+ };
109
+
110
+ /** The params shared by `ontology lookup` and its deprecated `ontology show` alias. */
111
+ const BY_NAME_PARAMS: ParamSpec[] = [
112
+ {
113
+ kind: "string",
114
+ name: "prefix",
115
+ doc: "The namespace prefix (ds) or full URI.",
116
+ positional: true,
117
+ required: true,
118
+ complete: { kind: "names", source: { from: "prefixes" } },
117
119
  },
118
- run: async (params: Record<string, unknown>, rt: PragmaRuntime) => {
119
- const session = await rt.store.get();
120
- const { prefix, namespace } = resolvePrefix(
121
- String(params.prefix),
122
- session.prefixes,
123
- );
124
- const focus = typeof params.class === "string" ? params.class : undefined;
125
- const wantProperties = params.properties === true || focus !== undefined;
120
+ {
121
+ kind: "boolean",
122
+ name: "properties",
123
+ doc: "Include the properties section (also implied by --detail standard or higher).",
124
+ },
125
+ {
126
+ kind: "boolean",
127
+ name: "fullUris",
128
+ doc: "Show full IRIs instead of prefixed.",
129
+ },
130
+ {
131
+ kind: "string",
132
+ name: "class",
133
+ doc: "Focus on one class and its properties.",
134
+ },
135
+ ];
136
+
137
+ /** The by-name read capability — a storeless-free, read-only MCP tool. */
138
+ const BY_NAME_CAPABILITY = {
139
+ needsStore: true,
140
+ mutates: false,
141
+ mcp: {
142
+ expose: true as const,
143
+ annotations: { readOnlyHint: true, openWorldHint: false },
144
+ },
145
+ };
126
146
 
127
- let classes = await queryClasses(
128
- rt,
129
- namespace,
130
- session.index.instanceCountByType,
147
+ /**
148
+ * The by-name read handler shared by `ontology lookup` and `ontology show`.
149
+ *
150
+ * @param params - The coerced param bag (`prefix`, `properties`, `fullUris`, `class`).
151
+ * @param rt - The runtime (store + disclosure precedence).
152
+ * @returns The namespace's classes (and, per disclosure, properties) payload.
153
+ */
154
+ async function runByName(
155
+ params: Record<string, unknown>,
156
+ rt: PragmaRuntime,
157
+ ): Promise<OntologyShowData> {
158
+ const session = await rt.store.get();
159
+ const { prefix, namespace } = resolvePrefix(
160
+ String(params.prefix),
161
+ session.prefixes,
162
+ );
163
+ const focus = typeof params.class === "string" ? params.class : undefined;
164
+ // Fold ontology's bespoke disclosure onto the canonical `--detail` (B5),
165
+ // resolving through the SAME {@link BY_NAME_DISCLOSURE} the verb declares (so
166
+ // the MCP `detail` param and its withDetail seeding resolve identically, not
167
+ // just the CLI `--detail` flag) and the SAME precedence packs use (flag >
168
+ // explicit config > default). The frozen `--properties` flag and a `--class`
169
+ // focus still force the section, so honouring the level is covenant-safe.
170
+ const level = await resolvePackDetail(rt, BY_NAME_DISCLOSURE);
171
+ const wantProperties =
172
+ params.properties === true || focus !== undefined || level !== "summary";
173
+
174
+ let classes = await queryClasses(
175
+ rt,
176
+ namespace,
177
+ session.index.instanceCountByType,
178
+ );
179
+ let properties = wantProperties ? await queryProperties(rt, namespace) : [];
180
+
181
+ if (focus !== undefined) {
182
+ const needle = focus.toLowerCase();
183
+ classes = classes.filter(
184
+ (c) =>
185
+ localName(c.uri).toLowerCase() === needle ||
186
+ c.label.toLowerCase() === needle,
187
+ );
188
+ const focusUris = new Set(classes.map((c) => c.uri));
189
+ properties = properties.filter(
190
+ (p) => p.domain !== undefined && focusUris.has(p.domain),
131
191
  );
132
- let properties = wantProperties ? await queryProperties(rt, namespace) : [];
192
+ }
133
193
 
134
- if (focus !== undefined) {
135
- const needle = focus.toLowerCase();
136
- classes = classes.filter(
137
- (c) =>
138
- localName(c.uri).toLowerCase() === needle ||
139
- c.label.toLowerCase() === needle,
140
- );
141
- const focusUris = new Set(classes.map((c) => c.uri));
142
- properties = properties.filter(
143
- (p) => p.domain !== undefined && focusUris.has(p.domain),
144
- );
145
- }
194
+ if (classes.length === 0 && properties.length === 0) {
195
+ throw PragmaError.notFound("ontology", String(params.prefix), {
196
+ recovery: {
197
+ message: "List loaded ontologies.",
198
+ cli: "pragma ontology list",
199
+ mcp: { tool: "ontology_list" },
200
+ },
201
+ });
202
+ }
146
203
 
147
- if (classes.length === 0 && properties.length === 0) {
148
- throw PragmaError.notFound("ontology", String(params.prefix), {
149
- recovery: {
150
- message: "List loaded ontologies.",
151
- cli: "pragma ontology list",
152
- mcp: { tool: "ontology_list" },
153
- },
154
- });
155
- }
204
+ return {
205
+ prefix,
206
+ namespace,
207
+ classes,
208
+ properties,
209
+ fullUris: params.fullUris === true,
210
+ ...(focus !== undefined ? { focus } : {}),
211
+ };
212
+ }
156
213
 
157
- return {
158
- prefix,
159
- namespace,
160
- classes,
161
- properties,
162
- fullUris: params.fullUris === true,
163
- ...(focus !== undefined ? { focus } : {}),
164
- };
165
- },
166
- };
214
+ /**
215
+ * Build one by-name read verb (`lookup` primary or `show` deprecated alias) —
216
+ * both share {@link BY_NAME_PARAMS}, {@link BY_NAME_DISCLOSURE}, the formatters,
217
+ * and {@link runByName}; only the path label, summary, doc, and examples differ.
218
+ */
219
+ function byNameVerb(
220
+ verb: "lookup" | "show",
221
+ summary: string,
222
+ examples: VerbSpec["examples"],
223
+ doc?: string,
224
+ ): VerbSpec<Record<string, unknown>, OntologyShowData> {
225
+ return {
226
+ path: ["ontology", verb],
227
+ summary,
228
+ ...(doc !== undefined ? { doc } : {}),
229
+ params: BY_NAME_PARAMS,
230
+ output: { formatters: ontologyShowFormatters },
231
+ examples,
232
+ disclosure: BY_NAME_DISCLOSURE,
233
+ capability: BY_NAME_CAPABILITY,
234
+ run: runByName,
235
+ };
236
+ }
167
237
 
168
- /** The `ontology` verbs (`list`, `show`). */
238
+ /** The `ontology` verbs (`list`, `lookup`, and the deprecated `show` alias). */
169
239
  export const ontologyListVerb = asVerb(listVerb);
170
- export const ontologyShowVerb = asVerb(showVerb);
240
+
241
+ /** `ontology lookup <prefix>` — the primary by-name schema read. */
242
+ export const ontologyLookupVerb = asVerb(
243
+ byNameVerb(
244
+ "lookup",
245
+ "Look up a namespace's classes (hierarchy + counts) and properties.",
246
+ [
247
+ { cmd: "pragma ontology lookup ds" },
248
+ { cmd: "pragma ontology lookup ds --properties" },
249
+ { cmd: "pragma ontology lookup ds --class Component" },
250
+ ],
251
+ ),
252
+ );
253
+
254
+ /**
255
+ * `ontology show <prefix>` — DEPRECATED alias of {@link ontologyLookupVerb}
256
+ * (AV-228 B1). Kept callable and blessed in the covenant; shares the same
257
+ * handler, so it behaves identically. New callers should use `ontology lookup`.
258
+ */
259
+ export const ontologyShowVerb = asVerb(
260
+ byNameVerb(
261
+ "show",
262
+ "(deprecated: use `ontology lookup`) Show a namespace's classes (hierarchy + counts) and properties.",
263
+ [
264
+ { cmd: "pragma ontology lookup ds", note: "prefer `lookup`" },
265
+ { cmd: "pragma ontology show ds", note: "deprecated alias" },
266
+ ],
267
+ "Deprecated alias of `ontology lookup` — retained for compatibility. Prefer `ontology lookup <prefix>`.",
268
+ ),
269
+ );
@@ -0,0 +1,94 @@
1
+ import chalk from "chalk";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ promptListFormatters,
5
+ promptLookupFormatters,
6
+ } from "./prompt.render.js";
7
+ import type { PromptListData, PromptLookupData } from "./types.js";
8
+
9
+ /** Run `body` with stdout's `isTTY` forced to `value`, then restore it. */
10
+ function withStdoutTty(value: boolean | undefined, body: () => void): void {
11
+ const stream = process.stdout as { isTTY?: boolean };
12
+ const saved = stream.isTTY;
13
+ stream.isTTY = value;
14
+ try {
15
+ body();
16
+ } finally {
17
+ stream.isTTY = saved;
18
+ }
19
+ }
20
+
21
+ const LOOKUP: PromptLookupData = {
22
+ name: "build-a-block",
23
+ description: "Scaffold a block",
24
+ arguments: [
25
+ { name: "blockName", required: true, description: "The block name" },
26
+ { name: "tier", required: false },
27
+ ],
28
+ body: "Create {{blockName}} in {{tier}}.",
29
+ };
30
+
31
+ const LIST: PromptListData = {
32
+ prompts: [
33
+ { name: "build-a-block", description: "Scaffold a block", arguments: [] },
34
+ ],
35
+ };
36
+
37
+ describe("promptLookupFormatters", () => {
38
+ it("underlines the plain name with an ═ rule (B7)", () => {
39
+ const lines = promptLookupFormatters.plain(LOOKUP).split("\n");
40
+ expect(lines.at(0)).toBe("build-a-block");
41
+ expect(lines.at(1)).toBe("═".repeat(24));
42
+ expect(lines.at(2)).toBe("");
43
+ expect(lines).toContain(" args: blockName (required), tier");
44
+ });
45
+
46
+ it("heads the llm entity at H2 with H3 sub-sections (B2)", () => {
47
+ const lines = promptLookupFormatters.llm(LOOKUP).split("\n");
48
+ expect(lines.at(0)).toBe("## build-a-block");
49
+ expect(lines).toContain("### Arguments");
50
+ expect(lines).toContain("### Template");
51
+ // The old H1 title / H2 sub-sections are gone.
52
+ expect(lines).not.toContain("# build-a-block");
53
+ expect(lines).not.toContain("## Arguments");
54
+ expect(lines).not.toContain("## Template");
55
+ });
56
+ });
57
+
58
+ describe("promptListFormatters", () => {
59
+ it("heads the llm list at H2, never H1 (B2)", () => {
60
+ const out = promptListFormatters.llm(LIST);
61
+ expect(out.startsWith("## Prompts")).toBe(true);
62
+ expect(out).not.toMatch(/^# Prompts/m);
63
+ });
64
+ });
65
+
66
+ describe("promptLookupFormatters — shared style seam (B7)", () => {
67
+ const prevLevel = chalk.level;
68
+ afterEach(() => {
69
+ chalk.level = prevLevel;
70
+ });
71
+
72
+ it("styles the plain title on a color-capable TTY", () => {
73
+ chalk.level = 1;
74
+ withStdoutTty(true, () => {
75
+ // The title/rule/label now route through the same style seam block and
76
+ // skill lookups use — previously a prompt title stayed unstyled on a TTY.
77
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: asserting the ESC byte is the point
78
+ expect(promptLookupFormatters.plain(LOOKUP)).toMatch(/\x1b\[/);
79
+ });
80
+ });
81
+
82
+ it("keeps piped plain output byte-stable off a TTY", () => {
83
+ chalk.level = 3;
84
+ withStdoutTty(undefined, () => {
85
+ const out = promptLookupFormatters.plain(LOOKUP);
86
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: asserting NO ESC byte survives
87
+ expect(out).not.toMatch(/\x1b\[/);
88
+ const lines = out.split("\n");
89
+ expect(lines.at(0)).toBe("build-a-block");
90
+ expect(lines.at(1)).toBe("═".repeat(24));
91
+ expect(lines).toContain(" args: blockName (required), tier");
92
+ });
93
+ });
94
+ });
@@ -2,6 +2,7 @@
2
2
  * Formatters for the `prompt` content noun — plain, llm, json.
3
3
  */
4
4
 
5
+ import { defaultStyle } from "../../kernel/render/style.js";
5
6
  import type { Formatters } from "../../kernel/spec/types.js";
6
7
  import type {
7
8
  PromptArgument,
@@ -29,7 +30,8 @@ export const promptListFormatters: Formatters<PromptListData> = {
29
30
  },
30
31
  llm(data) {
31
32
  if (data.prompts.length === 0) return "_No prompts in the store._";
32
- const lines = ["# Prompts"];
33
+ // H2 for a content read — consistent with the shared list renderer (B2).
34
+ const lines = ["## Prompts"];
33
35
  for (const prompt of data.prompts) {
34
36
  lines.push(
35
37
  `- \`${prompt.name}\` — ${prompt.description ?? ""}`.trimEnd(),
@@ -45,23 +47,38 @@ export const promptListFormatters: Formatters<PromptListData> = {
45
47
 
46
48
  export const promptLookupFormatters: Formatters<PromptLookupData> = {
47
49
  plain(data) {
48
- const lines = [data.name];
50
+ // Full delegation to `renderLookupPlain` is infeasible: the description line
51
+ // carries no field label and the template body is appended raw, whereas the
52
+ // shared renderer only emits `label: value` fields + indented sections. So
53
+ // route the TITLE + rule + field label through the SAME style seam it uses
54
+ // (bold title, dim rule, cyan label) — closing the TTY drift where a prompt
55
+ // title stayed unstyled — and keep the bespoke body inline. Off a TTY the
56
+ // styler is inert, so piped output stays byte-stable (B7).
57
+ const style = defaultStyle();
58
+ const rule = "═".repeat(Math.max(data.name.length, 24));
59
+ const lines = [style.bold(data.name), style.dim(rule), ""];
49
60
  if (data.description) lines.push(` ${data.description}`);
50
- lines.push(` args: ${argTokens(data.arguments)}`, "", data.body);
61
+ lines.push(
62
+ ` ${style.cyan("args")}: ${argTokens(data.arguments)}`,
63
+ "",
64
+ data.body,
65
+ );
51
66
  return lines.join("\n");
52
67
  },
53
68
  llm(data) {
54
- const lines = [`# ${data.name}`];
69
+ // H2 entity title with H3 sub-sections — the hierarchy the shared
70
+ // `renderLookupLlm` uses (`## title` / `### section`) (B2).
71
+ const lines = [`## ${data.name}`];
55
72
  if (data.description) lines.push(data.description);
56
73
  if (data.arguments.length > 0) {
57
- lines.push("", "## Arguments");
74
+ lines.push("", "### Arguments");
58
75
  for (const arg of data.arguments) {
59
76
  lines.push(
60
77
  `- \`${arg.name}\`${arg.required ? " (required)" : ""}${arg.description ? ` — ${arg.description}` : ""}`,
61
78
  );
62
79
  }
63
80
  }
64
- lines.push("", "## Template", data.body);
81
+ lines.push("", "### Template", data.body);
65
82
  return lines.join("\n");
66
83
  },
67
84
  json(data) {
@@ -1,6 +1,10 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
5
  import { listPromptSummaries } from "../../kernel/project/mcp/prompts/source.js";
3
6
  import { bootRuntime } from "../../kernel/runtime/boot.js";
7
+ import { writeLock } from "../../kernel/runtime/lock.js";
4
8
  import type { GlobalFlags } from "../../kernel/spec/types.js";
5
9
  import { CANONICAL_TTL } from "../../testing/fixtures/graph/canonical.js";
6
10
  import {
@@ -117,3 +121,60 @@ describe("prompt — storeless native listing (PROTECTED)", () => {
117
121
  expect(runtime.store.booted).toBe(false);
118
122
  });
119
123
  });
124
+
125
+ /**
126
+ * D1/D4 — cold-store convergence. The `prompt_list` tool is store-backed
127
+ * (`needsStore`), so a cold store throws STORE_UNAVAILABLE through its pre-check;
128
+ * native `prompts/list`/`prompts/get` must present the SAME diagnostic rather
129
+ * than silently listing a stale index (D1) or reporting a bogus "not found"
130
+ * (D4). A lock pointing at an evicted pack is the cold "configured but unbuilt"
131
+ * store (the pack is absent from the XDG-isolated cache).
132
+ */
133
+ describe("prompt — cold-store convergence, tool + native agree (D1/D4)", () => {
134
+ let cold: McpHarness;
135
+
136
+ beforeAll(async () => {
137
+ const cwd = mkdtempSync(join(tmpdir(), "pragma-prompt-cold-"));
138
+ writeLock(cwd, { version: 1, contentHash: "b".repeat(64), packs: [] });
139
+ cold = await projectMcp(capabilities, cwd);
140
+ });
141
+ afterAll(async () => {
142
+ await cold.cleanup();
143
+ });
144
+
145
+ it("prompt_list (tool) and prompts/list (native) BOTH surface STORE_UNAVAILABLE", async () => {
146
+ const tool = await cold.callTool("prompt_list");
147
+ expect(tool.ok).toBe(false);
148
+ expect((tool.error as { code: string }).code).toBe("STORE_UNAVAILABLE");
149
+ // The native list no longer diverges into a silent [] / stale index listing.
150
+ await expect(cold.listPrompts()).rejects.toThrow(/store unavailable/i);
151
+ });
152
+
153
+ it("both surfaces carry the same `sources_update` recovery cold", async () => {
154
+ const tool = await cold.callTool("prompt_list");
155
+ const toolRecovery = (
156
+ tool.error as { recovery?: { mcp?: { tool?: string } } }
157
+ ).recovery;
158
+ expect(toolRecovery?.mcp?.tool).toBe("sources_update");
159
+
160
+ // Native carries the recovery in the JSON-RPC error `data` — never dropped.
161
+ const nativeErr = await cold.listPrompts().then(
162
+ () => undefined,
163
+ (error: unknown) => error,
164
+ );
165
+ const data = (
166
+ nativeErr as { data?: { recovery?: { mcp?: { tool?: string } } } }
167
+ ).data;
168
+ expect(data?.recovery?.mcp?.tool).toBe("sources_update");
169
+ });
170
+
171
+ it("native prompts/get reports STORE_UNAVAILABLE cold, not a bogus 'not found' (D4)", async () => {
172
+ const error = await cold.getPrompt("build-a-block").then(
173
+ () => undefined,
174
+ (caught: unknown) => caught as Error,
175
+ );
176
+ expect(error?.message).toMatch(/store unavailable/i);
177
+ // The genuine-miss message must NOT mask the cold store.
178
+ expect(error?.message).not.toMatch(/not found/i);
179
+ });
180
+ });
@@ -0,0 +1,32 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { emitReference } from "../kernel/spec/emitReference.js";
5
+ import { capabilities } from "./index.js";
6
+
7
+ /** Read a committed reference page exactly as a consumer (or lychee) would. */
8
+ function readCommitted(relPath: string): string {
9
+ return readFileSync(
10
+ fileURLToPath(new URL(`../../docs/reference/${relPath}`, import.meta.url)),
11
+ "utf-8",
12
+ );
13
+ }
14
+
15
+ describe("reference docs drift-guard — emitReference == committed (PROTECTED)", () => {
16
+ const emitted = emitReference(capabilities);
17
+
18
+ it("every generated page is byte-identical to the committed file", () => {
19
+ for (const [relPath, expected] of emitted) {
20
+ expect(readCommitted(relPath)).toBe(expected);
21
+ }
22
+ });
23
+
24
+ it("the committed file set matches the generated key set (no orphans, none missing)", () => {
25
+ const committed = readdirSync(
26
+ fileURLToPath(new URL("../../docs/reference/", import.meta.url)),
27
+ )
28
+ .filter((name) => name.endsWith(".md"))
29
+ .sort();
30
+ expect(committed).toEqual([...emitted.keys()].sort());
31
+ });
32
+ });
@@ -17,7 +17,9 @@
17
17
  */
18
18
 
19
19
  import { readPackIndex } from "../../kernel/completion/entitySource.js";
20
+ import { asPragmaError } from "../../kernel/error/fromTaskError.js";
20
21
  import { PragmaError } from "../../kernel/error/PragmaError.js";
22
+ import { mcpErrorFrom } from "../../kernel/project/mcp/mcpError.js";
21
23
  import type { PackIndex } from "../../kernel/runtime/graphpack/types.js";
22
24
  import { readEntity } from "../../kernel/runtime/readEntity.js";
23
25
  import type { McpResourceProvider } from "../../kernel/spec/types.js";
@@ -88,7 +90,18 @@ export function buildResourceList(
88
90
  const boxRank = (e: typeof a) => (e.box === "tbox" ? 0 : 1);
89
91
  return boxRank(a) - boxRank(b) || a.name.localeCompare(b.name);
90
92
  });
91
- return ordered.map((entity) => {
93
+ // Dedup by resource URI: an OWL-punned subject (a class/property IRI ALSO
94
+ // asserted as a domain individual) is indexed as TWO entities — a tbox and an
95
+ // abox facet — that compact to the SAME `pragma:<uri>`. Mapping both would
96
+ // list one URI twice (A8). The sort above puts the tbox facet first, so
97
+ // keeping the first occurrence per URI surfaces the schema facet (its higher
98
+ // priority + instance count) and drops the punned duplicate.
99
+ const seen = new Set<string>();
100
+ const resources: ListedResource[] = [];
101
+ for (const entity of ordered) {
102
+ const uri = `pragma:${entity.prefixed ?? entity.name}`;
103
+ if (seen.has(uri)) continue;
104
+ seen.add(uri);
92
105
  const isTbox = entity.box === "tbox";
93
106
  // Port of the old shell's `_meta` taxonomy: `pragma/box` + a priority that
94
107
  // ranks schema (classes/properties) above individuals, so an agent browsing
@@ -104,8 +117,8 @@ export function buildResourceList(
104
117
  if (typeof instanceCount === "number") {
105
118
  meta["pragma/instanceCount"] = instanceCount;
106
119
  }
107
- return {
108
- uri: `pragma:${entity.prefixed ?? entity.name}`,
120
+ resources.push({
121
+ uri,
109
122
  name: entity.label || entity.name,
110
123
  ...(entity.description ? { description: entity.description } : {}),
111
124
  mimeType: "application/json" as const,
@@ -114,8 +127,9 @@ export function buildResourceList(
114
127
  priority: isTbox ? CLASS_PRIORITY : INDIVIDUAL_PRIORITY,
115
128
  },
116
129
  _meta: meta,
117
- };
118
- });
130
+ });
131
+ }
132
+ return resources;
119
133
  }
120
134
 
121
135
  /**
@@ -180,6 +194,9 @@ export const resourceProvider: McpResourceProvider = {
180
194
  const { ResourceTemplate } = await import(
181
195
  "@modelcontextprotocol/sdk/server/mcp.js"
182
196
  );
197
+ const { McpError, ErrorCode } = await import(
198
+ "@modelcontextprotocol/sdk/types.js"
199
+ );
183
200
  const template = new ResourceTemplate(URI_TEMPLATE, {
184
201
  list: async () => ({
185
202
  resources: buildResourceList(readPackIndex(runtime.cwd)),
@@ -215,15 +232,12 @@ export const resourceProvider: McpResourceProvider = {
215
232
  ],
216
233
  };
217
234
  } catch (error) {
218
- const message =
219
- error instanceof PragmaError
220
- ? JSON.stringify({ code: error.code, message: error.message })
221
- : String(error);
222
- return {
223
- contents: [
224
- { uri: url.href, mimeType: "text/plain", text: message },
225
- ],
226
- };
235
+ // Surface the failure as a JSON-RPC error (the resource-read analogue
236
+ // of a tool result's `isError`), preserving the machine code AND the
237
+ // recovery in `data` — never swallow it into `text/plain` content an
238
+ // agent reads as a successful entity (which dropped the recovery and
239
+ // masked a cold store as though the entity itself were malformed).
240
+ throw mcpErrorFrom(asPragmaError(error), { McpError, ErrorCode });
227
241
  }
228
242
  },
229
243
  );