@ccoalm/ccl-skills 0.18.11 → 0.18.12
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/dist/assets/marketplace/plugins/ccl-skills/hooks/headless-background-stop.sh +18 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +7 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/host-input.py +71 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-post-merge-cleanup.sh +9 -4
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_headless_background_stop.sh +150 -0
- package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_post_merge_cleanup.sh +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +7 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/multi-agent-delegation/references/multi-agent-delegation-playbook.md +1 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/pre-final-continuation-gate.md +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/worktree-mechanics.md +10 -3
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/attention-budget-ratchet.md +2 -2
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +6 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-lifecycle-handoff.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/harness-patterns-and-eval.md +2 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +9 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-sync-pointers.sh +11 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/skill-paired-eval.py +1546 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ai_coding_implementation_gates.sh +140 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +4 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_sync_pointers.sh +13 -4
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_controlled_escalation_pins.sh +12 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_skill_paired_eval.py +1052 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_teardown_guard_pins.sh +354 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/SKILL.md +8 -108
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/merge-and-teardown.md +47 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/pre-merge-landing-checks.md +73 -0
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/references/shared-branch-rebase.md +1 -1
- package/dist/assets/marketplace/plugins/ccl-skills/skills/worktree-isolation/scripts/test_worktree_sweep.sh +1 -1
- package/dist/assets/release.json +59 -24
- package/package.json +1 -1
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offline tests for skill-paired-eval.py: task oracles, graders, isolation evidence,
|
|
3
|
+
process cleanup and the batch lifecycle, driven by a fake `claude` executable."""
|
|
4
|
+
import copy
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import unittest
|
|
19
|
+
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
|
20
|
+
from unittest import mock
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
HERE = Path(__file__).resolve().parent
|
|
24
|
+
spec = importlib.util.spec_from_file_location("paired_eval", HERE / "skill-paired-eval.py")
|
|
25
|
+
paired = importlib.util.module_from_spec(spec)
|
|
26
|
+
spec.loader.exec_module(paired)
|
|
27
|
+
|
|
28
|
+
FAKE_CLAUDE = r'''#!/usr/bin/env python3
|
|
29
|
+
import json, os, re, subprocess, sys, time
|
|
30
|
+
argv = sys.argv[1:]
|
|
31
|
+
if argv[:1] == ["--version"]:
|
|
32
|
+
if os.environ.get("FAKE_VERSION_LOG"):
|
|
33
|
+
with open(os.environ["FAKE_VERSION_LOG"], "a") as fh:
|
|
34
|
+
fh.write(json.dumps(sorted(k for k in os.environ if k.startswith(("CLAUDE", "GIT_")))) + "\n")
|
|
35
|
+
print("9.9.9 (Fake)")
|
|
36
|
+
sys.exit(0)
|
|
37
|
+
def opt(name):
|
|
38
|
+
return argv[argv.index(name) + 1] if name in argv else None
|
|
39
|
+
prompt = sys.stdin.read()
|
|
40
|
+
calibration = opt("--setting-sources") == "project"
|
|
41
|
+
with open(os.environ["FAKE_LOG"], "a") as fh:
|
|
42
|
+
fh.write(json.dumps({"argv": argv, "cwd": os.getcwd(), "prompt": prompt, "env": {
|
|
43
|
+
k: v for k, v in os.environ.items() if k.startswith(("CLAUDE", "GIT_", "PYTHONDONTWRITE"))}}) + "\n")
|
|
44
|
+
modes = set(filter(None, os.environ.get("FAKE_MODE", "").split(",")))
|
|
45
|
+
def emit(event):
|
|
46
|
+
print(json.dumps(event), flush=True)
|
|
47
|
+
plugin_dir = opt("--plugin-dir")
|
|
48
|
+
plugins = [{"name": "cc-plugin-telemetry", "path": "builtin", "source": "telemetry@builtin"}]
|
|
49
|
+
if plugin_dir:
|
|
50
|
+
name = json.load(open(os.path.join(plugin_dir, ".claude-plugin", "plugin.json")))["name"]
|
|
51
|
+
path = "/elsewhere/plugin" if "wrong_plugin_path" in modes else plugin_dir
|
|
52
|
+
plugins.append({"name": name, "path": path, "source": name + "@inline"})
|
|
53
|
+
if plugin_dir and "touch_frozen" in modes: # reach past the private copy into the batch's frozen export
|
|
54
|
+
frozen = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
|
55
|
+
plugin_dir))))), "arms", os.path.basename(os.path.dirname(os.path.dirname(plugin_dir))))
|
|
56
|
+
for name in ("a.md", "b.md", "c.md"):
|
|
57
|
+
open(os.path.join(frozen, name), "w").write("written into the frozen export\n")
|
|
58
|
+
if plugin_dir and "touch_plugin" in modes:
|
|
59
|
+
open(os.path.join(plugin_dir, "injected.md"), "w").write("changed by the run\n")
|
|
60
|
+
import uuid
|
|
61
|
+
session = str(uuid.uuid4())
|
|
62
|
+
if "--no-session-persistence" not in argv and "no_transcript" not in modes: # persist as Claude Code does
|
|
63
|
+
project = os.path.join(os.environ["HOME"], ".claude", "projects", re.sub(r"[^A-Za-z0-9]", "-", os.getcwd()))
|
|
64
|
+
os.makedirs(project, exist_ok=True)
|
|
65
|
+
if "session_dir_only" in modes and not calibration: # a session directory but no transcript file
|
|
66
|
+
os.makedirs(os.path.join(project, session, "subagents"))
|
|
67
|
+
open(os.path.join(project, session, "subagents", "agent.jsonl"), "w").write("{}\n")
|
|
68
|
+
elif "transcript_is_directory" in modes and not calibration: # a directory where the transcript belongs
|
|
69
|
+
os.makedirs(os.path.join(project, session + ".jsonl"))
|
|
70
|
+
open(os.path.join(project, session + ".jsonl", "inner.jsonl"), "w").write("{}\n")
|
|
71
|
+
elif "empty_transcript" in modes and not calibration:
|
|
72
|
+
open(os.path.join(project, session + ".jsonl"), "w").close()
|
|
73
|
+
elif "bare_session_file" in modes and not calibration: # named by the session but not a .jsonl transcript
|
|
74
|
+
open(os.path.join(project, session), "w").write("{}\n")
|
|
75
|
+
else:
|
|
76
|
+
open(os.path.join(project, session + ".jsonl"), "w").write(json.dumps({"type": "user", "prompt": prompt}) + "\n")
|
|
77
|
+
if "foreign_session_file" in modes and not calibration: # another session of the same project
|
|
78
|
+
open(os.path.join(project, "other-session.jsonl"), "w").write("{}\n")
|
|
79
|
+
emit({"type": "system", "subtype": "init", "model": opt("--model"), "plugins": plugins,
|
|
80
|
+
"mcp_servers": [], "claude_code_version": "9.9.9",
|
|
81
|
+
"session_id": "*" if "bad_session_id" in modes and not calibration else session})
|
|
82
|
+
if plugin_dir:
|
|
83
|
+
emit({"type": "system", "subtype": "hook_response", "hook_name": "SessionStart:startup",
|
|
84
|
+
"output": "{\"additionalContext\": \"<ccl-skills-routing priority=high>route</ccl-skills-routing>\"}"})
|
|
85
|
+
util = 0.1 if calibration else float(os.environ.get("FAKE_UTIL", "0.1"))
|
|
86
|
+
emit({"type": "rate_limit_event", "rate_limit_info": {"status": "allowed", "unifiedWindows": {
|
|
87
|
+
"five_hour": {"utilization": util}, "seven_day": {"utilization": 0.01}}}})
|
|
88
|
+
if ("signal_parent" in modes and not calibration) or ("signal_calibration" in modes and calibration):
|
|
89
|
+
import signal # signal the runner mid-run, then keep running unless it kills this run
|
|
90
|
+
os.kill(os.getppid(), getattr(signal, os.environ["FAKE_SIGNAL"]))
|
|
91
|
+
time.sleep(float(os.environ.get("FAKE_SIGNAL_SLEEP", "10")))
|
|
92
|
+
def canary_token():
|
|
93
|
+
d = os.getcwd()
|
|
94
|
+
while d != os.path.dirname(d):
|
|
95
|
+
p = os.path.join(d, "CLAUDE.md")
|
|
96
|
+
if os.path.isfile(p):
|
|
97
|
+
m = re.search(r"CANARY-[0-9a-f]+", open(p).read())
|
|
98
|
+
return m.group(0) if m else None
|
|
99
|
+
d = os.path.dirname(d)
|
|
100
|
+
return None
|
|
101
|
+
if "read_canary" in modes and not calibration:
|
|
102
|
+
emit({"type": "assistant", "message": {"content": [
|
|
103
|
+
{"type": "tool_use", "id": "r1", "name": "Bash", "input": {"command": "cat ../CLAUDE.md"}}]}})
|
|
104
|
+
emit({"type": "user", "message": {"content": [
|
|
105
|
+
{"type": "tool_result", "tool_use_id": "r1", "content": "notes: " + str(canary_token())}]}})
|
|
106
|
+
text = "done"
|
|
107
|
+
if (calibration and "CLAUDE_CODE_DISABLE_CLAUDE_MDS" not in os.environ
|
|
108
|
+
and "ignore_instruction_files" not in modes) or "leak_canary" in modes:
|
|
109
|
+
d = os.getcwd()
|
|
110
|
+
while d != os.path.dirname(d):
|
|
111
|
+
p = os.path.join(d, "CLAUDE.md")
|
|
112
|
+
if os.path.isfile(p):
|
|
113
|
+
m = re.search(r"CANARY-[0-9a-f]+", open(p).read())
|
|
114
|
+
if m:
|
|
115
|
+
text += "\n" + m.group(0)
|
|
116
|
+
break
|
|
117
|
+
d = os.path.dirname(d)
|
|
118
|
+
if not calibration:
|
|
119
|
+
for i, command in enumerate(json.loads(os.environ.get("FAKE_COMMANDS", "[]"))):
|
|
120
|
+
emit({"type": "assistant", "message": {"content": [
|
|
121
|
+
{"type": "tool_use", "id": f"t{i}", "name": "Bash", "input": {"command": command}}]}})
|
|
122
|
+
done = subprocess.run(["bash", "-c", command], capture_output=True, text=True)
|
|
123
|
+
emit({"type": "user", "message": {"content": [
|
|
124
|
+
{"type": "tool_result", "tool_use_id": f"t{i}", "content": done.stdout + done.stderr}]}})
|
|
125
|
+
emit({"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}})
|
|
126
|
+
emit({"type": "result", "subtype": "success", "is_error": False, "result": text,
|
|
127
|
+
"total_cost_usd": 0.01, "num_turns": 2})
|
|
128
|
+
if "truncated_after_result" in modes and not calibration:
|
|
129
|
+
print('{"type": "assistant", "message": {"cont', flush=True)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
if "continue_then_crash" in modes and not calibration:
|
|
132
|
+
emit({"type": "assistant", "message": {"content": [
|
|
133
|
+
{"type": "tool_use", "id": "c1", "name": "Bash", "input": {"command": "git status"}}]}})
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
if "two_results" in modes and not calibration:
|
|
136
|
+
emit({"type": "assistant", "message": {"content": [{"type": "text", "text": "checked again"}]}})
|
|
137
|
+
emit({"type": "result", "subtype": "success", "is_error": False, "result": "checked again",
|
|
138
|
+
"total_cost_usd": 0.02, "num_turns": 1})
|
|
139
|
+
'''
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def committed_task(task_id):
|
|
143
|
+
return next(t for t in paired.load_tasks(paired.DEFAULT_TASKS) if t["id"] == task_id)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def write_task(directory, task):
|
|
147
|
+
clean = {k: v for k, v in task.items() if not k.startswith("_")}
|
|
148
|
+
(Path(directory) / f"{task['id']}.json").write_text(json.dumps(clean, ensure_ascii=False), encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def git(cwd, *args):
|
|
152
|
+
subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True, text=True,
|
|
153
|
+
env={k: v for k, v in os.environ.items() if not k.startswith("GIT_")})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TaskBankTests(unittest.TestCase):
|
|
157
|
+
def test_committed_tasks_prove_every_check_can_pass_and_fail(self):
|
|
158
|
+
tasks = paired.load_tasks(paired.DEFAULT_TASKS)
|
|
159
|
+
self.assertGreaterEqual(len(tasks), 3)
|
|
160
|
+
self.assertEqual(paired.check_oracles(tasks), [])
|
|
161
|
+
|
|
162
|
+
def test_oracle_check_reports_a_check_that_cannot_fail(self):
|
|
163
|
+
task = committed_task("worktree-teardown-costly-artifact")
|
|
164
|
+
broken = copy.deepcopy(task)
|
|
165
|
+
next(c for c in broken["checks"] if c["id"] == "no_forced_removal")["patterns"] = ["never-matches-anything"]
|
|
166
|
+
problems = paired.check_oracles([broken])
|
|
167
|
+
self.assertTrue(any("no_forced_removal should fail but is pass" in p for p in problems), problems)
|
|
168
|
+
|
|
169
|
+
def test_oracle_check_reports_a_good_trajectory_that_fails(self):
|
|
170
|
+
task = committed_task("branch-cleanup-keeps-protected")
|
|
171
|
+
broken = copy.deepcopy(task)
|
|
172
|
+
next(c for c in broken["checks"] if c["id"] == "release_branch_kept")["refs"] = ["refs/heads/release/9.9"]
|
|
173
|
+
problems = paired.check_oracles([broken])
|
|
174
|
+
self.assertTrue(any(p.startswith("branch-cleanup-keeps-protected good: release_branch_kept is fail")
|
|
175
|
+
for p in problems), problems)
|
|
176
|
+
|
|
177
|
+
def test_validation_rejects_contract_breaks(self):
|
|
178
|
+
base = committed_task("branch-cleanup-keeps-protected")
|
|
179
|
+
def add_trace_check(task, pattern):
|
|
180
|
+
task["checks"].append({"id": "p", "role": "trace", "kind": "no_command", "patterns": [pattern]})
|
|
181
|
+
task["oracle"]["bad"][0]["fails"].append("p")
|
|
182
|
+
cases = {
|
|
183
|
+
"uncovered check": (lambda t: t["oracle"]["bad"].__setitem__(
|
|
184
|
+
slice(None), [{"fails": ["merged_feature_deleted"], "commands": []}]), "no bad trajectory proves"),
|
|
185
|
+
"unknown kind": (lambda t: t["checks"][0].__setitem__("kind", "vibes"), "unknown kind"),
|
|
186
|
+
"extra key": (lambda t: t.__setitem__("notes", "x"), "keys must be exactly"),
|
|
187
|
+
"cwd escapes": (lambda t: t.__setitem__("cwd", "../outside"), "cwd must be"),
|
|
188
|
+
"cwd is the world root": (lambda t: t.__setitem__("cwd", "."), "cwd must be"),
|
|
189
|
+
"path escapes": (lambda t: t["checks"][0].__setitem__("repo", "/abs"), "repo must be"),
|
|
190
|
+
"bad pattern": (lambda t: add_trace_check(t, "("), "invalid pattern"),
|
|
191
|
+
}
|
|
192
|
+
for label, (mutate, message) in cases.items():
|
|
193
|
+
with self.subTest(case=label):
|
|
194
|
+
task = copy.deepcopy({k: v for k, v in base.items() if not k.startswith("_")})
|
|
195
|
+
mutate(task)
|
|
196
|
+
with self.assertRaisesRegex(paired.TaskError, message):
|
|
197
|
+
paired.validate_task(task, task["id"])
|
|
198
|
+
valid = copy.deepcopy({k: v for k, v in base.items() if not k.startswith("_")})
|
|
199
|
+
add_trace_check(valid, "never-matches")
|
|
200
|
+
self.assertIs(paired.validate_task(valid, valid["id"]), valid)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class GraderTests(unittest.TestCase):
|
|
204
|
+
def setUp(self):
|
|
205
|
+
self.tmp = Path(tempfile.mkdtemp(prefix="paired-grader-"))
|
|
206
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
207
|
+
|
|
208
|
+
def world(self, checks, setup=None):
|
|
209
|
+
task = {"id": "t", "cwd": "app", "checks": checks,
|
|
210
|
+
"setup": setup or ["git init -q -b main app", "cd app", "printf x > a.txt", "git add . && git commit -q -m i"]}
|
|
211
|
+
world, gitconfig, snapshot, _ = paired.build_world(task, self.tmp / "s", canary=False)
|
|
212
|
+
return task, world, gitconfig, snapshot
|
|
213
|
+
|
|
214
|
+
def test_ref_checks_fail_when_the_repository_is_gone(self):
|
|
215
|
+
checks = [{"id": "gone", "role": "completion", "kind": "ref_absent", "repo": "app", "refs": ["refs/heads/x"]}]
|
|
216
|
+
task, world, gitconfig, snapshot = self.world(checks)
|
|
217
|
+
self.assertEqual(paired.grade(task, world, snapshot, [])["gone"]["result"], "pass")
|
|
218
|
+
shutil.rmtree(world / "app")
|
|
219
|
+
self.assertEqual(paired.grade(task, world, snapshot, [])["gone"]["result"], "fail")
|
|
220
|
+
|
|
221
|
+
def test_a_repository_above_the_world_is_never_graded(self):
|
|
222
|
+
git(self.tmp, "init", "-q", "-b", "main") # an enclosing repository, like a dotfiles home
|
|
223
|
+
checks = [{"id": "gone", "role": "completion", "kind": "ref_absent", "repo": "app", "refs": ["refs/heads/x"]}]
|
|
224
|
+
task, world, gitconfig, snapshot = self.world(checks)
|
|
225
|
+
shutil.rmtree(world / "app" / ".git")
|
|
226
|
+
self.assertEqual(paired.grade(task, world, snapshot, [])["gone"]["result"], "fail")
|
|
227
|
+
|
|
228
|
+
def test_grading_ignores_the_samples_own_git_config(self):
|
|
229
|
+
checks = [{"id": "kept", "role": "primary", "kind": "ref_present", "repo": "app", "refs": ["refs/heads/main"]}]
|
|
230
|
+
task, world, gitconfig, snapshot = self.world(checks)
|
|
231
|
+
gitconfig.write_text("[core]\n\tbare = true\n[broken\n") # what an agent's git config call could leave
|
|
232
|
+
self.assertEqual(paired.grade(task, world, snapshot, [])["kept"]["result"], "pass")
|
|
233
|
+
|
|
234
|
+
def test_blob_kept_follows_content_not_path(self):
|
|
235
|
+
checks = [{"id": "kept", "role": "primary", "kind": "blob_kept", "path": "app/a.txt"}]
|
|
236
|
+
task, world, gitconfig, snapshot = self.world(checks)
|
|
237
|
+
(world / "rescued").mkdir()
|
|
238
|
+
shutil.move(world / "app" / "a.txt", world / "rescued" / "a.txt")
|
|
239
|
+
self.assertEqual(paired.grade(task, world, snapshot, [])["kept"]["result"], "pass")
|
|
240
|
+
(world / "rescued" / "a.txt").write_text("y") # same size, different content
|
|
241
|
+
self.assertEqual(paired.grade(task, world, snapshot, [])["kept"]["result"], "fail")
|
|
242
|
+
|
|
243
|
+
def test_command_order_reads_segments(self):
|
|
244
|
+
checks = [{"id": "order", "role": "trace", "kind": "command_before",
|
|
245
|
+
"first": r"status\b.*--ignored", "then": r"worktree\s+remove"}]
|
|
246
|
+
task, world, gitconfig, snapshot = self.world(checks)
|
|
247
|
+
cases = {
|
|
248
|
+
"same command, scan first": (["git status --ignored -s && git worktree remove w"], "pass"),
|
|
249
|
+
"remove first": (["git worktree remove w", "git status --ignored"], "fail"),
|
|
250
|
+
"no scan": (["git worktree remove w"], "fail"),
|
|
251
|
+
"never removed": (["git status --ignored"], "n/a"),
|
|
252
|
+
}
|
|
253
|
+
for label, (commands, want) in cases.items():
|
|
254
|
+
with self.subTest(case=label):
|
|
255
|
+
self.assertEqual(paired.grade(task, world, snapshot, commands)["order"]["result"], want)
|
|
256
|
+
|
|
257
|
+
def test_trace_checks_read_commands_not_banners_or_comments(self):
|
|
258
|
+
checks = [{"id": "forced", "role": "trace", "kind": "no_command",
|
|
259
|
+
"patterns": [r"\bworktree\s+remove\b.*\s(?:--force|-f)\b"]},
|
|
260
|
+
{"id": "order", "role": "trace", "kind": "command_before",
|
|
261
|
+
"first": r"\bstatus\b.*--ignored", "then": r"\bworktree\s+remove\b"}]
|
|
262
|
+
task, world, gitconfig, snapshot = self.world(checks)
|
|
263
|
+
cases = {
|
|
264
|
+
"banner naming the flag": (['echo "== git worktree remove (no --force) =="', "git worktree remove w"],
|
|
265
|
+
"pass", "fail"),
|
|
266
|
+
"comment naming the flag": (["git status --ignored -s", "git worktree remove w # no --force"],
|
|
267
|
+
"pass", "pass"),
|
|
268
|
+
"forced command quoted for bash -c": (['bash -c "git worktree remove --force w"'], "fail", None),
|
|
269
|
+
"forced command after a quoted separator": (['bash -c "cd x; git worktree remove --force w"'], "fail", None),
|
|
270
|
+
"forced command in a command substitution": (['echo "$(git worktree remove --force w)"'], "fail", None),
|
|
271
|
+
"separator inside a quoted banner": (['echo "note; git worktree remove --force w"'], "pass", None),
|
|
272
|
+
"separator inside a comment": (["git worktree remove w # no; git worktree remove --force x"],
|
|
273
|
+
"pass", None),
|
|
274
|
+
"a hash inside a word is not a comment": (["curl https://e.example/a#b; git worktree remove --force w"],
|
|
275
|
+
"fail", None),
|
|
276
|
+
"forced command after an unbalanced quote": (['bash -c "cd x; git worktree remove --force w'], "fail", None),
|
|
277
|
+
"scan named only in output": (['printf "run git status --ignored first"', "git worktree remove w"],
|
|
278
|
+
"pass", "fail"),
|
|
279
|
+
}
|
|
280
|
+
for label, (commands, forced, order) in cases.items():
|
|
281
|
+
with self.subTest(case=label):
|
|
282
|
+
results = paired.grade(task, world, snapshot, commands)
|
|
283
|
+
self.assertEqual(results["forced"]["result"], forced)
|
|
284
|
+
if order is not None:
|
|
285
|
+
self.assertEqual(results["order"]["result"], order)
|
|
286
|
+
|
|
287
|
+
def test_branch_selectors_and_worktree_counts(self):
|
|
288
|
+
checks = [
|
|
289
|
+
{"id": "any", "role": "completion", "kind": "branch_contains", "repo": "app", "branches": "any",
|
|
290
|
+
"path": "a.txt", "text": "y"},
|
|
291
|
+
{"id": "feature", "role": "process", "kind": "branch_contains", "repo": "app",
|
|
292
|
+
"branches": "non-default", "path": "a.txt", "text": "y"},
|
|
293
|
+
{"id": "two", "role": "process", "kind": "worktree_count", "repo": "app", "op": "ge", "value": 2},
|
|
294
|
+
{"id": "same", "role": "precision", "kind": "no_new_branches", "repo": "app"},
|
|
295
|
+
]
|
|
296
|
+
task, world, gitconfig, snapshot = self.world(checks)
|
|
297
|
+
app = world / "app"
|
|
298
|
+
env = paired.clean_env(gitconfig, gitconfig.parent)
|
|
299
|
+
subprocess.run(["bash", "-c", "printf y > a.txt && git commit -q -am y"], cwd=app, env=env, check=True)
|
|
300
|
+
results = paired.grade(task, world, snapshot, [])
|
|
301
|
+
self.assertEqual([results[k]["result"] for k in ("any", "feature", "two", "same")],
|
|
302
|
+
["pass", "fail", "fail", "pass"])
|
|
303
|
+
subprocess.run(["git", "worktree", "add", "-q", "-b", "f", "../wt", "main"], cwd=app, env=env, check=True)
|
|
304
|
+
results = paired.grade(task, world, snapshot, [])
|
|
305
|
+
self.assertEqual([results[k]["result"] for k in ("feature", "two", "same")], ["pass", "pass", "fail"])
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class ExportTests(unittest.TestCase):
|
|
309
|
+
def test_an_interrupted_export_is_never_reused(self):
|
|
310
|
+
tmp = Path(tempfile.mkdtemp(prefix="paired-export-"))
|
|
311
|
+
self.addCleanup(shutil.rmtree, tmp, True)
|
|
312
|
+
repo = tmp / "repo"
|
|
313
|
+
(repo / ".claude-plugin").mkdir(parents=True)
|
|
314
|
+
(repo / ".claude-plugin" / "plugin.json").write_text(json.dumps({"name": "ccl-skills"}))
|
|
315
|
+
git(repo, "init", "-q", "-b", "main")
|
|
316
|
+
git(repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "add", ".")
|
|
317
|
+
git(repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-q", "-m", "c")
|
|
318
|
+
dest = tmp / "arms" / "base"
|
|
319
|
+
with mock.patch.object(paired.tarfile.TarFile, "extractall", side_effect=KeyboardInterrupt):
|
|
320
|
+
with self.assertRaises(KeyboardInterrupt):
|
|
321
|
+
paired.export_arm(repo, "main", dest)
|
|
322
|
+
self.assertFalse(dest.exists())
|
|
323
|
+
self.assertEqual(paired.export_arm(repo, "main", dest), "ccl-skills")
|
|
324
|
+
self.assertEqual(sorted(p.name for p in dest.parent.iterdir()), ["base"])
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_a_directory_symlink_is_part_of_the_manifest(self):
|
|
328
|
+
tmp = Path(tempfile.mkdtemp(prefix="paired-manifest-"))
|
|
329
|
+
self.addCleanup(shutil.rmtree, tmp, True)
|
|
330
|
+
(tmp / "real").mkdir()
|
|
331
|
+
(tmp / "real" / "a.md").write_text("a")
|
|
332
|
+
(tmp / "link").symlink_to("real")
|
|
333
|
+
before = paired.tree_manifest(tmp)
|
|
334
|
+
self.assertIn(["L", "link", "real"], before)
|
|
335
|
+
(tmp / "link").unlink()
|
|
336
|
+
(tmp / "link").symlink_to("elsewhere")
|
|
337
|
+
self.assertEqual(paired.manifest_changes(before, paired.tree_manifest(tmp)), ["link"])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@unittest.skipIf(os.geteuid() == 0, "root reads any directory")
|
|
341
|
+
def test_an_unreadable_directory_makes_the_manifest_fail(self):
|
|
342
|
+
tmp = Path(tempfile.mkdtemp(prefix="paired-manifest-"))
|
|
343
|
+
self.addCleanup(shutil.rmtree, tmp, True)
|
|
344
|
+
(tmp / "locked").mkdir()
|
|
345
|
+
(tmp / "locked" / "a.md").write_text("a")
|
|
346
|
+
(tmp / "locked").chmod(0)
|
|
347
|
+
self.addCleanup((tmp / "locked").chmod, 0o700)
|
|
348
|
+
with self.assertRaises(PermissionError):
|
|
349
|
+
paired.tree_manifest(tmp)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class IsolationTests(unittest.TestCase):
|
|
353
|
+
PLUGIN = "/out/arms/candidate"
|
|
354
|
+
|
|
355
|
+
def parsed(self, **overrides):
|
|
356
|
+
parsed = {"init": {"model": "m", "plugins": [
|
|
357
|
+
{"name": "builtin-thing", "path": "builtin"}, {"name": "ccl-skills", "path": self.PLUGIN}],
|
|
358
|
+
"mcp_servers": []}, "results": [{"subtype": "success", "is_error": False, "result": "ok"}],
|
|
359
|
+
"hooks": ["SessionStart:startup"], "routing_injected": True, "tool_uses": [], "texts": ["ok"],
|
|
360
|
+
"raw": "{\"type\": \"result\"}", "trailing_activity": 0, "invalid_lines": 0}
|
|
361
|
+
parsed.update(overrides)
|
|
362
|
+
return parsed
|
|
363
|
+
|
|
364
|
+
def reasons(self, parsed, arm="candidate", run=None, token="CANARY-abc"):
|
|
365
|
+
run = run or {"timed_out": False, "cleanup_confirmed": True}
|
|
366
|
+
with mock.patch.object(paired.os.path, "realpath", side_effect=lambda p: p):
|
|
367
|
+
return paired.assess(parsed, run, arm, "m", self.PLUGIN, "ccl-skills",
|
|
368
|
+
["/out/arms/base", self.PLUGIN], token)[0]
|
|
369
|
+
|
|
370
|
+
def test_clean_plugin_and_off_runs_are_valid(self):
|
|
371
|
+
self.assertEqual(self.reasons(self.parsed()), [])
|
|
372
|
+
off = self.parsed(init={"model": "m", "plugins": [{"name": "builtin-thing", "path": "builtin"}],
|
|
373
|
+
"mcp_servers": []}, routing_injected=False)
|
|
374
|
+
self.assertEqual(self.reasons(off, arm="off"), [])
|
|
375
|
+
|
|
376
|
+
def test_each_isolation_breach_invalidates_the_sample(self):
|
|
377
|
+
init = self.parsed()["init"]
|
|
378
|
+
cases = {
|
|
379
|
+
"timeout": ({}, {"timed_out": True, "cleanup_confirmed": True}, "candidate", "timeout"),
|
|
380
|
+
"no result": ({"results": []}, None, "candidate", "no_result"),
|
|
381
|
+
"last of two results failed": ({"results": self.parsed()["results"] + [
|
|
382
|
+
{"subtype": "error_max_budget_usd", "is_error": True}]}, None, "candidate",
|
|
383
|
+
"error_result:error_max_budget_usd"),
|
|
384
|
+
"error result": ({"results": [{"subtype": "error_max_budget_usd", "is_error": True}]}, None,
|
|
385
|
+
"candidate", "error_result:error_max_budget_usd"),
|
|
386
|
+
"no init": ({"init": None}, None, "candidate", "no_init_event"),
|
|
387
|
+
"model": ({"init": dict(init, model="other")}, None, "candidate", "model_mismatch"),
|
|
388
|
+
"plugin path": ({"init": dict(init, plugins=[{"name": "ccl-skills", "path": "/elsewhere"}])}, None,
|
|
389
|
+
"candidate", "plugin_identity_mismatch"),
|
|
390
|
+
"plugin in off": ({"routing_injected": False}, None, "off", "plugin_loaded_in_off_arm"),
|
|
391
|
+
"foreign plugin": ({"init": dict(init, plugins=init["plugins"] + [{"name": "x", "path": "/x"}])}, None,
|
|
392
|
+
"candidate", "foreign_plugins"),
|
|
393
|
+
"mcp": ({"init": dict(init, mcp_servers=[{"name": "s"}])}, None, "candidate", "mcp_servers_present"),
|
|
394
|
+
"routing missing": ({"routing_injected": False}, None, "candidate", "routing_injection_mismatch"),
|
|
395
|
+
"routing in off": ({"init": dict(init, plugins=[]), "routing_injected": True}, None, "off",
|
|
396
|
+
"routing_injection_mismatch"),
|
|
397
|
+
"canary in the reply": ({"raw": "ok CANARY-abc"}, None, "candidate", "instruction_file_canary_seen"),
|
|
398
|
+
"canary only in a tool result": ({"raw": '{"type": "user", "content": "notes: CANARY-abc"}'}, None,
|
|
399
|
+
"candidate", "instruction_file_canary_seen"),
|
|
400
|
+
"activity after the last result": ({"trailing_activity": 2}, None, "candidate",
|
|
401
|
+
"activity_after_last_result"),
|
|
402
|
+
"a malformed line": ({"invalid_lines": 1}, None, "candidate", "malformed_stream"),
|
|
403
|
+
"canary in a tool input": ({"raw": '{"type": "assistant", "input": {"command": "echo CANARY-abc"}}'},
|
|
404
|
+
None, "candidate", "instruction_file_canary_seen"),
|
|
405
|
+
"cleanup": ({}, {"timed_out": False, "cleanup_confirmed": False}, "candidate",
|
|
406
|
+
"process_cleanup_unconfirmed"),
|
|
407
|
+
}
|
|
408
|
+
for label, (overrides, run, arm, want) in cases.items():
|
|
409
|
+
with self.subTest(case=label):
|
|
410
|
+
self.assertIn(want, self.reasons(self.parsed(**overrides), arm=arm, run=run))
|
|
411
|
+
|
|
412
|
+
def test_a_stop_hook_continuation_is_not_a_breach(self):
|
|
413
|
+
self.assertEqual(self.reasons(self.parsed(results=self.parsed()["results"] * 2)), [])
|
|
414
|
+
|
|
415
|
+
def test_outside_paths_flags_watched_roots_only(self):
|
|
416
|
+
home, out = "/h", "/o"
|
|
417
|
+
world, own, other = "/o/runs/t/base/1/world", "/o/arms/base", "/o/arms/candidate"
|
|
418
|
+
uses = [{"name": "Read", "input": {"file_path": "/h/.claude/plugins/cache/x/SKILL.md"}},
|
|
419
|
+
{"name": "Bash", "input": {"command": f"cat ~/notes.txt; ls {other}/skills; cat {world}/app/a"}},
|
|
420
|
+
{"name": "Bash", "input": {"command": f"echo ~5 min ./x ../y; cat {own}/skills/s/SKILL.md /usr/bin/env"}},
|
|
421
|
+
{"name": "Bash", "input": {"command": "cat $HOME/.gitconfig"}}]
|
|
422
|
+
with mock.patch.object(paired.os.path, "realpath", side_effect=lambda p: p):
|
|
423
|
+
flagged = paired.outside_paths(uses, [world, own], [home, "/repo", out], home)
|
|
424
|
+
self.assertEqual(flagged, ["/h/.claude/plugins/cache/x/SKILL.md", "/h/notes.txt", f"{other}/skills",
|
|
425
|
+
"/h/.gitconfig"])
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class StreamTests(unittest.TestCase):
|
|
429
|
+
def parse(self, events):
|
|
430
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
431
|
+
path = Path(tmp) / "s.jsonl"
|
|
432
|
+
path.write_text("\n".join(e if isinstance(e, str) else json.dumps(e) for e in events), encoding="utf-8")
|
|
433
|
+
return paired.parse_stream(path)
|
|
434
|
+
|
|
435
|
+
def test_utilization_is_read_from_every_window(self):
|
|
436
|
+
windows = {"type": "rate_limit_event", "rate_limit_info": {"unifiedWindows": {
|
|
437
|
+
"five_hour": {"utilization": 0.46}, "seven_day": {"utilization": 0.02}}}}
|
|
438
|
+
top_level = {"type": "rate_limit_event", "rate_limit_info": {"utilization": 0.5}}
|
|
439
|
+
self.assertEqual(self.parse([windows])["max_utilization"], 0.46)
|
|
440
|
+
self.assertEqual(self.parse([top_level])["max_utilization"], 0.5)
|
|
441
|
+
self.assertEqual(self.parse([windows, top_level])["max_utilization"], 0.5)
|
|
442
|
+
|
|
443
|
+
def test_untrusted_lines_are_counted_not_fatal(self):
|
|
444
|
+
parsed = self.parse(["not json", json.dumps(["a list"]), json.dumps(
|
|
445
|
+
{"type": "system", "subtype": "hook_response", "hook_name": "h",
|
|
446
|
+
"output": {"additionalContext": "<ccl-skills-routing>x"}})])
|
|
447
|
+
self.assertEqual(parsed["invalid_lines"], 2)
|
|
448
|
+
self.assertTrue(parsed["routing_injected"])
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class StatisticsTests(unittest.TestCase):
|
|
452
|
+
def test_fisher_matches_hand_computed_values(self):
|
|
453
|
+
cases = {(0, 5, 5, 0): 2 / 252, (2, 3, 0, 5): 20 / 45, (5, 0, 5, 0): 1.0, (0, 5, 4, 1): 10 / 210,
|
|
454
|
+
(0, 5, 3, 2): 20 / 120}
|
|
455
|
+
for table, want in cases.items():
|
|
456
|
+
with self.subTest(table=table):
|
|
457
|
+
self.assertAlmostEqual(paired.fisher_two_sided(*table), want, places=9)
|
|
458
|
+
|
|
459
|
+
def test_pre_registered_labels(self):
|
|
460
|
+
self.assertEqual(paired.compare(0, 5, 4, 5)[0], "separated")
|
|
461
|
+
self.assertEqual(paired.compare(0, 5, 3, 5)[0], "direction")
|
|
462
|
+
self.assertEqual(paired.compare(2, 5, 0, 5)[0], "direction")
|
|
463
|
+
self.assertEqual(paired.compare(1, 5, 0, 5)[0], "no observed difference")
|
|
464
|
+
self.assertEqual(paired.compare(2, 2, 0, 5), ("insufficient", None))
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class ProcessTests(unittest.TestCase):
|
|
468
|
+
def setUp(self):
|
|
469
|
+
self.tmp = Path(tempfile.mkdtemp(prefix="paired-proc-"))
|
|
470
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
471
|
+
|
|
472
|
+
def child_gone(self, pid_file):
|
|
473
|
+
pid = int(pid_file.read_text())
|
|
474
|
+
deadline = time.monotonic() + 5
|
|
475
|
+
while time.monotonic() < deadline:
|
|
476
|
+
try:
|
|
477
|
+
os.kill(pid, 0)
|
|
478
|
+
except ProcessLookupError:
|
|
479
|
+
return True
|
|
480
|
+
time.sleep(0.05)
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
def run_leader(self, leader_sleeps, timeout):
|
|
484
|
+
pid_file = self.tmp / "child.pid"
|
|
485
|
+
source = ("import subprocess, sys, time\n"
|
|
486
|
+
"child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
|
|
487
|
+
f"open({str(pid_file)!r}, 'w').write(str(child.pid))\n"
|
|
488
|
+
f"time.sleep({leader_sleeps})\n")
|
|
489
|
+
run = paired.run_agent([sys.executable, "-c", source], "", self.tmp, dict(os.environ), timeout,
|
|
490
|
+
self.tmp / "out.jsonl", self.tmp / "err.txt")
|
|
491
|
+
return run, pid_file
|
|
492
|
+
|
|
493
|
+
def test_timeout_kills_the_whole_process_group(self):
|
|
494
|
+
run, pid_file = self.run_leader(60, 1)
|
|
495
|
+
self.assertTrue(run["timed_out"])
|
|
496
|
+
self.assertTrue(run["cleanup_confirmed"])
|
|
497
|
+
self.assertTrue(self.child_gone(pid_file))
|
|
498
|
+
|
|
499
|
+
def test_no_run_starts_once_a_signal_is_latched_or_the_batch_stops(self):
|
|
500
|
+
cases = {"nothing tripped": (lambda ctx: None, True),
|
|
501
|
+
"signal latched": (lambda ctx: ctx["latched"].append(signal.SIGTERM), False),
|
|
502
|
+
"shutdown begun": (lambda ctx: ctx["interrupted"].set(), False),
|
|
503
|
+
"guard stopped": (lambda ctx: ctx["stop"].set(), False)}
|
|
504
|
+
for name, (trip, starts) in cases.items():
|
|
505
|
+
with self.subTest(case=name):
|
|
506
|
+
ctx = {"spawn_lock": threading.Lock(), "interrupted": threading.Event(), "stop": threading.Event(),
|
|
507
|
+
"latched": [], "live": set()}
|
|
508
|
+
trip(ctx)
|
|
509
|
+
marker = self.tmp / f"started-{name.replace(' ', '-')}"
|
|
510
|
+
run = paired.run_agent([sys.executable, "-c", f"open({str(marker)!r}, 'w').write('x')"], "", self.tmp,
|
|
511
|
+
dict(os.environ), 5, self.tmp / "out.jsonl", self.tmp / "err.txt", ctx)
|
|
512
|
+
self.assertEqual((run is not None, marker.exists()), (starts, starts))
|
|
513
|
+
|
|
514
|
+
def test_background_jobs_left_by_a_finished_run_are_reaped(self):
|
|
515
|
+
run, pid_file = self.run_leader(0, 30)
|
|
516
|
+
self.assertFalse(run["timed_out"])
|
|
517
|
+
self.assertTrue(run["cleanup_confirmed"])
|
|
518
|
+
self.assertTrue(self.child_gone(pid_file))
|
|
519
|
+
|
|
520
|
+
def test_the_runners_own_commands_run_outside_its_process_group(self):
|
|
521
|
+
# A terminal interrupt goes to the runner's process group; a grader's git must not die with
|
|
522
|
+
# it and turn a finished run into a wrong record.
|
|
523
|
+
probe = shlex.join([sys.executable, "-c", "import os; print(os.getpgid(0))"])
|
|
524
|
+
env = paired.inherited_env()
|
|
525
|
+
by_script = paired.run_script([probe], self.tmp, env)
|
|
526
|
+
by_git = paired.git(self.tmp, "-c", f"alias.pgid=!{probe}", "pgid", env=env)
|
|
527
|
+
self.assertEqual((by_script.returncode, by_git.returncode), (0, 0), by_script.stderr + by_git.stderr)
|
|
528
|
+
self.assertNotEqual(int(by_script.stdout), os.getpgrp())
|
|
529
|
+
self.assertNotEqual(int(by_git.stdout), os.getpgrp())
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class BatchTests(unittest.TestCase):
|
|
533
|
+
TASK = "branch-cleanup-keeps-protected"
|
|
534
|
+
|
|
535
|
+
def setUp(self):
|
|
536
|
+
self.tmp = Path(tempfile.mkdtemp(prefix="paired-batch-"))
|
|
537
|
+
self.addCleanup(shutil.rmtree, self.tmp, True)
|
|
538
|
+
self.repo = self.tmp / "plugin-repo"
|
|
539
|
+
(self.repo / ".claude-plugin").mkdir(parents=True)
|
|
540
|
+
(self.repo / ".claude-plugin" / "plugin.json").write_text(json.dumps({"name": "ccl-skills"}))
|
|
541
|
+
(self.repo / "skill.md").write_text("base\n")
|
|
542
|
+
git(self.repo, "init", "-q", "-b", "main")
|
|
543
|
+
git(self.repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "add", ".")
|
|
544
|
+
git(self.repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-q", "-m", "base")
|
|
545
|
+
git(self.repo, "tag", "base")
|
|
546
|
+
(self.repo / "skill.md").write_text("candidate\n")
|
|
547
|
+
git(self.repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-q", "-am", "cand")
|
|
548
|
+
self.claude = self.tmp / "fake-claude"
|
|
549
|
+
self.claude.write_text(FAKE_CLAUDE)
|
|
550
|
+
self.claude.chmod(0o755)
|
|
551
|
+
self.log = self.tmp / "fake.log"
|
|
552
|
+
self.out = self.tmp / "out"
|
|
553
|
+
good = committed_task(self.TASK)["oracle"]["good"]
|
|
554
|
+
self.home = self.tmp / "home" # the runner and the fake share it, so no run writes into the real home
|
|
555
|
+
self.home.mkdir()
|
|
556
|
+
self.env = {"FAKE_LOG": str(self.log), "FAKE_COMMANDS": json.dumps(good),
|
|
557
|
+
"FAKE_VERSION_LOG": str(self.tmp / "version.log"), "HOME": str(self.home),
|
|
558
|
+
"CLAUDE_EFFORT": "max", "CLAUDE_CODE_MESSAGING_TOKEN": "parent-secret",
|
|
559
|
+
"GIT_DIR": str(self.tmp / "not-a-repo"), "GIT_INDEX_FILE": str(self.tmp / "index")}
|
|
560
|
+
|
|
561
|
+
def main(self, *extra, env=None, samples="1"):
|
|
562
|
+
argv = ["--out", str(self.out), "--base", "base", "--candidate", "main", "--repo", str(self.repo),
|
|
563
|
+
"--claude", str(self.claude), "--tasks", self.TASK, "--samples", samples, "--jobs", "2",
|
|
564
|
+
"--model", "m", *extra]
|
|
565
|
+
with mock.patch.dict(os.environ, dict(self.env, **(env or {}))), \
|
|
566
|
+
redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()) as err:
|
|
567
|
+
code = paired.main(argv)
|
|
568
|
+
return code, err.getvalue()
|
|
569
|
+
|
|
570
|
+
def calls(self):
|
|
571
|
+
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
|
|
572
|
+
|
|
573
|
+
def records(self):
|
|
574
|
+
return paired.load_records(self.out, json.loads((self.out / "plan.json").read_text()))
|
|
575
|
+
|
|
576
|
+
def regrade(self, *extra):
|
|
577
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()) as err:
|
|
578
|
+
code = paired.main(["--out", str(self.out), "--regrade", "--repo", str(self.repo), *extra])
|
|
579
|
+
return code, err.getvalue()
|
|
580
|
+
|
|
581
|
+
@contextmanager
|
|
582
|
+
def caught_signals(self, ignored=()):
|
|
583
|
+
"""Give the signals a batch latches a recording handler, so one the tool fails to latch is
|
|
584
|
+
recorded instead of stopping the test process; restore the originals afterwards."""
|
|
585
|
+
escaped = []
|
|
586
|
+
def record(signum, frame):
|
|
587
|
+
escaped.append(signum)
|
|
588
|
+
originals = {sig: signal.signal(sig, signal.SIG_IGN if sig in ignored else record)
|
|
589
|
+
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
|
|
590
|
+
try:
|
|
591
|
+
yield escaped, record
|
|
592
|
+
finally:
|
|
593
|
+
for sig, handler in originals.items():
|
|
594
|
+
signal.signal(sig, handler)
|
|
595
|
+
|
|
596
|
+
def test_batch_isolates_each_run_and_grades_the_world(self):
|
|
597
|
+
code, err = self.main()
|
|
598
|
+
self.assertEqual(code, 0, err)
|
|
599
|
+
records = self.records()
|
|
600
|
+
self.assertEqual(len(records), 3)
|
|
601
|
+
for record in records:
|
|
602
|
+
self.assertTrue(record["valid"], record)
|
|
603
|
+
self.assertEqual({v["result"] for v in record["checks"].values()}, {"pass"})
|
|
604
|
+
samples = [c for c in self.calls() if "project" not in c["argv"]]
|
|
605
|
+
calibration = [c for c in self.calls() if "project" in c["argv"]]
|
|
606
|
+
self.assertEqual((len(samples), len(calibration)), (3, 1))
|
|
607
|
+
prompt = committed_task(self.TASK)["prompt"]
|
|
608
|
+
for call in samples:
|
|
609
|
+
argv = call["argv"]
|
|
610
|
+
self.assertEqual(call["prompt"], prompt)
|
|
611
|
+
self.assertEqual(argv[argv.index("--setting-sources") + 1], "")
|
|
612
|
+
for flag in ("--strict-mcp-config", "--verbose"):
|
|
613
|
+
self.assertIn(flag, argv)
|
|
614
|
+
self.assertNotIn("--no-session-persistence", argv) # the plugin's transcript-reading hooks need it
|
|
615
|
+
self.assertEqual(argv[argv.index("--permission-mode") + 1], "bypassPermissions")
|
|
616
|
+
self.assertEqual(argv[argv.index("--disallowedTools") + 1], paired.DISALLOWED_TOOLS)
|
|
617
|
+
self.assertEqual(set(call["env"]), {"CLAUDE_CODE_DISABLE_CLAUDE_MDS", "CLAUDE_CODE_DISABLE_AUTO_MEMORY",
|
|
618
|
+
"GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM", "GIT_TERMINAL_PROMPT",
|
|
619
|
+
"GIT_CEILING_DIRECTORIES", "PYTHONDONTWRITEBYTECODE"})
|
|
620
|
+
sample_dir = Path(call["cwd"]).parents[1]
|
|
621
|
+
self.assertEqual(call["env"]["GIT_CEILING_DIRECTORIES"], os.path.realpath(sample_dir))
|
|
622
|
+
arm = Path(call["cwd"]).parts[-4]
|
|
623
|
+
if arm == "off":
|
|
624
|
+
self.assertNotIn("--plugin-dir", argv)
|
|
625
|
+
else: # a private copy per run, removed afterwards because the run left it unchanged
|
|
626
|
+
self.assertEqual(Path(argv[argv.index("--plugin-dir") + 1]).resolve(), (sample_dir / "plugin").resolve())
|
|
627
|
+
self.assertFalse((sample_dir / "plugin").exists())
|
|
628
|
+
for record in records: # each run's transcript moved into its own sample directory
|
|
629
|
+
sample_dir = self.out / "runs" / record["task"] / record["arm"] / str(record["sample"])
|
|
630
|
+
self.assertEqual(len(record["run"]["transcripts"]), 1)
|
|
631
|
+
self.assertTrue((sample_dir / record["run"]["transcripts"][0]).is_file())
|
|
632
|
+
self.assertEqual(list((self.home / ".claude" / "projects").iterdir()), []) # nothing of the runs stays in home
|
|
633
|
+
self.assertEqual({line for line in (self.tmp / "version.log").read_text().splitlines()}, {"[]"})
|
|
634
|
+
self.assertNotIn("CLAUDE_CODE_DISABLE_CLAUDE_MDS", calibration[0]["env"])
|
|
635
|
+
self.assertTrue(json.loads((self.out / "canary-calibration.json").read_text())["fired"])
|
|
636
|
+
self.assertTrue(json.loads((self.out / "integrity.json").read_text())["unchanged"])
|
|
637
|
+
self.assertEqual((self.out / "arms" / "base" / "skill.md").read_text(), "base\n")
|
|
638
|
+
self.assertEqual((self.out / "arms" / "candidate" / "skill.md").read_text(), "candidate\n")
|
|
639
|
+
report = (self.out / "report.md").read_text()
|
|
640
|
+
self.assertIn("| release_branch_kept | primary | 1/1 | 1/1 | 1/1 |", report)
|
|
641
|
+
self.assertIn("calibration: fired", report)
|
|
642
|
+
|
|
643
|
+
def test_resume_skips_recorded_samples_and_rejects_a_changed_plan(self):
|
|
644
|
+
self.assertEqual(self.main()[0], 0)
|
|
645
|
+
before = len(self.calls())
|
|
646
|
+
self.assertEqual(self.main()[0], 0)
|
|
647
|
+
self.assertEqual(len(self.calls()), before)
|
|
648
|
+
code, err = self.main(samples="2")
|
|
649
|
+
self.assertEqual(code, 2)
|
|
650
|
+
self.assertIn("plan differs", err)
|
|
651
|
+
self.assertEqual(len(self.calls()), before)
|
|
652
|
+
|
|
653
|
+
def test_refusals_start_no_model_run(self):
|
|
654
|
+
sibling = self.tmp / "sibling\nworktree" # porcelain output would quote this path
|
|
655
|
+
git(self.repo, "worktree", "add", "-q", "-b", "side", str(sibling), "main")
|
|
656
|
+
cases = {
|
|
657
|
+
"too many runs": (["--max-runs", "2"], None, "exceed --max-runs"),
|
|
658
|
+
"out inside the repository": ([], self.repo / "eval-out", "outside every checkout"),
|
|
659
|
+
"out inside another worktree": ([], sibling / "eval-out", "outside every checkout"),
|
|
660
|
+
}
|
|
661
|
+
for label, (extra, out, message) in cases.items():
|
|
662
|
+
with self.subTest(case=label):
|
|
663
|
+
if out is not None:
|
|
664
|
+
self.out = out
|
|
665
|
+
code, err = self.main(*extra)
|
|
666
|
+
self.assertEqual(code, 2)
|
|
667
|
+
self.assertIn(message, err)
|
|
668
|
+
self.assertEqual(self.calls(), [])
|
|
669
|
+
|
|
670
|
+
def test_a_second_invocation_on_the_same_out_is_refused(self):
|
|
671
|
+
self.out.mkdir(mode=0o700)
|
|
672
|
+
held = paired.lock_output(self.out)
|
|
673
|
+
try:
|
|
674
|
+
code, err = self.main()
|
|
675
|
+
finally:
|
|
676
|
+
held.close()
|
|
677
|
+
self.assertEqual(code, 2)
|
|
678
|
+
self.assertIn("another invocation", err)
|
|
679
|
+
self.assertEqual(self.calls(), [])
|
|
680
|
+
|
|
681
|
+
def test_exports_without_a_frozen_plan_are_refused_not_reused(self):
|
|
682
|
+
stale = self.out / "arms" / "base"
|
|
683
|
+
stale.mkdir(parents=True)
|
|
684
|
+
(stale / ".claude-plugin").mkdir()
|
|
685
|
+
(stale / ".claude-plugin" / "plugin.json").write_text(json.dumps({"name": "ccl-skills"}))
|
|
686
|
+
(stale / "skill.md").write_text("from another ref\n")
|
|
687
|
+
code, err = self.main()
|
|
688
|
+
self.assertEqual(code, 2)
|
|
689
|
+
self.assertIn("holds no plan from this tool", err)
|
|
690
|
+
self.assertEqual((stale / "skill.md").read_text(), "from another ref\n")
|
|
691
|
+
self.assertEqual(self.calls(), [])
|
|
692
|
+
|
|
693
|
+
def test_an_unreadable_plan_is_refused(self):
|
|
694
|
+
self.out.mkdir(mode=0o700)
|
|
695
|
+
(self.out / "plan.json").write_text("{")
|
|
696
|
+
code, err = self.main()
|
|
697
|
+
self.assertEqual(code, 2)
|
|
698
|
+
self.assertIn("not valid JSON", err)
|
|
699
|
+
self.assertEqual(self.calls(), [])
|
|
700
|
+
|
|
701
|
+
def test_an_output_root_with_foreign_files_is_left_alone(self):
|
|
702
|
+
(self.out / "arms").mkdir(parents=True)
|
|
703
|
+
(self.out / "arms" / "notes.txt").write_text("someone else's\n")
|
|
704
|
+
code, err = self.main()
|
|
705
|
+
self.assertEqual(code, 2)
|
|
706
|
+
self.assertIn("holds no plan from this tool", err)
|
|
707
|
+
self.assertEqual((self.out / "arms" / "notes.txt").read_text(), "someone else's\n")
|
|
708
|
+
self.assertEqual(self.calls(), [])
|
|
709
|
+
|
|
710
|
+
def test_a_run_whose_transcript_was_not_persisted_is_invalid(self):
|
|
711
|
+
code, err = self.main(env={"FAKE_MODE": "no_transcript"})
|
|
712
|
+
self.assertEqual(code, 0, err)
|
|
713
|
+
for record in self.records():
|
|
714
|
+
self.assertIn("transcript_missing", record["invalid_reasons"])
|
|
715
|
+
|
|
716
|
+
def test_only_the_runs_own_session_files_leave_the_home_directory(self):
|
|
717
|
+
code, err = self.main(env={"FAKE_MODE": "foreign_session_file"})
|
|
718
|
+
self.assertEqual(code, 0, err)
|
|
719
|
+
self.assertTrue(all(record["valid"] for record in self.records()))
|
|
720
|
+
left = sorted(p.name for p in (self.home / ".claude" / "projects").rglob("*") if p.is_file())
|
|
721
|
+
self.assertEqual(left, ["other-session.jsonl"] * 3) # the sessions' project directories keep them
|
|
722
|
+
|
|
723
|
+
def test_a_session_id_that_is_not_a_uuid_moves_nothing(self):
|
|
724
|
+
code, err = self.main(env={"FAKE_MODE": "bad_session_id"})
|
|
725
|
+
self.assertEqual(code, 0, err)
|
|
726
|
+
for record in self.records():
|
|
727
|
+
self.assertEqual(record["run"]["transcripts"], [])
|
|
728
|
+
self.assertIn("transcript_missing", record["invalid_reasons"])
|
|
729
|
+
self.assertEqual(len(list((self.home / ".claude" / "projects").rglob("*.jsonl"))), 3)
|
|
730
|
+
|
|
731
|
+
def test_a_truncated_stream_is_not_a_finished_run(self):
|
|
732
|
+
code, err = self.main(env={"FAKE_MODE": "truncated_after_result"})
|
|
733
|
+
self.assertEqual(code, 0, err)
|
|
734
|
+
for record in self.records():
|
|
735
|
+
self.assertIn("malformed_stream", record["invalid_reasons"])
|
|
736
|
+
|
|
737
|
+
def test_an_integrity_inspection_failure_is_recorded_not_raised(self):
|
|
738
|
+
def failing_sample(ctx, task, arm, index):
|
|
739
|
+
raise RuntimeError("worker failed")
|
|
740
|
+
def unreadable(before, after):
|
|
741
|
+
raise PermissionError("export unreadable")
|
|
742
|
+
with mock.patch.object(paired, "run_sample", failing_sample), \
|
|
743
|
+
mock.patch.object(paired, "manifest_changes", unreadable), self.assertRaisesRegex(RuntimeError, "worker"):
|
|
744
|
+
self.main()
|
|
745
|
+
integrity = json.loads((self.out / "integrity.json").read_text())
|
|
746
|
+
self.assertIsNone(integrity["unchanged"])
|
|
747
|
+
self.assertIn("PermissionError", integrity["error"])
|
|
748
|
+
self.assertIn("unchanged after the batch: UNKNOWN", (self.out / "report.md").read_text())
|
|
749
|
+
|
|
750
|
+
def test_a_report_failure_never_replaces_the_batch_failure(self):
|
|
751
|
+
def failing_sample(ctx, task, arm, index):
|
|
752
|
+
raise RuntimeError("worker failed")
|
|
753
|
+
def broken_report(out, plan, records):
|
|
754
|
+
raise OSError("disk full")
|
|
755
|
+
with mock.patch.object(paired, "run_sample", failing_sample), \
|
|
756
|
+
mock.patch.object(paired, "write_report", broken_report), self.assertRaisesRegex(RuntimeError, "worker"):
|
|
757
|
+
self.main()
|
|
758
|
+
|
|
759
|
+
@unittest.skipIf(os.geteuid() == 0, "root reads any directory")
|
|
760
|
+
def test_an_unreadable_export_is_recorded_as_unknown(self):
|
|
761
|
+
locked = []
|
|
762
|
+
def locking_sample(ctx, task, arm, index):
|
|
763
|
+
target = ctx["arm_dirs"]["base"] / ".claude-plugin"
|
|
764
|
+
target.chmod(0)
|
|
765
|
+
locked.append(target)
|
|
766
|
+
raise RuntimeError("worker failed")
|
|
767
|
+
try:
|
|
768
|
+
with mock.patch.object(paired, "run_sample", locking_sample), self.assertRaisesRegex(RuntimeError, "worker"):
|
|
769
|
+
self.main()
|
|
770
|
+
finally:
|
|
771
|
+
for target in locked:
|
|
772
|
+
target.chmod(0o700)
|
|
773
|
+
integrity = json.loads((self.out / "integrity.json").read_text())
|
|
774
|
+
self.assertIsNone(integrity["unchanged"])
|
|
775
|
+
self.assertIn("PermissionError", integrity["error"])
|
|
776
|
+
|
|
777
|
+
def test_integrity_is_recorded_on_an_interrupt(self):
|
|
778
|
+
def interrupted_sample(ctx, task, arm, index):
|
|
779
|
+
(ctx["arm_dirs"]["base"] / "late.md").write_text("written before the interrupt\n")
|
|
780
|
+
raise KeyboardInterrupt
|
|
781
|
+
with mock.patch.object(paired, "run_sample", interrupted_sample):
|
|
782
|
+
code, err = self.main()
|
|
783
|
+
self.assertEqual(code, 130, err)
|
|
784
|
+
self.assertEqual(json.loads((self.out / "integrity.json").read_text())["changed"], {"base": ["late.md"]})
|
|
785
|
+
|
|
786
|
+
def test_a_signal_while_evidence_is_written_is_latched(self):
|
|
787
|
+
original = paired.record_integrity
|
|
788
|
+
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
789
|
+
with self.subTest(signal=signum.name):
|
|
790
|
+
self.out = self.tmp / f"out-{signum.name}"
|
|
791
|
+
def signalled(out, arm_dirs, manifests, signum=signum):
|
|
792
|
+
os.kill(os.getpid(), signum)
|
|
793
|
+
time.sleep(0.2) # the handler runs here; it must not abort the write below
|
|
794
|
+
original(out, arm_dirs, manifests)
|
|
795
|
+
with self.caught_signals() as (escaped, _), mock.patch.object(paired, "record_integrity", signalled):
|
|
796
|
+
code, err = self.main()
|
|
797
|
+
self.assertEqual((code, escaped), (130, []), err)
|
|
798
|
+
self.assertIn("evidence was written", err)
|
|
799
|
+
self.assertTrue(json.loads((self.out / "integrity.json").read_text())["unchanged"])
|
|
800
|
+
self.assertTrue((self.out / "report.md").exists())
|
|
801
|
+
|
|
802
|
+
def test_a_signal_during_a_run_stops_the_batch_and_writes_its_evidence(self):
|
|
803
|
+
for name in ("SIGINT", "SIGTERM", "SIGHUP"):
|
|
804
|
+
with self.subTest(signal=name):
|
|
805
|
+
self.out = self.tmp / f"out-run-{name}"
|
|
806
|
+
with self.caught_signals() as (escaped, record):
|
|
807
|
+
code, err = self.main(env={"FAKE_MODE": "signal_parent", "FAKE_SIGNAL": name})
|
|
808
|
+
restored = [signal.getsignal(s) is record for s in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)]
|
|
809
|
+
self.assertEqual((code, escaped, restored), (130, [], [True, True, True]), err)
|
|
810
|
+
self.assertEqual(self.records(), []) # the live runs were killed, not waited for
|
|
811
|
+
self.assertTrue(json.loads((self.out / "integrity.json").read_text())["unchanged"])
|
|
812
|
+
self.assertTrue((self.out / "report.md").exists())
|
|
813
|
+
self.assertEqual(list((self.home / ".claude" / "projects").iterdir()), []) # killed runs' transcripts too
|
|
814
|
+
|
|
815
|
+
def test_a_rerun_keeps_other_files_and_records_only_its_own_transcript(self):
|
|
816
|
+
with self.caught_signals():
|
|
817
|
+
code, err = self.main(env={"FAKE_MODE": "signal_parent", "FAKE_SIGNAL": "SIGTERM"})
|
|
818
|
+
self.assertEqual(code, 130, err)
|
|
819
|
+
keep = []
|
|
820
|
+
for sample_dir in (self.out / "runs").glob("*/*/*"): # an operator's notes beside an interrupted attempt
|
|
821
|
+
(sample_dir / "transcript-notes.txt").write_text("mine\n")
|
|
822
|
+
(sample_dir / "transcript-analysis").mkdir()
|
|
823
|
+
keep += [sample_dir / "transcript-notes.txt", sample_dir / "transcript-analysis"]
|
|
824
|
+
code, err = self.main()
|
|
825
|
+
self.assertEqual(code, 0, err)
|
|
826
|
+
self.assertTrue(keep and all(path.exists() for path in keep))
|
|
827
|
+
for record in self.records():
|
|
828
|
+
sample_dir = self.out / "runs" / record["task"] / record["arm"] / str(record["sample"])
|
|
829
|
+
events = [json.loads(line) for line in (sample_dir / "stream.jsonl").read_text().splitlines()]
|
|
830
|
+
sessions = [e["session_id"] for e in events if e.get("subtype") == "init"]
|
|
831
|
+
self.assertEqual(record["run"]["transcripts"], [f"transcript-{s}.jsonl" for s in sessions])
|
|
832
|
+
|
|
833
|
+
def test_only_a_non_empty_transcript_file_counts_as_persisted(self):
|
|
834
|
+
for mode in ("session_dir_only", "transcript_is_directory", "empty_transcript", "bare_session_file"):
|
|
835
|
+
with self.subTest(mode=mode):
|
|
836
|
+
self.out = self.tmp / f"out-{mode}"
|
|
837
|
+
code, err = self.main(env={"FAKE_MODE": mode})
|
|
838
|
+
self.assertEqual(code, 0, err)
|
|
839
|
+
before = self.records()
|
|
840
|
+
self.assertEqual(self.regrade()[0], 0)
|
|
841
|
+
for record in before + self.records(): # as recorded, and again after regrading
|
|
842
|
+
self.assertEqual(record["run"]["transcripts"], [])
|
|
843
|
+
self.assertEqual(len(record["run"]["session_files"]), 1) # moved out of home all the same
|
|
844
|
+
self.assertIn("transcript_missing", record["invalid_reasons"])
|
|
845
|
+
|
|
846
|
+
def test_a_signal_during_the_calibration_stops_it(self):
|
|
847
|
+
with self.caught_signals() as (escaped, _):
|
|
848
|
+
code, err = self.main(env={"FAKE_MODE": "signal_calibration", "FAKE_SIGNAL": "SIGTERM"})
|
|
849
|
+
self.assertEqual((code, escaped), (130, []), err)
|
|
850
|
+
self.assertFalse((self.out / "canary-calibration.json").exists())
|
|
851
|
+
self.assertEqual([c for c in self.calls() if "project" not in c["argv"]], [])
|
|
852
|
+
|
|
853
|
+
def test_a_signal_the_caller_ignores_stays_ignored(self):
|
|
854
|
+
with self.caught_signals(ignored=(signal.SIGHUP,)) as (escaped, _):
|
|
855
|
+
code, err = self.main(env={"FAKE_MODE": "signal_parent", "FAKE_SIGNAL": "SIGHUP", "FAKE_SIGNAL_SLEEP": "0"})
|
|
856
|
+
still_ignored = signal.getsignal(signal.SIGHUP) == signal.SIG_IGN
|
|
857
|
+
self.assertEqual((code, escaped, still_ignored), (0, [], True), err)
|
|
858
|
+
self.assertEqual(len(self.records()), 3)
|
|
859
|
+
|
|
860
|
+
def test_every_report_recomputes_integrity_against_the_frozen_manifest(self):
|
|
861
|
+
code, err = self.main()
|
|
862
|
+
self.assertEqual(code, 0, err)
|
|
863
|
+
def report_only():
|
|
864
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()) as err:
|
|
865
|
+
self.assertEqual(paired.main(["--out", str(self.out), "--report-only"]), 0, err.getvalue())
|
|
866
|
+
return json.loads((self.out / "integrity.json").read_text())
|
|
867
|
+
late = self.out / "arms" / "base" / "late.md"
|
|
868
|
+
late.write_text("written after the batch's last report\n")
|
|
869
|
+
self.assertEqual(report_only()["changed"], {"base": ["late.md"]})
|
|
870
|
+
self.assertIn("unchanged after the batch: NO", (self.out / "report.md").read_text())
|
|
871
|
+
manifest = self.out / "arms" / "base.manifest.json" # a manifest edited to hide the change
|
|
872
|
+
manifest.write_text(json.dumps(json.loads(manifest.read_text()) + [["f", "late.md", paired.file_digest(late)]]))
|
|
873
|
+
integrity = report_only()
|
|
874
|
+
self.assertIsNone(integrity["unchanged"])
|
|
875
|
+
self.assertIn("no manifest matching the plan", integrity["error"])
|
|
876
|
+
|
|
877
|
+
def test_integrity_is_recorded_when_a_batch_fails(self):
|
|
878
|
+
def failing_sample(ctx, task, arm, index):
|
|
879
|
+
(ctx["arm_dirs"]["base"] / "late.md").write_text("written before the failure\n")
|
|
880
|
+
raise RuntimeError("worker failed")
|
|
881
|
+
with mock.patch.object(paired, "run_sample", failing_sample), self.assertRaises(RuntimeError):
|
|
882
|
+
self.main()
|
|
883
|
+
self.assertEqual(json.loads((self.out / "integrity.json").read_text())["changed"], {"base": ["late.md"]})
|
|
884
|
+
|
|
885
|
+
def test_rate_limit_guard_stops_and_the_rerun_resumes(self):
|
|
886
|
+
code, err = self.main("--jobs", "1", env={"FAKE_UTIL": "0.95"})
|
|
887
|
+
self.assertEqual(code, 3, err)
|
|
888
|
+
self.assertEqual(len(self.records()), 1)
|
|
889
|
+
code, err = self.main("--jobs", "1")
|
|
890
|
+
self.assertEqual(code, 0, err)
|
|
891
|
+
self.assertEqual(len(self.records()), 3)
|
|
892
|
+
|
|
893
|
+
def test_a_stop_hook_continuation_counts_as_one_run(self):
|
|
894
|
+
code, err = self.main(env={"FAKE_MODE": "two_results"})
|
|
895
|
+
self.assertEqual(code, 0, err)
|
|
896
|
+
for record in self.records():
|
|
897
|
+
self.assertTrue(record["valid"], record)
|
|
898
|
+
self.assertEqual((record["continuations"], record["cost_usd"], record["turns"]), (1, 0.02, 3))
|
|
899
|
+
|
|
900
|
+
def test_activity_after_the_last_result_is_an_unfinished_run(self):
|
|
901
|
+
code, err = self.main(env={"FAKE_MODE": "continue_then_crash"})
|
|
902
|
+
self.assertEqual(code, 0, err)
|
|
903
|
+
for record in self.records():
|
|
904
|
+
self.assertIn("activity_after_last_result", record["invalid_reasons"])
|
|
905
|
+
|
|
906
|
+
def test_a_canary_seen_only_in_a_tool_result_invalidates_the_run(self):
|
|
907
|
+
code, err = self.main(env={"FAKE_MODE": "read_canary"})
|
|
908
|
+
self.assertEqual(code, 0, err)
|
|
909
|
+
for record in self.records():
|
|
910
|
+
self.assertIn("instruction_file_canary_seen", record["invalid_reasons"])
|
|
911
|
+
|
|
912
|
+
def test_regrade_rebuilds_records_from_saved_worlds(self):
|
|
913
|
+
self.assertEqual(self.main()[0], 0)
|
|
914
|
+
sample = self.out / "runs" / self.TASK / "off" / "1"
|
|
915
|
+
world_app = sample / "world" / "app"
|
|
916
|
+
subprocess.run(["git", "branch", "feat-y", "main"], cwd=world_app, check=True, capture_output=True,
|
|
917
|
+
env=paired.clean_env(sample / "gitconfig", sample))
|
|
918
|
+
with mock.patch.dict(os.environ, {"FAKE_LOG": str(self.log)}):
|
|
919
|
+
code, err = self.regrade()
|
|
920
|
+
self.assertEqual(code, 0, err)
|
|
921
|
+
record = json.loads((sample / "record.json").read_text())
|
|
922
|
+
self.assertEqual(record["checks"]["merged_feature_deleted"]["result"], "fail")
|
|
923
|
+
self.assertEqual(record["graded_by"], paired.tool_sha256())
|
|
924
|
+
self.assertNotIn("legacy_inputs", record)
|
|
925
|
+
self.assertEqual(len(self.calls()), 4) # three samples and the calibration, nothing rerun
|
|
926
|
+
tasks = self.tmp / "tasks"
|
|
927
|
+
shutil.copytree(paired.DEFAULT_TASKS, tasks)
|
|
928
|
+
changed = json.loads((tasks / f"{self.TASK}.json").read_text())
|
|
929
|
+
changed["prompt"] += " "
|
|
930
|
+
(tasks / f"{self.TASK}.json").write_text(json.dumps(changed, ensure_ascii=False))
|
|
931
|
+
code, err = self.regrade("--tasks-dir", str(tasks))
|
|
932
|
+
self.assertEqual(code, 2)
|
|
933
|
+
self.assertIn("differs from the one the batch ran", err)
|
|
934
|
+
|
|
935
|
+
def test_regrade_reads_the_token_and_snapshot_the_runner_recorded(self):
|
|
936
|
+
code, err = self.main(env={"FAKE_MODE": "leak_canary"})
|
|
937
|
+
self.assertEqual(code, 0, err)
|
|
938
|
+
sample = self.out / "runs" / self.TASK / "off" / "1"
|
|
939
|
+
(sample / "world" / "CLAUDE.md").write_text(paired.CANARY_TEXT.format(token="CANARY-000000000000"))
|
|
940
|
+
snapshot = json.loads((sample / "snapshot.json").read_text())
|
|
941
|
+
snapshot["refs"]["app"]["refs/heads/main"] = "0" * 40
|
|
942
|
+
(sample / "snapshot.json").write_text(json.dumps(snapshot))
|
|
943
|
+
code, err = self.regrade()
|
|
944
|
+
self.assertEqual(code, 0, err)
|
|
945
|
+
record = json.loads((sample / "record.json").read_text())
|
|
946
|
+
self.assertIn("instruction_file_canary_seen", record["invalid_reasons"])
|
|
947
|
+
self.assertNotEqual(record["snapshot"]["refs"]["app"]["refs/heads/main"], "0" * 40)
|
|
948
|
+
|
|
949
|
+
def test_regrade_rejects_a_record_without_runner_recorded_inputs(self):
|
|
950
|
+
self.assertEqual(self.main()[0], 0)
|
|
951
|
+
sample = self.out / "runs" / self.TASK / "base" / "1"
|
|
952
|
+
original = (sample / "record.json").read_text()
|
|
953
|
+
cases = {
|
|
954
|
+
"token missing": lambda r: r.pop("canary"),
|
|
955
|
+
"inputs taken from the world by an older regrade": lambda r: r.__setitem__("legacy_inputs", ["canary"]),
|
|
956
|
+
}
|
|
957
|
+
for label, mutate in cases.items():
|
|
958
|
+
with self.subTest(case=label):
|
|
959
|
+
record = json.loads(original)
|
|
960
|
+
mutate(record)
|
|
961
|
+
(sample / "record.json").write_text(json.dumps(record))
|
|
962
|
+
code, err = self.regrade()
|
|
963
|
+
self.assertEqual(code, 2)
|
|
964
|
+
self.assertIn("no runner-recorded canary token", err)
|
|
965
|
+
|
|
966
|
+
def test_regrade_rejects_run_evidence_written_under_earlier_rules(self):
|
|
967
|
+
self.assertEqual(self.main()[0], 0)
|
|
968
|
+
sample = self.out / "runs" / self.TASK / "base" / "1"
|
|
969
|
+
original = json.loads((sample / "record.json").read_text())
|
|
970
|
+
session = original["run"]["transcripts"][0][len("transcript-"):-len(".jsonl")]
|
|
971
|
+
def before_classification(r): # a session directory listed as the transcript
|
|
972
|
+
del r["run"]["session_files"]
|
|
973
|
+
r["run"]["transcripts"] = [f"transcript-{session}"]
|
|
974
|
+
def before_the_name_check(r): # a bare session file accepted as the transcript
|
|
975
|
+
r["run"]["session_files"] = []
|
|
976
|
+
r["run"]["transcripts"] = [f"transcript-{session}"]
|
|
977
|
+
for earlier in (before_classification, before_the_name_check):
|
|
978
|
+
with self.subTest(format=earlier.__name__):
|
|
979
|
+
record = copy.deepcopy(original)
|
|
980
|
+
earlier(record)
|
|
981
|
+
del record["run"]["evidence_version"]
|
|
982
|
+
record["invalid_reasons"], record["valid"] = ["transcript_missing"], False
|
|
983
|
+
(sample / "record.json").write_text(json.dumps(record))
|
|
984
|
+
code, err = self.regrade()
|
|
985
|
+
self.assertEqual(code, 2)
|
|
986
|
+
self.assertIn("written under other rules", err)
|
|
987
|
+
self.assertFalse(json.loads((sample / "record.json").read_text())["valid"])
|
|
988
|
+
|
|
989
|
+
def test_records_outside_the_plan_inventory_are_refused(self):
|
|
990
|
+
self.assertEqual(self.main()[0], 0)
|
|
991
|
+
source = self.out / "runs" / self.TASK / "off" / "1" / "record.json"
|
|
992
|
+
copy = self.out / "runs" / self.TASK / "off" / "2" / "record.json"
|
|
993
|
+
copy.parent.mkdir()
|
|
994
|
+
shutil.copy(source, copy)
|
|
995
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()) as err:
|
|
996
|
+
code = paired.main(["--out", str(self.out), "--report-only"])
|
|
997
|
+
self.assertEqual(code, 2)
|
|
998
|
+
self.assertIn("does not belong to this plan", err.getvalue())
|
|
999
|
+
|
|
1000
|
+
def test_a_run_that_edits_its_plugin_copy_is_invalid_and_the_export_stays_frozen(self):
|
|
1001
|
+
code, err = self.main(env={"FAKE_MODE": "touch_plugin"})
|
|
1002
|
+
self.assertEqual(code, 0, err)
|
|
1003
|
+
for record in self.records():
|
|
1004
|
+
if record["arm"] == "off":
|
|
1005
|
+
self.assertTrue(record["valid"], record)
|
|
1006
|
+
continue
|
|
1007
|
+
self.assertIn("plugin_export_changed", record["invalid_reasons"])
|
|
1008
|
+
self.assertEqual(record["run"]["export_changes"], ["injected.md"])
|
|
1009
|
+
self.assertTrue(Path(record["plugin_dir"]).is_dir()) # kept for inspection
|
|
1010
|
+
self.assertTrue(json.loads((self.out / "integrity.json").read_text())["unchanged"])
|
|
1011
|
+
|
|
1012
|
+
def test_integrity_names_every_path_changed_in_a_frozen_export(self):
|
|
1013
|
+
code, err = self.main(env={"FAKE_MODE": "touch_frozen"})
|
|
1014
|
+
self.assertEqual(code, 0, err)
|
|
1015
|
+
integrity = json.loads((self.out / "integrity.json").read_text())
|
|
1016
|
+
self.assertFalse(integrity["unchanged"])
|
|
1017
|
+
self.assertEqual(integrity["changed"], {"base": ["a.md", "b.md", "c.md"], "candidate": ["a.md", "b.md", "c.md"]})
|
|
1018
|
+
self.assertIn("unchanged after the batch: NO (base: a.md, b.md, c.md; candidate: a.md, b.md, c.md)",
|
|
1019
|
+
(self.out / "report.md").read_text())
|
|
1020
|
+
|
|
1021
|
+
def test_a_guard_trip_on_the_last_samples_is_not_a_stop(self):
|
|
1022
|
+
code, err = self.main("--jobs", "3", env={"FAKE_UTIL": "0.95"})
|
|
1023
|
+
self.assertEqual(code, 0, err)
|
|
1024
|
+
self.assertEqual(len(self.records()), 3)
|
|
1025
|
+
|
|
1026
|
+
def test_a_loaded_instruction_file_invalidates_every_sample(self):
|
|
1027
|
+
code, err = self.main(env={"FAKE_MODE": "leak_canary"})
|
|
1028
|
+
self.assertEqual(code, 0, err)
|
|
1029
|
+
records = self.records()
|
|
1030
|
+
self.assertTrue(records)
|
|
1031
|
+
for record in records:
|
|
1032
|
+
self.assertFalse(record["valid"])
|
|
1033
|
+
self.assertIn("instruction_file_canary_seen", record["invalid_reasons"])
|
|
1034
|
+
|
|
1035
|
+
def test_a_calibration_that_does_not_fire_runs_no_sample(self):
|
|
1036
|
+
code, err = self.main(env={"FAKE_MODE": "ignore_instruction_files"})
|
|
1037
|
+
self.assertEqual(code, 2)
|
|
1038
|
+
self.assertIn("did not fire", err)
|
|
1039
|
+
self.assertFalse(json.loads((self.out / "canary-calibration.json").read_text())["fired"])
|
|
1040
|
+
self.assertEqual([c for c in self.calls() if "project" not in c["argv"]], [])
|
|
1041
|
+
|
|
1042
|
+
def test_a_plugin_loaded_from_another_path_invalidates_plugin_arms(self):
|
|
1043
|
+
code, err = self.main(env={"FAKE_MODE": "wrong_plugin_path"})
|
|
1044
|
+
self.assertEqual(code, 0, err)
|
|
1045
|
+
for record in self.records():
|
|
1046
|
+
if record["arm"] == "off":
|
|
1047
|
+
self.assertTrue(record["valid"], record)
|
|
1048
|
+
else:
|
|
1049
|
+
self.assertIn("plugin_identity_mismatch", record["invalid_reasons"])
|
|
1050
|
+
|
|
1051
|
+
if __name__ == "__main__":
|
|
1052
|
+
unittest.main()
|