chill-agent 1.4.0 → 1.6.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/.agents/rules/ponytail.md +30 -0
- package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -3
- package/.cline-plugin/plugin.js +6 -0
- package/.clinerules/ponytail.md +30 -0
- package/.codex-plugin/plugin.json +4 -3
- package/.cursor/rules/ponytail.mdc +36 -0
- package/.cursor-plugin/marketplace.json +16 -0
- package/.cursor-plugin/plugin.json +2 -2
- package/.devin-plugin/plugin.json +2 -2
- package/.grok-plugin/marketplace.json +16 -0
- package/.kimi-plugin/plugin.json +11 -29
- package/.kiro/steering/ponytail.md +35 -0
- package/.omp-plugin/marketplace.json +15 -0
- package/.openclaw/skills/ponytail/SKILL.md +47 -0
- package/.openclaw/skills/ponytail-audit/SKILL.md +100 -0
- package/.openclaw/skills/ponytail-debt/SKILL.md +43 -0
- package/.openclaw/skills/ponytail-gain/SKILL.md +51 -0
- package/.openclaw/skills/ponytail-help/SKILL.md +76 -0
- package/.openclaw/skills/ponytail-review/SKILL.md +98 -0
- package/.opencode/command/ponytail-audit.md +5 -0
- package/.opencode/command/ponytail-debt.md +5 -0
- package/.opencode/command/ponytail-gain.md +5 -0
- package/.opencode/command/ponytail-help.md +5 -0
- package/.opencode/command/ponytail-review.md +5 -0
- package/.opencode/command/ponytail.md +5 -0
- package/.opencode/plugins/index.js +4 -0
- package/.opencode/plugins/ponytail-frontmatter.cjs +63 -0
- package/.opencode/plugins/ponytail.mjs +206 -0
- package/.qoder-plugin/plugin.json +12 -26
- package/.windsurf/rules/ponytail.md +30 -0
- package/AGENTS.md +32 -0
- package/__init__.py +228 -0
- package/assets/antislop-banner.png +0 -0
- package/assets/band/band.png +0 -0
- package/assets/band/board.png +0 -0
- package/assets/benchmark-3model.svg +21 -0
- package/assets/benchmark-agentic.svg +62 -0
- package/assets/codex-icon.png +0 -0
- package/assets/compare/code/after.webp +0 -0
- package/assets/compare/code/before.webp +0 -0
- package/assets/compare/text/after.webp +0 -0
- package/assets/compare/text/before.webp +0 -0
- package/assets/compare/ui/w-all.webp +0 -0
- package/assets/compare/ui/w-antislop.webp +0 -0
- package/assets/compare/ui/w-design.webp +0 -0
- package/assets/compare/ui/without.webp +0 -0
- package/assets/logo-dark.png +0 -0
- package/assets/logo-dark.svg +115 -0
- package/assets/logo-greenpt-dark.svg +27 -0
- package/assets/logo-greenpt.svg +27 -0
- package/assets/logo.png +0 -0
- package/assets/photoreunion-logo-dark.svg +4 -0
- package/assets/photoreunion-logo-light.svg +4 -0
- package/assets/profile.png +0 -0
- package/assets/retriever-logo-dark.svg +4 -0
- package/assets/retriever-logo-light.svg +4 -0
- package/assets/stories-banner.png +0 -0
- package/assets/v5/audit-es.png +0 -0
- package/assets/v5/audit-ja.png +0 -0
- package/assets/v5/audit-ko.png +0 -0
- package/assets/v5/audit-zh-CN.png +0 -0
- package/assets/v5/audit.png +0 -0
- package/assets/v5/beforeafter-es.png +0 -0
- package/assets/v5/beforeafter-ja.png +0 -0
- package/assets/v5/beforeafter-ko.png +0 -0
- package/assets/v5/beforeafter-zh-CN.png +0 -0
- package/assets/v5/beforeafter.png +0 -0
- package/assets/v5/chart-es.png +0 -0
- package/assets/v5/chart-ja.png +0 -0
- package/assets/v5/chart-ko.png +0 -0
- package/assets/v5/chart-zh-CN.png +0 -0
- package/assets/v5/chart.png +0 -0
- package/assets/v5/hero-es.jpg +0 -0
- package/assets/v5/hero-ja.jpg +0 -0
- package/assets/v5/hero-ko.jpg +0 -0
- package/assets/v5/hero-zh-CN.jpg +0 -0
- package/assets/v5/hero.jpg +0 -0
- package/assets/v5/ladder-es.png +0 -0
- package/assets/v5/ladder-ja.png +0 -0
- package/assets/v5/ladder-ko.png +0 -0
- package/assets/v5/ladder-zh-CN.png +0 -0
- package/assets/v5/ladder.png +0 -0
- package/assets/v5/review-es.png +0 -0
- package/assets/v5/review-ja.png +0 -0
- package/assets/v5/review-ko.png +0 -0
- package/assets/v5/review-zh-CN.png +0 -0
- package/assets/v5/review.png +0 -0
- package/assets/v5/tests-es.png +0 -0
- package/assets/v5/tests-ja.png +0 -0
- package/assets/v5/tests-ko.png +0 -0
- package/assets/v5/tests-zh-CN.png +0 -0
- package/assets/v5/tests.png +0 -0
- package/assets/waitlist-banner-es.png +0 -0
- package/assets/waitlist-banner-ja.png +0 -0
- package/assets/waitlist-banner-ko.png +0 -0
- package/assets/waitlist-banner-zh-CN.png +0 -0
- package/assets/waitlist-banner.png +0 -0
- package/benchmarks/.env.example +2 -0
- package/benchmarks/agentic/README.md +201 -0
- package/benchmarks/agentic/answer.py +288 -0
- package/benchmarks/agentic/compare.py +93 -0
- package/benchmarks/agentic/complete.py +178 -0
- package/benchmarks/agentic/judge.py +198 -0
- package/benchmarks/agentic/run.py +543 -0
- package/benchmarks/agentic/tasks.py +1544 -0
- package/benchmarks/arms/baseline.js +2 -0
- package/benchmarks/arms/caveman-SKILL.md +67 -0
- package/benchmarks/arms/caveman.js +8 -0
- package/benchmarks/arms/ponytail.js +8 -0
- package/benchmarks/behavior.js +54 -0
- package/benchmarks/behavior.yaml +40 -0
- package/benchmarks/correctness.js +291 -0
- package/benchmarks/loc.js +17 -19
- package/benchmarks/promptfooconfig.gemini.yaml +32 -0
- package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
- package/benchmarks/promptfooconfig.yaml +41 -0
- package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
- package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
- package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
- package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
- package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
- package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
- package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
- package/benchmarks/results/2026-06-18-agentic.md +219 -0
- package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
- package/benchmarks/results/2026-10-07-agentic.md +186 -0
- package/cli/index.mjs +241 -0
- package/cli/lib/banner.mjs +21 -0
- package/cli/lib/install.mjs +498 -0
- package/cli/lib/settings.mjs +48 -0
- package/cli/package-lock.json +87 -0
- package/cli/package.json +42 -0
- package/cli/scripts/smoke-test.mjs +95 -0
- package/cli/scripts/smoke-worker.mjs +411 -0
- package/cli/scripts/sync-skills.mjs +81 -0
- package/commands/ponytail-audit.toml +2 -0
- package/commands/ponytail-debt.toml +2 -0
- package/commands/ponytail-gain.toml +2 -0
- package/commands/ponytail-help.toml +2 -0
- package/commands/ponytail-review.toml +2 -0
- package/commands/ponytail.toml +2 -0
- package/examples/email-validation.md +156 -0
- package/examples/group-by.md +35 -0
- package/examples/infinite-scroll.md +58 -0
- package/examples/number-formatting.md +37 -0
- package/examples/rate-limit.md +272 -0
- package/examples/react-countdown.md +390 -0
- package/examples/url-params.md +41 -0
- package/gemini-extension.json +2 -2
- package/hooks/band/board.ts +560 -0
- package/hooks/band/logo.ts +3 -0
- package/hooks/band/metrics.ts +311 -0
- package/hooks/band/register.tsx +495 -0
- package/hooks/band/types.d.ts +34 -0
- package/hooks/chill-statusline.ps1 +1 -1
- package/hooks/claude-band.json +6 -0
- package/hooks/claude-codex-hooks.json +34 -4
- package/hooks/copilot-hooks.json +12 -2
- package/hooks/cursor-hooks.json +2 -2
- package/hooks/ponytail-activate.js +201 -0
- package/hooks/ponytail-band-import.js +274 -0
- package/hooks/ponytail-config.js +175 -0
- package/hooks/ponytail-instructions.js +98 -0
- package/hooks/ponytail-map.js +111 -0
- package/hooks/ponytail-mode-tracker.js +216 -0
- package/hooks/ponytail-runtime.js +182 -0
- package/hooks/ponytail-statusline.ps1 +24 -0
- package/hooks/ponytail-statusline.sh +18 -0
- package/hooks/ponytail-subagent.js +94 -0
- package/hooks/qoder-hooks.json +19 -4
- package/i18n/README.ko.md +230 -0
- package/package.json +17 -6
- package/pi-extension/index.js +254 -0
- package/pi-extension/package.json +8 -0
- package/pi-extension/test/extension.test.js +367 -0
- package/pi-extension/test/helpers.test.js +179 -0
- package/plugin.yaml +21 -12
- package/rules/antislop.mdc +18 -0
- package/scripts/build-openclaw-skills.js +60 -0
- package/scripts/check-repo.mjs +389 -0
- package/scripts/check-rule-copies.js +74 -0
- package/scripts/check-versions.js +77 -0
- package/scripts/cursor-hooks.js +145 -0
- package/scripts/generate-chillslop.mjs +42 -0
- package/scripts/generate-examples.mjs +149 -0
- package/scripts/generate-plugin-doors.mjs +108 -0
- package/scripts/publish-openclaw-skills.js +75 -0
- package/scripts/skill-index.mjs +81 -0
- package/scripts/sync-ponytail.mjs +65 -0
- package/scripts/uninstall.js +92 -0
- package/skills/antislop/SKILL.md +700 -0
- package/skills/antislop/VERSION +1 -0
- package/skills/antislop-code/SKILL.md +128 -0
- package/skills/antislop-copywriting/SKILL.md +407 -0
- package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-human/SKILL.md +204 -0
- package/skills/antislop-human/contrast-check.py +168 -0
- package/skills/antislop-human/contrast-mcp.py +121 -0
- package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
- package/skills/antislop-human/references/WCAG.md +127 -0
- package/skills/antislop-layoutmobile/SKILL.md +170 -0
- package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
- package/skills/antislop-ui/SKILL.md +344 -0
- package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
- package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
- package/skills/plugin.json +5 -0
- package/skills/ponytail/SKILL.md +52 -0
- package/skills/ponytail-audit/SKILL.md +103 -0
- package/skills/ponytail-debt/SKILL.md +44 -0
- package/skills/ponytail-gain/SKILL.md +52 -0
- package/skills/ponytail-help/SKILL.md +76 -0
- package/skills/ponytail-review/SKILL.md +101 -0
- package/src/package.json +3 -0
- package/tests/activation-map.test.js +63 -0
- package/tests/band-import.test.js +111 -0
- package/tests/band.test.tsx +520 -0
- package/tests/behavior.test.js +80 -0
- package/tests/commands.test.js +94 -0
- package/tests/copilot-plugin.test.js +35 -0
- package/tests/correctness.test.js +245 -0
- package/tests/cursor-hooks.test.js +343 -0
- package/tests/gemini-extension.test.js +91 -0
- package/tests/grok-plugin.test.js +39 -0
- package/tests/hermes-plugin.test.js +403 -0
- package/tests/hooks-windows.test.js +205 -0
- package/tests/hooks.test.js +1005 -0
- package/tests/kimi-plugin.test.js +25 -0
- package/tests/loc.test.js +18 -0
- package/tests/map.test.js +30 -0
- package/tests/openclaw-skills.test.js +26 -0
- package/tests/opencode-plugin.test.js +340 -0
- package/tests/package-scripts.test.js +35 -0
- package/tests/package.test.js +23 -0
- package/tests/qoder-plugin.test.js +84 -0
- package/tests/uninstall.test.js +202 -0
- /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
- /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
- /package/tests/{rules.test.js → rules.test.mjs} +0 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agentic, multi-file benchmark for ponytail.
|
|
3
|
+
|
|
4
|
+
Runs each (task x arm x model) through a real headless Claude Code session in an isolated
|
|
5
|
+
temp workspace seeded with a starter file, then scores the produced files deterministically
|
|
6
|
+
for CORRECTNESS and SAFETY -- the axis the single-shot promptfoo bench was blind to.
|
|
7
|
+
|
|
8
|
+
Over-engineering is proxied by SOURCE file count + source LOC (tests are counted separately,
|
|
9
|
+
never as bloat -- writing a test is good practice, not over-engineering). An LLM-judge
|
|
10
|
+
over-engineering score is a later pass.
|
|
11
|
+
|
|
12
|
+
python run.py --selftest
|
|
13
|
+
Verify every scorer (good passes, bad is caught). No API, no spend. Run first, always.
|
|
14
|
+
|
|
15
|
+
python run.py --all --models haiku,sonnet,opus --runs 5
|
|
16
|
+
Live run (spends API). Workspaces kept under runs/<stamp>/ for inspection.
|
|
17
|
+
|
|
18
|
+
python run.py --rescore runs/<stamp>
|
|
19
|
+
Recompute metrics + aggregate from kept workspaces. No API. Use after changing a
|
|
20
|
+
metric or scorer so you never pay the API twice for a measurement tweak.
|
|
21
|
+
|
|
22
|
+
ponytail: the claude CLI is the harness (already installed, we run inside it). No SDK
|
|
23
|
+
dependency. The CLI's JSON output already carries cost/tokens/duration/permission_denials.
|
|
24
|
+
"""
|
|
25
|
+
import argparse, concurrent.futures, datetime, json, os, re, shutil, signal, statistics, subprocess, sys, tempfile, zlib
|
|
26
|
+
from collections import defaultdict
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from tasks import QUICK, TASKS
|
|
30
|
+
|
|
31
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
32
|
+
RUNS_DIR = Path(__file__).resolve().parent / "runs"
|
|
33
|
+
|
|
34
|
+
def _skill(rel): return (ROOT / rel).read_text(encoding="utf-8")
|
|
35
|
+
ARMS = {
|
|
36
|
+
"baseline": lambda: None,
|
|
37
|
+
"ponytail": lambda: _skill("skills/ponytail/SKILL.md"),
|
|
38
|
+
"caveman": lambda: _skill("benchmarks/arms/caveman-SKILL.md"),
|
|
39
|
+
"yagni": lambda: "Follow YAGNI principles.",
|
|
40
|
+
"yagni-oneliner": lambda: "Follow YAGNI principles, and prefer one-liner solutions.",
|
|
41
|
+
"brief": lambda: "Be brief.", # the two words that matched caveman in an independent test
|
|
42
|
+
"ponytail2": lambda: None, # plugin arm: PONYTAIL2_PLUGIN_DIR points at the candidate
|
|
43
|
+
"concise": lambda: None, # Claude Code's built-in Concise output style, no plugin
|
|
44
|
+
}
|
|
45
|
+
SETTINGS_ARMS = {"concise": '{"outputStyle": "Concise"}'} # arms that differ only by a settings value
|
|
46
|
+
# Shorthands; any other value is passed to `claude --model` as is, so new models need no code change.
|
|
47
|
+
MODELS = {"haiku": "claude-haiku-4-5-20251001", "sonnet": "claude-sonnet-5-5", "opus": "claude-opus-5-5",
|
|
48
|
+
"fable": "claude-fable-5-1"}
|
|
49
|
+
|
|
50
|
+
# Skills are plugins activated by a SessionStart hook. To test exactly one at a time we exclude the
|
|
51
|
+
# user's globally-enabled plugins (--setting-sources project,local) and load one plugin from its
|
|
52
|
+
# cache dir (--plugin-dir). The smoke test verifies activation by output style.
|
|
53
|
+
PLUGIN_ARMS = ("ponytail", "caveman", "ponytail2") # arms activated via --plugin-dir (vs raw --append prompts)
|
|
54
|
+
PLUGIN_CACHE = Path.home() / ".claude" / "plugins" / "cache"
|
|
55
|
+
|
|
56
|
+
def _plugin_dir(name):
|
|
57
|
+
"""Resolve a plugin's cache dir portably -- hardcoding one machine's absolute path
|
|
58
|
+
(e.g. C:\\Users\\<you>\\...) made the ponytail/caveman arms unreproducible off that box.
|
|
59
|
+
Order: env override -> latest version dir under ~/.claude/plugins/cache -> clear error.
|
|
60
|
+
Resolved per-arm at use-site so a missing caveman install can't block a ponytail-only run."""
|
|
61
|
+
env = os.environ.get(f"{name.upper()}_PLUGIN_DIR")
|
|
62
|
+
if env: return env
|
|
63
|
+
base = PLUGIN_CACHE / name / name
|
|
64
|
+
versions = sorted(p for p in base.glob("*") if p.is_dir()) if base.exists() else []
|
|
65
|
+
if not versions:
|
|
66
|
+
sys.exit(f"{name} plugin dir not found under {base}; install the plugin or set {name.upper()}_PLUGIN_DIR")
|
|
67
|
+
return str(versions[-1]) # latest version dir; not pinned to one machine's hash
|
|
68
|
+
|
|
69
|
+
CELL_TIMEOUT = 300 # seconds per cell; a hung agent is force-killed (process tree) so the pool can't freeze
|
|
70
|
+
|
|
71
|
+
# Added to every arm's system prompt, identically. We measure code PRODUCTION, not execution: agents
|
|
72
|
+
# write the implementation and stop. No live verification -- earlier attempts had agents open a browser,
|
|
73
|
+
# hit the template's login wall, and retry, inflating tokens/time with flailing instead of code. Writing
|
|
74
|
+
# tests is still explicitly allowed, so ponytail's "leave a runnable check" discipline is not suppressed.
|
|
75
|
+
NO_RUN = ("Write the implementation (include tests if you normally would for a change like this). "
|
|
76
|
+
"Do not run a dev server, install dependencies, run a database, or open a browser to verify -- "
|
|
77
|
+
"just write the code and stop. Only the code you write is measured, not its execution.")
|
|
78
|
+
|
|
79
|
+
def _is_test(p: Path, workdir: Path):
|
|
80
|
+
rel = p.relative_to(workdir)
|
|
81
|
+
name = p.name.lower()
|
|
82
|
+
return (name.startswith("test_") or name.endswith("_test.py") or name == "conftest.py"
|
|
83
|
+
or any(part.lower() in ("test", "tests") for part in rel.parts[:-1]))
|
|
84
|
+
|
|
85
|
+
CODE_EXT = {".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".css", ".go", ".rs", ".java", ".rb", ".sh"}
|
|
86
|
+
|
|
87
|
+
def _count(p: Path, with_comments: bool):
|
|
88
|
+
try: lines = p.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
89
|
+
except Exception: return 0
|
|
90
|
+
n = 0
|
|
91
|
+
for ln in lines:
|
|
92
|
+
s = ln.strip()
|
|
93
|
+
if not s: continue
|
|
94
|
+
if not with_comments and s.startswith(("#", "//", "*", "/*", "*/")): continue
|
|
95
|
+
n += 1
|
|
96
|
+
return n
|
|
97
|
+
|
|
98
|
+
_SELFCHECK_DEFS = ("def demo(", "def _demo(", "def selfcheck(", "def _selfcheck(",
|
|
99
|
+
"def _check(", "def _smoke(", "def smoke(")
|
|
100
|
+
def _selfcheck_split(p: Path):
|
|
101
|
+
"""Split a produced .py file at the first TOP-LEVEL self-check marker (a `__main__` guard or a
|
|
102
|
+
demo()/selfcheck() function) through end of file. Returns (src_total, src_code, sc_total,
|
|
103
|
+
sc_code), counted like _count. On a surgical task that delivers ONE function, an in-file self-
|
|
104
|
+
check is the runnable check ponytail's rule asks for -- a positive signal, not source bloat --
|
|
105
|
+
so it is split off here and counted as test LOC instead of penalising the arm that wrote it."""
|
|
106
|
+
try: lines = p.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
107
|
+
except Exception: return 0, 0, 0, 0
|
|
108
|
+
start = None
|
|
109
|
+
for i, ln in enumerate(lines):
|
|
110
|
+
if ln[:1] not in (" ", "\t") and (ln.startswith("if __name__") or ln.startswith(_SELFCHECK_DEFS)):
|
|
111
|
+
start = i; break
|
|
112
|
+
def cnt(seq):
|
|
113
|
+
t = c = 0
|
|
114
|
+
for ln in seq:
|
|
115
|
+
s = ln.strip()
|
|
116
|
+
if not s: continue
|
|
117
|
+
t += 1
|
|
118
|
+
if not s.startswith(("#", "//", "*", "/*", "*/")): c += 1
|
|
119
|
+
return t, c
|
|
120
|
+
if start is None:
|
|
121
|
+
t, c = cnt(lines); return t, c, 0, 0
|
|
122
|
+
t, c = cnt(lines[:start]); st, sc = cnt(lines[start:])
|
|
123
|
+
return t, c, st, sc
|
|
124
|
+
|
|
125
|
+
def gz(text):
|
|
126
|
+
"""Information content in bytes: raw-deflate size (Minimum Description Length proxy). Repeated
|
|
127
|
+
names and boilerplate cost little, so packing code into one line gains much less here than in LOC."""
|
|
128
|
+
data = text.encode("utf-8", "ignore")
|
|
129
|
+
if not data: return 0
|
|
130
|
+
c = zlib.compressobj(9, zlib.DEFLATED, -15)
|
|
131
|
+
return len(c.compress(data) + c.flush())
|
|
132
|
+
|
|
133
|
+
def code_stats(workdir: Path, selfcheck_as_test: bool = False):
|
|
134
|
+
"""LOC over code-extension source files only (generated images/data can't pollute it).
|
|
135
|
+
total_loc counts every non-blank line including comments and docstrings -- the bloat a vibe
|
|
136
|
+
baseline actually produces. src_loc is code-only, for the breakdown. Tests tracked separately,
|
|
137
|
+
never as bloat. selfcheck_as_test (surgical tasks): an in-file __main__/demo() self-check is
|
|
138
|
+
reclassified from source to test, so following ponytail's 'leave a runnable check' rule is not
|
|
139
|
+
counted as code bloat against it."""
|
|
140
|
+
fixture = set() # files that were seeded, not delivered
|
|
141
|
+
fm = workdir / "_fixture_files.json"
|
|
142
|
+
if fm.exists():
|
|
143
|
+
try: fixture = set(json.loads(fm.read_text(encoding="utf-8")))
|
|
144
|
+
except Exception: pass
|
|
145
|
+
def _rel(p): return str(p.relative_to(workdir)).replace("\\", "/")
|
|
146
|
+
files = [p for p in workdir.rglob("*") if p.is_file() and p.suffix in CODE_EXT
|
|
147
|
+
and "__pycache__" not in p.parts and "node_modules" not in p.parts
|
|
148
|
+
and not p.name.startswith((".", "_")) and _rel(p) not in fixture]
|
|
149
|
+
src = [p for p in files if not _is_test(p, workdir)]
|
|
150
|
+
tst = [p for p in files if _is_test(p, workdir)]
|
|
151
|
+
test_loc = sum(_count(p, True) for p in tst)
|
|
152
|
+
gz_code = gz("\n".join(p.read_text(encoding="utf-8", errors="ignore") for p in src))
|
|
153
|
+
if selfcheck_as_test:
|
|
154
|
+
total = code = sc_test = 0
|
|
155
|
+
for p in src:
|
|
156
|
+
t, c, st, _ = _selfcheck_split(p)
|
|
157
|
+
total += t; code += c; sc_test += st
|
|
158
|
+
return {"files": len(files), "src_files": len(src),
|
|
159
|
+
"total_loc": total, "src_loc": code,
|
|
160
|
+
"test_files": len(tst), "test_loc": test_loc + sc_test, "gz_code": gz_code}
|
|
161
|
+
return {"files": len(files), "src_files": len(src),
|
|
162
|
+
"total_loc": sum(_count(p, True) for p in src), # incl comments + docstrings (the bloat)
|
|
163
|
+
"src_loc": sum(_count(p, False) for p in src), # code only
|
|
164
|
+
"test_files": len(tst), "test_loc": test_loc, "gz_code": gz_code}
|
|
165
|
+
|
|
166
|
+
def _git(workdir, *args):
|
|
167
|
+
return subprocess.run([shutil.which("git") or "git", *args], cwd=str(workdir),
|
|
168
|
+
capture_output=True, text=True)
|
|
169
|
+
|
|
170
|
+
def _git_snapshot(workdir):
|
|
171
|
+
"""Commit the seeded repo so we can diff exactly what the agent changes."""
|
|
172
|
+
_git(workdir, "init", "-q")
|
|
173
|
+
_git(workdir, "add", "-A")
|
|
174
|
+
_git(workdir, "-c", "user.email=bench@local", "-c", "user.name=bench",
|
|
175
|
+
"commit", "-q", "-m", "base", "--no-verify")
|
|
176
|
+
|
|
177
|
+
_SKIP_DIFF = ("-lock", ".lock", ".gen.ts", "lock.json", "routeTree.gen")
|
|
178
|
+
def git_diff_stats(workdir):
|
|
179
|
+
"""Added lines (incl comments) of code files the agent created OR modified, vs the seeded
|
|
180
|
+
base. This is the delivered-code metric and matches the '+N' a PR/diff shows. Tests counted
|
|
181
|
+
separately; lockfiles/generated files skipped."""
|
|
182
|
+
_git(workdir, "add", "-A")
|
|
183
|
+
out = _git(workdir, "diff", "--cached", "--numstat", "HEAD").stdout
|
|
184
|
+
loc = files = test_loc = test_files = 0
|
|
185
|
+
src_paths = []
|
|
186
|
+
for line in out.splitlines():
|
|
187
|
+
parts = line.split("\t")
|
|
188
|
+
if len(parts) != 3: continue
|
|
189
|
+
added, _deleted, path = parts
|
|
190
|
+
if added == "-": continue # binary
|
|
191
|
+
if Path(path).suffix not in CODE_EXT: continue
|
|
192
|
+
if any(k in path for k in _SKIP_DIFF) or "node_modules" in path: continue
|
|
193
|
+
n = int(added)
|
|
194
|
+
if _is_test(Path(workdir) / path, Path(workdir)): test_loc += n; test_files += 1
|
|
195
|
+
else: loc += n; files += 1; src_paths.append(path)
|
|
196
|
+
added = _git(workdir, "diff", "--cached", "-U0", "HEAD", "--", *src_paths).stdout if src_paths else ""
|
|
197
|
+
gz_code = gz("\n".join(l[1:] for l in added.splitlines() if l.startswith("+") and not l.startswith("+++")))
|
|
198
|
+
return {"files": files, "src_files": files, "total_loc": loc, "src_loc": loc,
|
|
199
|
+
"test_files": test_files, "test_loc": test_loc, "gz_code": gz_code}
|
|
200
|
+
|
|
201
|
+
def _write(path: Path, content):
|
|
202
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
path.write_text(content, encoding="utf-8")
|
|
204
|
+
|
|
205
|
+
def selftest():
|
|
206
|
+
"""Each task's good ref must score correct+safe; the bad ref must be caught on its
|
|
207
|
+
declared axis. Verifies the instruments before any API spend."""
|
|
208
|
+
failures = 0
|
|
209
|
+
for tid, task in TASKS.items():
|
|
210
|
+
if task.get("open"): continue # open tasks measure LOC only, no good/bad refs
|
|
211
|
+
axis = task.get("axis", "safe")
|
|
212
|
+
for kind in ("good", "bad"):
|
|
213
|
+
with tempfile.TemporaryDirectory() as d:
|
|
214
|
+
ref = task[kind] if isinstance(task[kind], dict) else {task["file"]: task[kind]}
|
|
215
|
+
for fn, content in {**task.get("seed", {}), **ref}.items(): # seed, then the ref on top
|
|
216
|
+
_write(Path(d) / fn, content)
|
|
217
|
+
r = task["score"](Path(d))
|
|
218
|
+
ok = (r["correct"] == 1 and r["safe"] == 1) if kind == "good" else (r[axis] == 0)
|
|
219
|
+
print(f"{'ok ' if ok else 'XX '} {tid:12} {kind:4} correct={r['correct']} "
|
|
220
|
+
f"safe={r['safe']} axis={axis} {r['reason']}")
|
|
221
|
+
failures += 0 if ok else 1
|
|
222
|
+
row = aggregate([{"task": "t", "arm": "a", "model": "m", "error": "boom"}])[0]
|
|
223
|
+
ok = row["safe_rate"] == 0 and row["correct_rate"] == 0
|
|
224
|
+
print(f"{'ok ' if ok else 'XX '} aggregate a crashed cell counts as failed")
|
|
225
|
+
failures += 0 if ok else 1
|
|
226
|
+
failures += _selftest_plugin_dir()
|
|
227
|
+
failures += _selftest_kill()
|
|
228
|
+
print(f"\nselftest: {'all instruments valid' if not failures else str(failures) + ' BROKEN'}")
|
|
229
|
+
return failures
|
|
230
|
+
|
|
231
|
+
def _selftest_plugin_dir():
|
|
232
|
+
"""Plugin-dir resolution must be portable: env override wins, and a missing install
|
|
233
|
+
fails loudly (sys.exit) instead of silently passing a non-existent path to --plugin-dir."""
|
|
234
|
+
fails = 0
|
|
235
|
+
sentinel = "/tmp/ponytail-selftest-plugin-dir"
|
|
236
|
+
previous = os.environ.get("PONYTAIL_PLUGIN_DIR")
|
|
237
|
+
os.environ["PONYTAIL_PLUGIN_DIR"] = sentinel
|
|
238
|
+
try:
|
|
239
|
+
ok_env = _plugin_dir("ponytail") == sentinel
|
|
240
|
+
finally: # restore, never delete: this selftest runs before
|
|
241
|
+
if previous is None: # every live run, so deleting it silently swapped the
|
|
242
|
+
del os.environ["PONYTAIL_PLUGIN_DIR"] # user's override for the newest cached plugin version
|
|
243
|
+
else:
|
|
244
|
+
os.environ["PONYTAIL_PLUGIN_DIR"] = previous
|
|
245
|
+
ok_env = ok_env and os.environ.get("PONYTAIL_PLUGIN_DIR") == previous
|
|
246
|
+
print(f"{'ok ' if ok_env else 'XX '} plugin_dir env override honored and restored")
|
|
247
|
+
fails += 0 if ok_env else 1
|
|
248
|
+
missing = "ponytail-does-not-exist-xyz" # no env, no cache entry -> must sys.exit
|
|
249
|
+
try:
|
|
250
|
+
_plugin_dir(missing); ok_miss = False # reached only if it did NOT exit -> broken
|
|
251
|
+
except SystemExit:
|
|
252
|
+
ok_miss = True
|
|
253
|
+
print(f"{'ok ' if ok_miss else 'XX '} plugin_dir miss clear error (sys.exit)")
|
|
254
|
+
return fails + (0 if ok_miss else 1)
|
|
255
|
+
|
|
256
|
+
def _tree_kill(proc):
|
|
257
|
+
"""Tree-kill one timed-out cell, never a blanket kill (that would also take down this
|
|
258
|
+
Claude Code session). Windows: taskkill /T walks the child PIDs. POSIX has no taskkill,
|
|
259
|
+
so the cell runs in its own session (Popen start_new_session) and we kill the group."""
|
|
260
|
+
if os.name == "nt":
|
|
261
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
262
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
263
|
+
else:
|
|
264
|
+
try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
265
|
+
except ProcessLookupError: pass # already exited
|
|
266
|
+
|
|
267
|
+
def _selftest_kill():
|
|
268
|
+
"""tree-kill must actually terminate a cell that outran its timeout, on this platform."""
|
|
269
|
+
p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"],
|
|
270
|
+
start_new_session=(os.name != "nt"))
|
|
271
|
+
_tree_kill(p)
|
|
272
|
+
try: ok = p.wait(timeout=10) is not None
|
|
273
|
+
except subprocess.TimeoutExpired: ok = False; p.kill()
|
|
274
|
+
print(f"{'ok ' if ok else 'XX '} tree_kill terminates a timed-out cell")
|
|
275
|
+
return 0 if ok else 1
|
|
276
|
+
|
|
277
|
+
def chat_code_loc(text):
|
|
278
|
+
"""LOC of fenced code blocks in a chat answer: (total incl comments, code-only)."""
|
|
279
|
+
total = code = 0
|
|
280
|
+
for b in re.findall(r"```[a-zA-Z0-9_+-]*\r?\n(.*?)```", text or "", re.S):
|
|
281
|
+
for ln in b.splitlines():
|
|
282
|
+
s = ln.strip()
|
|
283
|
+
if not s: continue
|
|
284
|
+
total += 1
|
|
285
|
+
if not s.startswith(("#", "//", "*", "/*", "*/")): code += 1
|
|
286
|
+
return total, code
|
|
287
|
+
|
|
288
|
+
def score_workspace(task_id, arm, model, workdir: Path):
|
|
289
|
+
meta, result_text = {}, ""
|
|
290
|
+
cj = workdir / "_claude.json"
|
|
291
|
+
if cj.exists():
|
|
292
|
+
try:
|
|
293
|
+
j = json.loads(cj.read_text(encoding="utf-8"))
|
|
294
|
+
u = j.get("usage") or {}
|
|
295
|
+
meta = {"cost": j.get("total_cost_usd"), "duration_ms": j.get("duration_ms"),
|
|
296
|
+
"turns": j.get("num_turns"), "denials": len(j.get("permission_denials") or []),
|
|
297
|
+
"out_tokens": u.get("output_tokens"), "in_tokens": u.get("input_tokens"),
|
|
298
|
+
"cache_tokens": (u.get("cache_read_input_tokens") or 0) + (u.get("cache_creation_input_tokens") or 0)}
|
|
299
|
+
result_text = j.get("result", "")
|
|
300
|
+
meta["reply_words"] = len(result_text.split())
|
|
301
|
+
# share of the reply that is not redundancy: compressed / raw. Filler and repeated
|
|
302
|
+
# recaps compress well; dense information does not. Reported, not optimized blindly.
|
|
303
|
+
meta["reply_density"] = round(gz(result_text) / max(1, len(result_text.encode())), 3)
|
|
304
|
+
except Exception: pass
|
|
305
|
+
surgical = not TASKS[task_id].get("open") and not TASKS[task_id].get("fixture")
|
|
306
|
+
diffed = TASKS[task_id].get("fixture") or TASKS[task_id].get("diff")
|
|
307
|
+
stats = git_diff_stats(workdir) if diffed else code_stats(workdir, selfcheck_as_test=surgical)
|
|
308
|
+
# open/explain tasks answer in the chat, not a file. If no source file was written, count the
|
|
309
|
+
# code the agent delivered in its chat answer so the comparison isn't a false zero.
|
|
310
|
+
if TASKS[task_id].get("open") and stats["total_loc"] == 0 and result_text:
|
|
311
|
+
t, c = chat_code_loc(result_text)
|
|
312
|
+
stats = {**stats, "total_loc": t, "src_loc": c, "src_files": 1 if t else 0}
|
|
313
|
+
if TASKS[task_id].get("fixture"):
|
|
314
|
+
sc = {"correct": 1 if stats.get("total_loc", 0) > 0 else 0, "safe": 1, "reason": "git-diff"}
|
|
315
|
+
else:
|
|
316
|
+
sc = TASKS[task_id]["score"](workdir)
|
|
317
|
+
return {"task": task_id, "arm": arm, "model": model, **sc, **stats, **meta}
|
|
318
|
+
|
|
319
|
+
def _base_cmd(claude, prompt, model):
|
|
320
|
+
return [claude, "-p", prompt, "--model", MODELS.get(model, model),
|
|
321
|
+
"--permission-mode", "bypassPermissions", "--output-format", "json",
|
|
322
|
+
"--setting-sources", "project,local", "--strict-mcp-config",
|
|
323
|
+
"--disallowedTools", "Bash"]
|
|
324
|
+
|
|
325
|
+
def warm_cache(model):
|
|
326
|
+
"""One throwaway call before the pool. Without it the first parallel cells each pay the cold
|
|
327
|
+
prompt-cache write of the shared prefix (smoke run: ~$0.32 vs ~$0.10 per cell, in both arms),
|
|
328
|
+
which is ordering noise, not an arm effect. Cells running back to back keep the cache warm."""
|
|
329
|
+
with tempfile.TemporaryDirectory() as d:
|
|
330
|
+
subprocess.run(_base_cmd(shutil.which("claude"), "Reply with: ok", model) + ["--append-system-prompt", NO_RUN],
|
|
331
|
+
cwd=d, capture_output=True, timeout=CELL_TIMEOUT,
|
|
332
|
+
env={**os.environ, "CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1", "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"})
|
|
333
|
+
|
|
334
|
+
def run_cell(task_id, arm, model, workdir: Path):
|
|
335
|
+
task = TASKS[task_id]
|
|
336
|
+
if task.get("fixture"): # copy a real repo in; record what was seeded
|
|
337
|
+
fx = Path(task["fixture"]) # absolute path, or a name under fixtures/
|
|
338
|
+
if not fx.is_absolute(): fx = Path(__file__).resolve().parent / "fixtures" / task["fixture"]
|
|
339
|
+
shutil.copytree(fx, workdir, dirs_exist_ok=True,
|
|
340
|
+
ignore=shutil.ignore_patterns("node_modules", ".git", "build", "dist",
|
|
341
|
+
"dist-ssr", ".vite", "*.log", "__pycache__",
|
|
342
|
+
"storage", ".venv", "venv", ".pytest_cache",
|
|
343
|
+
"*.mp4", "*.mp3", "*.wav", "*.mov",
|
|
344
|
+
"*service-account*.json",
|
|
345
|
+
"nul", "con", "prn", "aux",
|
|
346
|
+
"DatePicker*.tsx", "DatePicker*.jsx"))
|
|
347
|
+
manifest = sorted(str(p.relative_to(workdir)).replace("\\", "/")
|
|
348
|
+
for p in workdir.rglob("*") if p.is_file())
|
|
349
|
+
(workdir / "_fixture_files.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
350
|
+
for fn, content in task.get("seed", {}).items():
|
|
351
|
+
_write(workdir / fn, content)
|
|
352
|
+
if task.get("fixture") or task.get("diff"): _git_snapshot(workdir) # baseline commit -> diff the agent's changes
|
|
353
|
+
claude = shutil.which("claude")
|
|
354
|
+
if not claude: sys.exit("claude CLI not found on PATH")
|
|
355
|
+
# Skills are PLUGINS (SessionStart hook); --append of the SKILL text does NOT activate them.
|
|
356
|
+
# Exclude the user's globally-enabled plugins for every arm, then load exactly the one this arm
|
|
357
|
+
# needs from its cache dir. baseline loads none; yagni-oneliner is a raw prompt so it uses --append.
|
|
358
|
+
# No live verification (see NO_RUN): --strict-mcp-config drops all MCP servers so there is no browser
|
|
359
|
+
# tool, and --disallowedTools Bash blocks running a server/db/npm. An agent writes with
|
|
360
|
+
# Read/Write/Edit/Glob/Grep and stops -- no login wall, no browser thrash. We measure code, not execution.
|
|
361
|
+
cmd = _base_cmd(claude, task["prompt"], model)
|
|
362
|
+
append = NO_RUN # all arms get NO_RUN, identically
|
|
363
|
+
if arm in PLUGIN_ARMS:
|
|
364
|
+
cmd += ["--plugin-dir", _plugin_dir(arm)] # real activation of exactly one plugin
|
|
365
|
+
elif arm in SETTINGS_ARMS:
|
|
366
|
+
cmd += ["--settings", SETTINGS_ARMS[arm]]
|
|
367
|
+
else:
|
|
368
|
+
extra = ARMS[arm]() # baseline -> None; yagni-oneliner -> the prompt
|
|
369
|
+
if extra: append = extra + "\n\n" + NO_RUN
|
|
370
|
+
cmd += ["--append-system-prompt", append]
|
|
371
|
+
out_path, err_path = workdir / "_claude.json", workdir / "_claude.stderr.txt"
|
|
372
|
+
# stdout -> file, never a PIPE: on Windows a hung agent's child processes can hold a stdout PIPE
|
|
373
|
+
# open forever, so subprocess.run(timeout=) never fires and the worker freezes. Writing to a file
|
|
374
|
+
# lets proc.wait(timeout) return reliably; on timeout _tree_kill ends ONLY this cell's process
|
|
375
|
+
# tree -- never a blanket kill, which would also take down this Claude Code session.
|
|
376
|
+
try:
|
|
377
|
+
with open(out_path, "wb") as so, open(err_path, "wb") as se:
|
|
378
|
+
# Cells live under this repo, so without these every arm (baseline too) also loads the
|
|
379
|
+
# repo's AGENTS.md, which is the ponytail ruleset, plus the user's auto-memory.
|
|
380
|
+
env = {**os.environ, "CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1", "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"}
|
|
381
|
+
proc = subprocess.Popen(cmd, cwd=str(workdir), stdout=so, stderr=se, env=env,
|
|
382
|
+
start_new_session=(os.name != "nt"))
|
|
383
|
+
try:
|
|
384
|
+
proc.wait(timeout=CELL_TIMEOUT)
|
|
385
|
+
except subprocess.TimeoutExpired:
|
|
386
|
+
_tree_kill(proc)
|
|
387
|
+
try: proc.wait(timeout=15)
|
|
388
|
+
except Exception: pass
|
|
389
|
+
se.write(f"\n[KILLED after {CELL_TIMEOUT}s timeout]".encode())
|
|
390
|
+
except Exception as e:
|
|
391
|
+
out_path.write_text(json.dumps({"error": str(e)[:300]}), encoding="utf-8")
|
|
392
|
+
return score_workspace(task_id, arm, model, workdir)
|
|
393
|
+
|
|
394
|
+
def aggregate(results):
|
|
395
|
+
groups = defaultdict(list)
|
|
396
|
+
for r in results: groups[(r["task"], r["arm"], r["model"])].append(r)
|
|
397
|
+
rows = []
|
|
398
|
+
for (t, a, m), cells in sorted(groups.items()):
|
|
399
|
+
n = len(cells)
|
|
400
|
+
costs = [c["cost"] for c in cells if c.get("cost") is not None]
|
|
401
|
+
passes = sum(1 for c in cells if c.get("correct") and c.get("safe"))
|
|
402
|
+
words = [c["reply_words"] for c in cells if c.get("reply_words") is not None]
|
|
403
|
+
loc_cells = [c for c in cells if c.get("total_loc", 0) > 0] # LOC only where code was delivered
|
|
404
|
+
nl = len(loc_cells)
|
|
405
|
+
rows.append({"task": t, "arm": a, "model": m, "n": n,
|
|
406
|
+
# a crashed cell has only "error": count it as failed instead of crashing the summary
|
|
407
|
+
"safe_rate": round(sum(c.get("safe") or 0 for c in cells) / n, 3),
|
|
408
|
+
"correct_rate": round(sum(c.get("correct") or 0 for c in cells) / n, 3),
|
|
409
|
+
"wrote_file_rate": round(nl / n, 3),
|
|
410
|
+
"total_loc_median": statistics.median(c["total_loc"] for c in loc_cells) if nl else 0,
|
|
411
|
+
"src_loc_median": statistics.median(c["src_loc"] for c in loc_cells) if nl else 0,
|
|
412
|
+
"total_loc_max": max((c["total_loc"] for c in loc_cells), default=0),
|
|
413
|
+
"src_files_median": statistics.median(c["src_files"] for c in loc_cells) if nl else 0,
|
|
414
|
+
"wrote_tests_rate": round(sum(1 for c in cells if c.get("test_files", 0) > 0) / n, 3),
|
|
415
|
+
"cost_mean": round(statistics.mean(costs), 4) if costs else None,
|
|
416
|
+
# what a finished task costs: all spend over the cells that passed both gates
|
|
417
|
+
"cost_per_pass": round(sum(costs) / passes, 4) if costs and passes else None,
|
|
418
|
+
"reply_words_median": statistics.median(words) if words else None,
|
|
419
|
+
"out_tokens_mean": (round(statistics.mean([c["out_tokens"] for c in cells if c.get("out_tokens") is not None]))
|
|
420
|
+
if any(c.get("out_tokens") is not None for c in cells) else None),
|
|
421
|
+
"total_tokens_mean": (round(statistics.mean([(c.get("in_tokens") or 0) + (c.get("out_tokens") or 0) + (c.get("cache_tokens") or 0)
|
|
422
|
+
for c in cells if c.get("out_tokens") is not None]))
|
|
423
|
+
if any(c.get("out_tokens") is not None for c in cells) else None),
|
|
424
|
+
"time_s_mean": (round(statistics.mean([c["duration_ms"] / 1000 for c in cells if c.get("duration_ms") is not None]), 1)
|
|
425
|
+
if any(c.get("duration_ms") is not None for c in cells) else None)})
|
|
426
|
+
return rows
|
|
427
|
+
|
|
428
|
+
def print_table(rows):
|
|
429
|
+
by = defaultdict(list)
|
|
430
|
+
for r in rows: by[(r["task"], r["model"])].append(r)
|
|
431
|
+
for (task, model), rs in sorted(by.items()):
|
|
432
|
+
print(f"\n=== {task} ({model}, n={rs[0]['n']}) ===")
|
|
433
|
+
print(f" {'arm':16} {'wrote%':>7} {'correct':>8} {'safe':>5} {'LOC':>7} {'tot_tok':>9} {'$/run':>8} {'$/pass':>8} {'words':>6} {'time_s':>7}")
|
|
434
|
+
for r in sorted(rs, key=lambda x: x["arm"]):
|
|
435
|
+
c = ("$" + format(r["cost_mean"], ".4f")) if r["cost_mean"] is not None else "-"
|
|
436
|
+
tt = r.get("total_tokens_mean"); t = r.get("time_s_mean")
|
|
437
|
+
cp = ("$" + format(r["cost_per_pass"], ".4f")) if r.get("cost_per_pass") is not None else "-"
|
|
438
|
+
w = r.get("reply_words_median")
|
|
439
|
+
print(f" {r['arm']:16} {r.get('wrote_file_rate', 1.0):>7} {r['correct_rate']:>8} {r['safe_rate']:>5} "
|
|
440
|
+
f"{r['total_loc_median']:>7} {(tt if tt is not None else '-'):>9} {c:>8} {cp:>8} "
|
|
441
|
+
f"{(w if w is not None else '-'):>6} {(t if t is not None else '-'):>7}")
|
|
442
|
+
|
|
443
|
+
def rescore(run_dir):
|
|
444
|
+
run_dir = Path(run_dir)
|
|
445
|
+
if not run_dir.exists(): # accept "<stamp>" or "runs/<stamp>" from any cwd
|
|
446
|
+
run_dir = RUNS_DIR / run_dir.name
|
|
447
|
+
results = []
|
|
448
|
+
for ws in sorted(p for p in run_dir.iterdir() if p.is_dir()):
|
|
449
|
+
parts = ws.name.split("__")
|
|
450
|
+
if len(parts) != 4 or parts[0] not in TASKS: continue
|
|
451
|
+
tid, arm, model, _r = parts
|
|
452
|
+
results.append(score_workspace(tid, arm, model, ws))
|
|
453
|
+
rows = aggregate(results)
|
|
454
|
+
(run_dir / "results.json").write_text(json.dumps({"rescored": True, "results": results}, indent=2), encoding="utf-8")
|
|
455
|
+
(run_dir / "summary.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
|
456
|
+
print_table(rows)
|
|
457
|
+
print(f"\nrescored {len(results)} cells from {run_dir}")
|
|
458
|
+
|
|
459
|
+
def _claude_version():
|
|
460
|
+
try: return subprocess.run([shutil.which("claude"), "--version"], capture_output=True, text=True).stdout.strip()
|
|
461
|
+
except Exception: return "unknown"
|
|
462
|
+
|
|
463
|
+
def main():
|
|
464
|
+
ap = argparse.ArgumentParser()
|
|
465
|
+
ap.add_argument("--selftest", action="store_true")
|
|
466
|
+
ap.add_argument("--rescore", help="recompute metrics from a kept run dir (no API)")
|
|
467
|
+
ap.add_argument("--task", help="single task id")
|
|
468
|
+
ap.add_argument("--all", action="store_true", help="all tasks")
|
|
469
|
+
ap.add_argument("--arms", default=",".join(ARMS))
|
|
470
|
+
ap.add_argument("--model", help="single model (shorthand for --models)")
|
|
471
|
+
ap.add_argument("--models", default="haiku", help="comma list: haiku,sonnet,opus")
|
|
472
|
+
ap.add_argument("--runs", type=int, default=1)
|
|
473
|
+
ap.add_argument("--workers", type=int, default=4, help="cells to run concurrently (default 4; cells are fully isolated)")
|
|
474
|
+
args = ap.parse_args()
|
|
475
|
+
|
|
476
|
+
if args.selftest:
|
|
477
|
+
sys.exit(1 if selftest() else 0)
|
|
478
|
+
if args.rescore:
|
|
479
|
+
return rescore(args.rescore)
|
|
480
|
+
if selftest():
|
|
481
|
+
sys.exit("instruments broken; refusing to spend on the API")
|
|
482
|
+
|
|
483
|
+
task_ids = (list(TASKS) if args.all else QUICK if args.task == "quick"
|
|
484
|
+
else ([t.strip() for t in args.task.split(",")] if args.task else []))
|
|
485
|
+
if not task_ids: sys.exit("give --task <id> (comma list ok), --all, or --rescore <dir>")
|
|
486
|
+
arms = [a.strip() for a in args.arms.split(",")]
|
|
487
|
+
models = [m.strip() for m in (args.model or args.models).split(",")]
|
|
488
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
489
|
+
out_dir = RUNS_DIR / stamp
|
|
490
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
|
|
492
|
+
cells = [(tid, arm, model, r)
|
|
493
|
+
for tid in task_ids for model in models for arm in arms for r in range(args.runs)]
|
|
494
|
+
total = len(cells)
|
|
495
|
+
results, done = [], 0
|
|
496
|
+
|
|
497
|
+
# Cells run outside any git repo, then move into out_dir. Inside a repo, Claude Code shows the
|
|
498
|
+
# model that repo's branch and recent commit messages, so this repo's own history leaked into
|
|
499
|
+
# every task without its own git snapshot.
|
|
500
|
+
scratch = Path(tempfile.mkdtemp(prefix=f"ponytail-bench-{stamp}-"))
|
|
501
|
+
|
|
502
|
+
def _one(spec):
|
|
503
|
+
tid, arm, model, r = spec
|
|
504
|
+
name = f"{tid}__{arm}__{model}__{r}"
|
|
505
|
+
ws = scratch / name
|
|
506
|
+
ws.mkdir(parents=True, exist_ok=True)
|
|
507
|
+
try:
|
|
508
|
+
return run_cell(tid, arm, model, ws)
|
|
509
|
+
finally:
|
|
510
|
+
shutil.move(str(ws), str(out_dir / name))
|
|
511
|
+
|
|
512
|
+
for m in models: warm_cache(m)
|
|
513
|
+
print(f"running {total} cells, {args.workers} at a time", flush=True)
|
|
514
|
+
# Cells are fully isolated (own copy + own claude context), so they parallelize safely.
|
|
515
|
+
# To STOP a parallel run, kill the whole tree: taskkill /PID <pid> /T /F. Killing just the
|
|
516
|
+
# python orchestrator orphans the concurrent `claude` children and they keep spending.
|
|
517
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as ex:
|
|
518
|
+
futs = {ex.submit(_one, s): s for s in cells}
|
|
519
|
+
for fut in concurrent.futures.as_completed(futs):
|
|
520
|
+
tid, arm, model, r = futs[fut]
|
|
521
|
+
try:
|
|
522
|
+
res = fut.result()
|
|
523
|
+
except Exception as e:
|
|
524
|
+
res = {"task": tid, "arm": arm, "model": model, "error": str(e)[:200]}
|
|
525
|
+
results.append(res)
|
|
526
|
+
done += 1
|
|
527
|
+
print(f" [{done}/{total}] {tid} / {arm} / {model} #{r} "
|
|
528
|
+
f"LOC={res.get('total_loc')} "
|
|
529
|
+
f"tok={(res.get('in_tokens') or 0) + (res.get('out_tokens') or 0) + (res.get('cache_tokens') or 0)} "
|
|
530
|
+
f"cost=${res.get('cost')} time={round((res.get('duration_ms') or 0) / 1000, 1)}s "
|
|
531
|
+
f"correct={res.get('correct')}", flush=True)
|
|
532
|
+
(out_dir / "results.json").write_text(json.dumps(
|
|
533
|
+
{"date": stamp, "models": {m: MODELS.get(m, m) for m in models},
|
|
534
|
+
"claude": _claude_version(), "results": results}, indent=2), encoding="utf-8")
|
|
535
|
+
|
|
536
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
537
|
+
rows = aggregate(results)
|
|
538
|
+
(out_dir / "summary.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
|
539
|
+
print_table(rows)
|
|
540
|
+
print(f"\nwrote {out_dir}/results.json + summary.json ({len(results)} cells)")
|
|
541
|
+
|
|
542
|
+
if __name__ == "__main__":
|
|
543
|
+
main()
|