skill-scoreboard 0.1.0__py3-none-any.whl

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 (44) hide show
  1. skill_scoreboard/__init__.py +3 -0
  2. skill_scoreboard/ab.py +93 -0
  3. skill_scoreboard/cli.py +477 -0
  4. skill_scoreboard/config.py +55 -0
  5. skill_scoreboard/evaluators/__init__.py +31 -0
  6. skill_scoreboard/evaluators/ast_assertions.py +142 -0
  7. skill_scoreboard/evaluators/base.py +48 -0
  8. skill_scoreboard/evaluators/llm_judge.py +80 -0
  9. skill_scoreboard/evaluators/pydev_static.py +98 -0
  10. skill_scoreboard/evaluators/pydev_tests.py +62 -0
  11. skill_scoreboard/evaluators/skill_activation.py +20 -0
  12. skill_scoreboard/evaluators/trajectory_efficiency.py +54 -0
  13. skill_scoreboard/llm.py +110 -0
  14. skill_scoreboard/metrics/__init__.py +1 -0
  15. skill_scoreboard/metrics/loops.py +153 -0
  16. skill_scoreboard/metrics/stats.py +29 -0
  17. skill_scoreboard/metrics/tokens.py +59 -0
  18. skill_scoreboard/models/__init__.py +91 -0
  19. skill_scoreboard/models/portfolio.py +143 -0
  20. skill_scoreboard/models/results.py +98 -0
  21. skill_scoreboard/models/task.py +187 -0
  22. skill_scoreboard/models/trajectory.py +105 -0
  23. skill_scoreboard/portfolio/__init__.py +31 -0
  24. skill_scoreboard/portfolio/critic.py +76 -0
  25. skill_scoreboard/portfolio/lint.py +138 -0
  26. skill_scoreboard/portfolio/mine.py +84 -0
  27. skill_scoreboard/portfolio/scorecard.py +264 -0
  28. skill_scoreboard/portfolio/skills.py +80 -0
  29. skill_scoreboard/portfolio/tfidf.py +114 -0
  30. skill_scoreboard/portfolio/triggers.py +251 -0
  31. skill_scoreboard/report.py +177 -0
  32. skill_scoreboard/runner/__init__.py +8 -0
  33. skill_scoreboard/runner/base.py +28 -0
  34. skill_scoreboard/runner/claude_cli.py +117 -0
  35. skill_scoreboard/runner/ndjson.py +210 -0
  36. skill_scoreboard/runner/replay.py +50 -0
  37. skill_scoreboard/runner/workspace.py +132 -0
  38. skill_scoreboard/runs.py +69 -0
  39. skill_scoreboard/trial.py +112 -0
  40. skill_scoreboard-0.1.0.dist-info/METADATA +361 -0
  41. skill_scoreboard-0.1.0.dist-info/RECORD +44 -0
  42. skill_scoreboard-0.1.0.dist-info/WHEEL +4 -0
  43. skill_scoreboard-0.1.0.dist-info/entry_points.txt +2 -0
  44. skill_scoreboard-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """skill-scoreboard: evaluation framework for Claude Code skills and pydev plugins."""
2
+
3
+ __version__ = "0.1.0"
skill_scoreboard/ab.py ADDED
@@ -0,0 +1,93 @@
1
+ """A/B differential engine: Control (--bare, no skill) vs Treatment (skill loaded)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ from skill_scoreboard.llm import JsonCaller
9
+ from skill_scoreboard.metrics.stats import mean, median, pct_delta, rate
10
+ from skill_scoreboard.models.results import ABReport, ArmAggregate, TrialResult
11
+ from skill_scoreboard.models.task import LoadedTask
12
+ from skill_scoreboard.runner.base import AgentRunner
13
+ from skill_scoreboard.trial import Arm, run_trial
14
+
15
+ DEFAULT_TRIALS = 3
16
+
17
+
18
+ def utc_now() -> str:
19
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
20
+
21
+
22
+ def aggregate_arm(arm: Arm, trials: list[TrialResult]) -> ArmAggregate:
23
+ totals = [float(t.metrics.total_tokens) for t in trials]
24
+ return ArmAggregate(
25
+ arm=arm,
26
+ n=len(trials),
27
+ pass_rate=rate(sum(t.passed for t in trials), len(trials)),
28
+ mean_total_tokens=mean(totals),
29
+ median_total_tokens=median(totals),
30
+ mean_cost_usd=mean([t.metrics.cost_usd for t in trials]),
31
+ mean_duration_seconds=mean([t.metrics.duration_seconds for t in trials]),
32
+ mean_duplicate_calls=mean([float(t.metrics.duplicate_tool_calls) for t in trials]),
33
+ mean_re_reads=mean([float(t.metrics.file_re_reads) for t in trials]),
34
+ skill_activation_rate=rate(sum(t.skill_activated for t in trials), len(trials)),
35
+ any_cost_estimated=any(t.metrics.cost_estimated for t in trials),
36
+ )
37
+
38
+
39
+ def compute_deltas(control: ArmAggregate, treatment: ArmAggregate) -> dict[str, float]:
40
+ """treatment - control; negative token/cost/duration deltas mean savings."""
41
+ return {
42
+ "pass_rate_delta": treatment.pass_rate - control.pass_rate,
43
+ "token_delta_pct": pct_delta(treatment.mean_total_tokens, control.mean_total_tokens),
44
+ "cost_delta_usd": treatment.mean_cost_usd - control.mean_cost_usd,
45
+ "duration_delta_pct": pct_delta(
46
+ treatment.mean_duration_seconds, control.mean_duration_seconds
47
+ ),
48
+ "duplicate_calls_delta": treatment.mean_duplicate_calls - control.mean_duplicate_calls,
49
+ "re_reads_delta": treatment.mean_re_reads - control.mean_re_reads,
50
+ "skill_activation_delta": (
51
+ treatment.skill_activation_rate - control.skill_activation_rate
52
+ ),
53
+ }
54
+
55
+
56
+ def run_ab(
57
+ loaded: LoadedTask,
58
+ runners: dict[Arm, AgentRunner],
59
+ *,
60
+ trials: int | None = None,
61
+ llm: JsonCaller | None = None,
62
+ out_dir: Path | None = None,
63
+ keep_workspace: bool = False,
64
+ ) -> ABReport:
65
+ n_trials = trials or loaded.spec.trials or DEFAULT_TRIALS
66
+ all_trials: list[TrialResult] = []
67
+ per_arm: dict[Arm, list[TrialResult]] = {"control": [], "treatment": []}
68
+ arms: tuple[Arm, ...] = ("control", "treatment")
69
+ for arm in arms:
70
+ for index in range(n_trials):
71
+ result = run_trial(
72
+ loaded,
73
+ runners[arm],
74
+ arm,
75
+ index,
76
+ llm=llm,
77
+ out_dir=out_dir,
78
+ keep_workspace=keep_workspace,
79
+ )
80
+ per_arm[arm].append(result)
81
+ all_trials.append(result)
82
+
83
+ control = aggregate_arm("control", per_arm["control"])
84
+ treatment = aggregate_arm("treatment", per_arm["treatment"])
85
+ return ABReport(
86
+ task_id=loaded.spec.id,
87
+ skill_name=loaded.skill_name,
88
+ control=control,
89
+ treatment=treatment,
90
+ deltas=compute_deltas(control, treatment),
91
+ trials=all_trials,
92
+ generated_at=utc_now(),
93
+ )
@@ -0,0 +1,477 @@
1
+ """`skill-scoreboard` — command-line entry point for skill-scoreboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from skill_scoreboard import __version__, config
12
+ from skill_scoreboard.ab import DEFAULT_TRIALS, run_ab, utc_now
13
+ from skill_scoreboard.llm import JsonCaller, default_caller
14
+ from skill_scoreboard.models.portfolio import (
15
+ AuditReport,
16
+ CriticReport,
17
+ MiningReport,
18
+ TriggerReport,
19
+ )
20
+ from skill_scoreboard.models.results import ABReport, TaskReport
21
+ from skill_scoreboard.models.task import RunLimits, load_task
22
+ from skill_scoreboard.portfolio import (
23
+ build_scorecard,
24
+ cluster_overlaps,
25
+ critique_all,
26
+ discover_skills,
27
+ lint_all,
28
+ load_probes,
29
+ mine_sessions,
30
+ run_triggers,
31
+ )
32
+ from skill_scoreboard.portfolio.skills import parse_skill
33
+ from skill_scoreboard.portfolio.triggers import cross_activation_pairs
34
+ from skill_scoreboard.report import render_ab_report, render_leaderboard, render_task_report
35
+ from skill_scoreboard.runner.base import AgentRunner
36
+ from skill_scoreboard.runner.claude_cli import ClaudeCliRunner
37
+ from skill_scoreboard.runner.replay import ReplayRunner
38
+ from skill_scoreboard.runs import new_run_dir, save_json, save_meta
39
+ from skill_scoreboard.trial import run_trial
40
+
41
+ EXIT_OK = 0
42
+ EXIT_FAILED = 1
43
+ EXIT_USAGE = 2
44
+
45
+
46
+ def _make_runner(
47
+ kind: str, fixtures: list[str] | None, claude_bin: str
48
+ ) -> AgentRunner:
49
+ if kind == "replay":
50
+ if not fixtures:
51
+ raise SystemExit("--runner replay needs at least one --fixture")
52
+ return ReplayRunner([Path(f) for f in fixtures])
53
+ return ClaudeCliRunner(claude_bin=claude_bin)
54
+
55
+
56
+ def _resolve_llm(no_llm: bool) -> JsonCaller | None:
57
+ return None if no_llm else default_caller()
58
+
59
+
60
+ def _task_paths(target: str) -> list[Path]:
61
+ path = Path(target)
62
+ if path.is_dir():
63
+ return sorted(p for p in path.glob("*.y*ml"))
64
+ return [path]
65
+
66
+
67
+ # --------------------------------------------------------------------------- validate
68
+
69
+
70
+ def cmd_validate(args: argparse.Namespace) -> int:
71
+ errors = 0
72
+ for raw in args.paths:
73
+ path = Path(raw)
74
+ if not path.exists():
75
+ print(f"MISSING {path}")
76
+ errors += 1
77
+ continue
78
+ kind = args.kind
79
+ if kind == "auto":
80
+ name = path.name.lower()
81
+ if "probe" in name:
82
+ kind = "probes"
83
+ elif path.suffix.lower() == ".md":
84
+ kind = "skill"
85
+ else:
86
+ kind = "task"
87
+ try:
88
+ if kind == "task":
89
+ loaded = load_task(path)
90
+ evals = ", ".join(e.type for e in loaded.spec.evaluations)
91
+ print(f"OK {path} [task {loaded.spec.id}] evaluations: {evals}")
92
+ elif kind == "probes":
93
+ probes = load_probes(path)
94
+ total = sum(len(p.should_trigger) for p in probes.probes)
95
+ print(
96
+ f"OK {path} [probes] {len(probes.probes)} skills, "
97
+ f"{total} positive probes, {len(probes.shared_negatives)} shared negatives"
98
+ )
99
+ else:
100
+ meta = parse_skill(path)
101
+ print(f"OK {path} [skill {meta.name}] ~{meta.token_estimate} tokens")
102
+ except Exception as exc: # noqa: BLE001 - report, don't traceback at the CLI
103
+ print(f"INVALID {path}\n {exc}")
104
+ errors += 1
105
+ return EXIT_FAILED if errors else EXIT_OK
106
+
107
+
108
+ # --------------------------------------------------------------------------- run / ab
109
+
110
+
111
+ def _apply_overrides(limits: RunLimits, args: argparse.Namespace) -> RunLimits:
112
+ updates: dict[str, Any] = {}
113
+ if getattr(args, "model", None):
114
+ updates["model"] = args.model
115
+ if getattr(args, "max_turns", None):
116
+ updates["max_turns"] = args.max_turns
117
+ if getattr(args, "max_budget_usd", None):
118
+ updates["max_budget_usd"] = args.max_budget_usd
119
+ return limits.model_copy(update=updates) if updates else limits
120
+
121
+
122
+ def cmd_run(args: argparse.Namespace) -> int:
123
+ loaded = load_task(args.task)
124
+ loaded.spec.run_limits = _apply_overrides(loaded.spec.run_limits, args)
125
+ runner = _make_runner(args.runner, args.fixture, args.claude_bin)
126
+ out_dir = new_run_dir(Path(args.out_dir), loaded.spec.id)
127
+ save_meta(out_dir, {"command": "run", "arm": args.arm, "runner": args.runner})
128
+
129
+ trials = [
130
+ run_trial(
131
+ loaded,
132
+ runner,
133
+ args.arm,
134
+ index,
135
+ llm=_resolve_llm(args.no_llm),
136
+ out_dir=out_dir,
137
+ keep_workspace=args.keep_workspace,
138
+ )
139
+ for index in range(args.trials)
140
+ ]
141
+ report = TaskReport(
142
+ task_id=loaded.spec.id,
143
+ skill_name=loaded.skill_name,
144
+ arm=args.arm,
145
+ trials=trials,
146
+ generated_at=utc_now(),
147
+ )
148
+ save_json(report, out_dir / "run_report.json")
149
+ markdown = render_task_report(report)
150
+ (out_dir / "report.md").write_text(markdown, encoding="utf-8")
151
+ print(markdown)
152
+ print(f"\nArtifacts: {out_dir}")
153
+ return EXIT_OK if all(t.passed for t in trials) else EXIT_FAILED
154
+
155
+
156
+ def cmd_ab(args: argparse.Namespace) -> int:
157
+ llm = _resolve_llm(args.no_llm)
158
+ overall_ok = True
159
+ for task_path in _task_paths(args.task):
160
+ loaded = load_task(task_path)
161
+ loaded.spec.run_limits = _apply_overrides(loaded.spec.run_limits, args)
162
+ runners: dict[str, AgentRunner] = {
163
+ "control": _make_runner(args.runner, args.control_fixture, args.claude_bin),
164
+ "treatment": _make_runner(args.runner, args.treatment_fixture, args.claude_bin),
165
+ }
166
+ out_dir = new_run_dir(Path(args.out_dir), loaded.spec.id)
167
+ save_meta(out_dir, {"command": "ab", "runner": args.runner, "trials": args.trials})
168
+ report = run_ab(
169
+ loaded,
170
+ runners, # type: ignore[arg-type]
171
+ trials=args.trials,
172
+ llm=llm,
173
+ out_dir=out_dir,
174
+ keep_workspace=args.keep_workspace,
175
+ )
176
+ save_json(report, out_dir / "ab_report.json")
177
+ markdown = render_ab_report(report)
178
+ (out_dir / "report.md").write_text(markdown, encoding="utf-8")
179
+ if args.json:
180
+ Path(args.json).write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
181
+ if args.md:
182
+ Path(args.md).write_text(markdown, encoding="utf-8")
183
+ print(markdown)
184
+ print(f"\nArtifacts: {out_dir}")
185
+ overall_ok &= report.deltas.get("pass_rate_delta", 0.0) >= 0
186
+ return EXIT_OK if overall_ok else EXIT_FAILED
187
+
188
+
189
+ def cmd_report(args: argparse.Namespace) -> int:
190
+ run_dir = Path(args.run_dir)
191
+ ab_path, run_path = run_dir / "ab_report.json", run_dir / "run_report.json"
192
+ if ab_path.is_file():
193
+ report: ABReport | TaskReport = ABReport.model_validate_json(
194
+ ab_path.read_text(encoding="utf-8")
195
+ )
196
+ markdown = render_ab_report(report) # type: ignore[arg-type]
197
+ elif run_path.is_file():
198
+ report = TaskReport.model_validate_json(run_path.read_text(encoding="utf-8"))
199
+ markdown = render_task_report(report)
200
+ else:
201
+ print(f"no ab_report.json or run_report.json under {run_dir}", file=sys.stderr)
202
+ return EXIT_USAGE
203
+ print(report.model_dump_json(indent=2) if args.format == "json" else markdown)
204
+ return EXIT_OK
205
+
206
+
207
+ # --------------------------------------------------------------------------- portfolio
208
+
209
+
210
+ def cmd_audit(args: argparse.Namespace) -> int:
211
+ skills = discover_skills(args.skills_dir)
212
+ if not skills:
213
+ print(f"no skills found under {args.skills_dir}", file=sys.stderr)
214
+ return EXIT_USAGE
215
+ lint_reports = lint_all(skills)
216
+ clusters = cluster_overlaps(skills, threshold=args.similarity_threshold)
217
+
218
+ trigger_reports: list[TriggerReport] = []
219
+ confusion: dict[str, dict[str, int]] | None = None
220
+ if args.probes:
221
+ probes = load_probes(args.probes)
222
+ mode = args.trigger_mode or probes.defaults.mode
223
+ runner = (
224
+ ClaudeCliRunner(claude_bin=args.claude_bin) if mode == "headless" else None
225
+ )
226
+ trigger_reports, confusion = run_triggers(
227
+ probes,
228
+ skills,
229
+ mode=mode,
230
+ llm=_resolve_llm(args.no_llm),
231
+ runner=runner,
232
+ )
233
+
234
+ critics: list[CriticReport] = []
235
+ if args.critic:
236
+ llm = _resolve_llm(args.no_llm)
237
+ if llm is None:
238
+ print("--critic needs API credentials (or drop --critic)", file=sys.stderr)
239
+ return EXIT_USAGE
240
+ critics = critique_all(
241
+ skills, llm, model=args.critic_model, lint_by_skill={r.skill: r for r in lint_reports}
242
+ )
243
+
244
+ audit = AuditReport(
245
+ skills_dir=str(args.skills_dir),
246
+ lint=lint_reports,
247
+ clusters=clusters,
248
+ triggers=trigger_reports,
249
+ confusion=confusion,
250
+ critics=critics,
251
+ generated_at=utc_now(),
252
+ )
253
+ if args.json:
254
+ Path(args.json).parent.mkdir(parents=True, exist_ok=True)
255
+ Path(args.json).write_text(audit.model_dump_json(indent=2) + "\n", encoding="utf-8")
256
+
257
+ print(f"# Audit: {args.skills_dir} ({len(skills)} skills)\n")
258
+ for lint_report in sorted(lint_reports, key=lambda r: r.score):
259
+ print(f"{lint_report.score:.2f} {lint_report.skill}")
260
+ for finding in lint_report.findings:
261
+ print(f" [{finding.severity}] {finding.check}: {finding.message}")
262
+ if clusters:
263
+ print("\n## Overlap clusters")
264
+ for cluster in clusters:
265
+ print(f" {', '.join(cluster.skills)} (similarity {cluster.max_similarity:.2f})")
266
+ if trigger_reports:
267
+ print("\n## Trigger accuracy")
268
+ for trigger_report in trigger_reports:
269
+ print(
270
+ f" {trigger_report.skill}: F1 {trigger_report.f1:.2f} "
271
+ f"(precision {trigger_report.precision:.2f}, "
272
+ f"recall {trigger_report.recall:.2f}, tp={trigger_report.tp} "
273
+ f"fp={trigger_report.fp} fn={trigger_report.fn})"
274
+ )
275
+ if critics:
276
+ print("\n## Critic")
277
+ for critic in critics:
278
+ print(f" {critic.score:.2f} {critic.skill}")
279
+ for issue in critic.issues[:5]:
280
+ print(f" - {issue}")
281
+ if args.json:
282
+ print(f"\nWrote {args.json}")
283
+ return EXIT_OK
284
+
285
+
286
+ def cmd_mine(args: argparse.Namespace) -> int:
287
+ known = None
288
+ if args.skills_dir:
289
+ known = [s.name for s in discover_skills(args.skills_dir)]
290
+ report = mine_sessions(list(args.paths), known_skills=known)
291
+ if args.json:
292
+ Path(args.json).parent.mkdir(parents=True, exist_ok=True)
293
+ Path(args.json).write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
294
+ print(f"# Mined {report.sessions_scanned} session transcript(s)\n")
295
+ print(f"{'skill':<32} {'uses':>6} {'sessions':>9} {'est tokens':>12} {'est cost':>10}")
296
+ for usage in report.skills:
297
+ print(
298
+ f"{usage.skill:<32} {usage.activations:>6} {usage.sessions:>9} "
299
+ f"{usage.est_tokens:>12,} {usage.est_cost_usd:>10.4f}"
300
+ )
301
+ if args.json:
302
+ print(f"\nWrote {args.json}")
303
+ return EXIT_OK
304
+
305
+
306
+ def _parse_weights(raw: str | None) -> dict[str, float] | None:
307
+ if not raw:
308
+ return None
309
+ weights = dict(config.DEFAULT_WEIGHTS)
310
+ for pair in raw.split(","):
311
+ key, _, value = pair.partition("=")
312
+ key = key.strip()
313
+ if key not in weights:
314
+ raise SystemExit(f"unknown weight {key!r}; expected one of {sorted(weights)}")
315
+ weights[key] = float(value)
316
+ return weights
317
+
318
+
319
+ def cmd_rank(args: argparse.Namespace) -> int:
320
+ skills = discover_skills(args.skills_dir)
321
+ lint_reports = lint_all(skills)
322
+ clusters = cluster_overlaps(skills, threshold=args.similarity_threshold)
323
+ trigger_reports: list[TriggerReport] = []
324
+ critics: list[CriticReport] = []
325
+ cross: list[tuple[str, str, float]] = []
326
+
327
+ if args.audit:
328
+ audit = AuditReport.model_validate_json(Path(args.audit).read_text(encoding="utf-8"))
329
+ lint_reports = audit.lint or lint_reports
330
+ clusters = audit.clusters or clusters
331
+ trigger_reports = audit.triggers
332
+ critics = audit.critics
333
+ if audit.confusion:
334
+ cross = cross_activation_pairs(audit.confusion)
335
+
336
+ ab_reports: list[ABReport] = []
337
+ if args.runs:
338
+ from skill_scoreboard.runs import load_ab_reports
339
+
340
+ ab_reports = load_ab_reports(Path(args.runs))
341
+
342
+ mining = None
343
+ if args.mine:
344
+ mining = MiningReport.model_validate_json(Path(args.mine).read_text(encoding="utf-8"))
345
+
346
+ report = build_scorecard(
347
+ skills,
348
+ lint_reports=lint_reports,
349
+ trigger_reports=trigger_reports,
350
+ ab_reports=ab_reports,
351
+ critic_reports=critics,
352
+ mining=mining,
353
+ clusters=clusters,
354
+ cross_activation=cross,
355
+ weights=_parse_weights(args.weights),
356
+ )
357
+ if args.audit and trigger_reports:
358
+ audit_confusion = AuditReport.model_validate_json(
359
+ Path(args.audit).read_text(encoding="utf-8")
360
+ ).confusion
361
+ report.confusion = audit_confusion
362
+
363
+ markdown = render_leaderboard(report)
364
+ if args.md:
365
+ Path(args.md).parent.mkdir(parents=True, exist_ok=True)
366
+ Path(args.md).write_text(markdown, encoding="utf-8")
367
+ if args.json:
368
+ Path(args.json).parent.mkdir(parents=True, exist_ok=True)
369
+ Path(args.json).write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
370
+ print(markdown)
371
+ if args.md:
372
+ print(f"Wrote {args.md}")
373
+ return EXIT_OK
374
+
375
+
376
+ # --------------------------------------------------------------------------- parser
377
+
378
+
379
+ def build_parser() -> argparse.ArgumentParser:
380
+ parser = argparse.ArgumentParser(
381
+ prog="skill-scoreboard",
382
+ description="Evaluate Claude Code skills: A/B differentials, token waste, "
383
+ "skill adherence, and whole-library ranking.",
384
+ )
385
+ parser.add_argument("--version", action="version", version=f"skill-scoreboard {__version__}")
386
+ parser.add_argument(
387
+ "--out-dir", default="runs", help="where run artifacts land (default: runs)"
388
+ )
389
+ parser.add_argument(
390
+ "--no-llm",
391
+ action="store_true",
392
+ help="never call the API: skips llm_judge, critic, and classifier triggers",
393
+ )
394
+ parser.add_argument("--claude-bin", default="claude", help="claude CLI binary name/path")
395
+ sub = parser.add_subparsers(dest="command", required=True)
396
+
397
+ p_validate = sub.add_parser("validate", help="schema-check task/probes/skill files")
398
+ p_validate.add_argument("paths", nargs="+")
399
+ p_validate.add_argument("--kind", choices=["auto", "task", "probes", "skill"], default="auto")
400
+ p_validate.set_defaults(func=cmd_validate)
401
+
402
+ def add_run_limits(p: argparse.ArgumentParser) -> None:
403
+ p.add_argument("--model", help="override run_limits.model")
404
+ p.add_argument("--max-turns", type=int, help="override run_limits.max_turns")
405
+ p.add_argument("--max-budget-usd", type=float, help="override run_limits.max_budget_usd")
406
+ p.add_argument("--runner", choices=["claude", "replay"], default="claude")
407
+ p.add_argument("--keep-workspace", action="store_true")
408
+
409
+ p_run = sub.add_parser("run", help="run one arm of a task (debugging)")
410
+ p_run.add_argument("task")
411
+ p_run.add_argument("--arm", choices=["control", "treatment"], default="treatment")
412
+ p_run.add_argument("--trials", type=int, default=1)
413
+ p_run.add_argument("--fixture", action="append", help="replay NDJSON (repeatable)")
414
+ add_run_limits(p_run)
415
+ p_run.set_defaults(func=cmd_run)
416
+
417
+ p_ab = sub.add_parser("ab", help="A/B a task: control (no skill) vs treatment")
418
+ p_ab.add_argument("task", help="task.yaml or a directory of them")
419
+ p_ab.add_argument("--trials", type=int, default=DEFAULT_TRIALS)
420
+ p_ab.add_argument("--control-fixture", action="append")
421
+ p_ab.add_argument("--treatment-fixture", action="append")
422
+ p_ab.add_argument("--json", help="also write the ABReport JSON here")
423
+ p_ab.add_argument("--md", help="also write the Markdown report here")
424
+ add_run_limits(p_ab)
425
+ p_ab.set_defaults(func=cmd_ab)
426
+
427
+ p_report = sub.add_parser("report", help="re-render a stored run directory")
428
+ p_report.add_argument("run_dir")
429
+ p_report.add_argument("--format", choices=["md", "json"], default="md")
430
+ p_report.set_defaults(func=cmd_report)
431
+
432
+ p_audit = sub.add_parser("audit", help="Tier 0/1 audit of a skill library")
433
+ p_audit.add_argument("skills_dir")
434
+ p_audit.add_argument("--probes", help="probes.yaml enables trigger accuracy (Tier 1)")
435
+ p_audit.add_argument("--trigger-mode", choices=["classifier", "headless"])
436
+ p_audit.add_argument("--critic", action="store_true", help="add LLM critique (costs tokens)")
437
+ p_audit.add_argument("--critic-model", default=config.DEFAULT_CRITIC_MODEL)
438
+ p_audit.add_argument(
439
+ "--similarity-threshold", type=float, default=config.DEFAULT_SIMILARITY_THRESHOLD
440
+ )
441
+ p_audit.add_argument("--json", help="write the AuditReport JSON here")
442
+ p_audit.set_defaults(func=cmd_audit)
443
+
444
+ p_mine = sub.add_parser("mine", help="mine real session transcripts for skill usage")
445
+ p_mine.add_argument("paths", nargs="+")
446
+ p_mine.add_argument("--skills-dir", help="restrict to skills found in this library")
447
+ p_mine.add_argument("--json")
448
+ p_mine.set_defaults(func=cmd_mine)
449
+
450
+ p_rank = sub.add_parser("rank", help="compose stored artifacts into a leaderboard")
451
+ p_rank.add_argument("skills_dir")
452
+ p_rank.add_argument("--audit", help="audit.json from `skill-scoreboard audit`")
453
+ p_rank.add_argument("--runs", help="runs/ directory holding ab_report.json files")
454
+ p_rank.add_argument("--mine", help="mine.json from `skill-scoreboard mine`")
455
+ p_rank.add_argument("--weights", help="e.g. lint=0.1,trigger=0.2,ab=0.5,critic=0.2")
456
+ p_rank.add_argument(
457
+ "--similarity-threshold", type=float, default=config.DEFAULT_SIMILARITY_THRESHOLD
458
+ )
459
+ p_rank.add_argument("--md")
460
+ p_rank.add_argument("--json")
461
+ p_rank.set_defaults(func=cmd_rank)
462
+ return parser
463
+
464
+
465
+ def main(argv: Sequence[str] | None = None) -> int:
466
+ parser = build_parser()
467
+ args = parser.parse_args(list(argv) if argv is not None else None)
468
+ try:
469
+ result: int = args.func(args)
470
+ except (FileNotFoundError, ValueError, RuntimeError) as exc:
471
+ print(f"error: {exc}", file=sys.stderr)
472
+ return EXIT_USAGE
473
+ return result
474
+
475
+
476
+ if __name__ == "__main__":
477
+ raise SystemExit(main())
@@ -0,0 +1,55 @@
1
+ """Central configuration: pricing, default models, scorecard weights, thresholds.
2
+
3
+ Prices are USD per million tokens (input, output), Anthropic first-party API
4
+ rates. Used only to *estimate* cost when the runner does not report
5
+ ``total_cost_usd``; estimated costs are always flagged as such in results.
6
+ Cache reads are billed at 0.1x the input rate, cache creation at 1.25x.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # model id -> (input $/MTok, output $/MTok)
12
+ PRICING: dict[str, tuple[float, float]] = {
13
+ "claude-fable-5": (10.0, 50.0),
14
+ "claude-opus-5": (5.0, 25.0),
15
+ "claude-sonnet-5": (2.0, 10.0),
16
+ "claude-haiku-4-5": (1.0, 5.0),
17
+ }
18
+
19
+ CACHE_READ_MULTIPLIER = 0.1
20
+ CACHE_CREATION_MULTIPLIER = 1.25
21
+
22
+ #: Fallback pricing for models absent from PRICING (opus-tier, conservative).
23
+ DEFAULT_PRICING: tuple[float, float] = (5.0, 25.0)
24
+
25
+ #: Rough token estimate used when per-message usage is absent from the stream.
26
+ CHARS_PER_TOKEN = 4
27
+
28
+ DEFAULT_JUDGE_MODEL = "claude-opus-5"
29
+ DEFAULT_CLASSIFIER_MODEL = "claude-haiku-4-5"
30
+ DEFAULT_CRITIC_MODEL = "claude-opus-5"
31
+
32
+ #: Portfolio scorecard component weights (renormalized over available tiers).
33
+ DEFAULT_WEIGHTS: dict[str, float] = {
34
+ "lint": 0.15,
35
+ "trigger": 0.25,
36
+ "ab": 0.40,
37
+ "critic": 0.20,
38
+ }
39
+
40
+ #: TF-IDF cosine similarity at or above which two skills join an overlap cluster.
41
+ #: Cosine on prose rarely exceeds ~0.6 even for genuine duplicates, while
42
+ #: unrelated skills sit near 0 — so 0.45 separates them with room to spare.
43
+ #: Tune per library size with `skill-scoreboard audit --similarity-threshold`.
44
+ DEFAULT_SIMILARITY_THRESHOLD = 0.45
45
+
46
+ #: Cross-activation fraction above which the confusion matrix signals a merge.
47
+ CROSS_ACTIVATION_MERGE_THRESHOLD = 0.30
48
+
49
+ #: Skill body token estimates above these thresholds draw lint findings
50
+ #: (the skill's own footprint is paid on every activation).
51
+ SKILL_TOKENS_WARNING = 2000
52
+ SKILL_TOKENS_ERROR = 4000
53
+
54
+ #: Files larger than this (in lines) count toward the re-read waste metric.
55
+ LARGE_FILE_LINES = 100
@@ -0,0 +1,31 @@
1
+ """Evaluator registry: importing this package registers all built-in evaluators."""
2
+
3
+ from skill_scoreboard.evaluators import ( # noqa: F401 (registration side effects)
4
+ ast_assertions,
5
+ llm_judge,
6
+ pydev_static,
7
+ pydev_tests,
8
+ skill_activation,
9
+ trajectory_efficiency,
10
+ )
11
+ from skill_scoreboard.evaluators.base import (
12
+ EvalContext,
13
+ get_evaluator,
14
+ register,
15
+ registered_types,
16
+ )
17
+ from skill_scoreboard.models.results import EvalResult
18
+ from skill_scoreboard.models.task import TaskSpec
19
+
20
+
21
+ def run_evaluations(task: TaskSpec, ctx: EvalContext) -> list[EvalResult]:
22
+ return [get_evaluator(spec.type)(spec, ctx) for spec in task.evaluations]
23
+
24
+
25
+ __all__ = [
26
+ "EvalContext",
27
+ "get_evaluator",
28
+ "register",
29
+ "registered_types",
30
+ "run_evaluations",
31
+ ]