@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.
- package/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- 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
|
+
}
|