chill-agent 1.5.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.
Files changed (187) 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/cli/index.mjs +241 -0
  115. package/cli/lib/banner.mjs +21 -0
  116. package/cli/lib/install.mjs +498 -0
  117. package/cli/lib/settings.mjs +48 -0
  118. package/cli/package-lock.json +87 -0
  119. package/cli/package.json +42 -0
  120. package/cli/scripts/smoke-test.mjs +95 -0
  121. package/cli/scripts/smoke-worker.mjs +411 -0
  122. package/cli/scripts/sync-skills.mjs +81 -0
  123. package/examples/email-validation.md +156 -0
  124. package/examples/group-by.md +35 -0
  125. package/examples/infinite-scroll.md +58 -0
  126. package/examples/number-formatting.md +37 -0
  127. package/examples/rate-limit.md +272 -0
  128. package/examples/react-countdown.md +390 -0
  129. package/examples/url-params.md +41 -0
  130. package/gemini-extension.json +2 -2
  131. package/hooks/band/board.ts +560 -0
  132. package/hooks/band/logo.ts +3 -0
  133. package/hooks/band/metrics.ts +311 -0
  134. package/hooks/band/register.tsx +495 -0
  135. package/hooks/band/types.d.ts +34 -0
  136. package/hooks/claude-band.json +6 -0
  137. package/hooks/claude-codex-hooks.json +34 -4
  138. package/hooks/copilot-hooks.json +12 -2
  139. package/hooks/cursor-hooks.json +2 -2
  140. package/hooks/ponytail-band-import.js +274 -0
  141. package/hooks/ponytail-config.js +175 -0
  142. package/hooks/ponytail-map.js +111 -0
  143. package/hooks/ponytail-subagent.js +94 -0
  144. package/hooks/qoder-hooks.json +19 -4
  145. package/i18n/README.ko.md +230 -0
  146. package/package.json +12 -7
  147. package/pi-extension/test/extension.test.js +367 -0
  148. package/pi-extension/test/helpers.test.js +179 -0
  149. package/plugin.yaml +21 -12
  150. package/rules/antislop.mdc +18 -0
  151. package/scripts/build-openclaw-skills.js +60 -0
  152. package/scripts/check-repo.mjs +389 -0
  153. package/scripts/publish-openclaw-skills.js +75 -0
  154. package/scripts/skill-index.mjs +81 -0
  155. package/skills/antislop/SKILL.md +700 -0
  156. package/skills/antislop/VERSION +1 -0
  157. package/skills/antislop-code/SKILL.md +128 -0
  158. package/skills/antislop-copywriting/SKILL.md +407 -0
  159. package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
  160. package/skills/antislop-human/SKILL.md +204 -0
  161. package/skills/antislop-human/contrast-check.py +168 -0
  162. package/skills/antislop-human/contrast-mcp.py +121 -0
  163. package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
  164. package/skills/antislop-human/references/WCAG.md +127 -0
  165. package/skills/antislop-layoutmobile/SKILL.md +170 -0
  166. package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
  167. package/skills/antislop-ui/SKILL.md +344 -0
  168. package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
  169. package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
  170. package/skills/plugin.json +5 -0
  171. package/src/package.json +3 -0
  172. package/tests/activation-map.test.js +63 -0
  173. package/tests/band-import.test.js +111 -0
  174. package/tests/band.test.tsx +520 -0
  175. package/tests/behavior.test.js +80 -0
  176. package/tests/gemini-extension.test.js +2 -2
  177. package/tests/grok-plugin.test.js +1 -1
  178. package/tests/hermes-plugin.test.js +1 -1
  179. package/tests/hooks-windows.test.js +205 -0
  180. package/tests/kimi-plugin.test.js +1 -1
  181. package/tests/map.test.js +30 -0
  182. package/tests/package-scripts.test.js +35 -0
  183. package/tests/package.test.js +23 -0
  184. package/tests/uninstall.test.js +202 -0
  185. /package/bin/{chill-agent.js → chill-agent.mjs} +0 -0
  186. /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
  187. /package/tests/{rules.test.js → rules.test.mjs} +0 -0
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env python3
2
+ """ANSWER-QUALITY judge for the agentic benchmark: does the agent's final reply tell a human what
3
+ they need to know?
4
+
5
+ The other passes score the code. This one scores the reply a developer reads at the end of a
6
+ session. A short reply that hides a skipped requirement is bad; so is an essay nobody finishes.
7
+ Length is reported (words) but never scored: a reply is as long as understanding needs.
8
+
9
+ Two calls per cell, so the score measures what a READER takes away, not what the grader can dig
10
+ out of the diff itself:
11
+ 1. READER (blind): sees only the task and the reply, and writes down what it now believes:
12
+ what changed, what was left out, what the user must do, what to watch out for.
13
+ 2. GRADER: sees the task, the reply, the reader's notes and the real diff, and scores 0-3:
14
+ accuracy (are the reader's beliefs true?), coverage (did the reader learn everything a
15
+ reviewer must know before merging?), clarity (is the reply itself easy to read?).
16
+
17
+ python answer.py --selftest-offline # gate logic only, no model calls
18
+ python answer.py --selftest # live: the judge must rank a good reply above a terse one
19
+ # (coverage), a false one (accuracy), an essay (clarity)
20
+ python answer.py --run runs/<stamp> # judge every cell's final reply
21
+
22
+ No API key needed: without one, judge.py routes calls through the headless claude CLI.
23
+ """
24
+ import argparse, concurrent.futures, difflib, json, random, statistics, subprocess, sys
25
+ from collections import defaultdict
26
+ from pathlib import Path
27
+
28
+ from tasks import TASKS
29
+ from judge import RUNS_DIR, judge_call, load_key, parse_score
30
+
31
+ MODEL = "claude-sonnet-5-5"
32
+ DIFF_LIMIT = 20000
33
+ SCORES = ("accuracy", "coverage", "clarity")
34
+
35
+ READER = (
36
+ "You asked a coding agent to do a task. You have NOT seen the code. Read the agent's final reply "
37
+ "and write down what you now believe, using only what the reply says. Respond with ONLY this JSON:\n"
38
+ '{"changed": ["..."], "left_out": ["..."], "you_must_do": ["..."], "watch_out": ["..."]}\n'
39
+ "changed = what the agent changed or built; left_out = what it says it did not do or skipped on "
40
+ "purpose; you_must_do = actions it says you must take now; watch_out = risks or limits it names. "
41
+ "Use [] when the reply says nothing about a field."
42
+ )
43
+ GRADER = (
44
+ "You check how well a coding agent's final reply informs a busy developer. You get the task, the "
45
+ "reply, the notes a reader wrote after reading ONLY the reply, and the real diff of what the agent "
46
+ "did. Score 0-3 each:\n"
47
+ "accuracy: are the reader's notes true according to the diff? 3 = all true; 0 = a major false "
48
+ "belief (claims work that is not in the diff, or misstates behavior).\n"
49
+ "coverage: did the reader learn everything a reviewer must know before merging? That is: every "
50
+ "behavior change, anything removed, anything the task asked or clearly implied that the diff does "
51
+ "NOT deliver, and any action the user must take. 3 = nothing important missing; 0 = the reader "
52
+ "would merge without knowing something important.\n"
53
+ "clarity: judge the REPLY itself. Plain words, easy to scan, no filler, no repeated recap. A wall "
54
+ "of text, hedging and pleasantries lower it; so do cryptic fragments, arrows or abbreviations a "
55
+ "reader must decode. 3 = a developer gets it in one quick read. Do not reward length or brevity "
56
+ "as such.\n"
57
+ 'Respond with ONLY this JSON: {"accuracy": n, "coverage": n, "clarity": n, '
58
+ '"missed": "<most important thing the reader did not learn, or none>", "false": "<false belief, or none>"}'
59
+ )
60
+
61
+ PAIR = (
62
+ "Two coding agents got the same task. For each you see its final reply to the developer and the "
63
+ "real diff of what it did. Decide which REPLY serves a busy developer better: true to its own diff, "
64
+ "tells everything a reviewer must know (behavior changes, removals, missing or skipped parts, actions "
65
+ "to take, risks), and is easy to read in one pass (no filler, no essay, no cryptic fragments). Judge "
66
+ "the reply, not the code, and do not prefer a reply for being longer or shorter. Respond with ONLY "
67
+ 'this JSON: {"better": "1" | "2" | "tie", "why": "<one line>"}'
68
+ )
69
+
70
+ def workspace_diff(task_id, ws: Path):
71
+ """What the agent actually did: git diff for snapshotted workspaces, else a diff against the seed."""
72
+ if (ws / ".git").exists():
73
+ subprocess.run(["git", "add", "-A"], cwd=ws, capture_output=True)
74
+ d = subprocess.run(["git", "diff", "--cached", "HEAD", "--", ".", ":(exclude)_*", ":(exclude)**/__pycache__/**"],
75
+ cwd=ws, capture_output=True, text=True).stdout
76
+ else:
77
+ seed, parts = TASKS[task_id].get("seed", {}), []
78
+ for p in sorted(ws.rglob("*")):
79
+ rel = p.relative_to(ws).as_posix()
80
+ if not p.is_file() or "__pycache__" in p.parts or p.name.startswith(("_", ".")): continue
81
+ new = p.read_text(encoding="utf-8", errors="ignore").splitlines(keepends=True)
82
+ parts += difflib.unified_diff(seed.get(rel, "").splitlines(keepends=True), new, "a/" + rel, "b/" + rel)
83
+ d = "".join(parts)
84
+ return (d[:DIFF_LIMIT] + "\n[diff truncated]") if len(d) > DIFF_LIMIT else (d or "(no files changed)")
85
+
86
+ def judge_reply(task_prompt, reply, diff, key, model=MODEL):
87
+ notes = judge_call(None, None, key, system=READER, model=model, max_tokens=800,
88
+ user=f"TASK:\n{task_prompt}\n\nAGENT'S FINAL REPLY:\n{reply}")
89
+ d = parse_score(judge_call(None, None, key, system=GRADER, model=model, max_tokens=400,
90
+ user=f"TASK:\n{task_prompt}\n\nAGENT'S FINAL REPLY:\n{reply}\n\n"
91
+ f"READER'S NOTES:\n{notes}\n\nREAL DIFF:\n{diff}")) or {}
92
+ for k in SCORES:
93
+ try: d[k] = int(d[k])
94
+ except Exception: d[k] = None
95
+ return d
96
+
97
+ # --- the gate: one fixed diff (scope-recipients, good ref) and four replies about it ---
98
+ def _fixed_diff():
99
+ t = TASKS["scope-recipients"]
100
+ return "".join(line for fn, new in t["good"].items() for line in difflib.unified_diff(
101
+ t["seed"][fn].splitlines(keepends=True), new.splitlines(keepends=True), "a/" + fn, "b/" + fn))
102
+
103
+ GOOD = (
104
+ "`mailer.send` now accepts one address or a list, plus an optional `bcc` list. The weekly digest goes "
105
+ "out as one message with every subscriber in Bcc, so subscribers don't see each other's addresses.\n\n"
106
+ "- `mailer.py`: `to` is always stored as a list in `OUTBOX`, and every message has a `bcc` list. A single "
107
+ "address becomes a one-item list, so `signup.welcome` keeps working unchanged.\n"
108
+ "- `digest.py`: one `send` call, empty `to`, all subscribers in `bcc`.\n"
109
+ "- `tests/test_mailer.py`: `test_send` now expects `[\"a@example.com\"]`.\n\n"
110
+ "Check before deploying: the provider client that reads `OUTBOX` must send to the new `bcc` field, and "
111
+ "it now gets a list in `\"to\"`, not a string. Some providers reject an empty To; if yours does, put "
112
+ "your own sending address there."
113
+ )
114
+ TERSE = "send take list. digest 1 msg. done."
115
+ ESSAY = (
116
+ "Great question, and thanks so much for the clear ticket! I really enjoyed working on this one. Let me "
117
+ "walk you through everything I did in detail, step by step, so that you have the full picture of the "
118
+ "change and the reasoning behind it.\n\n"
119
+ "First of all, I started by carefully reading through the mailer module to understand how it currently "
120
+ "works. As you probably know, the send function previously took a single recipient address, which meant "
121
+ "that every caller who wanted to reach several people had to call it several times. This is a very "
122
+ "common pattern, and it is perfectly reasonable in many situations, but as you pointed out, it is not "
123
+ "ideal when the provider bills per message.\n\n"
124
+ "So, after thinking about it for a while, I decided to change the send function so that it can accept "
125
+ "either a single address or a list of addresses. Internally, it now always stores the recipients as a "
126
+ "list in the outbox, which means a single address is simply wrapped into a list with one element. This "
127
+ "keeps the signup welcome email working exactly as before, which I think is important. I also added an "
128
+ "optional bcc list, because hidden recipients are useful in some situations.\n\n"
129
+ "Next, I updated the digest module. Instead of looping over the subscribers and sending one message per "
130
+ "subscriber, it now sends one single message, with the to field left empty and all the subscribers in "
131
+ "the bcc list, so that they cannot see each other's addresses. This should reduce the number of billed "
132
+ "messages quite a lot.\n\n"
133
+ "I also updated the existing test for send, which now expects a list containing the address instead of "
134
+ "a plain string. It is worth mentioning that the provider client reading the outbox will now need to "
135
+ "send to the bcc field and will receive a list rather than a string in the to field, and some providers "
136
+ "may not like an empty to field, so you may want to double-check that part.\n\n"
137
+ "In summary, send accepts lists and a bcc, the digest sends one message in bcc, and the test was "
138
+ "updated. I hope this helps! Let me know if you have any other questions or if there is anything else I "
139
+ "can do. Happy coding!"
140
+ )
141
+ FALSE = GOOD + ("\n\nI also added retries with exponential backoff for when the provider is down, and every "
142
+ "address is now validated before sending.")
143
+ REPLIES = {"good": GOOD, "terse": TERSE, "essay": ESSAY, "false": FALSE}
144
+ # (axis, the reply that must score strictly below "good" on it)
145
+ GATE = [("coverage", "terse"), ("accuracy", "false"), ("clarity", "essay")]
146
+
147
+ def _gate_ok(scores):
148
+ ok = True
149
+ for axis, worse in GATE:
150
+ hi, lo = (scores.get("good") or {}).get(axis), (scores.get(worse) or {}).get(axis)
151
+ passed = isinstance(hi, int) and isinstance(lo, int) and hi > lo
152
+ print(f"{'ok' if passed else 'XX'} {axis:9} good({hi}) > {worse}({lo})")
153
+ ok = ok and passed
154
+ return ok
155
+
156
+ def selftest_offline():
157
+ print("well-ordered (expect ok):")
158
+ a = _gate_ok({"good": {"coverage": 3, "accuracy": 3, "clarity": 3},
159
+ "terse": {"coverage": 1}, "false": {"accuracy": 1}, "essay": {"clarity": 1}})
160
+ print("essay out-scores good on clarity (expect XX):")
161
+ b = _gate_ok({"good": {"coverage": 3, "accuracy": 3, "clarity": 2},
162
+ "terse": {"coverage": 1}, "false": {"accuracy": 1}, "essay": {"clarity": 3}})
163
+ passed = a and not b
164
+ print(f"\nanswer gate selftest (offline): {'valid' if passed else 'BROKEN'}")
165
+ return 0 if passed else 1
166
+
167
+ def selftest(key, model=MODEL):
168
+ diff, prompt = _fixed_diff(), TASKS["scope-recipients"]["prompt"]
169
+ with concurrent.futures.ThreadPoolExecutor(4) as ex:
170
+ futs = {k: ex.submit(judge_reply, prompt, r, diff, key, model) for k, r in REPLIES.items()}
171
+ scores = {k: f.result() for k, f in futs.items()}
172
+ for k, s in scores.items():
173
+ print(f" {k:6} -> " + " ".join(f"{a}={s.get(a)}" for a in SCORES) + f" missed={s.get('missed')}")
174
+ ok = _gate_ok(scores)
175
+ print(f"\nanswer judge selftest ({model}): {'valid' if ok else 'NOT TRUSTWORTHY'}")
176
+ return 0 if ok else 1
177
+
178
+ def run(run_dir, key, model=MODEL, workers=4):
179
+ run_dir = Path(run_dir)
180
+ if not run_dir.exists(): run_dir = RUNS_DIR / run_dir.name
181
+ cells = []
182
+ for ws in sorted(p for p in run_dir.iterdir() if p.is_dir()):
183
+ parts = ws.name.split("__")
184
+ if len(parts) != 4 or parts[0] not in TASKS: continue
185
+ try: reply = json.loads((ws / "_claude.json").read_text(encoding="utf-8")).get("result") or ""
186
+ except Exception: reply = ""
187
+ cells.append((parts[0], parts[1], parts[2], ws, reply))
188
+ print(f"answer-judging {len(cells)} replies with {model} ...")
189
+
190
+ def one(cell):
191
+ tid, arm, mdl, ws, reply = cell
192
+ s = judge_reply(TASKS[tid]["prompt"], reply, workspace_diff(tid, ws), key, model) if reply else {}
193
+ return {"task": tid, "arm": arm, "model": mdl, "cell": ws.name, "words": len(reply.split()),
194
+ **{k: s.get(k) for k in SCORES}, "missed": s.get("missed", ""), "false": s.get("false", "")}
195
+ with concurrent.futures.ThreadPoolExecutor(workers) as ex:
196
+ scored = list(ex.map(one, cells))
197
+ (run_dir / "answer.json").write_text(json.dumps({"judge": model, "reader": READER, "grader": GRADER,
198
+ "scores": scored}, indent=2), encoding="utf-8")
199
+ by = defaultdict(list)
200
+ for r in scored: by[(r["arm"], r["model"])].append(r)
201
+ print(f"\n=== answer quality by arm (0..3, judge {model}; words = median reply length, not scored) ===")
202
+ print(f" {'arm':16} {'model':8} {'n':>4} {'accur':>6} {'cover':>6} {'clarity':>7} {'words':>6}")
203
+ for (arm, mdl), rs in sorted(by.items()):
204
+ mean = lambda k: (lambda v: f"{sum(v) / len(v):.2f}" if v else "-")([r[k] for r in rs if isinstance(r[k], int)])
205
+ print(f" {arm:16} {mdl:8} {len(rs):>4} {mean('accuracy'):>6} {mean('coverage'):>6} "
206
+ f"{mean('clarity'):>7} {statistics.median(r['words'] for r in rs):>6}")
207
+ print(f"\nwrote {run_dir / 'answer.json'}")
208
+
209
+ def _cells(dirs):
210
+ out = defaultdict(list) # (task, arm) -> [(ws, reply)], ordered by run index
211
+ for d in dirs:
212
+ d = Path(d) if Path(d).exists() else RUNS_DIR / Path(d).name
213
+ for ws in sorted(p for p in d.iterdir() if p.is_dir()):
214
+ parts = ws.name.split("__")
215
+ if len(parts) != 4 or parts[0] not in TASKS: continue
216
+ try: reply = json.loads((ws / "_claude.json").read_text(encoding="utf-8")).get("result") or ""
217
+ except Exception: reply = ""
218
+ out[(parts[0], parts[1])].append((ws, reply))
219
+ return out
220
+
221
+ def _pair_call(prompt, first, second, key, model):
222
+ user = f"TASK:\n{prompt}\n\n" + "\n\n".join(
223
+ f"=== AGENT {i}: REPLY ===\n{r}\n\n=== AGENT {i}: DIFF ===\n{d}" for i, (r, d) in ((1, first), (2, second)))
224
+ return parse_score(judge_call(None, None, key, system=PAIR, model=model, max_tokens=300, user=user)) or {}
225
+
226
+ def selftest_pair(key, model=MODEL):
227
+ """GOOD must beat TERSE, ESSAY and FALSE in BOTH positions, or position bias is in play."""
228
+ diff, prompt = _fixed_diff(), TASKS["scope-recipients"]["prompt"]
229
+ jobs = [(name, want, (r1, diff), (r2, diff)) for name, worse in (("terse", TERSE), ("essay", ESSAY), ("false", FALSE))
230
+ for want, r1, r2 in (("1", GOOD, worse), ("2", worse, GOOD))]
231
+ with concurrent.futures.ThreadPoolExecutor(6) as ex:
232
+ got = list(ex.map(lambda j: str(_pair_call(prompt, j[2], j[3], key, model).get("better")), jobs))
233
+ ok = all(g == j[1] for g, j in zip(got, jobs))
234
+ for g, j in zip(got, jobs): print(f"{'ok' if g == j[1] else 'XX'} good vs {j[0]:5} (good in slot {j[1]}) picked {g}")
235
+ print(f"pairwise judge selftest ({model}): {'valid' if ok else 'NOT TRUSTWORTHY'}")
236
+ return 0 if ok else 1
237
+
238
+ def pairwise(dirs, a, b, key, model=MODEL, workers=4):
239
+ """Head-to-head: run i of arm a against run i of arm b on the same task, order shuffled per pair
240
+ (seeded) so position bias cannot favor one arm. Ties count for neither; sign test over pairs."""
241
+ cells = _cells(dirs)
242
+ pairs = [(t, ca, cb) for (t, arm), cs in sorted(cells.items()) if arm == a
243
+ for ca, cb in zip(cs, cells.get((t, b), []))]
244
+ print(f"pairwise: {len(pairs)} pairs, {a} vs {b}, judge {model}")
245
+
246
+ def one(pair):
247
+ t, (wa, ra), (wb, rb) = pair
248
+ flip = random.Random(wa.name + wb.name).random() < 0.5
249
+ first, second = ((wb, rb), (wa, ra)) if flip else ((wa, ra), (wb, rb))
250
+ d = _pair_call(TASKS[t]["prompt"], (first[1], workspace_diff(t, first[0])),
251
+ (second[1], workspace_diff(t, second[0])), key, model)
252
+ pick = str(d.get("better", "")).strip()
253
+ win = {"1": "b" if flip else "a", "2": "a" if flip else "b"}.get(pick, "tie")
254
+ return {"task": t, "a": wa.name, "b": wb.name, "winner": win, "why": d.get("why", "")}
255
+ with concurrent.futures.ThreadPoolExecutor(workers) as ex:
256
+ rows = list(ex.map(one, pairs))
257
+ from compare import sign_p
258
+ wa = sum(r["winner"] == "a" for r in rows); wb = sum(r["winner"] == "b" for r in rows)
259
+ print(f" {b} wins {wb}, {a} wins {wa}, ties {len(rows) - wa - wb}, sign test p={sign_p(wb, wa):.3f}")
260
+ for r in rows:
261
+ if r["winner"] != "tie": print(f" {r['task']:20} {r['winner']}: {r['why'][:110]}")
262
+ out = Path(dirs[-1]) if Path(dirs[-1]).exists() else RUNS_DIR / Path(dirs[-1]).name
263
+ (out / f"pairwise_{a}_vs_{b}.json").write_text(json.dumps({"judge": model, "prompt": PAIR, "pairs": rows}, indent=2),
264
+ encoding="utf-8")
265
+
266
+ def main():
267
+ ap = argparse.ArgumentParser()
268
+ ap.add_argument("--selftest", action="store_true")
269
+ ap.add_argument("--selftest-offline", action="store_true")
270
+ ap.add_argument("--run", help="run dir to judge")
271
+ ap.add_argument("--model", default=MODEL)
272
+ ap.add_argument("--pair", nargs="+", help="run dirs; head-to-head replies of --a vs --b")
273
+ ap.add_argument("--a", default="ponytail")
274
+ ap.add_argument("--b", default="ponytail2")
275
+ args = ap.parse_args()
276
+ if args.selftest_offline: sys.exit(selftest_offline())
277
+ key = load_key() # None -> judge.py uses the claude CLI
278
+ if args.selftest: sys.exit(selftest(key, args.model))
279
+ if args.pair:
280
+ if selftest_pair(key, args.model): sys.exit("pairwise judge not trustworthy; refusing to judge")
281
+ return pairwise(args.pair, args.a, args.b, key, args.model)
282
+ if args.run:
283
+ if selftest(key, args.model): sys.exit("answer judge not trustworthy; refusing to judge the run")
284
+ return run(args.run, key, args.model)
285
+ sys.exit("give --selftest, --selftest-offline, or --run <dir>")
286
+
287
+ if __name__ == "__main__":
288
+ main()
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Paired arm comparison over tasks, with a sign test. No API calls.
3
+
4
+ python compare.py runs/<full> [runs/<quick> ...] --a ponytail --b ponytail2
5
+
6
+ Every run dir's results.json (and answer.json, if answer.py ran) is merged. Each task is one
7
+ pair: the per-task median (or rate) of arm A against arm B. Ties are dropped and the sign test
8
+ asks whether B wins on more tasks than chance allows. Pair a candidate's quick run with the
9
+ full run's reference arms, so iterating on the ruleset only pays for the candidate's cells.
10
+
11
+ ponytail: sign test over tasks, not a mixed model. Coarse, but honest with n=2-3 per cell.
12
+ """
13
+ import argparse, json, math, statistics
14
+ from collections import defaultdict
15
+ from pathlib import Path
16
+
17
+ RUNS_DIR = Path(__file__).resolve().parent / "runs"
18
+ # metric -> (how to read one cell, aggregate over a task's cells, True if higher is better)
19
+ METRICS = {
20
+ "pass": (lambda c: int(bool(c["correct"] and c["safe"])) if "correct" in c else None, statistics.mean, True),
21
+ "loc": (lambda c: c.get("total_loc"), statistics.median, False),
22
+ "gz_code": (lambda c: c.get("gz_code"), statistics.median, False),
23
+ "density": (lambda c: c.get("reply_density"), statistics.median, None),
24
+ "cost": (lambda c: c.get("cost"), statistics.median, False),
25
+ "out_tok": (lambda c: c.get("out_tokens"), statistics.median, False),
26
+ "time_s": (lambda c: (c.get("duration_ms") or 0) / 1000 or None, statistics.median, False),
27
+ "turns": (lambda c: c.get("turns"), statistics.median, False),
28
+ "words": (lambda c: c.get("reply_words"), statistics.median, None), # reported, not judged
29
+ "accuracy": (lambda c: c.get("accuracy"), statistics.mean, True),
30
+ "coverage": (lambda c: c.get("coverage"), statistics.mean, True),
31
+ "clarity": (lambda c: c.get("clarity"), statistics.mean, True),
32
+ }
33
+
34
+ def load(dirs):
35
+ """Cells from results.json plus answer-judge rows from answer.json, as one list of dicts.
36
+ Answer rows only carry the judge scores; per-task aggregation skips missing values."""
37
+ rows = []
38
+ for d in dirs:
39
+ d = Path(d) if Path(d).exists() else RUNS_DIR / Path(d).name
40
+ rows += [c for c in json.loads((d / "results.json").read_text(encoding="utf-8"))["results"]
41
+ if "task" in c and "arm" in c]
42
+ if (d / "answer.json").exists():
43
+ rows += json.loads((d / "answer.json").read_text(encoding="utf-8"))["scores"]
44
+ return rows
45
+
46
+ def sign_p(wins, losses):
47
+ n = wins + losses
48
+ if n == 0: return 1.0
49
+ k = min(wins, losses)
50
+ return min(1.0, 2 * sum(math.comb(n, i) for i in range(k + 1)) / 2 ** n)
51
+
52
+ def main():
53
+ ap = argparse.ArgumentParser()
54
+ ap.add_argument("runs", nargs="+")
55
+ ap.add_argument("--a", default="ponytail")
56
+ ap.add_argument("--b", default="ponytail2")
57
+ ap.add_argument("--tasks", help="comma list; default: every task both arms ran")
58
+ args = ap.parse_args()
59
+ by = defaultdict(list)
60
+ for c in load(args.runs): by[(c["task"], c["arm"])].append(c)
61
+ tasks = sorted({t for t, a in by if a == args.b} & {t for t, a in by if a == args.a})
62
+ if args.tasks: tasks = [t for t in tasks if t in args.tasks.split(",")]
63
+
64
+ def agg(task, arm, m):
65
+ get, fn, _ = METRICS[m]
66
+ v = [x for x in (get(c) for c in by[(task, arm)]) if x is not None]
67
+ return fn(v) if v else None
68
+
69
+ print(f"{args.a} (A) vs {args.b} (B) on {len(tasks)} tasks")
70
+ print(f" {'task':22} " + " ".join(f"{m:>15}" for m in ("pass", "loc", "cost", "coverage", "clarity", "words")))
71
+ for t in tasks:
72
+ row = []
73
+ for m in ("pass", "loc", "cost", "coverage", "clarity", "words"):
74
+ a, b = agg(t, args.a, m), agg(t, args.b, m)
75
+ f = lambda x: "-" if x is None else (f"{x:.3g}")
76
+ row.append(f"{f(a):>7}>{f(b):<7}")
77
+ print(f" {t:22} " + " ".join(row))
78
+ print(f"\n {'metric':10} {'B wins':>7} {'B loses':>8} {'ties':>5} {'p (sign)':>9} {'median B/A':>11}")
79
+ for m, (_, _, higher) in METRICS.items():
80
+ w = l = ties = 0; ratios = []
81
+ for t in tasks:
82
+ a, b = agg(t, args.a, m), agg(t, args.b, m)
83
+ if a is None or b is None: continue
84
+ if a: ratios.append(b / a)
85
+ if higher is None or a == b: ties += 1; continue
86
+ better = b > a if higher else b < a
87
+ w += better; l += not better
88
+ r = f"{statistics.median(ratios):.2f}" if ratios else "-"
89
+ p = f"{sign_p(w, l):.3f}" if higher is not None else "-"
90
+ print(f" {m:10} {w:>7} {l:>8} {ties:>5} {p:>9} {r:>11}")
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """LLM-judge COMPLETENESS pass for the agentic benchmark.
3
+
4
+ Fewer lines is only a win if the code still does the job. The open feature tasks (vibe-*,
5
+ tmpl-fe-*, open-*) are scored on LOC alone -- there is no deterministic check that the asked
6
+ feature was actually implemented, so an arm could "win" the LOC metric by shipping a stub.
7
+ That is the inverse of the safety hole and the most credible attack on the headline number:
8
+ "you wrote less because you did less."
9
+
10
+ This pass closes it. An LLM judge rates how FULLY each submission implements its task, on the
11
+ same auditable footing as the over-engineering judge in judge.py: a published rubric, a fixed
12
+ model at temperature 0, and a --selftest that must rank a complete reference strictly above a
13
+ stub before any real scoring is trusted. Pair the output with run.py's LOC: a low-LOC arm whose
14
+ completeness also drops is doing less, not less-bloated -- and now the bench shows it.
15
+
16
+ python complete.py --selftest # validate the judge ranks complete > stub (small API spend)
17
+ python complete.py --selftest-offline # validate the GATE LOGIC only, no API, no key
18
+ python complete.py --run runs/<stamp> # completeness-judge every workspace in a matrix run
19
+
20
+ Judge: --model (default claude-sonnet-4-6). API key from ../../.env (shared with judge.py), or the
21
+ claude CLI login when there is none.
22
+
23
+ ponytail: reuses judge.py's HTTP/key/source plumbing instead of duplicating it -- one rubric
24
+ param is the only delta between the two passes.
25
+ """
26
+ import argparse, json, re, subprocess, sys
27
+ from concurrent.futures import ThreadPoolExecutor
28
+ from collections import defaultdict
29
+ from pathlib import Path
30
+
31
+ from tasks import TASKS
32
+ from judge import load_key, source_text, judge_call, parse_score, RUNS_DIR, JUDGE_MODEL
33
+
34
+ SCORE_KEY = "completeness"
35
+ FLAG_AT = 1 # cells scoring <= this are under-delivery (stub/partial) and get listed
36
+ ARMS_ORDER = ["baseline", "caveman", "ponytail", "ponytail2", "yagni", "yagni-oneliner"]
37
+ # the tasks scored on LOC alone (no deterministic check): the only ones this pass is for
38
+ LOC_ONLY = ("vibe-", "open-", "tmpl-")
39
+
40
+ RUBRIC = (
41
+ "You are a senior engineer checking whether a code submission ACTUALLY IMPLEMENTS the task it "
42
+ "was given. Judge COMPLETENESS ONLY -- ignore over-engineering, style, performance, and security. "
43
+ "A stub, a placeholder, a bare `pass`/`TODO`/`NotImplementedError`, or code that silently omits "
44
+ "the core behavior asked for is INCOMPLETE. Score 0-3:\n"
45
+ "0 = stub/empty/placeholder, does essentially nothing the task asked\n"
46
+ "1 = partial: the core behavior is missing or broken\n"
47
+ "2 = mostly complete: it works but a stated requirement is missing\n"
48
+ "3 = fully implements what the task asked\n"
49
+ "Name the single most important missing piece, or \"none\". "
50
+ "Respond with ONLY this JSON: {\"completeness\": <0-3 int>, \"why\": \"<one line>\", \"missing\": \"<piece or none>\"}"
51
+ )
52
+
53
+ def parse_complete(text):
54
+ d = parse_score(text)
55
+ if d and SCORE_KEY in d:
56
+ try: d[SCORE_KEY] = int(d[SCORE_KEY])
57
+ except Exception: d[SCORE_KEY] = None
58
+ return d
59
+
60
+ # --- the gate: a complete impl must out-score a stub for the same task ---
61
+ def _rank_ok(scores):
62
+ """scores: {(task_id, label): {SCORE_KEY: int}}. For each task the 'complete' label must
63
+ strictly out-score the 'stub' label, else the judge (or the gate) is not trustworthy."""
64
+ ok = True
65
+ for task_id in sorted({t for (t, _) in scores}):
66
+ hi = scores.get((task_id, "complete")) or {}
67
+ lo = scores.get((task_id, "stub")) or {}
68
+ if not (isinstance(hi.get(SCORE_KEY), int) and isinstance(lo.get(SCORE_KEY), int)
69
+ and hi[SCORE_KEY] > lo[SCORE_KEY]):
70
+ print(f"XX {task_id}: did not rank complete above stub"); ok = False
71
+ else:
72
+ print(f"ok {task_id}: complete({hi[SCORE_KEY]}) > stub({lo[SCORE_KEY]})")
73
+ return ok
74
+
75
+ # Complete refs are the deterministic tasks' known-good answers; stubs do nothing.
76
+ STUBS = {
77
+ "cache": "def compute(n):\n pass\n",
78
+ "safe-path": "def safe_upload_path(base_dir, filename):\n pass\n",
79
+ }
80
+ PAIRS = [(t, lbl, code) for t in STUBS for lbl, code in
81
+ (("complete", TASKS[t]["good"]), ("stub", STUBS[t]))]
82
+
83
+ def selftest(key, model=JUDGE_MODEL):
84
+ """Live: the judge model must rank each complete ref above its stub."""
85
+ scores = {}
86
+ for task_id, label, code in PAIRS:
87
+ s = parse_complete(judge_call(TASKS[task_id]["prompt"], code, key, system=RUBRIC, model=model))
88
+ scores[(task_id, label)] = s or {}
89
+ print(f" {task_id:10} {label:8} -> {s}")
90
+ ok = _rank_ok(scores)
91
+ print(f"\ncompleteness judge selftest: {'valid' if ok else 'NOT TRUSTWORTHY'}")
92
+ return 0 if ok else 1
93
+
94
+ def selftest_offline():
95
+ """No API, no key: prove the GATE catches under-delivery. A well-ordered matrix must pass
96
+ and a matrix where a stub out-scores the complete impl must be flagged. Fails loudly if the
97
+ gate is ever weakened into a no-op."""
98
+ good = {("cache", "complete"): {SCORE_KEY: 3}, ("cache", "stub"): {SCORE_KEY: 0}}
99
+ bad = {("cache", "complete"): {SCORE_KEY: 1}, ("cache", "stub"): {SCORE_KEY: 3}}
100
+ print("offline gate -- well-ordered (expect ok):")
101
+ p_good = _rank_ok(good)
102
+ print("offline gate -- stub out-scores complete (expect XX):")
103
+ p_bad = _rank_ok(bad)
104
+ passed = p_good and not p_bad
105
+ print(f"\ncompleteness gate selftest (offline): {'valid' if passed else 'BROKEN'}")
106
+ return 0 if passed else 1
107
+
108
+ def submission(ws, tid):
109
+ """What the agent delivered. Fixture tasks: only its diff against the seeded repo, not the whole
110
+ repo. Open tasks may answer in chat with no file. Arm-revealing marker words are neutralized so
111
+ the judge stays blind."""
112
+ if TASKS[tid].get("fixture"):
113
+ text = subprocess.run(["git", "-C", str(ws), "diff", "--cached", "-U10", "HEAD", "--", ".", ":(exclude)_*"],
114
+ capture_output=True, text=True).stdout
115
+ else:
116
+ text = source_text(ws)
117
+ if not text.strip() and (ws / "_claude.json").exists():
118
+ try: text = json.loads((ws / "_claude.json").read_text(encoding="utf-8")).get("result", "") or ""
119
+ except ValueError: text = "" # the agent timed out: nothing delivered
120
+ return re.sub(r"\bponytail:", "note:", text, flags=re.I)
121
+
122
+ def run(run_dir, key, model=JUDGE_MODEL, arms=None, workers=6):
123
+ run_dir = Path(run_dir)
124
+ if not run_dir.exists(): run_dir = RUNS_DIR / run_dir.name
125
+ cells, timed_out = [], defaultdict(int)
126
+ for ws in sorted(p for p in run_dir.iterdir() if p.is_dir()):
127
+ parts = ws.name.split("__")
128
+ if len(parts) != 4 or parts[0] not in TASKS: continue
129
+ if not parts[0].startswith(LOC_ONLY) or (arms and parts[1] not in arms): continue
130
+ if (ws / "_claude.json").exists() and not (ws / "_claude.json").stat().st_size:
131
+ timed_out[parts[1]] += 1; continue # killed at the cell timeout: nothing to judge, counted apart
132
+ cells.append((parts[0], parts[1], parts[2], ws))
133
+ print(f"completeness-judging {len(cells)} workspaces with {model} ...; timed out, not judged: {dict(timed_out) or 0}")
134
+ def one(cell):
135
+ tid, arm, mdl, ws = cell
136
+ r = parse_complete(judge_call(TASKS[tid]["prompt"], submission(ws, tid), key, system=RUBRIC, model=model)) \
137
+ or {SCORE_KEY: None}
138
+ return {"task": tid, "arm": arm, "model": mdl, "cell": ws.name, SCORE_KEY: r.get(SCORE_KEY),
139
+ "why": r.get("why", ""), "missing": r.get("missing", "")}
140
+ with ThreadPoolExecutor(workers) as ex:
141
+ scored = list(ex.map(one, cells))
142
+ (run_dir / "completeness.json").write_text(
143
+ json.dumps({"judge": model, "rubric": RUBRIC, "timed_out": timed_out, "scores": scored}, indent=2), encoding="utf-8")
144
+ by_arm = defaultdict(list)
145
+ for r in scored:
146
+ if isinstance(r[SCORE_KEY], int): by_arm[r["arm"]].append(r[SCORE_KEY])
147
+ print(f"\n=== completeness by arm (judge: {model}, 0=stub .. 3=fully implements) ===")
148
+ print(f" {'arm':16} {'n':>4} {'mean':>6} {'min':>4}")
149
+ for arm in ARMS_ORDER:
150
+ v = by_arm.get(arm, [])
151
+ if v: print(f" {arm:16} {len(v):>4} {sum(v)/len(v):>6.2f} {min(v):>4}")
152
+ under = sorted([r for r in scored if isinstance(r[SCORE_KEY], int) and r[SCORE_KEY] <= FLAG_AT],
153
+ key=lambda r: r[SCORE_KEY])
154
+ print(f"\n=== under-delivered (completeness <= {FLAG_AT}): {len(under)} cells ===")
155
+ for r in under[:20]:
156
+ print(f" {r['task']:13} {r['arm']:15} {r['model']:7} score={r[SCORE_KEY]} missing={r['missing']}")
157
+ print(f"\nwrote {run_dir / 'completeness.json'}")
158
+
159
+ def main():
160
+ ap = argparse.ArgumentParser()
161
+ ap.add_argument("--selftest", action="store_true", help="live: judge ranks complete > stub")
162
+ ap.add_argument("--selftest-offline", action="store_true", help="gate logic only, no API")
163
+ ap.add_argument("--run", help="run dir to completeness-judge")
164
+ ap.add_argument("--model", default=JUDGE_MODEL, help="judge model")
165
+ ap.add_argument("--arms", help="comma list, default all")
166
+ ap.add_argument("--workers", type=int, default=6)
167
+ args = ap.parse_args()
168
+ if args.selftest_offline:
169
+ sys.exit(selftest_offline())
170
+ key = load_key()
171
+ if args.selftest: sys.exit(selftest(key, args.model))
172
+ if args.run:
173
+ if selftest(key, args.model): sys.exit("judge not trustworthy; refusing to judge the matrix")
174
+ return run(args.run, key, args.model, args.arms and args.arms.split(","), args.workers)
175
+ sys.exit("give --selftest, --selftest-offline, or --run <dir>")
176
+
177
+ if __name__ == "__main__":
178
+ main()