@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,1093 @@
1
+ import { exists, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import chalk from "chalk";
4
+ import { CAPABILITY_MATRIX, validateMatrixSync } from "./adapters/capabilities";
5
+ import { adapterRegistry } from "./adapters/index";
6
+ import { resolveKiroInclusion } from "./adapters/kiro-inclusion";
7
+ import {
8
+ ASSET_CONVENTION_RULES,
9
+ ASSET_CONVENTIONS,
10
+ documentsAgentLoop,
11
+ } from "./asset-conventions";
12
+ import { generateCatalog } from "./catalog";
13
+ import { loadCollections, validateArtifactCollectionRefs } from "./collections";
14
+ import { HARNESS_FORMAT_REGISTRY, resolveFormat } from "./format-registry";
15
+ import {
16
+ BUILTIN_PREDICATES,
17
+ ExpressionSyntaxError,
18
+ type ParsedExpression,
19
+ parseExpression,
20
+ validateReferences,
21
+ } from "./hooks/expression";
22
+ import { aggregateOutcomes, runRegistryCheck } from "./outcomes/registry";
23
+ import {
24
+ isParseError,
25
+ parseHooksYaml,
26
+ parseKnowledgeMd,
27
+ parseMcpServersYaml,
28
+ } from "./parser";
29
+ import {
30
+ type CanonicalHook,
31
+ isStdioServer,
32
+ type Outcome,
33
+ SUPPORTED_HARNESSES,
34
+ type ValidationError,
35
+ type ValidationResult,
36
+ type ValidationWarning,
37
+ } from "./schemas";
38
+ import { loadWorkspaceConfig, validateWorkspaceConfig } from "./workspace";
39
+
40
+ export type { ValidationError, ValidationResult, ValidationWarning };
41
+
42
+ // ── Security validation ───────────────────────────────────────────────────────
43
+
44
+ /** Prompt injection and override patterns — errors, not warnings. */
45
+ const PROMPT_INJECTION_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
46
+ {
47
+ pattern: /ignore\s+(previous|prior|all)\s+instructions/i,
48
+ message:
49
+ 'Contains prompt injection pattern: "ignore previous instructions"',
50
+ },
51
+ {
52
+ pattern:
53
+ /disregard\s+(your|all)\s+(guidelines|rules|training|instructions)/i,
54
+ message: 'Contains prompt injection pattern: "disregard your guidelines"',
55
+ },
56
+ {
57
+ pattern: /you\s+are\s+now\s+(?!a\s+(?:skill|power|workflow|agent|prompt))/i,
58
+ message: 'Contains identity override pattern: "you are now"',
59
+ },
60
+ {
61
+ pattern: /act\s+as\s+(?:if\s+you\s+have\s+no|without)\s+restrictions/i,
62
+ message:
63
+ 'Contains restriction bypass pattern: "act as if you have no restrictions"',
64
+ },
65
+ {
66
+ pattern: /\[SYSTEM\]/,
67
+ message: "Contains fake system prompt marker: [SYSTEM]",
68
+ },
69
+ {
70
+ pattern: /\bDAN\b|\bDo Anything Now\b/i,
71
+ message: "Contains known jailbreak marker: DAN / Do Anything Now",
72
+ },
73
+ ];
74
+
75
+ /** Hook command patterns that suggest exfiltration or remote code execution. */
76
+ const DANGEROUS_HOOK_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
77
+ {
78
+ pattern: /curl\s+.*(?:\$\{?[A-Z_]{3,}\}?|\$\()/,
79
+ message:
80
+ "Hook command uses curl with an environment variable or command substitution — potential exfiltration",
81
+ },
82
+ {
83
+ pattern: /wget\s+.*https?:\/\//,
84
+ message: "Hook command makes outbound request via wget",
85
+ },
86
+ {
87
+ pattern: /\bnc\b.*\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/,
88
+ message: "Hook command uses netcat with a remote IP address",
89
+ },
90
+ {
91
+ pattern: /python.*-c.*(?:exec|eval|__import__)/,
92
+ message: "Hook command runs inline Python with exec/eval",
93
+ },
94
+ {
95
+ pattern: /node\s+-e\s+/,
96
+ message: "Hook command runs inline Node.js code",
97
+ },
98
+ {
99
+ pattern: /bash\s+-c\s+.*base64/,
100
+ message: "Hook command runs base64-encoded payload via bash",
101
+ },
102
+ ];
103
+
104
+ /** MCP server command patterns that indicate risky execution. */
105
+ const DANGEROUS_MCP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
106
+ {
107
+ pattern: /^(?:bash|sh|zsh|fish)$/,
108
+ message:
109
+ "MCP server uses a shell interpreter as its command — high-risk execution surface",
110
+ },
111
+ {
112
+ pattern: /^python\d*$|^node$|^ruby$|^perl$/,
113
+ message:
114
+ "MCP server uses a language runtime as its command without a specific script path",
115
+ },
116
+ ];
117
+
118
+ /** Hidden/obfuscated content patterns — warnings. */
119
+ const OBFUSCATION_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
120
+ {
121
+ pattern: /[\u200B-\u200D\uFEFF\u2060]/,
122
+ message: "Body contains zero-width or invisible Unicode characters",
123
+ },
124
+ {
125
+ pattern: /[A-Za-z0-9+/]{80,}={0,2}/,
126
+ message:
127
+ "Body contains a long base64-like string that may encode hidden instructions",
128
+ },
129
+ ];
130
+
131
+ /**
132
+ * SPDX license identifiers (or families) whose terms require attribution be
133
+ * preserved. Kept small and explicit (ADR-0064); `CC-BY*` matches by prefix.
134
+ * Used only to raise an advisory under-credited warning, never to fail a build.
135
+ */
136
+ const ATTRIBUTION_REQUIRED_LICENSES = new Set([
137
+ "MPL-2.0",
138
+ "Apache-2.0",
139
+ "BSD-3-Clause",
140
+ "BSD-2-Clause",
141
+ ]);
142
+
143
+ function isAttributionRequiredLicense(license: string): boolean {
144
+ const id = license.trim();
145
+ if (id.length === 0) return false;
146
+ if (/^CC-BY/i.test(id)) return true;
147
+ return ATTRIBUTION_REQUIRED_LICENSES.has(id);
148
+ }
149
+
150
+ /**
151
+ * Run security-focused checks on an artifact.
152
+ * Returns additional errors and warnings to merge into the validation result.
153
+ */
154
+ export async function validateArtifactSecurity(
155
+ artifactPath: string,
156
+ ): Promise<{ errors: ValidationError[]; warnings: ValidationWarning[] }> {
157
+ const errors: ValidationError[] = [];
158
+ const warnings: ValidationWarning[] = [];
159
+ const knowledgeMdPath = join(artifactPath, "knowledge.md");
160
+ const hooksPath = join(artifactPath, "hooks.yaml");
161
+ const mcpPath = join(artifactPath, "mcp-servers.yaml");
162
+
163
+ // ── knowledge.md body ───────────────────────────────────────────────────────
164
+ const mdResult = await parseKnowledgeMd(knowledgeMdPath);
165
+ if (!isParseError(mdResult)) {
166
+ const body = mdResult.data.body;
167
+
168
+ for (const { pattern, message } of PROMPT_INJECTION_PATTERNS) {
169
+ if (pattern.test(body)) {
170
+ errors.push({ field: "body", message, filePath: knowledgeMdPath });
171
+ }
172
+ }
173
+
174
+ for (const { pattern, message } of OBFUSCATION_PATTERNS) {
175
+ if (pattern.test(body)) {
176
+ warnings.push({ field: "body", message, filePath: knowledgeMdPath });
177
+ }
178
+ }
179
+ }
180
+
181
+ // ── hooks.yaml ──────────────────────────────────────────────────────────────
182
+ if (await exists(hooksPath)) {
183
+ const hooksResult = await parseHooksYaml(hooksPath);
184
+ if (!isParseError(hooksResult)) {
185
+ for (const hook of hooksResult.data) {
186
+ if (hook.action.type === "run_command") {
187
+ const cmd = hook.action.command;
188
+ for (const { pattern, message } of DANGEROUS_HOOK_PATTERNS) {
189
+ if (pattern.test(cmd)) {
190
+ errors.push({
191
+ field: `hooks[${hook.name}].action.command`,
192
+ message,
193
+ filePath: hooksPath,
194
+ });
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ // ── mcp-servers.yaml ────────────────────────────────────────────────────────
203
+ if (await exists(mcpPath)) {
204
+ const mcpResult = await parseMcpServersYaml(mcpPath);
205
+ if (!isParseError(mcpResult)) {
206
+ for (const server of mcpResult.data) {
207
+ if (isStdioServer(server)) {
208
+ for (const { pattern, message } of DANGEROUS_MCP_PATTERNS) {
209
+ if (pattern.test(server.command)) {
210
+ warnings.push({
211
+ field: `mcp-servers[${server.name}].command`,
212
+ message,
213
+ filePath: mcpPath,
214
+ });
215
+ }
216
+ }
217
+ }
218
+ // Flag servers with env vars that look like credentials
219
+ for (const [key] of Object.entries(server.env)) {
220
+ if (/(?:key|secret|token|password|credential)/i.test(key)) {
221
+ warnings.push({
222
+ field: `mcp-servers[${server.name}].env.${key}`,
223
+ message: `MCP server references a credential-like env var "${key}" — ensure this is not hardcoded`,
224
+ filePath: mcpPath,
225
+ });
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ return { errors, warnings };
233
+ }
234
+
235
+ /**
236
+ * Detect cycles in a dependency graph using DFS.
237
+ * @param graph - Map from artifact name to its direct dependencies
238
+ * @returns Array of cycles, each cycle is an array of artifact names forming a loop
239
+ */
240
+ export function detectDependencyCycles(
241
+ graph: Map<string, string[]>,
242
+ ): string[][] {
243
+ const cycles: string[][] = [];
244
+ const visited = new Set<string>();
245
+ const inStack = new Set<string>();
246
+
247
+ function dfs(node: string, path: string[]): void {
248
+ if (inStack.has(node)) {
249
+ // Found a cycle — extract the cycle portion
250
+ const cycleStart = path.indexOf(node);
251
+ cycles.push(path.slice(cycleStart));
252
+ return;
253
+ }
254
+ if (visited.has(node)) return;
255
+
256
+ visited.add(node);
257
+ inStack.add(node);
258
+ path.push(node);
259
+
260
+ for (const dep of graph.get(node) ?? []) {
261
+ dfs(dep, [...path]);
262
+ }
263
+
264
+ inStack.delete(node);
265
+ }
266
+
267
+ for (const node of graph.keys()) {
268
+ if (!visited.has(node)) {
269
+ dfs(node, []);
270
+ }
271
+ }
272
+
273
+ return cycles;
274
+ }
275
+
276
+ /**
277
+ * Hook gate/postcondition reference validation (Req 3.7).
278
+ *
279
+ * For a parsed `hooks.yaml`, collect the union of all declared `state` keys
280
+ * across every hook in the file, then for each hook's `gate` and
281
+ * `postcondition` expression parse it and run {@link validateReferences}
282
+ * against that declared-key set. Any undefined state key or unknown predicate
283
+ * (not in {@link BUILTIN_PREDICATES}) yields a {@link ValidationError}
284
+ * identifying the hook name and the offending field. Malformed expressions are
285
+ * reported as validation errors too, so a broken gate/postcondition rejects the
286
+ * hook file rather than throwing.
287
+ *
288
+ * Pure: takes the already-parsed hooks and returns errors; no I/O.
289
+ */
290
+ export function validateHookReferences(
291
+ hooks: CanonicalHook[],
292
+ filePath: string,
293
+ ): ValidationError[] {
294
+ const errors: ValidationError[] = [];
295
+
296
+ // Union of all declared state keys across every hook in the file (Req 3.7).
297
+ const declaredStateKeys = new Set<string>();
298
+ for (const hook of hooks) {
299
+ if (hook.state) {
300
+ for (const key of Object.keys(hook.state)) {
301
+ declaredStateKeys.add(key);
302
+ }
303
+ }
304
+ }
305
+
306
+ const checkField = (
307
+ hook: CanonicalHook,
308
+ field: "gate" | "postcondition",
309
+ expr: string | undefined,
310
+ ): void => {
311
+ if (!expr) return;
312
+
313
+ let parsed: ParsedExpression;
314
+ try {
315
+ parsed = parseExpression(expr);
316
+ } catch (err) {
317
+ errors.push({
318
+ field: `hooks[${hook.name}].${field}`,
319
+ message:
320
+ err instanceof ExpressionSyntaxError
321
+ ? err.message
322
+ : `Failed to parse ${field} expression "${expr}"`,
323
+ filePath,
324
+ });
325
+ return;
326
+ }
327
+
328
+ const { undefinedStateKeys, undefinedPredicates } = validateReferences(
329
+ parsed,
330
+ declaredStateKeys,
331
+ );
332
+ for (const key of undefinedStateKeys) {
333
+ errors.push({
334
+ field: `hooks[${hook.name}].${field}`,
335
+ message: `${field} references undefined state key "state.${key}". Declare it in a hook's "state" field.`,
336
+ filePath,
337
+ });
338
+ }
339
+ for (const pred of undefinedPredicates) {
340
+ errors.push({
341
+ field: `hooks[${hook.name}].${field}`,
342
+ message: `${field} references unknown predicate "${pred}". Built-in predicates are: ${BUILTIN_PREDICATES.join(", ")}.`,
343
+ filePath,
344
+ });
345
+ }
346
+ };
347
+
348
+ for (const hook of hooks) {
349
+ checkField(hook, "gate", hook.gate);
350
+ checkField(hook, "postcondition", hook.postcondition);
351
+ }
352
+
353
+ return errors;
354
+ }
355
+
356
+ export async function validateArtifact(
357
+ artifactPath: string,
358
+ ): Promise<ValidationResult> {
359
+ const errors: ValidationError[] = [];
360
+ const artifactName = artifactPath.split("/").pop() || artifactPath;
361
+
362
+ // Check knowledge.md exists
363
+ const knowledgeMdPath = join(artifactPath, "knowledge.md");
364
+ if (!(await exists(knowledgeMdPath))) {
365
+ errors.push({
366
+ field: "knowledge.md",
367
+ message: "Missing required knowledge.md file",
368
+ filePath: knowledgeMdPath,
369
+ });
370
+ return { artifactName, valid: false, errors };
371
+ }
372
+
373
+ // Validate frontmatter
374
+ const warnings: ValidationWarning[] = [];
375
+ const mdResult = await parseKnowledgeMd(knowledgeMdPath);
376
+ if (isParseError(mdResult)) {
377
+ errors.push(...mdResult.errors);
378
+ } else {
379
+ // Validate harness names
380
+ const fm = mdResult.data.frontmatter;
381
+ for (const h of fm.harnesses) {
382
+ if (!(SUPPORTED_HARNESSES as readonly string[]).includes(h)) {
383
+ errors.push({
384
+ field: "harnesses",
385
+ message: `Unrecognized harness name: "${h}"`,
386
+ filePath: knowledgeMdPath,
387
+ });
388
+ }
389
+ }
390
+
391
+ // Validate harness-config keys reference harnesses in the list
392
+ const harnessConfig = mdResult.data.harnessConfig;
393
+ if (harnessConfig && typeof harnessConfig === "object") {
394
+ for (const key of Object.keys(harnessConfig)) {
395
+ const harnessKey = key as (typeof fm.harnesses)[number];
396
+ if (!fm.harnesses.includes(harnessKey)) {
397
+ // This is a warning, not an error — but we track it as a non-fatal error
398
+ errors.push({
399
+ field: "harness-config",
400
+ message: `harness-config key "${key}" references a harness not in the harnesses list`,
401
+ filePath: knowledgeMdPath,
402
+ });
403
+ }
404
+ }
405
+ }
406
+
407
+ // Lifecycle: deprecated assets must declare a successor (promoted to error in Phase 3)
408
+ if (fm.maturity === "deprecated" && !fm.successor) {
409
+ errors.push({
410
+ field: "successor",
411
+ message: `Asset has maturity "deprecated" but no "successor" field. Set "successor" to point to the replacement artifact.`,
412
+ filePath: knowledgeMdPath,
413
+ });
414
+ }
415
+
416
+ // Governance lane rules
417
+ if ((fm.trust === "official" || fm.trust === "partner") && !fm.license) {
418
+ warnings.push({
419
+ field: "license",
420
+ message: `Artifacts with trust "${fm.trust}" should declare a "license" field (e.g. MIT, Apache-2.0).`,
421
+ filePath: knowledgeMdPath,
422
+ });
423
+ }
424
+
425
+ if (fm["risk-level"] === "high" && !fm.trust) {
426
+ warnings.push({
427
+ field: "trust",
428
+ message: `Artifact has risk-level "high" but no "trust" lane set. Set "trust" to signal governance oversight.`,
429
+ filePath: knowledgeMdPath,
430
+ });
431
+ }
432
+
433
+ // ── Attribution warnings (ADR-0064; advisory, never fail the build) ──────
434
+ // 1. Un-credited import: has machine provenance but no human attribution.
435
+ const attribution = fm.attribution;
436
+ if (fm.provenance && !attribution?.upstream?.length) {
437
+ warnings.push({
438
+ field: "attribution",
439
+ message: `Artifact "${artifactName}" has a provenance record (imported) but no "attribution.upstream" credit. Run the import wizard or backfill to record upstream authorship.`,
440
+ filePath: knowledgeMdPath,
441
+ });
442
+ }
443
+ // 2. Under-credited attribution-required license: an upstream work under an
444
+ // attribution-required license with neither per-entry authors nor a
445
+ // block-level notice.
446
+ if (attribution?.upstream?.length) {
447
+ for (const [i, u] of attribution.upstream.entries()) {
448
+ if (
449
+ u.license &&
450
+ isAttributionRequiredLicense(u.license) &&
451
+ u.authors.length === 0 &&
452
+ !attribution.notice
453
+ ) {
454
+ warnings.push({
455
+ field: `attribution.upstream[${i}]`,
456
+ message: `Upstream work "${u.work}" is under attribution-required license "${u.license}" but has no authors and no block-level notice. Add authors or a notice to satisfy the license.`,
457
+ filePath: knowledgeMdPath,
458
+ });
459
+ }
460
+ }
461
+ }
462
+
463
+ // Asset-type-aware validation rules
464
+ const convention = ASSET_CONVENTIONS[fm.type];
465
+ if (convention) {
466
+ for (const ruleKey of convention.validationRuleKeys) {
467
+ switch (ruleKey) {
468
+ case "reference-pack-must-be-manual":
469
+ if (fm.inclusion !== "manual") {
470
+ warnings.push({
471
+ field: "inclusion",
472
+ message: ASSET_CONVENTION_RULES[ruleKey],
473
+ filePath: knowledgeMdPath,
474
+ });
475
+ }
476
+ break;
477
+
478
+ case "workflow-should-have-workflows-dir": {
479
+ const workflowsDir = join(artifactPath, "workflows");
480
+ if (!(await exists(workflowsDir))) {
481
+ warnings.push({
482
+ field: "workflows",
483
+ message: ASSET_CONVENTION_RULES[ruleKey],
484
+ filePath: knowledgeMdPath,
485
+ });
486
+ } else {
487
+ const wfEntries = await readdir(workflowsDir);
488
+ const mdFiles = wfEntries.filter((f) => f.endsWith(".md"));
489
+ if (mdFiles.length === 0) {
490
+ warnings.push({
491
+ field: "workflows",
492
+ message: ASSET_CONVENTION_RULES[ruleKey],
493
+ filePath: knowledgeMdPath,
494
+ });
495
+ }
496
+ }
497
+ break;
498
+ }
499
+
500
+ case "type-power-deprecated":
501
+ warnings.push({
502
+ field: "type",
503
+ message: ASSET_CONVENTION_RULES[ruleKey],
504
+ filePath: knowledgeMdPath,
505
+ });
506
+ break;
507
+
508
+ case "prompt-body-too-short":
509
+ case "agent-should-document-loop":
510
+ // body is checked separately after parsing knowledge.md
511
+ break;
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ // Prompt body length check (needs body from parse result, outside the if block)
518
+ if (!isParseError(mdResult)) {
519
+ const fm = mdResult.data.frontmatter;
520
+ if (fm.type === "prompt" && mdResult.data.body.length < 50) {
521
+ warnings.push({
522
+ field: "body",
523
+ message: ASSET_CONVENTION_RULES["prompt-body-too-short"],
524
+ filePath: knowledgeMdPath,
525
+ });
526
+ }
527
+ }
528
+
529
+ // Agent loop documentation check (needs body from parse result, outside the if block)
530
+ if (!isParseError(mdResult)) {
531
+ const fm = mdResult.data.frontmatter;
532
+ if (fm.type === "agent" && !documentsAgentLoop(mdResult.data.body)) {
533
+ warnings.push({
534
+ field: "body",
535
+ message: ASSET_CONVENTION_RULES["agent-should-document-loop"],
536
+ filePath: knowledgeMdPath,
537
+ });
538
+ }
539
+ }
540
+
541
+ // ── Kiro Progressive Steering validation ────────────────────────────────────
542
+ if (!isParseError(mdResult)) {
543
+ const fm = mdResult.data.frontmatter;
544
+ if (fm.harnesses.includes("kiro")) {
545
+ // Construct a minimal artifact-like object for resolveKiroInclusion
546
+ const artifactForResolver = {
547
+ frontmatter: fm as Record<string, unknown> & { inclusion: string },
548
+ } as Parameters<typeof resolveKiroInclusion>[0];
549
+
550
+ const resolved = resolveKiroInclusion(artifactForResolver);
551
+
552
+ // Access kiro harness-config for cross-field checks
553
+ const harnessConfig = fm["harness-config"] as
554
+ | Record<string, Record<string, unknown>>
555
+ | undefined;
556
+ const kiroConfig = harnessConfig?.kiro;
557
+
558
+ // Req 1.4: fileMatch with absent/empty fileMatchPattern → error
559
+ if (kiroConfig && typeof kiroConfig === "object") {
560
+ const hcInclusion = kiroConfig.inclusion;
561
+ if (hcInclusion === "fileMatch") {
562
+ const fmp = kiroConfig.fileMatchPattern;
563
+ if (!fmp || (typeof fmp === "string" && fmp.length === 0)) {
564
+ errors.push({
565
+ field: "harness-config.kiro.fileMatchPattern",
566
+ message:
567
+ 'fileMatchPattern is required when harness-config.kiro.inclusion is "fileMatch"',
568
+ filePath: knowledgeMdPath,
569
+ });
570
+ }
571
+ }
572
+
573
+ // Req 1.5: always/manual with non-empty fileMatchPattern → warning
574
+ if (
575
+ (hcInclusion === "always" || hcInclusion === "manual") &&
576
+ typeof kiroConfig.fileMatchPattern === "string" &&
577
+ kiroConfig.fileMatchPattern.length > 0
578
+ ) {
579
+ warnings.push({
580
+ field: "harness-config.kiro.fileMatchPattern",
581
+ message:
582
+ 'fileMatchPattern is ignored unless harness-config.kiro.inclusion is "fileMatch"',
583
+ filePath: knowledgeMdPath,
584
+ });
585
+ }
586
+ }
587
+
588
+ // Req 4.1: reference-pack + resolved always → warning
589
+ if (fm.type === "reference-pack" && resolved.mode === "always") {
590
+ warnings.push({
591
+ field: "harness-config.kiro.inclusion",
592
+ message: ASSET_CONVENTION_RULES["reference-pack-must-be-manual"],
593
+ filePath: knowledgeMdPath,
594
+ });
595
+ }
596
+
597
+ // Req 4.2 & 4.3: power-format + resolved always → warning(s)
598
+ const kiroFormat = resolveFormat(
599
+ "kiro",
600
+ kiroConfig as Record<string, unknown> | undefined,
601
+ );
602
+ if (kiroFormat.format === "power" && resolved.mode === "always") {
603
+ warnings.push({
604
+ field: "harness-config.kiro.inclusion",
605
+ message: ASSET_CONVENTION_RULES["kiro-power-should-be-progressive"],
606
+ filePath: knowledgeMdPath,
607
+ });
608
+
609
+ // Req 4.3: additionally warn if the artifact ships workflow files
610
+ const workflowsDir = join(artifactPath, "workflows");
611
+ if (await exists(workflowsDir)) {
612
+ const wfEntries = await readdir(workflowsDir);
613
+ const mdFiles = wfEntries.filter((f) => f.endsWith(".md"));
614
+ if (mdFiles.length > 0) {
615
+ warnings.push({
616
+ field: "harness-config.kiro.inclusion",
617
+ message:
618
+ ASSET_CONVENTION_RULES[
619
+ "kiro-power-workflow-should-be-progressive"
620
+ ],
621
+ filePath: knowledgeMdPath,
622
+ });
623
+ }
624
+ }
625
+ }
626
+
627
+ // Req 8.2: default inclusion → informational warning (suppressed by explicit "always" per Req 8.3)
628
+ if (resolved.source === "default") {
629
+ warnings.push({
630
+ field: "harness-config.kiro.inclusion",
631
+ message:
632
+ ASSET_CONVENTION_RULES["kiro-default-inclusion-informational"],
633
+ filePath: knowledgeMdPath,
634
+ });
635
+ }
636
+ }
637
+ }
638
+
639
+ // Validate hooks.yaml if present
640
+ const hooksPath = join(artifactPath, "hooks.yaml");
641
+ if (await exists(hooksPath)) {
642
+ const hooksResult = await parseHooksYaml(hooksPath);
643
+ if (isParseError(hooksResult)) {
644
+ errors.push(...hooksResult.errors);
645
+ } else {
646
+ // Reference validation for gate/postcondition expressions (Req 3.7):
647
+ // every state key and predicate must be declared/built-in.
648
+ errors.push(...validateHookReferences(hooksResult.data, hooksPath));
649
+ }
650
+ }
651
+
652
+ // Validate mcp-servers.yaml if present
653
+ const mcpPath = join(artifactPath, "mcp-servers.yaml");
654
+ if (await exists(mcpPath)) {
655
+ const mcpResult = await parseMcpServersYaml(mcpPath);
656
+ if (isParseError(mcpResult)) {
657
+ errors.push(...mcpResult.errors);
658
+ }
659
+ }
660
+
661
+ const result: ValidationResult = {
662
+ artifactName,
663
+ valid: errors.length === 0,
664
+ errors,
665
+ };
666
+ if (warnings.length > 0) {
667
+ result.warnings = warnings;
668
+ }
669
+ return result;
670
+ }
671
+
672
+ /**
673
+ * Collect all {artifactPath, artifactName} pairs from one or more source dirs.
674
+ * Mirrors the layout detection logic in catalog.ts and build.ts.
675
+ */
676
+ async function collectArtifactPaths(
677
+ sourceDirs: string[],
678
+ ): Promise<Array<{ path: string; name: string }>> {
679
+ const artifacts: Array<{ path: string; name: string }> = [];
680
+
681
+ for (const sourceDir of sourceDirs) {
682
+ if (!(await exists(sourceDir))) continue;
683
+
684
+ const dirEntries = await readdir(sourceDir, { withFileTypes: true });
685
+ const subdirs = dirEntries
686
+ .filter((e) => e.isDirectory())
687
+ .sort((a, b) => a.name.localeCompare(b.name));
688
+
689
+ for (const subdir of subdirs) {
690
+ const subdirPath = join(sourceDir, subdir.name);
691
+
692
+ if (await exists(join(subdirPath, "knowledge.md"))) {
693
+ artifacts.push({ path: subdirPath, name: subdir.name });
694
+ } else {
695
+ // Namespaced layout — recurse one level
696
+ const inner = await readdir(subdirPath, { withFileTypes: true });
697
+ const innerDirs = inner
698
+ .filter((e) => e.isDirectory())
699
+ .sort((a, b) => a.name.localeCompare(b.name));
700
+ for (const innerDir of innerDirs) {
701
+ const artifactPath = join(subdirPath, innerDir.name);
702
+ if (await exists(join(artifactPath, "knowledge.md"))) {
703
+ artifacts.push({ path: artifactPath, name: innerDir.name });
704
+ }
705
+ }
706
+ }
707
+ }
708
+ }
709
+
710
+ return artifacts;
711
+ }
712
+
713
+ /**
714
+ * Cross-artifact outcomes collision detection (Req 2F).
715
+ *
716
+ * Aggregates every `outcomes` declaration across all artifacts and runs the
717
+ * pure `runRegistryCheck` from the outcomes registry. This function is the thin
718
+ * I/O shell described in ADR-0041: it reads frontmatter, delegates all
719
+ * comparison logic to the pure core, and maps the resulting findings to
720
+ * validation errors and warnings.
721
+ *
722
+ * - `collision` (non-acknowledged) -> {@link ValidationError} including both
723
+ * outcome ids, both artifact names, the matched normalized shapes, and the
724
+ * keyword Jaccard score (Req 2F.2).
725
+ * - `duplicate-id` -> {@link ValidationError} (ids must be globally unique,
726
+ * Req 2F.4).
727
+ * - `ambiguous` -> {@link ValidationWarning} (does not block validation,
728
+ * Req 2F.3).
729
+ * - `acknowledged-overlap` -> no finding.
730
+ *
731
+ * Returns a single synthetic {@link ValidationResult} (artifact name
732
+ * `[outcomes-registry]`) when any finding is produced, or `null` when there is
733
+ * nothing to report. Errors mark the result invalid, which drives the existing
734
+ * non-zero exit path in {@link validateCommand} (Req 2F.2).
735
+ */
736
+ async function validateOutcomes(
737
+ artifactList: Array<{ path: string; name: string }>,
738
+ ): Promise<ValidationResult | null> {
739
+ // Aggregate outcomes from each artifact's (already schema-validated)
740
+ // frontmatter, and remember each artifact's knowledge.md path for diagnostics.
741
+ const artifactsWithOutcomes: Array<{ name: string; outcomes: Outcome[] }> =
742
+ [];
743
+ const knowledgeMdByName = new Map<string, string>();
744
+
745
+ for (const { path: artifactPath, name } of artifactList) {
746
+ const knowledgeMdPath = join(artifactPath, "knowledge.md");
747
+ knowledgeMdByName.set(name, knowledgeMdPath);
748
+
749
+ const mdResult = await parseKnowledgeMd(knowledgeMdPath);
750
+ if (isParseError(mdResult)) continue;
751
+
752
+ const outcomes = mdResult.data.frontmatter.outcomes ?? [];
753
+ if (outcomes.length > 0) {
754
+ artifactsWithOutcomes.push({ name, outcomes });
755
+ }
756
+ }
757
+
758
+ const report = runRegistryCheck(aggregateOutcomes(artifactsWithOutcomes));
759
+ if (report.findings.length === 0) return null;
760
+
761
+ const errors: ValidationError[] = [];
762
+ const warnings: ValidationWarning[] = [];
763
+
764
+ for (const finding of report.findings) {
765
+ const filePath =
766
+ knowledgeMdByName.get(finding.a.artifactName) ?? "knowledge.md";
767
+ const aId = finding.a.outcome.id;
768
+ const bId = finding.b.outcome.id;
769
+ const aArtifact = finding.a.artifactName;
770
+ const bArtifact = finding.b.artifactName;
771
+
772
+ switch (finding.kind) {
773
+ case "duplicate-id":
774
+ errors.push({
775
+ field: "outcomes",
776
+ message: `Duplicate outcome id "${aId}" declared in both "${aArtifact}" and "${bArtifact}". Outcome ids must be globally unique.`,
777
+ filePath,
778
+ });
779
+ break;
780
+
781
+ case "collision":
782
+ errors.push({
783
+ field: "outcomes",
784
+ message: `Outcome collision: "${aId}" (${aArtifact}) and "${bId}" (${bArtifact}) share normalized shapes ("${finding.inputShape}" → "${finding.outputShape}") with keyword Jaccard ${finding.jaccard?.toFixed(2)}. Acknowledge intentional overlap by adding each id to the other's "related" field.`,
785
+ filePath,
786
+ });
787
+ break;
788
+
789
+ case "ambiguous":
790
+ warnings.push({
791
+ field: "outcomes",
792
+ message: `Ambiguous outcome overlap: "${aId}" (${aArtifact}) and "${bId}" (${bArtifact}) match on exactly one tier (keyword Jaccard ${finding.jaccard?.toFixed(2)}). Review whether these outcomes overlap.`,
793
+ filePath,
794
+ });
795
+ break;
796
+
797
+ // "acknowledged-overlap" is intentional — no finding reported.
798
+ }
799
+ }
800
+
801
+ const result: ValidationResult = {
802
+ artifactName: "[outcomes-registry]",
803
+ valid: errors.length === 0,
804
+ errors,
805
+ };
806
+ if (warnings.length > 0) {
807
+ result.warnings = warnings;
808
+ }
809
+ return result;
810
+ }
811
+
812
+ export async function validateAll(
813
+ knowledgeDirs: string | string[],
814
+ ): Promise<ValidationResult[]> {
815
+ const sourceDirs = Array.isArray(knowledgeDirs)
816
+ ? knowledgeDirs
817
+ : [knowledgeDirs];
818
+ const results: ValidationResult[] = [];
819
+
820
+ const artifactList = await collectArtifactPaths(sourceDirs);
821
+
822
+ for (const { path: artifactPath } of artifactList) {
823
+ const result = await validateArtifact(artifactPath);
824
+ results.push(result);
825
+ }
826
+
827
+ // Cross-artifact dependency reference resolution across all source dirs
828
+ const artifactNames = new Set<string>(artifactList.map((a) => a.name));
829
+
830
+ for (const { path: artifactPath, name: artifactName } of artifactList) {
831
+ const knowledgeMdPath = join(artifactPath, "knowledge.md");
832
+ const mdResult = await parseKnowledgeMd(knowledgeMdPath);
833
+
834
+ if (isParseError(mdResult)) continue;
835
+
836
+ const fm = mdResult.data.frontmatter;
837
+ const warnings: ValidationWarning[] = [];
838
+
839
+ for (const dep of fm.depends) {
840
+ if (!artifactNames.has(dep)) {
841
+ warnings.push({
842
+ field: "depends",
843
+ message: `Unresolved dependency reference: "${dep}"`,
844
+ filePath: knowledgeMdPath,
845
+ });
846
+ }
847
+ }
848
+
849
+ for (const enh of fm.enhances) {
850
+ if (!artifactNames.has(enh)) {
851
+ warnings.push({
852
+ field: "enhances",
853
+ message: `Unresolved enhances reference: "${enh}"`,
854
+ filePath: knowledgeMdPath,
855
+ });
856
+ }
857
+ }
858
+
859
+ if (warnings.length > 0) {
860
+ const matchingResult = results.find(
861
+ (r) => r.artifactName === artifactName,
862
+ );
863
+ if (matchingResult) {
864
+ matchingResult.warnings = [
865
+ ...(matchingResult.warnings ?? []),
866
+ ...warnings,
867
+ ];
868
+ }
869
+ }
870
+ }
871
+
872
+ // Cycle detection across the depends graph
873
+ const dependsMap = new Map<string, string[]>();
874
+ for (const { path: artifactPath, name: artifactName } of artifactList) {
875
+ const knowledgeMdPath = join(artifactPath, "knowledge.md");
876
+ const mdResult = await parseKnowledgeMd(knowledgeMdPath);
877
+ if (!isParseError(mdResult)) {
878
+ dependsMap.set(artifactName, mdResult.data.frontmatter.depends);
879
+ }
880
+ }
881
+ const cycles = detectDependencyCycles(dependsMap);
882
+ if (cycles.length > 0) {
883
+ for (const cycle of cycles) {
884
+ const cycleStr = [...cycle, cycle[0]].join(" → ");
885
+ // Attach the error to the first artifact in the cycle
886
+ const matchingResult = results.find((r) => r.artifactName === cycle[0]);
887
+ if (matchingResult) {
888
+ matchingResult.valid = false;
889
+ matchingResult.errors = [
890
+ ...matchingResult.errors,
891
+ {
892
+ field: "depends",
893
+ message: `Dependency cycle detected: ${cycleStr}`,
894
+ filePath: join(
895
+ artifactList.find((a) => a.name === cycle[0])?.path ?? "",
896
+ "knowledge.md",
897
+ ),
898
+ },
899
+ ];
900
+ }
901
+ }
902
+ }
903
+
904
+ // Cross-artifact outcomes collision detection (Req 2F). Aggregates all
905
+ // declared outcomes and runs the pure registry check; collisions and
906
+ // duplicate ids surface as errors, ambiguous overlaps as warnings.
907
+ const outcomesResult = await validateOutcomes(artifactList);
908
+ if (outcomesResult) {
909
+ results.push(outcomesResult);
910
+ }
911
+
912
+ // Collection cross-check: warn if artifacts declare unknown collection names
913
+ const collectionsDir = "collections";
914
+ const collectionManifests = await loadCollections(collectionsDir);
915
+ if (collectionManifests.length > 0) {
916
+ // Build lightweight CatalogEntry-like objects from the validate results
917
+ const catalogEntries = await generateCatalog(sourceDirs);
918
+ const collectionWarnings = validateArtifactCollectionRefs(
919
+ catalogEntries,
920
+ collectionManifests,
921
+ );
922
+ for (const w of collectionWarnings) {
923
+ // Map to the matching result by artifact path prefix
924
+ const matchingResult = results.find((r) =>
925
+ w.filePath.includes(r.artifactName),
926
+ );
927
+ if (matchingResult) {
928
+ matchingResult.warnings = [...(matchingResult.warnings ?? []), w];
929
+ }
930
+ }
931
+ }
932
+
933
+ // Capability matrix validation: ensure matrix harnesses match adapter registry and format registry
934
+ const matrixSync = validateMatrixSync(
935
+ Object.keys(CAPABILITY_MATRIX),
936
+ Object.keys(adapterRegistry),
937
+ Object.keys(HARNESS_FORMAT_REGISTRY),
938
+ );
939
+ if (matrixSync.missing.length > 0 || matrixSync.extra.length > 0) {
940
+ // Create a synthetic validation result for the capability matrix
941
+ const matrixErrors: ValidationError[] = [];
942
+ for (const h of matrixSync.missing) {
943
+ matrixErrors.push({
944
+ field: "CAPABILITY_MATRIX",
945
+ message: `Missing capability matrix entry for harness: "${h}"`,
946
+ filePath: "src/adapters/capabilities.ts",
947
+ });
948
+ }
949
+ for (const h of matrixSync.extra) {
950
+ matrixErrors.push({
951
+ field: "CAPABILITY_MATRIX",
952
+ message: `Extra capability matrix entry for unknown harness: "${h}"`,
953
+ filePath: "src/adapters/capabilities.ts",
954
+ });
955
+ }
956
+ results.push({
957
+ artifactName: "[capability-matrix]",
958
+ valid: false,
959
+ errors: matrixErrors,
960
+ });
961
+ }
962
+
963
+ // Workspace config validation: validate workspace config if present
964
+ try {
965
+ const wsResult = await loadWorkspaceConfig(process.cwd());
966
+ if (wsResult) {
967
+ const wsErrors = await validateWorkspaceConfig(
968
+ wsResult.config,
969
+ process.cwd(),
970
+ artifactNames,
971
+ );
972
+ if (wsErrors.length > 0) {
973
+ const prefixedErrors: ValidationError[] = wsErrors.map((e) => ({
974
+ ...e,
975
+ message: `kanon.config.yaml: ${e.message}`,
976
+ }));
977
+ results.push({
978
+ artifactName: "[workspace-config]",
979
+ valid: false,
980
+ errors: prefixedErrors,
981
+ });
982
+ }
983
+ }
984
+ } catch {
985
+ // If workspace config loading fails (e.g., parse error), report it
986
+ results.push({
987
+ artifactName: "[workspace-config]",
988
+ valid: false,
989
+ errors: [
990
+ {
991
+ field: "workspace-config",
992
+ message: "kanon.config.yaml: Failed to load workspace configuration",
993
+ filePath: "kanon.config.yaml",
994
+ },
995
+ ],
996
+ });
997
+ }
998
+
999
+ return results;
1000
+ }
1001
+
1002
+ export const SOURCE_DIRS = ["knowledge", "packages"] as const;
1003
+
1004
+ export async function validateCommand(
1005
+ artifactPath?: string,
1006
+ options: { security?: boolean } = {},
1007
+ ): Promise<void> {
1008
+ let results: ValidationResult[];
1009
+
1010
+ if (artifactPath) {
1011
+ const result = await validateArtifact(artifactPath);
1012
+ results = [result];
1013
+ } else {
1014
+ results = await validateAll([...SOURCE_DIRS]);
1015
+ }
1016
+
1017
+ // Overlay security checks if --security flag is set
1018
+ if (options.security) {
1019
+ const sourceDirs = artifactPath ? [] : [...SOURCE_DIRS];
1020
+ const artifactPaths: string[] = [];
1021
+
1022
+ if (artifactPath) {
1023
+ artifactPaths.push(artifactPath);
1024
+ } else {
1025
+ for (const dir of sourceDirs) {
1026
+ if (!(await exists(dir))) continue;
1027
+ const entries = await readdir(dir, { withFileTypes: true });
1028
+ for (const entry of entries.filter((e) => e.isDirectory())) {
1029
+ artifactPaths.push(join(dir, entry.name));
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ for (const aPath of artifactPaths) {
1035
+ const aName = aPath.split("/").pop() ?? aPath;
1036
+ const secResult = await validateArtifactSecurity(aPath);
1037
+ const matching = results.find((r) => r.artifactName === aName);
1038
+ if (matching) {
1039
+ if (secResult.errors.length > 0) {
1040
+ matching.errors.push(...secResult.errors);
1041
+ matching.valid = false;
1042
+ }
1043
+ if (secResult.warnings.length > 0) {
1044
+ matching.warnings = [
1045
+ ...(matching.warnings ?? []),
1046
+ ...secResult.warnings,
1047
+ ];
1048
+ }
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ if (results.length === 0) {
1054
+ console.error(chalk.yellow("No artifacts found to validate."));
1055
+ process.exit(0);
1056
+ }
1057
+
1058
+ let hasErrors = false;
1059
+ let totalWarnings = 0;
1060
+ for (const result of results) {
1061
+ if (result.valid) {
1062
+ console.error(chalk.green(`✓ ${result.artifactName}`));
1063
+ } else {
1064
+ hasErrors = true;
1065
+ console.error(chalk.red(`✗ ${result.artifactName}`));
1066
+ for (const err of result.errors) {
1067
+ console.error(
1068
+ chalk.red(` ${err.field}: ${err.message} (${err.filePath})`),
1069
+ );
1070
+ }
1071
+ }
1072
+ if (result.warnings && result.warnings.length > 0) {
1073
+ totalWarnings += result.warnings.length;
1074
+ for (const warn of result.warnings) {
1075
+ console.error(
1076
+ chalk.yellow(` ⚠ ${warn.field}: ${warn.message} (${warn.filePath})`),
1077
+ );
1078
+ }
1079
+ }
1080
+ }
1081
+
1082
+ const passed = results.filter((r) => r.valid).length;
1083
+ const failed = results.filter((r) => !r.valid).length;
1084
+ let summary = `\n${passed} passed, ${failed} failed out of ${results.length} artifacts`;
1085
+ if (totalWarnings > 0) {
1086
+ summary += `, ${totalWarnings} warning${totalWarnings === 1 ? "" : "s"}`;
1087
+ }
1088
+ console.error(summary);
1089
+
1090
+ if (hasErrors) {
1091
+ process.exit(1);
1092
+ }
1093
+ }