@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Deterministic audit report generator — aggregates machine-generated audit outputs.
4
+
5
+ Assembles findings from multiple tools into a single dated markdown audit report:
6
+ - defect_escape.py telemetry (code quality metrics)
7
+ - mutation_test.py results (test quality gaps)
8
+ - claudemd_lint.py findings (documentation integrity)
9
+ - OUTCOMES-LEDGER.md verdict rates (fleet reliability)
10
+
11
+ All sources are optional by default (graceful degradation). Use --strict to require all.
12
+ Output is deterministic (no LLM calls, pure aggregation).
13
+
14
+ CLI:
15
+ python tools/audit_report.py [--defect-escape=<json>] [--mutation-test=<json>]
16
+ [--claudemd-lint=<json>] [--ledger=<path>]
17
+ [--out=<path>] [--strict]
18
+
19
+ Exit: 0=success, 1=error (missing sources in --strict mode, read errors, etc.)
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Dict, Optional, Tuple
28
+
29
+
30
+ def load_json_file(path: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
31
+ """Load a JSON file.
32
+
33
+ Args:
34
+ path: Path to JSON file or None
35
+
36
+ Returns:
37
+ Tuple of (data, error_msg). If path is None, returns (None, None).
38
+ On error, returns (None, error_msg).
39
+ """
40
+ if path is None:
41
+ return None, None
42
+
43
+ try:
44
+ # Use utf-8-sig to handle BOM on Windows
45
+ with open(path, "r", encoding="utf-8-sig") as f:
46
+ data = json.load(f)
47
+ return data, None
48
+ except FileNotFoundError:
49
+ return None, f"File not found: {path}"
50
+ except json.JSONDecodeError as e:
51
+ return None, f"Invalid JSON in {path}: {e}"
52
+ except IOError as e:
53
+ return None, f"Failed to read {path}: {e}"
54
+
55
+
56
+ def load_ledger_file(path: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
57
+ """Load a ledger markdown file.
58
+
59
+ Args:
60
+ path: Path to ledger file or None
61
+
62
+ Returns:
63
+ Tuple of (content, error_msg). If path is None, returns (None, None).
64
+ """
65
+ if path is None:
66
+ return None, None
67
+
68
+ try:
69
+ with open(path, "r", encoding="utf-8") as f:
70
+ content = f.read()
71
+ return content, None
72
+ except FileNotFoundError:
73
+ return None, f"File not found: {path}"
74
+ except IOError as e:
75
+ return None, f"Failed to read {path}: {e}"
76
+
77
+
78
+ def parse_ledger_verdicts(ledger_content: str) -> Dict[str, int]:
79
+ """Parse OUTCOMES-LEDGER.md and count verdicts.
80
+
81
+ Args:
82
+ ledger_content: Markdown table content
83
+
84
+ Returns:
85
+ Dict mapping verdict string (OK, FAILED, EMPTY, HUNG) to count.
86
+ """
87
+ verdicts = {"OK": 0, "FAILED": 0, "EMPTY": 0, "HUNG": 0}
88
+
89
+ # Skip header lines (first 2)
90
+ lines = ledger_content.strip().split("\n")[2:]
91
+
92
+ for line in lines:
93
+ # Parse markdown table row: | col1 | col2 | ... |
94
+ if not line.startswith("|"):
95
+ continue
96
+
97
+ cells = [cell.strip() for cell in line.split("|")]
98
+ # Skip empty cells at start/end (from split)
99
+ cells = [c for c in cells if c]
100
+
101
+ # Verdict is typically at position 6 (0-indexed): ISO, agent_type, model, duration, tokens_in, tokens_out, verdict
102
+ if len(cells) >= 7:
103
+ verdict = cells[6]
104
+ if verdict in verdicts:
105
+ verdicts[verdict] += 1
106
+
107
+ return verdicts
108
+
109
+
110
+ def format_defect_escape_section(data: Optional[Dict[str, Any]]) -> str:
111
+ """Format defect escape telemetry section."""
112
+ if data is None:
113
+ return "## Defect Escape Telemetry\n\n*Not available*\n\n"
114
+
115
+ feature = data.get("feature_commits", 0)
116
+ fixforward = data.get("fixforward_commits", 0)
117
+ rate = data.get("fixforward_rate", 0)
118
+ first_try = data.get("first_try_estimate")
119
+ window = data.get("window", {})
120
+ since = window.get("since", "unknown")
121
+
122
+ section = f"""## Defect Escape Telemetry
123
+
124
+ **Window:** {since}
125
+
126
+ | Metric | Value |
127
+ |--------|-------|
128
+ | Feature commits | {feature} |
129
+ | Fix-forward commits | {fixforward} |
130
+ | Fix-forward rate | {rate:.2%} |
131
+ | First-try estimate | {first_try if first_try is not None else "N/A"}{f" ({first_try:.2%})" if first_try is not None else ""} |
132
+
133
+ """
134
+ return section
135
+
136
+
137
+ def format_mutation_test_section(data: Optional[Dict[str, Any]]) -> str:
138
+ """Format mutation testing section."""
139
+ if data is None:
140
+ return "## Mutation Testing\n\n*Not available*\n\n"
141
+
142
+ killed = data.get("killed", 0)
143
+ survived = data.get("survived", 0)
144
+ total = killed + survived
145
+ survival_rate = (survived / total * 100) if total > 0 else 0
146
+
147
+ section = f"""## Mutation Testing
148
+
149
+ | Metric | Value |
150
+ |--------|-------|
151
+ | Mutations killed | {killed} |
152
+ | Mutations survived | {survived} |
153
+ | Total mutations | {total} |
154
+ | Survival rate (test gaps) | {survival_rate:.1f}% |
155
+
156
+ """
157
+
158
+ # List survived mutations if present
159
+ mutations = data.get("mutations", [])
160
+ if mutations:
161
+ section += "### Survived Mutations (Test Gaps)\n\n"
162
+ for mut in mutations:
163
+ line = mut.get("line", "?")
164
+ orig = mut.get("original", "?")
165
+ mutated = mut.get("mutated", "?")
166
+ section += f"- {line}: `{orig}` -> `{mutated}`\n"
167
+ section += "\n"
168
+
169
+ return section
170
+
171
+
172
+ def format_claudemd_lint_section(data: Optional[Dict[str, Any]]) -> str:
173
+ """Format CLAUDE.md lint findings section."""
174
+ if data is None:
175
+ return "## CLAUDE.md Lint\n\n*Not available*\n\n"
176
+
177
+ count = data.get("count", 0)
178
+ section = f"""## CLAUDE.md Lint
179
+
180
+ **Findings:** {count}
181
+
182
+ """
183
+
184
+ findings = data.get("findings", [])
185
+ if findings:
186
+ for finding in findings:
187
+ ftype = finding.get("type", "unknown")
188
+ msg = finding.get("message", "")
189
+ line = finding.get("line", "?")
190
+ section += f"- [{ftype}] {msg} (line {line})\n"
191
+ section += "\n"
192
+ else:
193
+ section += "*No issues found*\n\n"
194
+
195
+ return section
196
+
197
+
198
+ def format_ledger_section(ledger_content: Optional[str]) -> str:
199
+ """Format fleet ledger summary section."""
200
+ if ledger_content is None:
201
+ return "## Fleet Ledger Summary\n\n*Not available*\n\n"
202
+
203
+ verdicts = parse_ledger_verdicts(ledger_content)
204
+ section = """## Fleet Ledger Summary
205
+
206
+ | Verdict | Count |
207
+ |---------|-------|
208
+ """
209
+
210
+ total = sum(verdicts.values())
211
+ for verdict in ["OK", "FAILED", "EMPTY", "HUNG"]:
212
+ count = verdicts.get(verdict, 0)
213
+ section += f"| {verdict} | {count} |\n"
214
+
215
+ if total > 0:
216
+ ok_rate = verdicts["OK"] / total * 100
217
+ section += f"\n**Success rate:** {ok_rate:.1f}%\n\n"
218
+ else:
219
+ section += "\n*No ledger entries*\n\n"
220
+
221
+ return section
222
+
223
+
224
+ def generate_report(
225
+ defect_escape_data: Optional[Dict[str, Any]],
226
+ mutation_test_data: Optional[Dict[str, Any]],
227
+ claudemd_lint_data: Optional[Dict[str, Any]],
228
+ ledger_content: Optional[str],
229
+ strict: bool = False,
230
+ timestamp: Optional[str] = None,
231
+ ) -> Tuple[str, Optional[str]]:
232
+ """Generate audit report from aggregated data.
233
+
234
+ Args:
235
+ defect_escape_data: defect_escape.py JSON
236
+ mutation_test_data: mutation_test.py JSON
237
+ claudemd_lint_data: claudemd_lint.py JSON
238
+ ledger_content: OUTCOMES-LEDGER.md content
239
+ strict: If True, fail if any source is missing
240
+ timestamp: ISO timestamp for report (default: current UTC time)
241
+
242
+ Returns:
243
+ Tuple of (report_markdown, error_msg). On success, error_msg is None.
244
+ """
245
+ # In strict mode, all sources must be present
246
+ if strict:
247
+ missing = []
248
+ if defect_escape_data is None:
249
+ missing.append("defect-escape")
250
+ if mutation_test_data is None:
251
+ missing.append("mutation-test")
252
+ if claudemd_lint_data is None:
253
+ missing.append("claudemd-lint")
254
+ if ledger_content is None:
255
+ missing.append("ledger")
256
+
257
+ if missing:
258
+ return "", f"Strict mode: missing sources: {', '.join(missing)}"
259
+
260
+ # Build report
261
+ if timestamp is None:
262
+ timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
263
+ report = f"""# Audit Report
264
+
265
+ **Generated:** {timestamp}
266
+
267
+ """
268
+
269
+ # Add each section
270
+ report += format_defect_escape_section(defect_escape_data)
271
+ report += format_mutation_test_section(mutation_test_data)
272
+ report += format_claudemd_lint_section(claudemd_lint_data)
273
+ report += format_ledger_section(ledger_content)
274
+
275
+ # Footer
276
+ report += f"""---
277
+
278
+ *Report generated by audit_report.py (deterministic, no external LLM calls)*
279
+ """
280
+
281
+ return report, None
282
+
283
+
284
+ def main():
285
+ """Main entry point."""
286
+ parser = argparse.ArgumentParser(
287
+ description="Generate deterministic audit report from machine outputs"
288
+ )
289
+ parser.add_argument(
290
+ "--defect-escape",
291
+ type=str,
292
+ default=None,
293
+ help="Path to defect_escape.py JSON output",
294
+ )
295
+ parser.add_argument(
296
+ "--mutation-test",
297
+ type=str,
298
+ default=None,
299
+ help="Path to mutation_test.py JSON output",
300
+ )
301
+ parser.add_argument(
302
+ "--claudemd-lint",
303
+ type=str,
304
+ default=None,
305
+ help="Path to claudemd_lint.py JSON output",
306
+ )
307
+ parser.add_argument(
308
+ "--ledger",
309
+ type=str,
310
+ default=None,
311
+ help="Path to OUTCOMES-LEDGER.md file",
312
+ )
313
+ parser.add_argument(
314
+ "--out",
315
+ type=str,
316
+ default=None,
317
+ help="Write report to file (default: stdout)",
318
+ )
319
+ parser.add_argument(
320
+ "--strict",
321
+ action="store_true",
322
+ help="Fail if any source is missing",
323
+ )
324
+ parser.add_argument(
325
+ "--timestamp",
326
+ type=str,
327
+ default=None,
328
+ help="ISO timestamp for report (default: current UTC time)",
329
+ )
330
+
331
+ args = parser.parse_args()
332
+
333
+ # Load all sources
334
+ defect_escape_data, defect_err = load_json_file(args.defect_escape)
335
+ mutation_test_data, mutation_err = load_json_file(args.mutation_test)
336
+ claudemd_lint_data, claudemd_err = load_json_file(args.claudemd_lint)
337
+ ledger_content, ledger_err = load_ledger_file(args.ledger)
338
+
339
+ # Check for load errors
340
+ errors = []
341
+ if defect_err:
342
+ errors.append(f"Defect escape: {defect_err}")
343
+ if mutation_err:
344
+ errors.append(f"Mutation test: {mutation_err}")
345
+ if claudemd_err:
346
+ errors.append(f"CLAUDE.md lint: {claudemd_err}")
347
+ if ledger_err:
348
+ errors.append(f"Ledger: {ledger_err}")
349
+
350
+ # In non-strict mode, print warnings but continue
351
+ if errors and not args.strict:
352
+ for error in errors:
353
+ print(f"Warning: {error}", file=sys.stderr)
354
+ elif errors and args.strict:
355
+ for error in errors:
356
+ print(f"Error: {error}", file=sys.stderr)
357
+ sys.exit(1)
358
+
359
+ # Generate report
360
+ report, err = generate_report(
361
+ defect_escape_data,
362
+ mutation_test_data,
363
+ claudemd_lint_data,
364
+ ledger_content,
365
+ strict=args.strict,
366
+ timestamp=args.timestamp,
367
+ )
368
+
369
+ if err:
370
+ print(f"Error: {err}", file=sys.stderr)
371
+ sys.exit(1)
372
+
373
+ # Output report
374
+ if args.out:
375
+ try:
376
+ with open(args.out, "w", encoding="utf-8") as f:
377
+ f.write(report)
378
+ except IOError as e:
379
+ print(f"Error writing to {args.out}: {e}", file=sys.stderr)
380
+ sys.exit(1)
381
+ else:
382
+ print(report)
383
+
384
+ return 0
385
+
386
+
387
+ if __name__ == "__main__":
388
+ sys.exit(main())
@@ -39,7 +39,10 @@ from __future__ import annotations
39
39
  import argparse
40
40
  import json
41
41
  import re
42
+ import shutil
43
+ import subprocess
42
44
  import sys
45
+ import time
43
46
  from pathlib import Path
44
47
  from typing import Callable, Dict, List, Optional, Tuple
45
48
 
@@ -346,10 +349,101 @@ def mock_runner(prompt: str) -> str:
346
349
  return ""
347
350
 
348
351
 
352
+ # ---------------------------------------------------------------------------
353
+ # Claude CLI runner — shell the local `claude` command for real model calls.
354
+ # Gracefully unavailable if `claude` is not on PATH.
355
+ # ---------------------------------------------------------------------------
356
+
357
+ def _make_claude_runner(model_alias: str) -> ModelRunner:
358
+ """Create a runner that shells the local `claude` CLI.
359
+
360
+ Args:
361
+ model_alias: A model alias (e.g., "haiku", "sonnet", "opus") or full
362
+ model id (e.g., "claude-haiku-4-5-20251001"). Passed to
363
+ claude --model <alias>.
364
+
365
+ Returns:
366
+ A ModelRunner that shells `claude -p <prompt> --model <alias>
367
+ --output-format json` and returns (text, usage) with latency_ms.
368
+ Raises RuntimeError if claude is not available.
369
+ """
370
+ def runner(prompt: str) -> tuple:
371
+ if not shutil.which("claude"):
372
+ raise RuntimeError(
373
+ "claude CLI not found on PATH: install it or check your PATH. "
374
+ "See https://github.com/anthropic-ai/claude-code for setup."
375
+ )
376
+ try:
377
+ start = time.time()
378
+ result = subprocess.run(
379
+ ["claude", "-p", prompt, "--model", model_alias, "--output-format", "json"],
380
+ capture_output=True,
381
+ text=True,
382
+ timeout=300, # 5 minutes per task
383
+ )
384
+ elapsed_ms = (time.time() - start) * 1000
385
+ if result.returncode != 0:
386
+ raise RuntimeError(
387
+ f"claude CLI exited {result.returncode}: {result.stderr}"
388
+ )
389
+ # Parse JSON output
390
+ output_json = json.loads(result.stdout)
391
+ # Extract text and tokens from the JSON response
392
+ # The claude CLI returns a structure like:
393
+ # {"type": "result", "subtype": "success", "result": "...", "usage": {...}}
394
+ text = None
395
+ tokens = None
396
+ if isinstance(output_json, dict):
397
+ # Try new format first (--output-format json with --print)
398
+ if "result" in output_json:
399
+ text = output_json.get("result")
400
+ # Fallback to old message format if needed
401
+ elif "content" in output_json:
402
+ content = output_json.get("content")
403
+ if isinstance(content, list) and content:
404
+ first = content[0]
405
+ if isinstance(first, dict):
406
+ text = first.get("text")
407
+ # Extract token count if available
408
+ usage_obj = output_json.get("usage", {})
409
+ if isinstance(usage_obj, dict):
410
+ tokens = usage_obj.get("output_tokens")
411
+ if not text:
412
+ raise RuntimeError(
413
+ f"unable to extract text from claude CLI JSON response: {output_json}"
414
+ )
415
+ usage = {
416
+ "tokens": tokens,
417
+ "latency_ms": elapsed_ms,
418
+ }
419
+ return (text, usage)
420
+ except subprocess.TimeoutExpired:
421
+ raise RuntimeError(f"claude CLI timed out after 300s")
422
+ except json.JSONDecodeError as e:
423
+ raise RuntimeError(f"claude CLI output is not valid JSON: {e}")
424
+ except Exception as e:
425
+ raise RuntimeError(f"claude CLI error: {e}")
426
+
427
+ return runner
428
+
429
+
349
430
  RUNNERS: Dict[str, ModelRunner] = {
350
431
  "mock": mock_runner,
351
432
  }
352
433
 
434
+ # Register claude runners if claude CLI is available
435
+ _CLAUDE_AVAILABLE = bool(shutil.which("claude"))
436
+ if _CLAUDE_AVAILABLE:
437
+ for alias in ("haiku", "sonnet", "opus"):
438
+ try:
439
+ # Pre-flight check: make sure the runner can be created
440
+ # (we don't call it until later, but we want to register it)
441
+ runner = _make_claude_runner(alias)
442
+ RUNNERS[alias] = runner
443
+ except Exception:
444
+ # If something fails during registration, skip this model
445
+ pass
446
+
353
447
 
354
448
  def _fmt(value, suffix: str = "") -> str:
355
449
  return "-" if value is None else f"{value:g}{suffix}"
@@ -415,11 +509,14 @@ def print_comparison(summaries: List[dict], stream=None) -> None:
415
509
 
416
510
  def main(argv: Optional[List[str]] = None) -> int:
417
511
  parser = argparse.ArgumentParser(description=__doc__)
512
+ available_runners = sorted(RUNNERS.keys())
513
+ default_runner = "mock"
418
514
  parser.add_argument(
419
515
  "--runner",
420
- default="mock",
421
- choices=sorted(RUNNERS.keys()),
422
- help="Which registered model runner to score (default: mock, offline/zero-cost).",
516
+ default=default_runner,
517
+ choices=available_runners,
518
+ help=f"Which registered model runner to score (default: {default_runner}, offline/zero-cost). "
519
+ f"Available: {', '.join(available_runners)}",
423
520
  )
424
521
  parser.add_argument("--tasks", default=None, help="Override path to tasks.jsonl")
425
522
  parser.add_argument("--ground-truth", default=None, help="Override path to ground_truth.jsonl")