chill-agent 1.5.0 → 1.6.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 (188) hide show
  1. package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
  2. package/.claude-plugin/plugin.json +8 -3
  3. package/.cline-plugin/plugin.js +6 -0
  4. package/.codex-plugin/plugin.json +4 -3
  5. package/.cursor-plugin/marketplace.json +16 -0
  6. package/.cursor-plugin/plugin.json +2 -2
  7. package/.devin-plugin/plugin.json +2 -2
  8. package/.kimi-plugin/plugin.json +11 -29
  9. package/.omp-plugin/marketplace.json +15 -0
  10. package/.openclaw/skills/ponytail/SKILL.md +2 -7
  11. package/.openclaw/skills/ponytail-audit/SKILL.md +3 -6
  12. package/.openclaw/skills/ponytail-debt/SKILL.md +3 -4
  13. package/.openclaw/skills/ponytail-gain/SKILL.md +3 -4
  14. package/.openclaw/skills/ponytail-help/SKILL.md +3 -3
  15. package/.openclaw/skills/ponytail-review/SKILL.md +3 -6
  16. package/.opencode/plugins/ponytail.mjs +1 -1
  17. package/.qoder-plugin/plugin.json +12 -26
  18. package/AGENTS.md +32 -19
  19. package/__init__.py +1 -1
  20. package/assets/antislop-banner.png +0 -0
  21. package/assets/band/band.png +0 -0
  22. package/assets/band/board.png +0 -0
  23. package/assets/benchmark-3model.svg +21 -0
  24. package/assets/benchmark-agentic.svg +62 -0
  25. package/assets/codex-icon.png +0 -0
  26. package/assets/compare/code/after.webp +0 -0
  27. package/assets/compare/code/before.webp +0 -0
  28. package/assets/compare/text/after.webp +0 -0
  29. package/assets/compare/text/before.webp +0 -0
  30. package/assets/compare/ui/w-all.webp +0 -0
  31. package/assets/compare/ui/w-antislop.webp +0 -0
  32. package/assets/compare/ui/w-design.webp +0 -0
  33. package/assets/compare/ui/without.webp +0 -0
  34. package/assets/logo-dark.png +0 -0
  35. package/assets/logo-dark.svg +115 -0
  36. package/assets/logo-greenpt-dark.svg +27 -0
  37. package/assets/logo-greenpt.svg +27 -0
  38. package/assets/logo.png +0 -0
  39. package/assets/photoreunion-logo-dark.svg +4 -0
  40. package/assets/photoreunion-logo-light.svg +4 -0
  41. package/assets/profile.png +0 -0
  42. package/assets/retriever-logo-dark.svg +4 -0
  43. package/assets/retriever-logo-light.svg +4 -0
  44. package/assets/stories-banner.png +0 -0
  45. package/assets/v5/audit-es.png +0 -0
  46. package/assets/v5/audit-ja.png +0 -0
  47. package/assets/v5/audit-ko.png +0 -0
  48. package/assets/v5/audit-zh-CN.png +0 -0
  49. package/assets/v5/audit.png +0 -0
  50. package/assets/v5/beforeafter-es.png +0 -0
  51. package/assets/v5/beforeafter-ja.png +0 -0
  52. package/assets/v5/beforeafter-ko.png +0 -0
  53. package/assets/v5/beforeafter-zh-CN.png +0 -0
  54. package/assets/v5/beforeafter.png +0 -0
  55. package/assets/v5/chart-es.png +0 -0
  56. package/assets/v5/chart-ja.png +0 -0
  57. package/assets/v5/chart-ko.png +0 -0
  58. package/assets/v5/chart-zh-CN.png +0 -0
  59. package/assets/v5/chart.png +0 -0
  60. package/assets/v5/hero-es.jpg +0 -0
  61. package/assets/v5/hero-ja.jpg +0 -0
  62. package/assets/v5/hero-ko.jpg +0 -0
  63. package/assets/v5/hero-zh-CN.jpg +0 -0
  64. package/assets/v5/hero.jpg +0 -0
  65. package/assets/v5/ladder-es.png +0 -0
  66. package/assets/v5/ladder-ja.png +0 -0
  67. package/assets/v5/ladder-ko.png +0 -0
  68. package/assets/v5/ladder-zh-CN.png +0 -0
  69. package/assets/v5/ladder.png +0 -0
  70. package/assets/v5/review-es.png +0 -0
  71. package/assets/v5/review-ja.png +0 -0
  72. package/assets/v5/review-ko.png +0 -0
  73. package/assets/v5/review-zh-CN.png +0 -0
  74. package/assets/v5/review.png +0 -0
  75. package/assets/v5/tests-es.png +0 -0
  76. package/assets/v5/tests-ja.png +0 -0
  77. package/assets/v5/tests-ko.png +0 -0
  78. package/assets/v5/tests-zh-CN.png +0 -0
  79. package/assets/v5/tests.png +0 -0
  80. package/assets/waitlist-banner-es.png +0 -0
  81. package/assets/waitlist-banner-ja.png +0 -0
  82. package/assets/waitlist-banner-ko.png +0 -0
  83. package/assets/waitlist-banner-zh-CN.png +0 -0
  84. package/assets/waitlist-banner.png +0 -0
  85. package/benchmarks/.env.example +2 -0
  86. package/benchmarks/agentic/README.md +201 -0
  87. package/benchmarks/agentic/answer.py +288 -0
  88. package/benchmarks/agentic/compare.py +93 -0
  89. package/benchmarks/agentic/complete.py +178 -0
  90. package/benchmarks/agentic/judge.py +198 -0
  91. package/benchmarks/agentic/run.py +543 -0
  92. package/benchmarks/agentic/tasks.py +1544 -0
  93. package/benchmarks/arms/baseline.js +2 -0
  94. package/benchmarks/arms/caveman-SKILL.md +67 -0
  95. package/benchmarks/arms/caveman.js +8 -0
  96. package/benchmarks/arms/ponytail.js +8 -0
  97. package/benchmarks/behavior.js +54 -0
  98. package/benchmarks/behavior.yaml +40 -0
  99. package/benchmarks/correctness.js +291 -0
  100. package/benchmarks/loc.js +17 -19
  101. package/benchmarks/promptfooconfig.gemini.yaml +32 -0
  102. package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
  103. package/benchmarks/promptfooconfig.yaml +41 -0
  104. package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
  105. package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
  106. package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
  107. package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
  108. package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
  109. package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
  110. package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
  111. package/benchmarks/results/2026-06-18-agentic.md +219 -0
  112. package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
  113. package/benchmarks/results/2026-10-07-agentic.md +186 -0
  114. package/bin/chill-agent.js +6 -6
  115. package/bin/chill-agent.mjs +7 -0
  116. package/cli/index.mjs +241 -0
  117. package/cli/lib/banner.mjs +21 -0
  118. package/cli/lib/install.mjs +498 -0
  119. package/cli/lib/settings.mjs +48 -0
  120. package/cli/package-lock.json +87 -0
  121. package/cli/package.json +42 -0
  122. package/cli/scripts/smoke-test.mjs +95 -0
  123. package/cli/scripts/smoke-worker.mjs +411 -0
  124. package/cli/scripts/sync-skills.mjs +81 -0
  125. package/examples/email-validation.md +156 -0
  126. package/examples/group-by.md +35 -0
  127. package/examples/infinite-scroll.md +58 -0
  128. package/examples/number-formatting.md +37 -0
  129. package/examples/rate-limit.md +272 -0
  130. package/examples/react-countdown.md +390 -0
  131. package/examples/url-params.md +41 -0
  132. package/gemini-extension.json +2 -2
  133. package/hooks/band/board.ts +560 -0
  134. package/hooks/band/logo.ts +3 -0
  135. package/hooks/band/metrics.ts +311 -0
  136. package/hooks/band/register.tsx +495 -0
  137. package/hooks/band/types.d.ts +34 -0
  138. package/hooks/claude-band.json +6 -0
  139. package/hooks/claude-codex-hooks.json +34 -4
  140. package/hooks/copilot-hooks.json +12 -2
  141. package/hooks/cursor-hooks.json +2 -2
  142. package/hooks/ponytail-band-import.js +274 -0
  143. package/hooks/ponytail-config.js +175 -0
  144. package/hooks/ponytail-map.js +111 -0
  145. package/hooks/ponytail-subagent.js +94 -0
  146. package/hooks/qoder-hooks.json +19 -4
  147. package/i18n/README.ko.md +230 -0
  148. package/package.json +12 -7
  149. package/pi-extension/test/extension.test.js +367 -0
  150. package/pi-extension/test/helpers.test.js +179 -0
  151. package/plugin.yaml +21 -12
  152. package/rules/antislop.mdc +18 -0
  153. package/scripts/build-openclaw-skills.js +60 -0
  154. package/scripts/check-repo.mjs +389 -0
  155. package/scripts/publish-openclaw-skills.js +75 -0
  156. package/scripts/skill-index.mjs +81 -0
  157. package/skills/antislop/SKILL.md +700 -0
  158. package/skills/antislop/VERSION +1 -0
  159. package/skills/antislop-code/SKILL.md +128 -0
  160. package/skills/antislop-copywriting/SKILL.md +407 -0
  161. package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
  162. package/skills/antislop-human/SKILL.md +204 -0
  163. package/skills/antislop-human/contrast-check.py +168 -0
  164. package/skills/antislop-human/contrast-mcp.py +121 -0
  165. package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
  166. package/skills/antislop-human/references/WCAG.md +127 -0
  167. package/skills/antislop-layoutmobile/SKILL.md +170 -0
  168. package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
  169. package/skills/antislop-ui/SKILL.md +344 -0
  170. package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
  171. package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
  172. package/skills/plugin.json +5 -0
  173. package/src/package.json +3 -0
  174. package/tests/activation-map.test.js +63 -0
  175. package/tests/band-import.test.js +111 -0
  176. package/tests/band.test.tsx +520 -0
  177. package/tests/behavior.test.js +80 -0
  178. package/tests/gemini-extension.test.js +2 -2
  179. package/tests/grok-plugin.test.js +1 -1
  180. package/tests/hermes-plugin.test.js +1 -1
  181. package/tests/hooks-windows.test.js +205 -0
  182. package/tests/kimi-plugin.test.js +1 -1
  183. package/tests/map.test.js +30 -0
  184. package/tests/package-scripts.test.js +35 -0
  185. package/tests/package.test.js +23 -0
  186. package/tests/uninstall.test.js +202 -0
  187. /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
  188. /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()