Files
Dragan Spiridonov 2068c2932f fix(adr-093): clean up lingering retiring model refs in overrides + scripts
Post-verification sweep found 4 categories of missed references:

(a) 6 qe-* agents had explicit advisor.model: anthropic/claude-opus-4
    overriding the new 4.7 default from Commit 5:
    qe-coverage-specialist, qe-fleet-commander, qe-queen-coordinator,
    qe-risk-assessor, qe-root-cause-analyzer, qe-test-architect.
    Updated each to anthropic/claude-opus-4.7.

(b) scripts/adr-092-phase0a-harness.ts: EXECUTOR_MODEL /
    ADVISOR_MODEL constants for the ADR-092 Phase 0 eval harness
    still hardcoded Sonnet 4 / Opus 4. Updated to Sonnet 4.6 and
    Opus 4.7 so re-running the harness produces data consistent
    with the post-migration fleet.

(c) scripts/eval-driven-workflow.ts: YAML template's models_to_test
    list updated from claude-sonnet-4 / claude-3-haiku to
    claude-sonnet-4-6 / claude-haiku-4-5.

(d) .claude/settings.json:188 claudeFlow.modelPreferences.default
    updated from claude-opus-4-5-20251101 to claude-opus-4-7
    (explicit user approval).

Not touched in this commit:
* .claude/settings.json:189 modelPreferences.routing still
  'claude-3-5-haiku-20241022' (retiring 2026-06-15). Awaiting
  explicit user direction.
* .claude/skills/*/evals/*.yaml, assets/skills/a11y-ally/SKILL.md,
  assets/skills/testability-scoring/evals/*.yaml — skill eval
  YAMLs with model references. Scope deferred to user review.
* Historical docs/tests that reference retiring IDs as regression
  coverage (CHANGELOG, ADR-056, SPEC-043-C, model-mapping/registry
  test files).

Verification:
* 0 tsc errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:35:42 +00:00

705 lines
25 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Eval-Driven Development Workflow
*
* Enables the eval-driven development loop: baseline → write skill → compare.
* Two subcommands:
* init — Bootstrap eval scaffolding for a skill from its SKILL.md
* compare — Compare two eval run JSON files (before/after)
*
* Usage:
* npx tsx scripts/eval-driven-workflow.ts init <skill-name>
* npx tsx scripts/eval-driven-workflow.ts compare <before.json> <after.json>
* npx tsx scripts/eval-driven-workflow.ts --help
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, dirname } from 'path';
// ============================================================================
// CONSTANTS
// ============================================================================
const SKILLS_DIR = '.claude/skills';
const PLATFORM_PREFIXES = ['v3-', 'flow-nexus-', 'agentdb-', 'reasoningbank-', 'swarm-'];
// ============================================================================
// FRONTMATTER PARSER (shared pattern from score-skill-quality.ts)
// ============================================================================
function parseYamlFrontmatter(content: string): Record<string, unknown> {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const result: Record<string, unknown> = {};
const lines = match[1].split('\n');
let inNested = false;
let nestedKey = '';
const nestedObj: Record<string, unknown> = {};
for (const line of lines) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const indent = line.search(/\S/);
if (indent > 0 && inNested) {
const kv = line.trim().match(/^([\w_]+):\s*(.+)$/);
if (kv) nestedObj[kv[1]] = parseYamlValue(kv[2]);
continue;
}
if (indent === 0 && inNested) {
result[nestedKey] = { ...nestedObj };
inNested = false;
}
const kv = line.trim().match(/^([\w_]+):\s*(.*)$/);
if (kv) {
const [, key, value] = kv;
if (!value || value.trim() === '') {
inNested = true;
nestedKey = key;
Object.keys(nestedObj).forEach(k => delete nestedObj[k]);
} else {
result[key] = parseYamlValue(value.trim());
}
}
}
if (inNested) result[nestedKey] = { ...nestedObj };
return result;
}
function parseYamlValue(value: string): unknown {
const t = value.trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))
return t.slice(1, -1);
if (t.startsWith('[') && t.endsWith(']'))
return t.slice(1, -1).split(',').map(i => {
const s = i.trim();
return (s.startsWith('"') || s.startsWith("'")) ? s.slice(1, -1) : s;
});
if (t === 'true') return true;
if (t === 'false') return false;
const n = Number(t);
if (!isNaN(n) && t !== '') return n;
return t;
}
// ============================================================================
// PROJECT ROOT
// ============================================================================
function getProjectRoot(): string {
let dir = process.cwd();
while (dir !== '/') {
if (existsSync(join(dir, 'package.json'))) return dir;
dir = dirname(dir);
}
return process.cwd();
}
// ============================================================================
// KEYWORD EXTRACTION
// ============================================================================
/** Extract meaningful keywords from SKILL.md body for must_contain assertions */
function extractKeywords(body: string, frontmatter: Record<string, unknown>): string[] {
const keywords = new Set<string>();
// Extract tool/standard names commonly referenced in skills
const toolPatterns = [
/\b(owasp|pact|k6|artillery|jmeter|wcag|jest|vitest|playwright|cypress)\b/gi,
/\b(supertest|graphql|rest|grpc|openapi|swagger|postman|cucumber|gherkin)\b/gi,
/\b(bdd|tdd|mutation|stryker|sonarqube|eslint|docker|kubernetes|terraform)\b/gi,
/\b(kafka|rabbitmq|redis|postgresql|mongodb|oauth|jwt|saml)\b/gi,
/\b(xss|sqli|csrf|ssrf|sast|dast|sca|sbom|cve)\b/gi,
/\b(MCP|hooks|pre-edit|post-edit|session|memory|neural|swarm|agent)\b/gi,
];
for (const pattern of toolPatterns) {
const matches = body.match(pattern);
if (matches) {
for (const m of matches) keywords.add(m.toLowerCase());
}
}
// Extract markdown headings as domain keywords
const headings = body.match(/^#{1,3}\s+(.+)$/gm);
if (headings) {
for (const h of headings.slice(0, 8)) {
const text = h.replace(/^#+\s+/, '').trim().toLowerCase();
if (text.length > 3 && text.length < 40) keywords.add(text);
}
}
// Add tags from frontmatter
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
for (const tag of tags) {
if (typeof tag === 'string') keywords.add(tag.toLowerCase());
}
return [...keywords].slice(0, 12);
}
/** Extract the primary capability description from SKILL.md body */
function extractPrimaryCapability(body: string): string {
// Look for "What This Skill Does" or first substantial paragraph
const whatMatch = body.match(/##\s*What This Skill Does\s*\n+([\s\S]*?)(?=\n##|\n\*\*Key)/);
if (whatMatch) return whatMatch[1].trim().split('\n')[0];
// Fall back to first paragraph after the title
const firstPara = body.match(/^#[^#].*\n+([A-Z][\s\S]*?)(?=\n\n|\n##)/m);
if (firstPara) return firstPara[1].trim().split('\n')[0];
return 'the primary functionality described in SKILL.md';
}
// ============================================================================
// INIT SUBCOMMAND
// ============================================================================
function runInit(skillName: string): void {
const projectRoot = getProjectRoot();
const skillDir = join(projectRoot, SKILLS_DIR, skillName);
// 1. Validate skill exists
if (!existsSync(skillDir) || !statSync(skillDir).isDirectory()) {
console.error(`Error: Skill directory not found: ${SKILLS_DIR}/${skillName}`);
console.error(` Available skills are in ${SKILLS_DIR}/`);
process.exit(1);
}
// Check it's not a platform skill
if (PLATFORM_PREFIXES.some(p => skillName.startsWith(p))) {
console.error(`Error: '${skillName}' is a platform skill, not an AQE skill.`);
console.error(' Only AQE skills are supported by this workflow.');
process.exit(1);
}
// 2. Check if eval already exists
const evalsDir = join(skillDir, 'evals');
const evalPath = join(evalsDir, `${skillName}.yaml`);
if (existsSync(evalPath)) {
console.log(`Eval already exists: ${SKILLS_DIR}/${skillName}/evals/${skillName}.yaml`);
console.log(' To regenerate, delete the existing file first.');
process.exit(0);
}
// 3. Read SKILL.md
const mdPath = existsSync(join(skillDir, 'SKILL.md'))
? join(skillDir, 'SKILL.md')
: existsSync(join(skillDir, 'skill.md'))
? join(skillDir, 'skill.md')
: null;
if (!mdPath) {
console.error(`Error: No SKILL.md found in ${SKILLS_DIR}/${skillName}/`);
process.exit(1);
}
const content = readFileSync(mdPath, 'utf-8');
const frontmatter = parseYamlFrontmatter(content);
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
const body = bodyMatch ? bodyMatch[1] : content;
// 4. Extract info from SKILL.md
const description = String(frontmatter.description || frontmatter.name || skillName);
const category = String(frontmatter.category || 'general');
const priority = String(frontmatter.priority || 'p1');
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
const agents = Array.isArray(frontmatter.agents) ? frontmatter.agents : [];
const keywords = extractKeywords(body, frontmatter);
const primaryCapability = extractPrimaryCapability(body);
// 5. Build must_contain keywords for test cases (pick top relevant ones)
const mustContainBasic = keywords.slice(0, 3);
const mustContainCore = keywords.slice(0, 5);
// 6. Generate YAML
const today = new Date().toISOString().split('T')[0];
const yaml = `# =============================================================================
# AQE ${skillName} Skill Evaluation Test Suite v1.0.0
# Generated by eval-driven-workflow.ts on ${today}
# =============================================================================
#
# Eval-driven development workflow:
# 1. Review and customize test cases below (look for TODO comments)
# 2. Run baseline: npx tsx scripts/run-skill-eval.ts --skill ${skillName} --output before.json
# 3. Improve SKILL.md based on eval failures
# 4. Compare: npx tsx scripts/eval-driven-workflow.ts compare before.json after.json
#
# Schema: .claude/skills/.validation/schemas/skill-eval.schema.json
# Runner: scripts/run-skill-eval.ts
# =============================================================================
skill: ${skillName}
version: 1.0.0
description: >
Evaluation test suite for ${skillName} skill.
${description.slice(0, 120)}
# =============================================================================
# Multi-Model Configuration
# =============================================================================
models_to_test:
- claude-sonnet-4-6 # Primary model (high accuracy expected)
- claude-haiku-4-5 # Fast model (minimum quality bar)
# =============================================================================
# MCP Integration Configuration
# =============================================================================
mcp_integration:
enabled: true
namespace: skill-validation
query_patterns: true
track_outcomes: true
store_patterns: true
share_learning: true
update_quality_gate: true
target_agents:
- qe-learning-coordinator
- qe-queen-coordinator
# =============================================================================
# ReasoningBank Learning Configuration
# =============================================================================
learning:
store_success_patterns: true
store_failure_patterns: true
pattern_ttl_days: 90
min_confidence_to_store: 0.7
cross_model_comparison: true
# =============================================================================
# Result Format Configuration
# =============================================================================
result_format:
json_output: true
markdown_report: false
include_raw_output: false
include_timing: true
include_token_usage: true
# =============================================================================
# Test Cases — 5 seed cases generated from SKILL.md
# =============================================================================
# TODO: Review each test case and customize prompts, must_contain keywords,
# and validation thresholds for your specific skill behavior.
# =============================================================================
test_cases:
# ---------------------------------------------------------------------------
# tc001: Basic Invocation
# ---------------------------------------------------------------------------
- id: tc001_basic_invocation
description: "Skill responds to basic invocation with relevant output"
category: basic
priority: critical
input:
prompt: |
# TODO: Replace with a minimal, realistic prompt for this skill
I need help with ${skillName.replace(/-/g, ' ')}.
context:
language: typescript
expected_output:
must_contain:
${mustContainBasic.map(k => ` - "${k}"`).join('\n') || ' - "TODO_KEYWORD" # TODO: Add expected keywords'}
must_not_contain:
- "unable to"
- "I cannot"
validation:
schema_check: true
keyword_match_threshold: 0.6
reasoning_quality_min: 0.5
# ---------------------------------------------------------------------------
# tc002: Handles Empty/Missing Input
# ---------------------------------------------------------------------------
- id: tc002_handles_empty_input
description: "Skill handles empty or missing input gracefully"
category: edge_cases
priority: high
input:
prompt: ""
context:
language: unknown
expected_output:
must_contain:
- "provide" # TODO: Adjust — what should the skill say for empty input?
must_not_contain:
- "exception"
- "crash"
- "undefined"
validation:
schema_check: true
allow_partial: true
# ---------------------------------------------------------------------------
# tc003: Core Capability
# ---------------------------------------------------------------------------
- id: tc003_core_capability
description: "Tests the primary capability: ${primaryCapability.slice(0, 80)}"
category: core
priority: critical
input:
prompt: |
# TODO: Write a prompt that exercises the core capability of this skill
# Core capability: ${primaryCapability.slice(0, 100)}
Help me apply ${skillName.replace(/-/g, ' ')} to a sample project.
context:
language: typescript
framework: nodejs
expected_output:
must_contain:
${mustContainCore.map(k => ` - "${k}"`).join('\n') || ' - "TODO_KEYWORD" # TODO: Add expected keywords'}
must_not_contain:
- "error"
- "not supported"
validation:
schema_check: true
keyword_match_threshold: 0.8
reasoning_quality_min: 0.6
# ---------------------------------------------------------------------------
# tc004: Output Structure
# ---------------------------------------------------------------------------
- id: tc004_output_structure
description: "Validates output contains expected sections and structure"
category: structure
priority: high
input:
prompt: |
# TODO: Write a prompt that should produce well-structured output
Give me a comprehensive guide for ${skillName.replace(/-/g, ' ')}.
context:
language: typescript
expected_output:
must_contain:
- "##" # TODO: Expect markdown headings?
${mustContainBasic.slice(0, 2).map(k => ` - "${k}"`).join('\n') || ' - "TODO_KEYWORD"'}
must_not_contain:
- "TODO"
- "placeholder"
validation:
schema_check: true
keyword_match_threshold: 0.7
# ---------------------------------------------------------------------------
# tc005: Negative Control
# ---------------------------------------------------------------------------
- id: tc005_negative_control
description: "Input where skill should decline or redirect to another skill"
category: negative
priority: high
input:
prompt: |
# TODO: Write an out-of-scope prompt that this skill should NOT handle
How do I make a soufflé?
context:
language: unknown
expected_output:
must_not_contain:
- "recipe"
- "ingredients"
- "bake"
# TODO: What should the skill say when declining? Add must_contain keywords.
validation:
schema_check: true
allow_partial: true
# =============================================================================
# Success Criteria
# =============================================================================
success_criteria:
pass_rate: 0.8 # 80% starter threshold — increase as skill matures
critical_pass_rate: 1.0 # Critical tests must always pass
avg_reasoning_quality: 0.6
max_execution_time_ms: 300000
cross_model_variance: 0.2
# =============================================================================
# Metadata
# =============================================================================
metadata:
author: "eval-driven-workflow"
created: "${today}"
last_updated: "${today}"
coverage_target: "Core functionality and basic edge cases"
source_skill_category: "${category}"
source_skill_priority: "${priority}"
source_skill_tags: [${tags.map(t => `"${t}"`).join(', ')}]
source_skill_agents: [${agents.map(a => `"${a}"`).join(', ')}]
`;
// 7. Write file
if (!existsSync(evalsDir)) {
mkdirSync(evalsDir, { recursive: true });
}
writeFileSync(evalPath, yaml);
// 8. Print next steps
const keywordList = keywords.length > 0 ? keywords.join(', ') : '(none extracted — add manually)';
console.log(`Created eval scaffold: ${SKILLS_DIR}/${skillName}/evals/${skillName}.yaml`);
console.log(` 5 seed test cases generated from SKILL.md`);
console.log(` Keywords extracted: ${keywordList}`);
console.log('');
console.log('Next steps:');
console.log(' 1. Review and customize test cases (look for TODO comments)');
console.log(` 2. Run baseline: npx tsx scripts/run-skill-eval.ts --skill ${skillName} --output before.json`);
console.log(' 3. Improve SKILL.md based on eval failures');
console.log(` 4. Compare: npx tsx scripts/eval-driven-workflow.ts compare before.json after.json`);
}
// ============================================================================
// COMPARE SUBCOMMAND
// ============================================================================
interface TestCaseResult {
id: string;
passed: boolean;
skipped: boolean;
execution_time_ms: number;
}
interface ModelEvalResult {
model: string;
skill: string;
pass_rate: number;
critical_pass_rate: number;
total_execution_time_ms: number;
test_results: TestCaseResult[];
}
interface EvalRunResult {
skill: string;
model_results: ModelEvalResult[];
summary: {
avg_pass_rate: number;
};
}
function runCompare(beforePath: string, afterPath: string): void {
// 1. Read files
if (!existsSync(beforePath)) {
console.error(`Error: Before file not found: ${beforePath}`);
process.exit(1);
}
if (!existsSync(afterPath)) {
console.error(`Error: After file not found: ${afterPath}`);
process.exit(1);
}
let before: EvalRunResult;
let after: EvalRunResult;
try {
before = JSON.parse(readFileSync(beforePath, 'utf-8'));
after = JSON.parse(readFileSync(afterPath, 'utf-8'));
} catch (e) {
console.error(`Error: Failed to parse JSON: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}
const skillName = after.skill || before.skill || 'unknown';
// Use first model result for comparison (most common use case)
const beforeModel = before.model_results?.[0];
const afterModel = after.model_results?.[0];
if (!beforeModel || !afterModel) {
console.error('Error: Both files must contain at least one model_results entry.');
process.exit(1);
}
// 2. Compare aggregate metrics
const beforePassRate = beforeModel.pass_rate * 100;
const afterPassRate = afterModel.pass_rate * 100;
const deltaPassRate = afterPassRate - beforePassRate;
const beforeCritical = beforeModel.critical_pass_rate * 100;
const afterCritical = afterModel.critical_pass_rate * 100;
const deltaCritical = afterCritical - beforeCritical;
const beforeTime = beforeModel.total_execution_time_ms / 1000;
const afterTime = afterModel.total_execution_time_ms / 1000;
const deltaTime = afterTime - beforeTime;
// 3. Compare per-test-case results
const beforeResults = new Map<string, TestCaseResult>();
for (const r of beforeModel.test_results || []) {
beforeResults.set(r.id, r);
}
interface TestChange {
id: string;
before: string;
after: string;
label: string;
}
const changes: TestChange[] = [];
for (const r of afterModel.test_results || []) {
const b = beforeResults.get(r.id);
const bStatus = b ? (b.skipped ? 'SKIP' : b.passed ? 'PASS' : 'FAIL') : 'NEW';
const aStatus = r.skipped ? 'SKIP' : r.passed ? 'PASS' : 'FAIL';
let label: string;
if (bStatus === 'NEW') label = '(new test)';
else if (bStatus === 'FAIL' && aStatus === 'PASS') label = '(improved)';
else if (bStatus === 'PASS' && aStatus === 'FAIL') label = '(REGRESSION)';
else if (bStatus === aStatus) label = '(stable)';
else label = '(changed)';
changes.push({ id: r.id, before: bStatus, after: aStatus, label });
}
// Check for tests removed in after
for (const [id] of beforeResults) {
if (!afterModel.test_results?.find(r => r.id === id)) {
changes.push({ id, before: 'PASS/FAIL', after: 'REMOVED', label: '(removed)' });
}
}
// 4. Output diff table
const sep = '='.repeat(72);
const divider = '-'.repeat(54);
console.log(sep);
console.log(`EVAL COMPARISON: ${skillName}`);
console.log(sep);
console.log(`${'Metric'.padEnd(26)}${'Before'.padStart(10)}${'After'.padStart(10)}${'Delta'.padStart(10)}`);
console.log(divider);
const fmtPct = (v: number) => `${v.toFixed(1)}%`;
const fmtDelta = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`;
const fmtTime = (v: number) => `${v.toFixed(1)}s`;
const fmtTimeDelta = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}s`;
const arrow = (v: number) => v > 0 ? ' \u2191' : v < 0 ? ' \u2193' : '';
console.log(
`${'Pass rate'.padEnd(26)}${fmtPct(beforePassRate).padStart(10)}${fmtPct(afterPassRate).padStart(10)}${(fmtDelta(deltaPassRate) + arrow(deltaPassRate)).padStart(10)}`
);
console.log(
`${'Critical pass rate'.padEnd(26)}${fmtPct(beforeCritical).padStart(10)}${fmtPct(afterCritical).padStart(10)}${(fmtDelta(deltaCritical) + arrow(deltaCritical)).padStart(10)}`
);
console.log(
`${'Avg execution time'.padEnd(26)}${fmtTime(beforeTime).padStart(10)}${fmtTime(afterTime).padStart(10)}${fmtTimeDelta(deltaTime).padStart(10)}`
);
console.log('');
console.log('Test Case Changes:');
for (const c of changes) {
const idPad = c.id.length > 30 ? c.id.slice(0, 27) + '...' : c.id.padEnd(30);
console.log(` ${idPad} ${c.before} \u2192 ${c.after} ${c.label}`);
}
console.log(sep);
// 5. Determine result
const regressions = changes.filter(c => c.label === '(REGRESSION)');
if (deltaPassRate > 0) {
console.log(`Result: IMPROVEMENT (+${deltaPassRate.toFixed(1)}% pass rate)`);
} else if (deltaPassRate === 0 && regressions.length === 0) {
console.log('Result: NO CHANGE');
} else {
console.log(`Result: REGRESSION (${deltaPassRate.toFixed(1)}% pass rate, ${regressions.length} test(s) regressed)`);
}
console.log(sep);
// Exit code: 0 if after >= before, 1 if regression
process.exit(afterPassRate >= beforePassRate ? 0 : 1);
}
// ============================================================================
// HELP
// ============================================================================
function printHelp(): void {
console.log(`
Eval-Driven Development Workflow
Usage:
npx tsx scripts/eval-driven-workflow.ts init <skill-name>
npx tsx scripts/eval-driven-workflow.ts compare <before.json> <after.json>
Subcommands:
init <skill> Bootstrap eval YAML with 5 seed test cases from SKILL.md
compare <before> <after> Compare two eval run JSON files and show diff table
Options:
--help, -h Show this help
Workflow:
1. npx tsx scripts/eval-driven-workflow.ts init my-skill
2. Review generated YAML (look for TODO comments)
3. npx tsx scripts/run-skill-eval.ts --skill my-skill --output before.json
4. Edit SKILL.md to improve eval results
5. npx tsx scripts/run-skill-eval.ts --skill my-skill --output after.json
6. npx tsx scripts/eval-driven-workflow.ts compare before.json after.json
`);
}
// ============================================================================
// MAIN
// ============================================================================
function main(): void {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
printHelp();
process.exit(0);
}
const subcommand = args[0];
switch (subcommand) {
case 'init': {
const skillName = args[1];
if (!skillName) {
console.error('Error: Skill name required. Usage: eval-driven-workflow.ts init <skill-name>');
process.exit(1);
}
runInit(skillName);
break;
}
case 'compare': {
const beforePath = args[1];
const afterPath = args[2];
if (!beforePath || !afterPath) {
console.error('Error: Two JSON file paths required. Usage: eval-driven-workflow.ts compare <before.json> <after.json>');
process.exit(1);
}
runCompare(beforePath, afterPath);
break;
}
default:
console.error(`Unknown subcommand: ${subcommand}`);
console.error('Use --help for usage information.');
process.exit(1);
}
}
main();