@thinkingsage/kanon 0.8.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 (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Rosetta Stone — Pure Canonical Parser and Serializer
3
+ *
4
+ * Parses in-memory SourceDocuments into a validated KnowledgeArtifact candidate.
5
+ * Serializes a validated KnowledgeArtifact into a deterministic TranslationPlan.
6
+ * Returns structured TranslationDiagnostics for grammar/schema errors instead of
7
+ * throwing exceptions.
8
+ *
9
+ * CONSTRAINTS:
10
+ * - NO filesystem, process, clock, random, Git, or network imports
11
+ * - Accepts SourceDocument[] (in-memory documents with normalized relative paths)
12
+ * - Returns diagnostics, never throws for parse/validation failures
13
+ * - Serialization is deterministic: same input always produces same byte output
14
+ *
15
+ * Requirements: 4.5, 4.6, 5.1, 5.2, 5.3, 5.5, 5.6, 6.6, 12.1, 13.7
16
+ */
17
+
18
+ import matter from "gray-matter";
19
+ import * as yaml from "js-yaml";
20
+ import {
21
+ type CanonicalHook,
22
+ type Frontmatter,
23
+ FrontmatterSchema,
24
+ HarnessNameSchema,
25
+ HooksFileSchema,
26
+ type KnowledgeArtifact,
27
+ KnowledgeArtifactSchema,
28
+ type McpServerDefinition,
29
+ McpServersFileSchema,
30
+ type OutputFile,
31
+ type SourceDocumentInput,
32
+ type TranslationDiagnostic,
33
+ type TranslationPlan,
34
+ type WorkflowFile,
35
+ } from "../schemas";
36
+ import { codePointCompare } from "./contracts";
37
+ import { createDiagnostic } from "./diagnostics";
38
+ import { createPlan } from "./plan";
39
+
40
+ // ═══════════════════════════════════════════════════════════════════════════════
41
+ // Types
42
+ // ═══════════════════════════════════════════════════════════════════════════════
43
+
44
+ /**
45
+ * Context provided to the canonical parser alongside source documents.
46
+ */
47
+ export interface CanonicalParserContext {
48
+ /** Hint for the artifact name when not derivable from frontmatter. */
49
+ readonly artifactNameHint?: string;
50
+ /** Canonical schema version for validation context. */
51
+ readonly canonicalSchemaVersion?: string;
52
+ }
53
+
54
+ /**
55
+ * Output of the pure canonical parser.
56
+ */
57
+ export interface CanonicalParserOutput {
58
+ /** The parsed and validated KnowledgeArtifact, or undefined on blocking failure. */
59
+ readonly artifact: KnowledgeArtifact | undefined;
60
+ /** Structured diagnostics for any grammar/schema/validation issues. */
61
+ readonly diagnostics: TranslationDiagnostic[];
62
+ }
63
+
64
+ // ═══════════════════════════════════════════════════════════════════════════════
65
+ // Known Frontmatter Keys — Derived from Schema
66
+ // ═══════════════════════════════════════════════════════════════════════════════
67
+
68
+ /**
69
+ * Derive the set of known frontmatter keys from the Zod schema shape.
70
+ *
71
+ * FrontmatterSchema uses `.passthrough().superRefine()` which makes internal
72
+ * navigation unreliable across Zod versions. We use Zod's `keyof()` when
73
+ * available, and fall back to parsing a probe object to discover declared keys.
74
+ *
75
+ * We also include 'harness-config' which is validated via .passthrough() +
76
+ * superRefine but is a recognized canonical key.
77
+ */
78
+ function deriveKnownFrontmatterKeys(): ReadonlySet<string> {
79
+ // Strategy: parse a minimal valid object to extract the full key set.
80
+ // FrontmatterSchema.passthrough() means parsed output retains all keys,
81
+ // but the declared shape keys receive defaults/transforms. We can discover
82
+ // them by inspecting what `safeParse({name:"x"}).data` produces (defaults
83
+ // cause declared keys to appear even when not supplied).
84
+ const probe = FrontmatterSchema.safeParse({ name: "probe" });
85
+ const keys = new Set<string>();
86
+
87
+ // Add keys discovered from parsing (these are fields with defaults)
88
+ if (probe.success && probe.data && typeof probe.data === "object") {
89
+ for (const k of Object.keys(probe.data)) {
90
+ keys.add(k);
91
+ }
92
+ }
93
+
94
+ // Add all keys from CANONICAL_KEY_ORDER (covers optional fields without defaults)
95
+ for (const k of CANONICAL_KEY_ORDER) {
96
+ keys.add(k);
97
+ }
98
+
99
+ // harness-config is a recognized passthrough key validated in superRefine
100
+ keys.add("harness-config");
101
+ // Additional schema fields not in CANONICAL_KEY_ORDER
102
+ keys.add("migrations");
103
+ keys.add("outcomes");
104
+ keys.add("file_patterns");
105
+ return keys;
106
+ }
107
+
108
+ /**
109
+ * Cached set of known canonical frontmatter keys derived from FrontmatterSchema.
110
+ */
111
+ let _knownKeysCache: ReadonlySet<string> | undefined;
112
+
113
+ /**
114
+ * Returns the schema-derived set of known frontmatter keys.
115
+ * Cached after first derivation.
116
+ */
117
+ export function getKnownFrontmatterKeys(): ReadonlySet<string> {
118
+ if (!_knownKeysCache) {
119
+ _knownKeysCache = deriveKnownFrontmatterKeys();
120
+ }
121
+ return _knownKeysCache;
122
+ }
123
+
124
+ // ═══════════════════════════════════════════════════════════════════════════════
125
+ // Pure Canonical Parser
126
+ // ═══════════════════════════════════════════════════════════════════════════════
127
+
128
+ /**
129
+ * Parse in-memory source documents into a validated KnowledgeArtifact.
130
+ *
131
+ * This function is pure: no filesystem, process, clock, random, Git, or
132
+ * network access. All input is provided via `documents` and `context`.
133
+ *
134
+ * @param documents - In-memory documents with normalized relative paths
135
+ * @param context - Optional context (artifact name hint, schema version)
136
+ * @returns Parsed artifact (or undefined) plus structured diagnostics
137
+ */
138
+ export function parseCanonical(
139
+ documents: readonly SourceDocumentInput[],
140
+ context: CanonicalParserContext = {},
141
+ ): CanonicalParserOutput {
142
+ const diagnostics: TranslationDiagnostic[] = [];
143
+
144
+ // --- Step 1: Find knowledge.md ---
145
+ const knowledgeDoc = documents.find((doc) => doc.path === "knowledge.md");
146
+
147
+ if (!knowledgeDoc) {
148
+ diagnostics.push(
149
+ createDiagnostic("RS_CANONICAL_MISSING_KNOWLEDGE_MD", {
150
+ message: "knowledge.md not found in the provided document set.",
151
+ source: { path: "knowledge.md" },
152
+ }),
153
+ );
154
+ return { artifact: undefined, diagnostics };
155
+ }
156
+
157
+ // --- Step 2: Parse frontmatter from knowledge.md ---
158
+ const content =
159
+ typeof knowledgeDoc.content === "string"
160
+ ? knowledgeDoc.content
161
+ : new TextDecoder().decode(knowledgeDoc.content);
162
+
163
+ let parsed: matter.GrayMatterFile<string>;
164
+ try {
165
+ parsed = matter(content);
166
+ } catch (e: unknown) {
167
+ const msg = e instanceof Error ? e.message : String(e);
168
+ diagnostics.push(
169
+ createDiagnostic("RS_CANONICAL_INVALID_FRONTMATTER", {
170
+ message: `Invalid YAML frontmatter: ${msg}`,
171
+ source: { path: "knowledge.md" },
172
+ }),
173
+ );
174
+ return { artifact: undefined, diagnostics };
175
+ }
176
+
177
+ const rawData = parsed.data ?? {};
178
+
179
+ // Infer name from context hint if not in frontmatter
180
+ if (!rawData.name && context.artifactNameHint) {
181
+ rawData.name = context.artifactNameHint;
182
+ }
183
+
184
+ // --- Step 3: Split known fields from extra fields ---
185
+ const knownKeys = getKnownFrontmatterKeys();
186
+ const extraFields: Record<string, unknown> = {};
187
+ for (const [key, value] of Object.entries(rawData)) {
188
+ if (!knownKeys.has(key)) {
189
+ extraFields[key] = value;
190
+ }
191
+ }
192
+
193
+ // Extract harness-config before validation
194
+ const _harnessConfig: Record<string, unknown> =
195
+ rawData["harness-config"] ?? {};
196
+
197
+ // Validate frontmatter against schema
198
+ const fmResult = FrontmatterSchema.safeParse(rawData);
199
+ if (!fmResult.success) {
200
+ for (const issue of fmResult.error.issues) {
201
+ diagnostics.push(
202
+ createDiagnostic("RS_CANONICAL_INVALID", {
203
+ message: `Frontmatter validation error: ${issue.path.join(".") || "root"}: ${issue.message}`,
204
+ source: { path: "knowledge.md" },
205
+ }),
206
+ );
207
+ }
208
+ return { artifact: undefined, diagnostics };
209
+ }
210
+
211
+ const frontmatter: Frontmatter = fmResult.data;
212
+ const body = parsed.content.trim();
213
+
214
+ // --- Step 4: Parse hooks.yaml if present ---
215
+ const hooksDoc = documents.find((doc) => doc.path === "hooks.yaml");
216
+ let hooks: CanonicalHook[] = [];
217
+
218
+ if (hooksDoc) {
219
+ const hooksContent =
220
+ typeof hooksDoc.content === "string"
221
+ ? hooksDoc.content
222
+ : new TextDecoder().decode(hooksDoc.content);
223
+
224
+ let hooksParsed: unknown;
225
+ try {
226
+ // js-yaml 5.x throws "expected a document, but the input is empty" on
227
+ // empty/whitespace-only input, where 4.x returned undefined. Treat
228
+ // blank content as an empty document so the empty-array contract below
229
+ // holds across both versions.
230
+ hooksParsed =
231
+ hooksContent.trim() === "" ? undefined : yaml.load(hooksContent);
232
+ } catch (e: unknown) {
233
+ const msg = e instanceof Error ? e.message : String(e);
234
+ diagnostics.push(
235
+ createDiagnostic("RS_CANONICAL_INVALID_YAML", {
236
+ message: `Invalid YAML in hooks.yaml: ${msg}`,
237
+ source: { path: "hooks.yaml" },
238
+ }),
239
+ );
240
+ return { artifact: undefined, diagnostics };
241
+ }
242
+
243
+ // Empty YAML parses as null/undefined — treat as empty array
244
+ if (
245
+ hooksParsed === null ||
246
+ hooksParsed === undefined ||
247
+ (Array.isArray(hooksParsed) && hooksParsed.length === 0)
248
+ ) {
249
+ hooks = [];
250
+ } else {
251
+ const hooksValidation = HooksFileSchema.safeParse(hooksParsed);
252
+ if (!hooksValidation.success) {
253
+ for (const issue of hooksValidation.error.issues) {
254
+ diagnostics.push(
255
+ createDiagnostic("RS_CANONICAL_INVALID_YAML", {
256
+ message: `hooks.yaml validation error: ${issue.path.join(".") || "root"}: ${issue.message}`,
257
+ source: { path: "hooks.yaml" },
258
+ }),
259
+ );
260
+ }
261
+ return { artifact: undefined, diagnostics };
262
+ }
263
+ hooks = hooksValidation.data;
264
+ }
265
+ }
266
+
267
+ // --- Step 5: Parse mcp-servers.yaml if present ---
268
+ const mcpDoc = documents.find((doc) => doc.path === "mcp-servers.yaml");
269
+ let mcpServers: McpServerDefinition[] = [];
270
+
271
+ if (mcpDoc) {
272
+ const mcpContent =
273
+ typeof mcpDoc.content === "string"
274
+ ? mcpDoc.content
275
+ : new TextDecoder().decode(mcpDoc.content);
276
+
277
+ let mcpParsed: unknown;
278
+ try {
279
+ // See the hooks.yaml note above: js-yaml 5.x throws on empty input.
280
+ mcpParsed = mcpContent.trim() === "" ? undefined : yaml.load(mcpContent);
281
+ } catch (e: unknown) {
282
+ const msg = e instanceof Error ? e.message : String(e);
283
+ diagnostics.push(
284
+ createDiagnostic("RS_CANONICAL_INVALID_YAML", {
285
+ message: `Invalid YAML in mcp-servers.yaml: ${msg}`,
286
+ source: { path: "mcp-servers.yaml" },
287
+ }),
288
+ );
289
+ return { artifact: undefined, diagnostics };
290
+ }
291
+
292
+ if (
293
+ mcpParsed === null ||
294
+ mcpParsed === undefined ||
295
+ (Array.isArray(mcpParsed) && mcpParsed.length === 0)
296
+ ) {
297
+ mcpServers = [];
298
+ } else {
299
+ const mcpValidation = McpServersFileSchema.safeParse(mcpParsed);
300
+ if (!mcpValidation.success) {
301
+ for (const issue of mcpValidation.error.issues) {
302
+ diagnostics.push(
303
+ createDiagnostic("RS_CANONICAL_INVALID_YAML", {
304
+ message: `mcp-servers.yaml validation error: ${issue.path.join(".") || "root"}: ${issue.message}`,
305
+ source: { path: "mcp-servers.yaml" },
306
+ }),
307
+ );
308
+ }
309
+ return { artifact: undefined, diagnostics };
310
+ }
311
+ mcpServers = mcpValidation.data;
312
+ }
313
+ }
314
+
315
+ // --- Step 6: Parse workflows/** documents ---
316
+ const WORKFLOWS_PREFIX = "workflows/";
317
+ const workflowDocs = documents.filter((doc) =>
318
+ doc.path.startsWith(WORKFLOWS_PREFIX),
319
+ );
320
+
321
+ const workflows: WorkflowFile[] = [];
322
+ const seenNormalizedPaths = new Set<string>();
323
+
324
+ // Sort workflow documents by normalized filename using code-point comparison
325
+ const sortedWorkflowDocs = [...workflowDocs].sort((a, b) =>
326
+ codePointCompare(a.path, b.path),
327
+ );
328
+
329
+ for (const wfDoc of sortedWorkflowDocs) {
330
+ const relativePath = wfDoc.path.slice(WORKFLOWS_PREFIX.length);
331
+
332
+ // Check for directory traversal
333
+ if (relativePath.includes("..")) {
334
+ diagnostics.push(
335
+ createDiagnostic("RS_CANONICAL_WORKFLOW_TRAVERSAL", {
336
+ message: `Workflow path contains traversal: ${wfDoc.path}`,
337
+ source: { path: wfDoc.path },
338
+ }),
339
+ );
340
+ return { artifact: undefined, diagnostics };
341
+ }
342
+
343
+ // Check for duplicate normalized paths
344
+ const normalized = relativePath.toLowerCase();
345
+ if (seenNormalizedPaths.has(normalized)) {
346
+ diagnostics.push(
347
+ createDiagnostic("RS_CANONICAL_DUPLICATE_WORKFLOW", {
348
+ message: `Duplicate normalized workflow path: ${relativePath}`,
349
+ source: { path: wfDoc.path },
350
+ }),
351
+ );
352
+ return { artifact: undefined, diagnostics };
353
+ }
354
+ seenNormalizedPaths.add(normalized);
355
+
356
+ const wfContent =
357
+ typeof wfDoc.content === "string"
358
+ ? wfDoc.content
359
+ : new TextDecoder().decode(wfDoc.content);
360
+
361
+ // Derive workflow name from filename (strip extension, normalize separators)
362
+ const name = relativePath
363
+ .replace(/\.[^./]+$/, "")
364
+ .replace(/[/-]/g, " ")
365
+ .replace(/\b\w/g, (c) => c.toUpperCase());
366
+
367
+ workflows.push({
368
+ name,
369
+ filename: relativePath,
370
+ content: wfContent.trim(),
371
+ });
372
+ }
373
+
374
+ // --- Step 7: Parse body.<harness>.md documents ---
375
+ const BODY_OVERRIDE_RE = /^body\.(.+)\.md$/;
376
+ const bodyOverrides: Record<string, string> = {};
377
+
378
+ for (const doc of documents) {
379
+ const match = doc.path.match(BODY_OVERRIDE_RE);
380
+ if (!match) continue;
381
+
382
+ const harness = match[1];
383
+ const harnessValidation = HarnessNameSchema.safeParse(harness);
384
+ if (!harnessValidation.success) {
385
+ diagnostics.push(
386
+ createDiagnostic("RS_CANONICAL_INVALID_BODY_OVERRIDE", {
387
+ message: `Invalid harness name "${harness}" in body override file: ${doc.path}`,
388
+ source: { path: doc.path },
389
+ }),
390
+ );
391
+ // Invalid body overrides are diagnostics, not silent ignores
392
+ continue;
393
+ }
394
+
395
+ const overrideContent =
396
+ typeof doc.content === "string"
397
+ ? doc.content
398
+ : new TextDecoder().decode(doc.content);
399
+
400
+ // Strip frontmatter if present — only the body is used
401
+ const parsedOverride = matter(overrideContent);
402
+ bodyOverrides[harness] = parsedOverride.content.trim();
403
+ }
404
+
405
+ // --- Step 8: Assemble and validate the full artifact ---
406
+ const artifactName =
407
+ frontmatter.name || context.artifactNameHint || "unknown";
408
+
409
+ // sourcePath is a normalized logical source identifier, never an absolute path
410
+ const sourcePath = context.artifactNameHint || artifactName;
411
+
412
+ const artifact: KnowledgeArtifact = {
413
+ name: artifactName,
414
+ frontmatter,
415
+ body,
416
+ hooks,
417
+ mcpServers,
418
+ workflows,
419
+ sourcePath,
420
+ extraFields,
421
+ bodyOverrides,
422
+ };
423
+
424
+ const validated = KnowledgeArtifactSchema.safeParse(artifact);
425
+ if (!validated.success) {
426
+ for (const issue of validated.error.issues) {
427
+ diagnostics.push(
428
+ createDiagnostic("RS_CANONICAL_INVALID", {
429
+ message: `Artifact validation error: ${issue.path.join(".") || "root"}: ${issue.message}`,
430
+ source: { path: "knowledge.md" },
431
+ }),
432
+ );
433
+ }
434
+ return { artifact: undefined, diagnostics };
435
+ }
436
+
437
+ return { artifact: validated.data, diagnostics };
438
+ }
439
+
440
+ // ═══════════════════════════════════════════════════════════════════════════════
441
+ // Canonical Serializer
442
+ // ═══════════════════════════════════════════════════════════════════════════════
443
+
444
+ /**
445
+ * Canonical frontmatter key-order table. Keys present in this list are
446
+ * rendered first (in this order); remaining keys follow in code-point order.
447
+ */
448
+ const CANONICAL_KEY_ORDER: readonly string[] = [
449
+ "name",
450
+ "displayName",
451
+ "description",
452
+ "keywords",
453
+ "author",
454
+ "version",
455
+ "type",
456
+ "harnesses",
457
+ "inclusion",
458
+ "file_patterns",
459
+ "harness-config",
460
+ "categories",
461
+ "ecosystem",
462
+ "depends",
463
+ "enhances",
464
+ "id",
465
+ "license",
466
+ "maturity",
467
+ "trust",
468
+ "risk-level",
469
+ "audience",
470
+ "model-assumptions",
471
+ "successor",
472
+ "replaces",
473
+ "changelog",
474
+ "collections",
475
+ "inherit-hooks",
476
+ "visibility",
477
+ "priority",
478
+ ] as const;
479
+
480
+ /**
481
+ * Options for the canonical serializer.
482
+ */
483
+ export interface CanonicalSerializerOptions {
484
+ /** Whether to emit hooks.yaml/mcp-servers.yaml when empty (default: true) */
485
+ readonly emitEmptyAuxiliaryFiles?: boolean;
486
+ /** Whether to emit body.<harness>.md files (default: true) */
487
+ readonly emitBodyOverrides?: boolean;
488
+ /** Whether to emit workflow files (default: true) */
489
+ readonly emitWorkflows?: boolean;
490
+ }
491
+
492
+ /**
493
+ * Output of the canonical serializer.
494
+ */
495
+ export interface CanonicalSerializerOutput {
496
+ /** The generated translation plan, or undefined on blocking failure. */
497
+ readonly plan: TranslationPlan | undefined;
498
+ /** Structured diagnostics for any validation/collision issues. */
499
+ readonly diagnostics: TranslationDiagnostic[];
500
+ }
501
+
502
+ // ═══════════════════════════════════════════════════════════════════════════════
503
+ // Deterministic YAML Rendering Helper
504
+ // ═══════════════════════════════════════════════════════════════════════════════
505
+
506
+ /**
507
+ * Render an object to deterministic YAML with a custom key-order comparator.
508
+ *
509
+ * Uses js-yaml's dump() with:
510
+ * - sortKeys: custom comparison function based on priority table
511
+ * - lineWidth: 80
512
+ * - noRefs: true (disables aliases)
513
+ *
514
+ * Returns a string with exactly one trailing newline.
515
+ */
516
+ export function renderDeterministicYaml(
517
+ data: unknown,
518
+ keyOrder?: readonly string[],
519
+ ): string {
520
+ const prioritySet = new Map<string, number>();
521
+ if (keyOrder) {
522
+ for (let i = 0; i < keyOrder.length; i++) {
523
+ prioritySet.set(keyOrder[i], i);
524
+ }
525
+ }
526
+
527
+ const sortKeysFn = (a: string, b: string): number => {
528
+ const aIdx = prioritySet.get(a);
529
+ const bIdx = prioritySet.get(b);
530
+
531
+ if (aIdx !== undefined && bIdx !== undefined) {
532
+ return aIdx - bIdx;
533
+ }
534
+ if (aIdx !== undefined) return -1;
535
+ if (bIdx !== undefined) return 1;
536
+ return codePointCompare(a, b);
537
+ };
538
+
539
+ const result = yaml.dump(data, {
540
+ sortKeys: sortKeysFn,
541
+ // js-yaml 5.x removed the `noCompatMode` dump option. Its reworked scalar
542
+ // styler quotes YAML-1.1-ambiguous scalars (yes/no/on/off/true/null) by
543
+ // default, which is safe: values round-trip identically. No replacement
544
+ // flag is needed for deterministic canonical output.
545
+ lineWidth: 80,
546
+ noRefs: true,
547
+ });
548
+
549
+ // Ensure exactly one trailing newline
550
+ return result.replace(/\n*$/, "\n");
551
+ }
552
+
553
+ // ═══════════════════════════════════════════════════════════════════════════════
554
+ // Canonical Serializer
555
+ // ═══════════════════════════════════════════════════════════════════════════════
556
+
557
+ /**
558
+ * Serialize a validated KnowledgeArtifact into a deterministic TranslationPlan.
559
+ *
560
+ * This function is pure: no filesystem, process, clock, random, Git, or
561
+ * network access. It produces the same byte output for the same input.
562
+ *
563
+ * @param artifact - A validated KnowledgeArtifact to serialize
564
+ * @param options - Optional serializer options
565
+ * @returns A TranslationPlan (or undefined) plus structured diagnostics
566
+ */
567
+ export function serializeCanonical(
568
+ artifact: KnowledgeArtifact,
569
+ options?: CanonicalSerializerOptions,
570
+ ): CanonicalSerializerOutput {
571
+ const diagnostics: TranslationDiagnostic[] = [];
572
+ const opts: Required<CanonicalSerializerOptions> = {
573
+ emitEmptyAuxiliaryFiles: options?.emitEmptyAuxiliaryFiles ?? true,
574
+ emitBodyOverrides: options?.emitBodyOverrides ?? true,
575
+ emitWorkflows: options?.emitWorkflows ?? true,
576
+ };
577
+
578
+ // --- Step 1: Validate the artifact via KnowledgeArtifactSchema ---
579
+ const validated = KnowledgeArtifactSchema.safeParse(artifact);
580
+ if (!validated.success) {
581
+ for (const issue of validated.error.issues) {
582
+ diagnostics.push(
583
+ createDiagnostic("RS_CANONICAL_INVALID", {
584
+ message: `Artifact validation error: ${issue.path.join(".") || "root"}: ${issue.message}`,
585
+ source: { path: "knowledge.md" },
586
+ }),
587
+ );
588
+ }
589
+ return { plan: undefined, diagnostics };
590
+ }
591
+
592
+ const art = validated.data;
593
+
594
+ // --- Step 2: Check for extra field collisions with canonical keys ---
595
+ const knownKeys = getKnownFrontmatterKeys();
596
+ for (const key of Object.keys(art.extraFields)) {
597
+ if (knownKeys.has(key)) {
598
+ diagnostics.push(
599
+ createDiagnostic("RS_EXTRA_FIELD_COLLISION", {
600
+ message: `Extra field "${key}" collides with canonical frontmatter key.`,
601
+ source: { path: "knowledge.md" },
602
+ }),
603
+ );
604
+ return { plan: undefined, diagnostics };
605
+ }
606
+ }
607
+
608
+ // --- Step 3: Build frontmatter object for YAML rendering ---
609
+ const frontmatterData: Record<string, unknown> = {};
610
+
611
+ // Copy all canonical frontmatter fields (only those that are set/non-default)
612
+ const fm = art.frontmatter as Record<string, unknown>;
613
+ for (const [key, value] of Object.entries(fm)) {
614
+ if (value !== undefined) {
615
+ frontmatterData[key] = value;
616
+ }
617
+ }
618
+
619
+ // Merge extra fields (already verified no collisions)
620
+ for (const [key, value] of Object.entries(art.extraFields)) {
621
+ frontmatterData[key] = value;
622
+ }
623
+
624
+ // --- Step 4: Render knowledge.md ---
625
+ const frontmatterYaml = renderDeterministicYaml(
626
+ frontmatterData,
627
+ CANONICAL_KEY_ORDER,
628
+ );
629
+ const knowledgeMd = `---\n${frontmatterYaml}---\n${art.body}\n`;
630
+
631
+ const outputFiles: OutputFile[] = [];
632
+
633
+ outputFiles.push({
634
+ relativePath: "knowledge.md",
635
+ content: knowledgeMd,
636
+ executable: false,
637
+ });
638
+
639
+ // --- Step 5: Render hooks.yaml ---
640
+ if (art.hooks.length > 0 || opts.emitEmptyAuxiliaryFiles) {
641
+ let hooksContent: string;
642
+ if (art.hooks.length === 0) {
643
+ hooksContent = "[]\n";
644
+ } else {
645
+ // Render each hook with canonical key ordering for hook fields
646
+ const hookKeyOrder = [
647
+ "name",
648
+ "description",
649
+ "event",
650
+ "condition",
651
+ "action",
652
+ "gate",
653
+ "postcondition",
654
+ "state",
655
+ ];
656
+ hooksContent = renderDeterministicYaml(art.hooks, hookKeyOrder);
657
+ }
658
+ outputFiles.push({
659
+ relativePath: "hooks.yaml",
660
+ content: hooksContent,
661
+ executable: false,
662
+ });
663
+ }
664
+
665
+ // --- Step 6: Render mcp-servers.yaml ---
666
+ if (art.mcpServers.length > 0 || opts.emitEmptyAuxiliaryFiles) {
667
+ let mcpContent: string;
668
+ if (art.mcpServers.length === 0) {
669
+ mcpContent = "[]\n";
670
+ } else {
671
+ const mcpKeyOrder = [
672
+ "name",
673
+ "transport",
674
+ "command",
675
+ "args",
676
+ "url",
677
+ "env",
678
+ "timeout",
679
+ "autoApprove",
680
+ "disabled",
681
+ ];
682
+ mcpContent = renderDeterministicYaml(art.mcpServers, mcpKeyOrder);
683
+ }
684
+ outputFiles.push({
685
+ relativePath: "mcp-servers.yaml",
686
+ content: mcpContent,
687
+ executable: false,
688
+ });
689
+ }
690
+
691
+ // --- Step 7: Render workflows ---
692
+ if (opts.emitWorkflows && art.workflows.length > 0) {
693
+ // Sort workflows by filename using code-point comparison
694
+ const sortedWorkflows = [...art.workflows].sort((a, b) =>
695
+ codePointCompare(a.filename, b.filename),
696
+ );
697
+
698
+ for (const wf of sortedWorkflows) {
699
+ const content = `${wf.content}\n`;
700
+ outputFiles.push({
701
+ relativePath: `workflows/${wf.filename}`,
702
+ content,
703
+ executable: false,
704
+ });
705
+ }
706
+ }
707
+
708
+ // --- Step 8: Render body overrides ---
709
+ if (opts.emitBodyOverrides && Object.keys(art.bodyOverrides).length > 0) {
710
+ // Sort by harness name using code-point comparison
711
+ const sortedHarnesses = Object.keys(art.bodyOverrides).sort(
712
+ codePointCompare,
713
+ );
714
+
715
+ for (const harness of sortedHarnesses) {
716
+ const content = `${art.bodyOverrides[harness]}\n`;
717
+ outputFiles.push({
718
+ relativePath: `body.${harness}.md`,
719
+ content,
720
+ executable: false,
721
+ });
722
+ }
723
+ }
724
+
725
+ // --- Step 9: Create the plan via createPlan ---
726
+ const plan = createPlan("kanon-canonical", "1.0.0", outputFiles);
727
+
728
+ return { plan, diagnostics };
729
+ }