Skip to content

Commit ed802d0

Browse files
garrytanclaude
andcommitted
feat: eval CLI tools + docs cleanup
Add eval:list, eval:compare, eval:summary CLI scripts for exploring eval history from ~/.gstack-dev/evals/. eval:compare reuses the shared comparison functions from eval-store.ts. - eval:list: sorted table with branch/tier/cost filters - eval:compare: thin wrapper around compareEvalResults + formatComparison - eval:summary: aggregate stats, flaky test detection, branch rankings - Remove unused @anthropic-ai/claude-agent-sdk from devDependencies - Update CLAUDE.md: streaming docs, eval CLI commands, remove Agent SDK refs - Add GH Actions eval upload (P2) and web dashboard (P3) to TODOS.md Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 84f52f3 commit ed802d0

6 files changed

Lines changed: 373 additions & 11 deletions

File tree

‎CLAUDE.md‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,21 @@
55
```bash
66
bun install # install dependencies
77
bun test # run free tests (browse + snapshot + skill validation)
8-
bun run test:evals # run paid evals: LLM judge + Agent SDK E2E (~$4/run)
9-
bun run test:e2e # run Agent SDK E2E tests only (~$3.85/run)
8+
bun run test:evals # run paid evals: LLM judge + E2E (~$4/run)
9+
bun run test:e2e # run E2E tests only (~$3.85/run)
1010
bun run dev <cmd> # run CLI in dev mode, e.g. bun run dev goto https://example.com
1111
bun run build # gen docs + compile binaries
1212
bun run gen:skill-docs # regenerate SKILL.md files from templates
1313
bun run skill:check # health dashboard for all skills
1414
bun run dev:skill # watch mode: auto-regen + validate on change
15+
bun run eval:list # list all eval runs from ~/.gstack-dev/evals/
16+
bun run eval:compare # compare two eval runs (auto-picks most recent)
17+
bun run eval:summary # aggregate stats across all eval runs
1518
```
1619

17-
`test:evals` requires `ANTHROPIC_API_KEY` and must be run from a plain terminal
18-
(not inside Claude Code — nested Agent SDK sessions hang).
19-
20-
**Update (v0.3.5):** The session runner now strips CLAUDE* env vars automatically,
21-
so `test:evals` may work inside Claude Code. If E2E tests hang, run from a plain terminal.
20+
`test:evals` requires `ANTHROPIC_API_KEY`. E2E tests stream progress in real-time
21+
(tool-by-tool via `--output-format stream-json --verbose`). Results are persisted
22+
to `~/.gstack-dev/evals/` with auto-comparison against the previous run.
2223

2324
## Project structure
2425

@@ -35,12 +36,12 @@ gstack/
3536
│ ├── skill-check.ts # Health dashboard
3637
│ └── dev-skill.ts # Watch mode
3738
├── test/ # Skill validation + eval tests
38-
│ ├── helpers/ # skill-parser.ts, session-runner.ts, llm-judge.ts
39+
│ ├── helpers/ # skill-parser.ts, session-runner.ts, llm-judge.ts, eval-store.ts
3940
│ ├── fixtures/ # Ground truth JSON, planted-bug fixtures, eval baselines
4041
│ ├── skill-validation.test.ts # Tier 1: static validation (free, <1s)
4142
│ ├── gen-skill-docs.test.ts # Tier 1: generator quality (free, <1s)
4243
│ ├── skill-llm-eval.test.ts # Tier 3: LLM-as-judge (~$0.15/run)
43-
│ └── skill-e2e.test.ts # Tier 2: Agent SDK E2E (~$3.85/run)
44+
│ └── skill-e2e.test.ts # Tier 2: E2E via claude -p (~$3.85/run)
4445
├── ship/ # Ship workflow skill
4546
├── review/ # PR review skill
4647
├── plan-ceo-review/ # /plan-ceo-review skill

‎TODOS.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,27 @@
2222
**Depends on:** v0.3.5 shipping first (the `{{UPDATE_CHECK}}` resolver).
2323
**Effort:** S (small, ~20 min)
2424
**Priority:** P2 (prevents drift on next preamble change)
25+
26+
## GitHub Actions eval upload
27+
28+
**What:** Run eval suite in CI, upload result JSON as artifact, post summary comment on PR.
29+
30+
**Why:** Currently evals only run locally. CI integration would catch quality regressions before merge and provide a persistent record of eval results per PR.
31+
32+
**Context:** Requires `ANTHROPIC_API_KEY` in CI secrets. Cost is ~$4/run. The eval persistence system (v0.3.6) writes JSON to `~/.gstack-dev/evals/` — CI would upload these as GitHub Actions artifacts and use `eval:compare` to post a delta comment on the PR.
33+
34+
**Depends on:** Eval persistence shipping (v0.3.6).
35+
**Effort:** M (medium)
36+
**Priority:** P2
37+
38+
## Eval web dashboard
39+
40+
**What:** `bun run eval:dashboard` serves local HTML with charts: cost trending, detection rate over time, pass/fail history.
41+
42+
**Why:** The CLI tools (`eval:list`, `eval:compare`, `eval:summary`) are good for quick checks but visual charts are better for spotting trends over many runs.
43+
44+
**Context:** Reads the same `~/.gstack-dev/evals/*.json` files. ~200 lines HTML + chart.js code served via a simple Bun HTTP server. No external dependencies beyond what's already installed.
45+
46+
**Depends on:** Eval persistence + eval:list shipping (v0.3.6).
47+
**Effort:** M (medium)
48+
**Priority:** P3 (nice-to-have, revisit after eval system sees regular use)

‎package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
"test:e2e": "EVALS=1 bun test test/skill-e2e.test.ts",
1818
"skill:check": "bun run scripts/skill-check.ts",
1919
"dev:skill": "bun run scripts/dev-skill.ts",
20-
"start": "bun run browse/src/server.ts"
20+
"start": "bun run browse/src/server.ts",
21+
"eval:list": "bun run scripts/eval-list.ts",
22+
"eval:compare": "bun run scripts/eval-compare.ts",
23+
"eval:summary": "bun run scripts/eval-summary.ts"
2124
},
2225
"dependencies": {
2326
"playwright": "^1.58.2",
@@ -37,7 +40,6 @@
3740
"devtools"
3841
],
3942
"devDependencies": {
40-
"@anthropic-ai/claude-agent-sdk": "^0.2.75",
4143
"@anthropic-ai/sdk": "^0.78.0"
4244
}
4345
}

‎scripts/eval-compare.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Compare two eval runs from ~/.gstack-dev/evals/
4+
*
5+
* Usage:
6+
* bun run eval:compare # compare two most recent of same tier
7+
* bun run eval:compare <file> # compare file against its predecessor
8+
* bun run eval:compare <file-a> <file-b> # compare two specific files
9+
*/
10+
11+
import * as fs from 'fs';
12+
import * as path from 'path';
13+
import * as os from 'os';
14+
import {
15+
findPreviousRun,
16+
compareEvalResults,
17+
formatComparison,
18+
} from '../test/helpers/eval-store';
19+
import type { EvalResult } from '../test/helpers/eval-store';
20+
21+
const EVAL_DIR = path.join(os.homedir(), '.gstack-dev', 'evals');
22+
23+
function loadResult(filepath: string): EvalResult {
24+
// Resolve relative to EVAL_DIR if not absolute
25+
const resolved = path.isAbsolute(filepath) ? filepath : path.join(EVAL_DIR, filepath);
26+
if (!fs.existsSync(resolved)) {
27+
console.error(`File not found: ${resolved}`);
28+
process.exit(1);
29+
}
30+
return JSON.parse(fs.readFileSync(resolved, 'utf-8'));
31+
}
32+
33+
const args = process.argv.slice(2);
34+
35+
let beforeFile: string;
36+
let afterFile: string;
37+
38+
if (args.length === 2) {
39+
// Two explicit files
40+
beforeFile = args[0];
41+
afterFile = args[1];
42+
} else if (args.length === 1) {
43+
// One file — find its predecessor
44+
afterFile = args[0];
45+
const resolved = path.isAbsolute(afterFile) ? afterFile : path.join(EVAL_DIR, afterFile);
46+
const afterResult = loadResult(resolved);
47+
const prev = findPreviousRun(EVAL_DIR, afterResult.tier, afterResult.branch, resolved);
48+
if (!prev) {
49+
console.log('No previous run found to compare against.');
50+
process.exit(0);
51+
}
52+
beforeFile = prev;
53+
} else {
54+
// No args — find two most recent of the same tier
55+
let files: string[];
56+
try {
57+
files = fs.readdirSync(EVAL_DIR)
58+
.filter(f => f.endsWith('.json'))
59+
.sort()
60+
.reverse();
61+
} catch {
62+
console.log('No eval runs yet. Run: EVALS=1 bun run test:evals');
63+
process.exit(0);
64+
}
65+
66+
if (files.length < 2) {
67+
console.log('Need at least 2 eval runs to compare. Run evals again.');
68+
process.exit(0);
69+
}
70+
71+
// Most recent file
72+
afterFile = path.join(EVAL_DIR, files[0]);
73+
const afterResult = loadResult(afterFile);
74+
const prev = findPreviousRun(EVAL_DIR, afterResult.tier, afterResult.branch, afterFile);
75+
if (!prev) {
76+
console.log('No previous run of the same tier found to compare against.');
77+
process.exit(0);
78+
}
79+
beforeFile = prev;
80+
}
81+
82+
const beforeResult = loadResult(beforeFile);
83+
const afterResult = loadResult(afterFile);
84+
85+
// Warn if different tiers
86+
if (beforeResult.tier !== afterResult.tier) {
87+
console.warn(`Warning: comparing different tiers (${beforeResult.tier} vs ${afterResult.tier})`);
88+
}
89+
90+
// Warn on schema mismatch
91+
if (beforeResult.schema_version !== afterResult.schema_version) {
92+
console.warn(`Warning: schema version mismatch (${beforeResult.schema_version} vs ${afterResult.schema_version})`);
93+
}
94+
95+
const comparison = compareEvalResults(beforeResult, afterResult, beforeFile, afterFile);
96+
console.log(formatComparison(comparison));

‎scripts/eval-list.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* List eval runs from ~/.gstack-dev/evals/
4+
*
5+
* Usage: bun run eval:list [--branch <name>] [--tier e2e|llm-judge] [--limit N]
6+
*/
7+
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
import * as os from 'os';
11+
12+
const EVAL_DIR = path.join(os.homedir(), '.gstack-dev', 'evals');
13+
14+
// Parse args
15+
const args = process.argv.slice(2);
16+
let filterBranch: string | null = null;
17+
let filterTier: string | null = null;
18+
let limit = 20;
19+
20+
for (let i = 0; i < args.length; i++) {
21+
if (args[i] === '--branch' && args[i + 1]) { filterBranch = args[++i]; }
22+
else if (args[i] === '--tier' && args[i + 1]) { filterTier = args[++i]; }
23+
else if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[++i], 10); }
24+
}
25+
26+
// Read eval files
27+
let files: string[];
28+
try {
29+
files = fs.readdirSync(EVAL_DIR).filter(f => f.endsWith('.json'));
30+
} catch {
31+
console.log('No eval runs yet. Run: EVALS=1 bun run test:evals');
32+
process.exit(0);
33+
}
34+
35+
if (files.length === 0) {
36+
console.log('No eval runs yet. Run: EVALS=1 bun run test:evals');
37+
process.exit(0);
38+
}
39+
40+
// Parse top-level fields from each file
41+
interface RunSummary {
42+
file: string;
43+
timestamp: string;
44+
branch: string;
45+
tier: string;
46+
version: string;
47+
passed: number;
48+
total: number;
49+
cost: number;
50+
}
51+
52+
const runs: RunSummary[] = [];
53+
for (const file of files) {
54+
try {
55+
const data = JSON.parse(fs.readFileSync(path.join(EVAL_DIR, file), 'utf-8'));
56+
if (filterBranch && data.branch !== filterBranch) continue;
57+
if (filterTier && data.tier !== filterTier) continue;
58+
runs.push({
59+
file,
60+
timestamp: data.timestamp || '',
61+
branch: data.branch || 'unknown',
62+
tier: data.tier || 'unknown',
63+
version: data.version || '?',
64+
passed: data.passed || 0,
65+
total: data.total_tests || 0,
66+
cost: data.total_cost_usd || 0,
67+
});
68+
} catch { continue; }
69+
}
70+
71+
// Sort by timestamp descending
72+
runs.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
73+
74+
// Apply limit
75+
const displayed = runs.slice(0, limit);
76+
77+
// Print table
78+
console.log('');
79+
console.log(`Eval History (${runs.length} total runs)`);
80+
console.log('═'.repeat(90));
81+
console.log(
82+
' ' +
83+
'Date'.padEnd(17) +
84+
'Branch'.padEnd(28) +
85+
'Tier'.padEnd(12) +
86+
'Pass'.padEnd(8) +
87+
'Cost'.padEnd(8) +
88+
'Version'
89+
);
90+
console.log('─'.repeat(90));
91+
92+
for (const run of displayed) {
93+
const date = run.timestamp.replace('T', ' ').slice(0, 16);
94+
const branch = run.branch.length > 26 ? run.branch.slice(0, 23) + '...' : run.branch.padEnd(28);
95+
const pass = `${run.passed}/${run.total}`.padEnd(8);
96+
const cost = `$${run.cost.toFixed(2)}`.padEnd(8);
97+
console.log(` ${date.padEnd(17)}${branch}${run.tier.padEnd(12)}${pass}${cost}v${run.version}`);
98+
}
99+
100+
console.log('─'.repeat(90));
101+
102+
const totalCost = runs.reduce((s, r) => s + r.cost, 0);
103+
console.log(` ${runs.length} runs | Total spend: $${totalCost.toFixed(2)} | Showing: ${displayed.length}`);
104+
console.log(` Dir: ${EVAL_DIR}`);
105+
console.log('');

0 commit comments

Comments
 (0)