@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
package/src/eval.ts ADDED
@@ -0,0 +1,1169 @@
1
+ import { execSync } from "node:child_process";
2
+ import {
3
+ appendFile,
4
+ exists,
5
+ mkdir,
6
+ readdir,
7
+ readFile,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { basename, join, resolve } from "node:path";
12
+ import chalk from "chalk";
13
+ import * as yaml from "js-yaml";
14
+ import {
15
+ gradeProgressiveSteering,
16
+ type Workload,
17
+ } from "./eval/rubrics/kiro-progressive-steering";
18
+ import { parseHistory } from "./mutation/history";
19
+ import { MUTATION_HISTORY_PATH, runMutationTesting } from "./mutation/runner";
20
+ import type { HarnessName } from "./schemas";
21
+ import { type createTemplateEnv, renderTemplate } from "./template-engine";
22
+
23
+ export interface EvalOptions {
24
+ artifactName?: string;
25
+ harness?: HarnessName;
26
+ threshold?: number;
27
+ output?: string;
28
+ ci?: boolean;
29
+ provider?: string;
30
+ noContext?: boolean;
31
+ init?: string;
32
+ record?: boolean;
33
+ trend?: boolean;
34
+ }
35
+
36
+ export interface EvalTestResult {
37
+ description: string;
38
+ passed: boolean;
39
+ score: number;
40
+ expected?: string;
41
+ actual?: string;
42
+ assertion: string;
43
+ provider: string;
44
+ /** Model output or error text — shown when test fails */
45
+ response?: string;
46
+ /** Grading reason from llm-rubric judge */
47
+ reason?: string;
48
+ /** Provider-level error (e.g. auth failure, API error) */
49
+ error?: string;
50
+ }
51
+
52
+ export interface EvalResult {
53
+ configFile: string;
54
+ artifactName: string;
55
+ totalTests: number;
56
+ passed: number;
57
+ failed: number;
58
+ score: number;
59
+ details: EvalTestResult[];
60
+ }
61
+
62
+ interface EvalConfig {
63
+ configFile: string;
64
+ artifactName: string;
65
+ config: Record<string, unknown>;
66
+ }
67
+
68
+ interface ArtifactEvalDirectory {
69
+ artifactName: string;
70
+ path: string;
71
+ }
72
+
73
+ /**
74
+ * Collect artifact directories in both supported canonical layouts:
75
+ * knowledge/<artifact>/ and knowledge/<namespace>/<artifact>/.
76
+ *
77
+ * An eval directory is an artifact marker as well as knowledge.md. The eval
78
+ * scaffold intentionally creates knowledge/<artifact>/evals/ before the
79
+ * artifact's canonical document exists, so discovery must support that
80
+ * intermediate state.
81
+ */
82
+ async function collectArtifactEvalDirectories(
83
+ knowledgeDir: string,
84
+ ): Promise<ArtifactEvalDirectory[]> {
85
+ const artifactDirectories: ArtifactEvalDirectory[] = [];
86
+
87
+ if (!(await exists(knowledgeDir))) return artifactDirectories;
88
+
89
+ const entries = await readdir(knowledgeDir, { withFileTypes: true });
90
+ for (const entry of entries) {
91
+ if (!entry.isDirectory()) continue;
92
+
93
+ const entryPath = join(knowledgeDir, entry.name);
94
+ const hasKnowledgeDocument = await exists(join(entryPath, "knowledge.md"));
95
+ const hasEvalDirectory = await exists(join(entryPath, "evals"));
96
+ if (hasKnowledgeDocument || hasEvalDirectory) {
97
+ artifactDirectories.push({ artifactName: entry.name, path: entryPath });
98
+ continue;
99
+ }
100
+
101
+ const nestedEntries = await readdir(entryPath, { withFileTypes: true });
102
+ for (const nestedEntry of nestedEntries) {
103
+ if (!nestedEntry.isDirectory()) continue;
104
+
105
+ const artifactPath = join(entryPath, nestedEntry.name);
106
+ const hasNestedKnowledgeDocument = await exists(
107
+ join(artifactPath, "knowledge.md"),
108
+ );
109
+ const hasNestedEvalDirectory = await exists(join(artifactPath, "evals"));
110
+ if (hasNestedKnowledgeDocument || hasNestedEvalDirectory) {
111
+ artifactDirectories.push({
112
+ artifactName: nestedEntry.name,
113
+ path: artifactPath,
114
+ });
115
+ }
116
+ }
117
+ }
118
+
119
+ return artifactDirectories;
120
+ }
121
+
122
+ async function loadArtifactEvalConfigs(
123
+ artifactDirectory: ArtifactEvalDirectory,
124
+ ): Promise<EvalConfig[]> {
125
+ const evalsDir = join(artifactDirectory.path, "evals");
126
+ if (!(await exists(evalsDir))) return [];
127
+
128
+ const configs: EvalConfig[] = [];
129
+ const evalFiles = await readdir(evalsDir);
130
+ for (const file of evalFiles) {
131
+ if (!file.endsWith(".yaml") && !file.endsWith(".yml")) continue;
132
+ const filePath = join(evalsDir, file);
133
+ const raw = await readFile(filePath, "utf-8");
134
+ const config = yaml.load(raw) as Record<string, unknown>;
135
+ configs.push({
136
+ configFile: filePath,
137
+ artifactName: artifactDirectory.artifactName,
138
+ config,
139
+ });
140
+ }
141
+
142
+ return configs;
143
+ }
144
+
145
+ async function resolveArtifactDirectory(artifactName: string): Promise<string> {
146
+ const artifactDirectories = await collectArtifactEvalDirectories("knowledge");
147
+ const artifactDirectory = artifactDirectories.find(
148
+ (directory: ArtifactEvalDirectory): boolean =>
149
+ directory.artifactName === artifactName,
150
+ );
151
+
152
+ return artifactDirectory?.path ?? join("knowledge", artifactName);
153
+ }
154
+
155
+ export async function discoverEvalConfigs(
156
+ knowledgeDir: string,
157
+ topLevelEvalsDir: string,
158
+ artifactName?: string,
159
+ ): Promise<EvalConfig[]> {
160
+ const configs: EvalConfig[] = [];
161
+ const artifactDirectories =
162
+ await collectArtifactEvalDirectories(knowledgeDir);
163
+
164
+ for (const artifactDirectory of artifactDirectories) {
165
+ if (
166
+ artifactName !== undefined &&
167
+ artifactDirectory.artifactName !== artifactName
168
+ ) {
169
+ continue;
170
+ }
171
+ configs.push(...(await loadArtifactEvalConfigs(artifactDirectory)));
172
+ }
173
+
174
+ // Scan top-level evals/
175
+ if (await exists(topLevelEvalsDir)) {
176
+ const evalFiles = await readdir(topLevelEvalsDir);
177
+ for (const file of evalFiles) {
178
+ if (!file.endsWith(".yaml") && !file.endsWith(".yml")) continue;
179
+ if (file === "providers.yaml") continue; // Skip shared provider config
180
+ const filePath = join(topLevelEvalsDir, file);
181
+ const raw = await readFile(filePath, "utf-8");
182
+ const config = yaml.load(raw) as Record<string, unknown>;
183
+ configs.push({
184
+ configFile: filePath,
185
+ artifactName: "cross-artifact",
186
+ config,
187
+ });
188
+ }
189
+ }
190
+
191
+ return configs;
192
+ }
193
+
194
+ export function resolvePromptRefs(
195
+ config: Record<string, unknown>,
196
+ _distDir: string,
197
+ _harness?: HarnessName,
198
+ ): Record<string, unknown> {
199
+ const resolved = { ...config };
200
+ if (Array.isArray(resolved.prompts)) {
201
+ resolved.prompts = resolved.prompts.map((prompt: unknown) => {
202
+ if (typeof prompt === "string" && prompt.startsWith("file://")) {
203
+ const relPath = prompt.slice(7);
204
+ return `file://${resolve(relPath)}`;
205
+ }
206
+ return prompt;
207
+ });
208
+ }
209
+ return resolved;
210
+ }
211
+
212
+ export function applyHarnessContext(
213
+ prompt: string,
214
+ harness: HarnessName,
215
+ templateEnv: ReturnType<typeof createTemplateEnv>,
216
+ ): string {
217
+ try {
218
+ return renderTemplate(templateEnv, `${harness}.md.njk`, { prompt });
219
+ } catch {
220
+ // If no context template exists, return prompt as-is
221
+ return prompt;
222
+ }
223
+ }
224
+
225
+ export async function runEvals(options: EvalOptions): Promise<EvalResult[]> {
226
+ const knowledgeDir = "knowledge";
227
+ const evalsDir = "evals";
228
+ const distDir = "dist";
229
+ const _threshold = options.threshold ?? 0.7;
230
+ const maxRetries = options.ci ? 2 : 0;
231
+
232
+ const configs = await discoverEvalConfigs(
233
+ knowledgeDir,
234
+ evalsDir,
235
+ options.artifactName,
236
+ );
237
+
238
+ if (configs.length === 0) {
239
+ console.error(chalk.yellow("No eval configs found."));
240
+ return [];
241
+ }
242
+
243
+ const results: EvalResult[] = [];
244
+
245
+ for (const evalConfig of configs) {
246
+ const resolved = resolvePromptRefs(
247
+ evalConfig.config,
248
+ distDir,
249
+ options.harness,
250
+ );
251
+
252
+ // Allow CLI --provider to override the config's provider list
253
+ if (options.provider) {
254
+ resolved.providers = [{ id: options.provider }];
255
+ }
256
+
257
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
258
+ try {
259
+ // Dynamically import promptfoo
260
+ const promptfoo = await import("promptfoo");
261
+
262
+ // Suppress noisy GCP metadata probe that promptfoo triggers when
263
+ // falling through its credential chain — not actionable for users.
264
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
265
+ const str = typeof chunk === "string" ? chunk : String(chunk);
266
+ if (
267
+ str.includes("MetadataLookupWarning") ||
268
+ str.includes("gcp-metadata")
269
+ ) {
270
+ return true;
271
+ }
272
+ return (originalStderrWrite as typeof process.stderr.write)(
273
+ chunk as string,
274
+ ...(rest as [BufferEncoding?, (() => void)?]),
275
+ );
276
+ }) as typeof process.stderr.write;
277
+
278
+ // Retry loop — transient API errors (empty responses, rate limits)
279
+ // can cause a full wipeout; retry the whole eval config up to
280
+ // maxRetries times before accepting the result.
281
+ let evalResult: Awaited<ReturnType<typeof promptfoo.evaluate>>;
282
+ let attempt = 0;
283
+ while (true) {
284
+ evalResult = await promptfoo.evaluate(
285
+ resolved as Parameters<typeof promptfoo.evaluate>[0],
286
+ {
287
+ maxConcurrency: 2,
288
+ },
289
+ );
290
+
291
+ // Check if every result is an API error — likely transient
292
+ const allApiErrors =
293
+ evalResult.results.length > 0 &&
294
+ evalResult.results.every((r) => {
295
+ const rowAny = r as unknown as Record<string, unknown>;
296
+ return (
297
+ !r.success &&
298
+ typeof rowAny.error === "string" &&
299
+ (rowAny.error as string).includes("API call error")
300
+ );
301
+ });
302
+
303
+ if (!allApiErrors || attempt >= maxRetries) break;
304
+
305
+ attempt++;
306
+ const delay = attempt * 5_000; // 5s, 10s
307
+ console.error(
308
+ chalk.yellow(
309
+ ` ⟳ All ${evalResult.results.length} tests hit API errors — retrying (${attempt}/${maxRetries}) in ${delay / 1000}s…`,
310
+ ),
311
+ );
312
+ await new Promise((resolve) => setTimeout(resolve, delay));
313
+ }
314
+
315
+ process.stderr.write = originalStderrWrite;
316
+
317
+ const details: EvalTestResult[] = [];
318
+ let passed = 0;
319
+ let failed = 0;
320
+
321
+ if (evalResult.results) {
322
+ for (const row of evalResult.results) {
323
+ const testPassed = row.success;
324
+ if (testPassed) passed++;
325
+ else failed++;
326
+
327
+ const rowAny = row as unknown as Record<string, unknown>;
328
+ const gradingResult = rowAny.gradingResult as
329
+ | Record<string, unknown>
330
+ | undefined;
331
+ const responseObj = rowAny.response as
332
+ | Record<string, unknown>
333
+ | undefined;
334
+
335
+ details.push({
336
+ description: String(
337
+ row.description || row.testCase?.description || "",
338
+ ),
339
+ passed: testPassed,
340
+ score: row.score ?? (testPassed ? 1 : 0),
341
+ assertion: String(row.testCase?.assert?.[0]?.type || ""),
342
+ provider: String(row.provider?.id || ""),
343
+ response:
344
+ responseObj?.output != null
345
+ ? String(responseObj.output)
346
+ : undefined,
347
+ reason:
348
+ gradingResult?.reason != null
349
+ ? String(gradingResult.reason)
350
+ : undefined,
351
+ error: rowAny.error != null ? String(rowAny.error) : undefined,
352
+ });
353
+ }
354
+ }
355
+
356
+ const totalTests = passed + failed;
357
+ const score = totalTests > 0 ? passed / totalTests : 0;
358
+
359
+ results.push({
360
+ configFile: evalConfig.configFile,
361
+ artifactName: evalConfig.artifactName,
362
+ totalTests,
363
+ passed,
364
+ failed,
365
+ score,
366
+ details,
367
+ });
368
+ } catch (e: unknown) {
369
+ process.stderr.write = originalStderrWrite;
370
+ const msg = e instanceof Error ? e.message : String(e);
371
+ console.error(
372
+ chalk.red(`Error running eval ${evalConfig.configFile}: ${msg}`),
373
+ );
374
+ results.push({
375
+ configFile: evalConfig.configFile,
376
+ artifactName: evalConfig.artifactName,
377
+ totalTests: 0,
378
+ passed: 0,
379
+ failed: 1,
380
+ score: 0,
381
+ details: [],
382
+ });
383
+ }
384
+ }
385
+
386
+ return results;
387
+ }
388
+
389
+ export async function scaffoldEvals(artifactName: string): Promise<void> {
390
+ const artifactPath = await resolveArtifactDirectory(artifactName);
391
+ const artifactEvalsDir = join(artifactPath, "evals");
392
+ await mkdir(artifactEvalsDir, { recursive: true });
393
+
394
+ const configContent = `# Eval config for ${artifactName}
395
+ description: "Validate ${artifactName} steering produces correct guidance"
396
+
397
+ prompts:
398
+ - file://dist/kiro/${artifactName}/steering/${artifactName}.md
399
+
400
+ providers:
401
+ - id: bedrock:anthropic.claude-sonnet-4-6
402
+
403
+ tests:
404
+ - description: "Should provide relevant guidance"
405
+ vars:
406
+ user_query: "How should I use this?"
407
+ assert:
408
+ - type: llm-rubric
409
+ value: "Response should be helpful and relevant to ${artifactName}"
410
+ `;
411
+
412
+ await writeFile(
413
+ join(artifactEvalsDir, "promptfooconfig.yaml"),
414
+ configContent,
415
+ "utf-8",
416
+ );
417
+ console.error(
418
+ chalk.green(
419
+ `✓ Scaffolded eval config at ${artifactEvalsDir}/promptfooconfig.yaml`,
420
+ ),
421
+ );
422
+ console.error(`\nNext steps:`);
423
+ console.error(` 1. Edit the eval config with your test cases`);
424
+ console.error(` 2. Run \`kanon eval ${artifactName}\` to execute`);
425
+ }
426
+
427
+ export interface HistoryEntry {
428
+ ts: string;
429
+ sha: string;
430
+ artifact: string;
431
+ scores: Record<string, number>;
432
+ total: { passed: number; failed: number; score: number };
433
+ }
434
+
435
+ const HISTORY_FILE = "evals/history.jsonl";
436
+
437
+ function gitSha(): string {
438
+ try {
439
+ return execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim();
440
+ } catch {
441
+ return "unknown";
442
+ }
443
+ }
444
+
445
+ export async function recordResults(results: EvalResult[]): Promise<void> {
446
+ if (results.length === 0) return;
447
+
448
+ await mkdir("evals", { recursive: true });
449
+
450
+ const artifact = results[0].artifactName;
451
+ const scores: Record<string, number> = {};
452
+ let totalPassed = 0;
453
+ let totalFailed = 0;
454
+
455
+ for (const r of results) {
456
+ const label = basename(r.configFile, ".yaml");
457
+ scores[label] = r.score;
458
+ totalPassed += r.passed;
459
+ totalFailed += r.failed;
460
+ }
461
+
462
+ const totalTests = totalPassed + totalFailed;
463
+ const entry: HistoryEntry = {
464
+ ts: new Date().toISOString(),
465
+ sha: gitSha(),
466
+ artifact,
467
+ scores,
468
+ total: {
469
+ passed: totalPassed,
470
+ failed: totalFailed,
471
+ score: totalTests > 0 ? totalPassed / totalTests : 0,
472
+ },
473
+ };
474
+
475
+ await appendFile(HISTORY_FILE, `${JSON.stringify(entry)}\n`, "utf-8");
476
+ console.error(chalk.green(`✓ Recorded to ${HISTORY_FILE}`));
477
+ }
478
+
479
+ export async function showTrend(artifactName?: string): Promise<void> {
480
+ if (!(await exists(HISTORY_FILE))) {
481
+ console.error(
482
+ chalk.yellow("No history found. Run evals with --record first."),
483
+ );
484
+ return;
485
+ }
486
+
487
+ const raw = await readFile(HISTORY_FILE, "utf-8");
488
+ const lines = raw.trim().split("\n").filter(Boolean);
489
+ let entries: HistoryEntry[] = lines.map((l) => JSON.parse(l));
490
+
491
+ if (artifactName) {
492
+ entries = entries.filter((e) => e.artifact === artifactName);
493
+ }
494
+
495
+ if (entries.length === 0) {
496
+ console.error(
497
+ chalk.yellow(`No history for ${artifactName ?? "any artifact"}.`),
498
+ );
499
+ return;
500
+ }
501
+
502
+ // Group by artifact
503
+ const byArtifact = new Map<string, HistoryEntry[]>();
504
+ for (const e of entries) {
505
+ if (!byArtifact.has(e.artifact)) byArtifact.set(e.artifact, []);
506
+ byArtifact.get(e.artifact)?.push(e);
507
+ }
508
+
509
+ const col = 72;
510
+ const rule = () => chalk.dim("─".repeat(col));
511
+
512
+ for (const [artifact, runs] of byArtifact) {
513
+ console.error("");
514
+ console.error(rule());
515
+ console.error(
516
+ ` ${chalk.bold(artifact)} ${chalk.dim(`${runs.length} runs`)}`,
517
+ );
518
+ console.error(rule());
519
+
520
+ // Collect all score keys across runs
521
+ const allKeys = new Set<string>();
522
+ for (const r of runs) {
523
+ for (const k of Object.keys(r.scores)) allKeys.add(k);
524
+ }
525
+
526
+ // Print header
527
+ const keyList = [...allKeys];
528
+ console.error(
529
+ ` ${chalk.dim("date".padEnd(12))}${chalk.dim("sha".padEnd(10))}${keyList.map((k) => chalk.dim(k.slice(0, 14).padEnd(16))).join("")}${chalk.dim("total")}`,
530
+ );
531
+
532
+ // Print each run
533
+ for (const run of runs) {
534
+ const date = run.ts.slice(0, 10);
535
+ const sha = run.sha.padEnd(10);
536
+ const scoreCells = keyList.map((k) => {
537
+ const s = run.scores[k];
538
+ if (s === undefined) return chalk.dim("—".padEnd(16));
539
+ const pct = `${Math.round(s * 100)}%`;
540
+ const color =
541
+ s >= 0.8 ? chalk.green : s >= 0.5 ? chalk.yellow : chalk.red;
542
+ return color(pct.padEnd(16));
543
+ });
544
+ const totalPct = `${Math.round(run.total.score * 100)}%`;
545
+ const totalColor =
546
+ run.total.score >= 0.8
547
+ ? chalk.green
548
+ : run.total.score >= 0.5
549
+ ? chalk.yellow
550
+ : chalk.red;
551
+
552
+ console.error(
553
+ ` ${date} ${chalk.dim(sha)}${scoreCells.join("")}${totalColor(totalPct)}`,
554
+ );
555
+ }
556
+
557
+ // Sparkline for total score
558
+ if (runs.length >= 2) {
559
+ const sparks = "▁▂▃▄▅▆▇█";
560
+ const scores = runs.map((r) => r.total.score);
561
+ const min = Math.min(...scores);
562
+ const max = Math.max(...scores);
563
+ const range = max - min || 1;
564
+ const sparkline = scores
565
+ .map(
566
+ (s) => sparks[Math.round(((s - min) / range) * (sparks.length - 1))],
567
+ )
568
+ .join("");
569
+ const delta = scores[scores.length - 1] - scores[0];
570
+ const deltaStr =
571
+ delta >= 0
572
+ ? chalk.green(`+${Math.round(delta * 100)}%`)
573
+ : chalk.red(`${Math.round(delta * 100)}%`);
574
+ console.error("");
575
+ console.error(` ${chalk.dim("trend")} ${sparkline} ${deltaStr}`);
576
+ }
577
+ }
578
+
579
+ console.error("");
580
+ }
581
+
582
+ /**
583
+ * Display mutation testing score progression from mutation-history.jsonl.
584
+ * Reuses the sparkline style from standard eval trend display (Req 5.8).
585
+ */
586
+ export async function showMutationTrend(): Promise<void> {
587
+ if (!(await exists(MUTATION_HISTORY_PATH))) {
588
+ console.error(
589
+ chalk.yellow(
590
+ "No mutation history found. Run `kanon eval --mutation` first.",
591
+ ),
592
+ );
593
+ return;
594
+ }
595
+
596
+ const raw = await readFile(MUTATION_HISTORY_PATH, "utf-8");
597
+ const entries = parseHistory(raw);
598
+
599
+ if (entries.length === 0) {
600
+ console.error(chalk.yellow("No mutation history entries found."));
601
+ return;
602
+ }
603
+
604
+ const col = 72;
605
+ const rule = () => chalk.dim("─".repeat(col));
606
+
607
+ console.error("");
608
+ console.error(rule());
609
+ console.error(
610
+ ` ${chalk.bold("Mutation Testing")} ${chalk.dim(`${entries.length} runs`)}`,
611
+ );
612
+ console.error(rule());
613
+
614
+ // Print header
615
+ console.error(
616
+ ` ${chalk.dim("date".padEnd(12))}${chalk.dim("sha".padEnd(10))}${chalk.dim("mutants".padEnd(10))}${chalk.dim("killed".padEnd(10))}${chalk.dim("survived".padEnd(10))}${chalk.dim("kill rate")}`,
617
+ );
618
+
619
+ // Print each run
620
+ for (const run of entries) {
621
+ const date = run.ts.slice(0, 10);
622
+ const sha = run.sha.padEnd(10);
623
+ const total = String(run.totalMutants).padEnd(10);
624
+ const killed = String(run.killed).padEnd(10);
625
+ const survived = String(run.survived).padEnd(10);
626
+ const pct = `${Math.round(run.killRate * 100)}%`;
627
+ const color =
628
+ run.killRate >= 0.8
629
+ ? chalk.green
630
+ : run.killRate >= 0.5
631
+ ? chalk.yellow
632
+ : chalk.red;
633
+
634
+ console.error(
635
+ ` ${date} ${chalk.dim(sha)}${total}${killed}${survived}${color(pct)}`,
636
+ );
637
+ }
638
+
639
+ // Sparkline for kill rate
640
+ if (entries.length >= 2) {
641
+ const sparks = "▁▂▃▄▅▆▇█";
642
+ const scores = entries.map((r) => r.killRate);
643
+ const min = Math.min(...scores);
644
+ const max = Math.max(...scores);
645
+ const range = max - min || 1;
646
+ const sparkline = scores
647
+ .map((s) => sparks[Math.round(((s - min) / range) * (sparks.length - 1))])
648
+ .join("");
649
+ const delta = scores[scores.length - 1] - scores[0];
650
+ const deltaStr =
651
+ delta >= 0
652
+ ? chalk.green(`+${Math.round(delta * 100)}%`)
653
+ : chalk.red(`${Math.round(delta * 100)}%`);
654
+ console.error("");
655
+ console.error(` ${chalk.dim("trend")} ${sparkline} ${deltaStr}`);
656
+ }
657
+
658
+ console.error("");
659
+ }
660
+
661
+ export async function evalCommand(
662
+ artifact?: string,
663
+ options?: Record<string, unknown>,
664
+ ): Promise<void> {
665
+ const opts = options || {};
666
+
667
+ // Handle --init
668
+ if (opts.init) {
669
+ await scaffoldEvals(opts.init as string);
670
+ return;
671
+ }
672
+
673
+ // Handle --mutation mode (Req 5.1, 5.5, 5.6, 5.7, 5.8)
674
+ if (opts.mutation) {
675
+ // --mutation --trend: show mutation history instead of standard eval history
676
+ if (opts.trend) {
677
+ await showMutationTrend();
678
+ return;
679
+ }
680
+
681
+ // Parse threshold — default 0.80 for mutation mode (vs 0.7 for standard evals)
682
+ const threshold = opts.threshold
683
+ ? Number.parseFloat(opts.threshold as string)
684
+ : 0.8;
685
+
686
+ const result = await runMutationTesting({
687
+ threshold,
688
+ delta: opts.delta as boolean | undefined,
689
+ });
690
+
691
+ // Print results summary
692
+ const col = 72;
693
+ const rule = (c = "─") => chalk.dim(c.repeat(col));
694
+
695
+ console.error("");
696
+ console.error(rule());
697
+ console.error(` ${chalk.bold("Mutation Testing Results")}`);
698
+ console.error(rule());
699
+ console.error(
700
+ ` Total mutants: ${chalk.bold(String(result.totalMutants))}`,
701
+ );
702
+ console.error(` Killed: ${chalk.green(String(result.killed))}`);
703
+ console.error(` Survived: ${chalk.red(String(result.survived))}`);
704
+
705
+ const killPct = `${Math.round(result.killRate * 100)}%`;
706
+ const killColor = result.killRate >= threshold ? chalk.green : chalk.red;
707
+ console.error(` Kill rate: ${killColor(killPct)}`);
708
+ console.error(rule());
709
+
710
+ // Report surviving mutants (Req 5.9)
711
+ if (result.survivors.length > 0) {
712
+ console.error("");
713
+ console.error(chalk.yellow(" Surviving mutants:"));
714
+ for (const mutant of result.survivors) {
715
+ console.error("");
716
+ console.error(
717
+ chalk.dim(` ${mutant.filePath}:${mutant.line}`) +
718
+ ` [${mutant.operator}]`,
719
+ );
720
+ console.error(chalk.red(` - ${mutant.originalSnippet}`));
721
+ console.error(chalk.green(` + ${mutant.mutatedSnippet}`));
722
+ }
723
+ }
724
+
725
+ // Exit with code 1 if below threshold (Req 5.5)
726
+ if (result.killRate < threshold) {
727
+ console.error("");
728
+ console.error(
729
+ chalk.red(
730
+ ` ✗ Kill rate ${killPct} is below threshold ${Math.round(threshold * 100)}%`,
731
+ ),
732
+ );
733
+ process.exit(1);
734
+ } else {
735
+ console.error("");
736
+ console.error(
737
+ chalk.green(
738
+ ` ✓ Kill rate ${killPct} meets threshold ${Math.round(threshold * 100)}%`,
739
+ ),
740
+ );
741
+ }
742
+
743
+ return;
744
+ }
745
+
746
+ // Handle --trend (standard eval history)
747
+ if (opts.trend) {
748
+ await showTrend(artifact);
749
+ return;
750
+ }
751
+
752
+ // ── Rubric dispatch ──────────────────────────────────────────────────────
753
+ // When --harness kiro is selected and no --rubric is provided, default to
754
+ // progressive-steering. When --rubric is explicitly provided, dispatch to
755
+ // the matching rubric grader.
756
+ const harness = opts.harness as HarnessName | undefined;
757
+ const rubric =
758
+ (opts.rubric as string | undefined) ??
759
+ (harness === "kiro" ? "progressive-steering" : undefined);
760
+
761
+ if (rubric === "progressive-steering") {
762
+ await runProgressiveSteeringRubric(opts);
763
+ return;
764
+ }
765
+
766
+ const threshold = opts.threshold
767
+ ? Number.parseFloat(opts.threshold as string)
768
+ : 0.7;
769
+
770
+ const results = await runEvals({
771
+ artifactName: artifact,
772
+ harness,
773
+ threshold,
774
+ output: opts.output as string | undefined,
775
+ ci: opts.ci as boolean | undefined,
776
+ provider: opts.provider as string | undefined,
777
+ noContext: opts.context === false,
778
+ });
779
+
780
+ // ── Print results ────────────────────────────────────────────────────────
781
+
782
+ const col = 72; // terminal column width for rule lines
783
+ const rule = (c = "─") => chalk.dim(c.repeat(col));
784
+
785
+ let hasFailures = false;
786
+
787
+ for (const result of results) {
788
+ if (result.failed > 0) hasFailures = true;
789
+
790
+ const allPassed = result.failed === 0 && result.totalTests > 0;
791
+ const headerIcon = allPassed ? chalk.green("●") : chalk.red("●");
792
+
793
+ console.error("");
794
+ console.error(rule());
795
+ console.error(` ${headerIcon} ${chalk.bold(result.artifactName)}`);
796
+ console.error(` ${chalk.dim(result.configFile)}`);
797
+ console.error(rule());
798
+
799
+ if (result.totalTests === 0) {
800
+ console.error(chalk.yellow(" No tests ran."));
801
+ } else {
802
+ for (const detail of result.details) {
803
+ const icon = detail.passed ? chalk.green(" ✓") : chalk.red(" ✗");
804
+ console.error(
805
+ `${icon} ${detail.passed ? chalk.dim(detail.description) : detail.description}`,
806
+ );
807
+
808
+ if (!detail.passed) {
809
+ if (detail.error) {
810
+ // Wrap long error messages at ~60 chars
811
+ const words = detail.error.split(" ");
812
+ const lines: string[] = [];
813
+ let line = "";
814
+ for (const word of words) {
815
+ if ((line + word).length > 60 && line.length > 0) {
816
+ lines.push(line.trimEnd());
817
+ line = `${word} `;
818
+ } else {
819
+ line += `${word} `;
820
+ }
821
+ }
822
+ if (line.trim()) lines.push(line.trimEnd());
823
+ console.error(chalk.red(` ╰─ ${lines.shift()}`));
824
+ for (const l of lines) {
825
+ console.error(chalk.red(` ${l}`));
826
+ }
827
+ } else {
828
+ if (detail.response) {
829
+ const preview =
830
+ detail.response.length > 180
831
+ ? `${detail.response.slice(0, 180)}…`
832
+ : detail.response;
833
+ const oneLine = preview.replace(/\n+/g, " ↵ ");
834
+ console.error(chalk.dim(` ├─ model ${oneLine}`));
835
+ }
836
+ if (detail.reason) {
837
+ console.error(chalk.yellow(` ╰─ judge ${detail.reason}`));
838
+ }
839
+ }
840
+ }
841
+ }
842
+ }
843
+
844
+ // Score bar + summary
845
+ const pct = result.totalTests > 0 ? result.passed / result.totalTests : 0;
846
+ const barLen = 20;
847
+ const filled = Math.round(pct * barLen);
848
+ const bar =
849
+ chalk.green("█".repeat(filled)) + chalk.dim("░".repeat(barLen - filled));
850
+ const scoreColor = allPassed
851
+ ? chalk.green
852
+ : result.passed > 0
853
+ ? chalk.yellow
854
+ : chalk.red;
855
+ console.error("");
856
+ console.error(
857
+ ` ${bar} ${scoreColor(`${result.passed}/${result.totalTests} passed`)}` +
858
+ ` ${chalk.dim(`score ${result.score.toFixed(2)}`)}`,
859
+ );
860
+ }
861
+
862
+ console.error(`\n${rule()}`);
863
+
864
+ // Write JSON output if requested
865
+ if (opts.output) {
866
+ await writeFile(
867
+ opts.output as string,
868
+ JSON.stringify(results, null, 2),
869
+ "utf-8",
870
+ );
871
+ console.error(chalk.green(`Results written to ${opts.output}`));
872
+ }
873
+
874
+ // Record to history ledger if requested
875
+ if (opts.record) {
876
+ await recordResults(results);
877
+ }
878
+
879
+ if (hasFailures) {
880
+ process.exit(1);
881
+ }
882
+ }
883
+
884
+ // ── Progressive Steering rubric runner ───────────────────────────────────────
885
+
886
+ /**
887
+ * Serialise an object as canonical JSON: sorted keys at every nesting level,
888
+ * stable list order (lists are already stable-sorted by the grader).
889
+ */
890
+ function canonicalJsonStringify(obj: unknown): string {
891
+ return JSON.stringify(
892
+ obj,
893
+ (_key, value) => {
894
+ if (value && typeof value === "object" && !Array.isArray(value)) {
895
+ const sorted: Record<string, unknown> = {};
896
+ for (const k of Object.keys(value).sort()) {
897
+ sorted[k] = (value as Record<string, unknown>)[k];
898
+ }
899
+ return sorted;
900
+ }
901
+ return value;
902
+ },
903
+ 2,
904
+ );
905
+ }
906
+
907
+ /**
908
+ * Run the progressive-steering rubric against a compiled build.
909
+ *
910
+ * When --build is provided, uses it as the buildDir directly.
911
+ * Otherwise builds source artifacts into a tempdir.
912
+ *
913
+ * When --json is set, serialises ProgressiveSteeringResult as canonical JSON
914
+ * to --output path or stdout.
915
+ *
916
+ * Exit code: green/yellow → 0, red → 1.
917
+ */
918
+ async function runProgressiveSteeringRubric(
919
+ opts: Record<string, unknown>,
920
+ ): Promise<void> {
921
+ const buildDir = opts.build as string | undefined;
922
+ const jsonOutput = opts.json as boolean | undefined;
923
+ const outputPath = opts.output as string | undefined;
924
+
925
+ // Determine the build directory to grade
926
+ let effectiveBuildDir: string;
927
+
928
+ if (buildDir) {
929
+ // Use the provided build directory
930
+ effectiveBuildDir = resolve(buildDir);
931
+ if (!(await exists(effectiveBuildDir))) {
932
+ console.error(
933
+ chalk.red(
934
+ `Error: Build directory does not exist: ${effectiveBuildDir}`,
935
+ ),
936
+ );
937
+ process.exit(1);
938
+ }
939
+
940
+ // Descend to the harness directory when handed a parent that wraps it.
941
+ // The grader derives each artifact's name from the FIRST path segment
942
+ // (<artifact>/<artifact>.md), so a build dir one level above the harness
943
+ // folder makes every name resolve to "kiro" (or "dist"), silently
944
+ // zeroing FMP and MD and producing a false RED. Auto-correct the common
945
+ // wrappers — …/expected-build (→ kiro) and …/expected-build/dist
946
+ // (→ dist/kiro) — so the metric still reflects the real build.
947
+ for (const wrapper of ["dist/kiro", "kiro"]) {
948
+ const candidate = join(effectiveBuildDir, wrapper);
949
+ if (await exists(candidate)) {
950
+ effectiveBuildDir = candidate;
951
+ break;
952
+ }
953
+ }
954
+ } else {
955
+ // Build into a tempdir from source artifacts
956
+ const { build, SOURCE_DIRS } = await import("./build");
957
+ const tempDir = join(tmpdir(), `forge-eval-${Date.now()}`);
958
+ await mkdir(tempDir, { recursive: true });
959
+ const templatesDir = "templates/harness-adapters";
960
+ const mcpServersDir = "mcp-servers";
961
+
962
+ await build({
963
+ knowledgeDirs: [...SOURCE_DIRS],
964
+ distDir: tempDir,
965
+ templatesDir,
966
+ mcpServersDir,
967
+ harness: "kiro",
968
+ });
969
+
970
+ effectiveBuildDir = join(tempDir, "kiro");
971
+ }
972
+
973
+ // Load the workload that BELONGS to the build being graded.
974
+ //
975
+ // Precedence:
976
+ // 1. An explicit --workload path always wins.
977
+ // 2. When --build points inside a scenario's expected-build tree, use
978
+ // that same scenario's workload.json — pairing a build with a
979
+ // different scenario's workload produces a spurious RED (a scenario's
980
+ // workload encodes its own expectedFired[] / openedFiles[] ground
981
+ // truth, so a mismatch zeroes out FMP and MD).
982
+ // 3. Otherwise fall back to the first discovered fixture workload.
983
+ // 4. Empty workload when none is found.
984
+ let workload: Workload[] = [];
985
+ const explicitWorkload = opts.workload as string | undefined;
986
+ const fixturesBase = "fixtures/eval/kiro-progressive-steering";
987
+
988
+ async function readWorkload(path: string): Promise<Workload[]> {
989
+ const raw = await readFile(path, "utf-8");
990
+ return JSON.parse(raw) as Workload[];
991
+ }
992
+
993
+ if (explicitWorkload) {
994
+ const resolvedWorkload = resolve(explicitWorkload);
995
+ if (!(await exists(resolvedWorkload))) {
996
+ console.error(
997
+ chalk.red(`Error: Workload file does not exist: ${resolvedWorkload}`),
998
+ );
999
+ process.exit(1);
1000
+ }
1001
+ workload = await readWorkload(resolvedWorkload);
1002
+ } else if (buildDir && (await exists(fixturesBase))) {
1003
+ // Match the build against a scenario's expected-build directory.
1004
+ const scenarios = await readdir(fixturesBase, { withFileTypes: true });
1005
+ for (const scenario of scenarios) {
1006
+ if (!scenario.isDirectory()) continue;
1007
+ const scenarioBuild = resolve(
1008
+ join(fixturesBase, scenario.name, "expected-build"),
1009
+ );
1010
+ const workloadPath = join(fixturesBase, scenario.name, "workload.json");
1011
+ // effectiveBuildDir is the resolved --build path; it sits at or under
1012
+ // the scenario's expected-build tree (e.g. .../expected-build/kiro).
1013
+ if (
1014
+ (effectiveBuildDir === scenarioBuild ||
1015
+ effectiveBuildDir.startsWith(`${scenarioBuild}/`)) &&
1016
+ (await exists(workloadPath))
1017
+ ) {
1018
+ workload = await readWorkload(workloadPath);
1019
+ break;
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ // Fallback: first discovered fixture workload (e.g. grading a real dist/kiro).
1025
+ if (
1026
+ workload.length === 0 &&
1027
+ !explicitWorkload &&
1028
+ (await exists(fixturesBase))
1029
+ ) {
1030
+ const scenarios = await readdir(fixturesBase, { withFileTypes: true });
1031
+ for (const scenario of scenarios) {
1032
+ if (!scenario.isDirectory()) continue;
1033
+ const workloadPath = join(fixturesBase, scenario.name, "workload.json");
1034
+ if (await exists(workloadPath)) {
1035
+ workload = await readWorkload(workloadPath);
1036
+ break;
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ // Run the grader
1042
+ const result = await gradeProgressiveSteering(effectiveBuildDir, workload);
1043
+
1044
+ // Output results
1045
+ if (jsonOutput) {
1046
+ const jsonStr = canonicalJsonStringify(result);
1047
+ if (outputPath) {
1048
+ await writeFile(outputPath, jsonStr, "utf-8");
1049
+ console.error(chalk.green(`Rubric result written to ${outputPath}`));
1050
+ } else {
1051
+ console.log(jsonStr);
1052
+ }
1053
+ } else {
1054
+ // Polished terminal output: banner, metric table, per-file details
1055
+ const ratingColor =
1056
+ result.rating === "green"
1057
+ ? chalk.green
1058
+ : result.rating === "yellow"
1059
+ ? chalk.yellow
1060
+ : chalk.red;
1061
+ const ratingIcon =
1062
+ result.rating === "green"
1063
+ ? "🟢"
1064
+ : result.rating === "yellow"
1065
+ ? "🟡"
1066
+ : "🔴";
1067
+
1068
+ // Rating banner
1069
+ console.error("");
1070
+ console.error(
1071
+ ratingColor(
1072
+ ` ${ratingIcon} Progressive Steering: ${result.rating.toUpperCase()} (${result.score.toFixed(1)}/100)`,
1073
+ ),
1074
+ );
1075
+ console.error("");
1076
+
1077
+ // Metric table header
1078
+ const hdr = ` ${"Metric".padEnd(8)} ${"Target".padEnd(10)} ${"Actual".padEnd(10)} Status`;
1079
+ console.error(chalk.bold(hdr));
1080
+ console.error(` ${"─".repeat(42)}`);
1081
+
1082
+ // Green-gate targets per Design §3
1083
+ const metrics: Array<{
1084
+ name: string;
1085
+ target: string;
1086
+ actual: number;
1087
+ pass: boolean;
1088
+ }> = [
1089
+ {
1090
+ name: "AOCW",
1091
+ target: "≤ 0.40",
1092
+ actual: result.metrics.AOCW,
1093
+ pass: result.metrics.AOCW <= 0.4,
1094
+ },
1095
+ {
1096
+ name: "PR",
1097
+ target: "≥ 0.60",
1098
+ actual: result.metrics.PR,
1099
+ pass: result.metrics.PR >= 0.6,
1100
+ },
1101
+ {
1102
+ name: "FMP",
1103
+ target: "≥ 0.75",
1104
+ actual: result.metrics.FMP,
1105
+ pass: result.metrics.FMP >= 0.75,
1106
+ },
1107
+ {
1108
+ name: "MD",
1109
+ target: "≥ 0.50",
1110
+ actual: result.metrics.MD,
1111
+ pass: result.metrics.MD >= 0.5,
1112
+ },
1113
+ {
1114
+ name: "DER",
1115
+ target: "≤ 0.50",
1116
+ actual: result.metrics.DER,
1117
+ pass: result.metrics.DER <= 0.5,
1118
+ },
1119
+ {
1120
+ name: "WCA",
1121
+ target: "≥ 0.50",
1122
+ actual: result.metrics.WCA,
1123
+ pass: result.metrics.WCA >= 0.5,
1124
+ },
1125
+ ];
1126
+
1127
+ for (const m of metrics) {
1128
+ const status = m.pass ? chalk.green("✓") : chalk.red("✗");
1129
+ const actualStr = m.actual.toFixed(3);
1130
+ console.error(
1131
+ ` ${m.name.padEnd(8)} ${m.target.padEnd(10)} ${actualStr.padEnd(10)} ${status}`,
1132
+ );
1133
+ }
1134
+ console.error("");
1135
+
1136
+ // Per-file details when rating ≠ Green
1137
+ if (result.rating !== "green") {
1138
+ const { defaultSourceArtifacts, misalignedWizardArtifacts } =
1139
+ result.details;
1140
+ if (defaultSourceArtifacts.length > 0) {
1141
+ console.error(
1142
+ chalk.yellow(
1143
+ " Artifacts using default inclusion (should be explicit):",
1144
+ ),
1145
+ );
1146
+ for (const name of defaultSourceArtifacts) {
1147
+ console.error(` • ${name}`);
1148
+ }
1149
+ console.error("");
1150
+ }
1151
+ if (misalignedWizardArtifacts.length > 0) {
1152
+ console.error(
1153
+ chalk.yellow(
1154
+ " Power/reference-pack artifacts with always-on inclusion:",
1155
+ ),
1156
+ );
1157
+ for (const name of misalignedWizardArtifacts) {
1158
+ console.error(` • ${name}`);
1159
+ }
1160
+ console.error("");
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ // Propagate rating to exit code: green/yellow → 0, red → 1
1166
+ if (result.rating === "red") {
1167
+ process.exit(1);
1168
+ }
1169
+ }