@jesdi/skills 0.0.30 → 0.0.31
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/README.md +12 -6
- package/package.json +2 -2
- package/skills/implement-spec/ARCHITECTURE.md +113 -0
- package/skills/implement-spec/README.md +214 -0
- package/skills/implement-spec/SKILL.md +18 -70
- package/skills/implement-spec/driver.py +580 -0
- package/skills/implement-spec/driver_agents.py +145 -0
- package/skills/implement-spec/driver_answers.py +79 -0
- package/skills/implement-spec/driver_decisions.py +152 -0
- package/skills/implement-spec/driver_final.py +505 -0
- package/skills/implement-spec/driver_fixloop.py +170 -0
- package/skills/implement-spec/driver_gate.py +47 -0
- package/skills/implement-spec/driver_graph.py +42 -0
- package/skills/implement-spec/driver_merge.py +105 -0
- package/skills/implement-spec/driver_report.py +115 -0
- package/skills/implement-spec/driver_review.py +126 -0
- package/skills/implement-spec/driver_review_page.py +51 -0
- package/skills/implement-spec/driver_run.py +262 -0
- package/skills/implement-spec/driver_slots.py +64 -0
- package/skills/implement-spec/driver_state.py +105 -0
- package/skills/implement-spec/driver_summary.py +40 -0
- package/skills/implement-spec/driver_tickets.py +132 -0
- package/skills/implement-spec/driver_tidy.py +57 -0
- package/skills/implement-spec/models.json +10 -0
- package/skills/implement-spec/prompts/conflict.md +32 -0
- package/skills/implement-spec/prompts/correction.md +4 -0
- package/skills/implement-spec/prompts/correctness.md +34 -0
- package/skills/implement-spec/prompts/final-fixer.md +36 -0
- package/skills/implement-spec/prompts/final-reviewer.md +36 -0
- package/skills/implement-spec/prompts/final-scoped.md +1 -0
- package/skills/implement-spec/prompts/fixer.md +35 -0
- package/skills/implement-spec/prompts/implementer.md +33 -0
- package/skills/implement-spec/prompts/interrupted.md +1 -0
- package/skills/implement-spec/prompts/moved.md +2 -0
- package/skills/implement-spec/prompts/overruled.md +5 -0
- package/skills/implement-spec/prompts/pr-body.md +19 -0
- package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
- package/skills/implement-spec/prompts/rereview.md +3 -0
- package/skills/implement-spec/prompts/rerun.md +2 -0
- package/skills/implement-spec/prompts/reviewer.md +34 -0
- package/skills/implement-spec/prompts/stands.md +2 -0
- package/skills/implement-spec/prompts/test-writer.md +34 -0
- package/skills/implement-spec/prompts/timeout-restored.md +2 -0
- package/skills/implement-spec/prompts/timeout-shared.md +2 -0
- package/skills/implement-spec/prompts/timeout.md +2 -0
- package/skills/implement-spec/run.sh +101 -0
- package/skills/implement-spec/tests/conftest.py +38 -0
- package/skills/implement-spec/tests/test_agents.py +358 -0
- package/skills/implement-spec/tests/test_amendments.py +117 -0
- package/skills/implement-spec/tests/test_cli.py +220 -0
- package/skills/implement-spec/tests/test_decisions.py +434 -0
- package/skills/implement-spec/tests/test_driver.py +767 -0
- package/skills/implement-spec/tests/test_final.py +442 -0
- package/skills/implement-spec/tests/test_fixloop.py +523 -0
- package/skills/implement-spec/tests/test_graph.py +32 -0
- package/skills/implement-spec/tests/test_landing.py +355 -0
- package/skills/implement-spec/tests/test_merge.py +144 -0
- package/skills/implement-spec/tests/test_models.py +47 -0
- package/skills/implement-spec/tests/test_report.py +162 -0
- package/skills/implement-spec/tests/test_review.py +338 -0
- package/skills/implement-spec/tests/test_review_page.py +67 -0
- package/skills/implement-spec/tests/test_slots.py +32 -0
- package/skills/implement-spec/tests/test_state.py +68 -0
- package/skills/implement-spec/tests/test_stops.py +272 -0
- package/skills/implement-spec/tests/test_summary.py +28 -0
- package/skills/implement-spec/tests/test_tickets.py +72 -0
- package/skills/implement-spec/tests/test_tidy.py +582 -0
- package/skills/implement-spec/tests/test_walk.py +457 -0
- package/skills/review-page/template.html +1 -1
- package/skills/to-tickets/SKILL.md +19 -2
- package/skills-manifest.json +7 -7
- package/skills/implement-spec/history.md +0 -52
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Report validation acceptance tests, through driver_report.validate only.
|
|
2
|
+
|
|
3
|
+
context is a dict: repo (slot path), branch, start (HEAD sha when the role started),
|
|
4
|
+
log (the agent's log file), round (fix-loop round, default 0).
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from driver_report import validate
|
|
12
|
+
|
|
13
|
+
GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"]
|
|
14
|
+
RED = "FAILED tests/test_export.py::test_rows - AttributeError"
|
|
15
|
+
MUTATION = "FAILED tests/test_export.py::test_rows after breaking demo/export.py:2"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def git(root, *args):
|
|
19
|
+
return subprocess.run([*GIT, *args], cwd=root, check=True, capture_output=True, text=True).stdout.strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def commit(root, path, text):
|
|
23
|
+
(root / path).parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
(root / path).write_text(text)
|
|
25
|
+
git(root, "add", "-A")
|
|
26
|
+
git(root, "commit", "-q", "-m", f"edit {path}")
|
|
27
|
+
return git(root, "rev-parse", "HEAD")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def world(tmp_path):
|
|
32
|
+
"""main: base. ticket/01: base -> tests (test-writer) -> impl. other: base -> stray."""
|
|
33
|
+
root = tmp_path / "slot"
|
|
34
|
+
root.mkdir()
|
|
35
|
+
git(root, "init", "-q", "-b", "main")
|
|
36
|
+
base = commit(root, "demo/export.py", "def export_csv():\n return ''\n")
|
|
37
|
+
git(root, "checkout", "-q", "-b", "other")
|
|
38
|
+
stray = commit(root, "stray.txt", "x\n")
|
|
39
|
+
git(root, "checkout", "-q", "-b", "ticket/01", "main")
|
|
40
|
+
tests = commit(root, "tests/test_export.py", "def test_rows():\n assert False\n")
|
|
41
|
+
impl = commit(root, "demo/export.py", "def export_csv():\n return 'rows'\n")
|
|
42
|
+
(tmp_path / "agent.log").write_text(f"collected 1 item\n{RED}\n{MUTATION}\n")
|
|
43
|
+
shas = dict(base=base, stray=stray, tests=tests, impl=impl)
|
|
44
|
+
|
|
45
|
+
def check(role, report, **context):
|
|
46
|
+
path = tmp_path / f"{role}-0.json"
|
|
47
|
+
path.write_text(report if isinstance(report, str) else json.dumps(report))
|
|
48
|
+
ctx = {"repo": str(root), "branch": "ticket/01", "log": str(tmp_path / "agent.log"), "round": 0}
|
|
49
|
+
return validate(role, str(path), {**ctx, **context})
|
|
50
|
+
return shas, check, tmp_path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def common(**changes):
|
|
54
|
+
return {"status": "DONE", "commits": [], "files": [], "concerns": [], **changes}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def writer(shas, **changes):
|
|
58
|
+
return common(**{"commits": [shas["tests"]], "files": ["tests/test_export.py"], "locked": ["tests/test_export.py"],
|
|
59
|
+
"red": [{"test": "tests/test_export.py::test_rows", "line": RED}], **changes})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def implementer(shas, **changes):
|
|
63
|
+
return common(**{"commits": [shas["impl"]], "files": ["demo/export.py"], "green": ["1 passed"], **changes})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def finding(**changes):
|
|
67
|
+
return {"severity": "minor", "file": "demo/export.py", "line": 2, "title": "t", "evidence": "e", "kind": "code",
|
|
68
|
+
**changes}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def reviewer(**changes):
|
|
72
|
+
return common(**{"findings": [finding()], "mutations": [
|
|
73
|
+
{"test": "tests/test_export.py::test_rows", "file": "demo/export.py", "line": 2, "red": MUTATION}], **changes})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_valid_reports_pass_and_unknown_status_is_named(world):
|
|
77
|
+
shas, check, _ = world
|
|
78
|
+
assert check("test-writer", writer(shas), start=shas["base"]) == []
|
|
79
|
+
assert check("implementer", implementer(shas), start=shas["tests"]) == []
|
|
80
|
+
assert check("reviewer", reviewer(), start=shas["impl"]) == []
|
|
81
|
+
errors = check("implementer", implementer(shas, status="FINISHED"), start=shas["tests"])
|
|
82
|
+
assert errors and any("status" in e and "FINISHED" in e for e in errors)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("field", ["status", "commits", "files", "concerns"])
|
|
86
|
+
def test_missing_common_field_is_named(world, field):
|
|
87
|
+
shas, check, _ = world
|
|
88
|
+
assert check("implementer", implementer(shas), start=shas["tests"]) == []
|
|
89
|
+
report = implementer(shas)
|
|
90
|
+
del report[field]
|
|
91
|
+
assert any(field in e for e in check("implementer", report, start=shas["tests"]))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("role, field", [("test-writer", "locked"), ("test-writer", "red"), ("implementer", "green")])
|
|
95
|
+
def test_missing_role_field_is_named(world, role, field):
|
|
96
|
+
shas, check, _ = world
|
|
97
|
+
report = writer(shas) if role == "test-writer" else implementer(shas)
|
|
98
|
+
start = shas["base"] if role == "test-writer" else shas["tests"]
|
|
99
|
+
assert check(role, report, start=start) == []
|
|
100
|
+
del report[field]
|
|
101
|
+
assert any(field in e for e in check(role, report, start=start))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize("path", ["missing", "empty", "not json"])
|
|
105
|
+
def test_unusable_report_is_an_error_not_a_crash(world, path):
|
|
106
|
+
shas, check, tmp = world
|
|
107
|
+
assert check("implementer", implementer(shas), start=shas["tests"]) == []
|
|
108
|
+
if path == "missing":
|
|
109
|
+
assert validate("implementer", str(tmp / "nope.json"), {"repo": str(tmp / "slot"), "branch": "ticket/01",
|
|
110
|
+
"start": shas["tests"], "log": str(tmp / "agent.log")})
|
|
111
|
+
return
|
|
112
|
+
assert check("implementer", "" if path == "empty" else "{oops", start=shas["tests"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_commit_off_the_ticket_branch_or_unknown_is_a_violation(world):
|
|
116
|
+
shas, check, _ = world
|
|
117
|
+
assert check("implementer", implementer(shas), start=shas["tests"]) == []
|
|
118
|
+
for sha in (shas["stray"], "0123456789abcdef0123456789abcdef01234567"):
|
|
119
|
+
errors = check("implementer", implementer(shas, commits=[sha]), start=shas["tests"])
|
|
120
|
+
assert any(sha[:7] in e for e in errors), errors
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_commit_predating_the_role_start_is_a_violation(world):
|
|
124
|
+
shas, check, _ = world
|
|
125
|
+
assert check("implementer", implementer(shas), start=shas["tests"]) == []
|
|
126
|
+
errors = check("implementer", implementer(shas, commits=[shas["tests"], shas["impl"]]), start=shas["tests"])
|
|
127
|
+
assert any(shas["tests"][:7] in e for e in errors), errors
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_implementer_without_a_new_commit_is_a_violation(world):
|
|
131
|
+
shas, check, _ = world
|
|
132
|
+
assert check("implementer", implementer(shas), start=shas["tests"]) == []
|
|
133
|
+
assert any("commit" in e for e in check("implementer", implementer(shas, commits=[]), start=shas["tests"]))
|
|
134
|
+
assert check("implementer", implementer(shas), start=shas["impl"]) # its only commit is not new
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_locked_path_absent_from_the_commit_is_a_violation(world):
|
|
138
|
+
shas, check, _ = world
|
|
139
|
+
assert check("test-writer", writer(shas), start=shas["base"]) == []
|
|
140
|
+
errors = check("test-writer", writer(shas, locked=["tests/test_export.py", "tests/test_other.py"]),
|
|
141
|
+
start=shas["base"])
|
|
142
|
+
assert any("tests/test_other.py" in e for e in errors), errors
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_red_line_absent_from_the_log_is_a_violation(world):
|
|
146
|
+
shas, check, _ = world
|
|
147
|
+
assert check("test-writer", writer(shas), start=shas["base"]) == []
|
|
148
|
+
report = writer(shas, red=[{"test": "t", "line": "FAILED tests/test_export.py::test_rows - KeyError"}])
|
|
149
|
+
assert "red line not found in log" in check("test-writer", report, start=shas["base"])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_reviewer_fields_and_mutation_proofs(world):
|
|
153
|
+
shas, check, _ = world
|
|
154
|
+
assert check("reviewer", reviewer(), start=shas["impl"], round=1) == []
|
|
155
|
+
assert any("severity" in e for e in check("reviewer", reviewer(findings=[finding(severity="huge")]),
|
|
156
|
+
start=shas["impl"]))
|
|
157
|
+
assert any("kind" in e for e in check("reviewer", reviewer(findings=[finding(kind="style")]), start=shas["impl"]))
|
|
158
|
+
no_proof = reviewer()
|
|
159
|
+
del no_proof["mutations"]
|
|
160
|
+
assert any("mutations" in e for e in check("reviewer", no_proof, start=shas["impl"], round=1))
|
|
161
|
+
fake = reviewer(mutations=[{"test": "t", "file": "demo/export.py", "line": 2, "red": "never ran"}])
|
|
162
|
+
assert any("not found in log" in e for e in check("reviewer", fake, start=shas["impl"], round=1))
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Ticket 04 acceptance tests: review, correctness pass, fix loop, fixture corrections, conflict agent.
|
|
2
|
+
|
|
3
|
+
All through `sh run.sh --runner <script>` on the fixture repository (the rig of test_walk). The fake runner also plays
|
|
4
|
+
reviewer, correctness, fixer, conflict and a second test-writer call; a role it has no script for reports nothing wrong.
|
|
5
|
+
|
|
6
|
+
Interface choices (ticket 04):
|
|
7
|
+
- the runner replaces the CLI, so a model entry is observed in the ledger: the line of a call names its role and entry.
|
|
8
|
+
- a fix round runs the role `fixer` (report `fixer-<round>.json`) on the implementer's model list: entry 0 in rounds
|
|
9
|
+
1-2, entry 1 in rounds 3-4. `state.json` `round` counts fix rounds only. The review that follows round K writes
|
|
10
|
+
`reviewer-K.json` (the first review `reviewer-0.json`); a correctness pass writes `correctness-K.json`.
|
|
11
|
+
- `mutations[].test` is `<locked file>::test_x`; a re-review prompt names each mutation proof to repeat as
|
|
12
|
+
`<file>:<line>`, with the line number it has at the new tip.
|
|
13
|
+
- an installed skill without a sibling `review-diff/correctness.md` makes the driver exit 3 before any agent, and the
|
|
14
|
+
message names `review-diff/correctness.md`.
|
|
15
|
+
- a second conflict on one merge: the PR branch moved again while the conflict agent worked.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from test_cli import SKILL, ticket, three, git, models_file
|
|
25
|
+
from test_walk import RUNNER, Rig, impl, plan, tw
|
|
26
|
+
|
|
27
|
+
# The fake runner of test_walk plus two hooks: `git` runs before the step's files, `git_after` right after them.
|
|
28
|
+
HOOKED = RUNNER.replace(
|
|
29
|
+
'for path, text in step.get("files", {}).items():',
|
|
30
|
+
'for cmd in step.get("git", []):\n git(*cmd)\nfor path, text in step.get("files", {}).items():', 1).replace(
|
|
31
|
+
'sys.stdout.write(step.get("stdout", ""))',
|
|
32
|
+
'for cmd in step.get("git_after", []):\n git(*cmd)\nsys.stdout.write(step.get("stdout", ""))', 1)
|
|
33
|
+
assert HOOKED.count("git_after") == 1 and HOOKED.count('step.get("git", [])') == 1
|
|
34
|
+
|
|
35
|
+
ONE = {"01-export-rows.md": ticket("01", "Export rows")}
|
|
36
|
+
RISKY = {"02-quote-fields.md": ticket("02", "Quote fields", risk="persistence")}
|
|
37
|
+
OPUS, FABLE, SOL = "claude-opus-5@medium", "claude-fable-5-1@high", "openai/gpt-sol@medium"
|
|
38
|
+
LONG = "".join(f"v{i} = {i}\n" for i in range(1, 41))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Review(Rig):
|
|
42
|
+
"""The rig of test_walk with the hooked runner and per-test model lists."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, tmp, files=ONE, models=None, **kw):
|
|
45
|
+
super().__init__(tmp, files, **kw)
|
|
46
|
+
(self.rig / "run-agent").write_text(HOOKED)
|
|
47
|
+
self.models = models_file(tmp, **(models or {}))
|
|
48
|
+
|
|
49
|
+
def led(self, *words):
|
|
50
|
+
return [l for l in self.ledger() if all(w in l for w in words)]
|
|
51
|
+
|
|
52
|
+
def tip(self):
|
|
53
|
+
return self.walked() # the tip the walk made: the end of run tidies it away (ticket 08)
|
|
54
|
+
|
|
55
|
+
def folder(self, nn="01"):
|
|
56
|
+
return self.repo / ".agent/implement" / nn
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def finding(title, severity="important", kind="code", file="demo/export.py", line=99):
|
|
60
|
+
return dict(severity=severity, file=file, line=line, title=title, evidence=title, kind=kind)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def red(nn, line):
|
|
64
|
+
return f"FAILED tests/test_{nn}.py::test_x - AssertionError at demo/export.py:{line}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def review(nn="01", findings=(), lines=(1,), **extra):
|
|
68
|
+
"""A reviewer step: findings plus a mutation proof per production line, its red line printed to the log."""
|
|
69
|
+
proofs = [dict(test=f"tests/test_{nn}.py::test_x", file="demo/export.py", line=n, red=red(nn, n)) for n in lines]
|
|
70
|
+
report = dict(status="DONE", commits=[], files=[], concerns=[], findings=list(findings), mutations=proofs)
|
|
71
|
+
return {"stdout": "".join(p["red"] + "\n" for p in proofs), "report": report, **extra}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def correct(findings=(), **extra):
|
|
75
|
+
report = dict(status="DONE", commits=[], files=[], concerns=[], findings=list(findings))
|
|
76
|
+
return {"report": report, **extra}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fix(nn, n, text=None, **changes):
|
|
80
|
+
"""A fixer step: one commit on the ticket branch."""
|
|
81
|
+
step = impl(nn, text=text or f"def fn_{nn}():\n return 'fix{n}'\n", commit=f"fix: {nn} round {n}", **changes)
|
|
82
|
+
step["report"]["ticket"] = nn
|
|
83
|
+
return step
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def edit(text, **new):
|
|
87
|
+
"""`text` with line N replaced (keyword `l<N>`)."""
|
|
88
|
+
lines = text.splitlines(keepends=True)
|
|
89
|
+
for key, value in new.items():
|
|
90
|
+
lines[int(key[1:]) - 1] = value + "\n"
|
|
91
|
+
return "".join(lines)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------- locked-file check
|
|
95
|
+
|
|
96
|
+
def test_a_changed_locked_file_is_a_critical_finding_that_starts_a_fix_round(tmp_path):
|
|
97
|
+
rig = Review(tmp_path)
|
|
98
|
+
locked = tw("01")["files"]
|
|
99
|
+
cheat = impl("01", files={"demo/export.py": "def fn_01():\n return '01'\n", "tests/test_01.py": "# edited\n"})
|
|
100
|
+
steps = plan(**{"01/implementer": [cheat], "01/fixer": [fix("01", 1, files={**locked, "demo/export.py": "x = 1\n"})]})
|
|
101
|
+
rig.run(steps)
|
|
102
|
+
assert rig.code == 0, rig.output()
|
|
103
|
+
(line,) = rig.led("locked file changed: tests/test_01.py")
|
|
104
|
+
assert "critical" in line.lower()
|
|
105
|
+
assert len(rig.calls("01", "fixer")) == 1 and rig.state()["tickets"]["01"]["round"] == 1
|
|
106
|
+
assert git(rig.repo, "show", "feat/csv:tests/test_01.py") == locked["tests/test_01.py"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------- correctness pass
|
|
110
|
+
|
|
111
|
+
@pytest.fixture(scope="module")
|
|
112
|
+
def pair(tmp_path_factory):
|
|
113
|
+
"""Ticket 02 (Risk: persistence), codex on PATH: reviewer and correctness each wait for the other to start."""
|
|
114
|
+
rig = Review(tmp_path_factory.mktemp("pair"), RISKY, {"correctness": [SOL, OPUS], "implementer": [OPUS]})
|
|
115
|
+
steps = plan(**{
|
|
116
|
+
"02/reviewer": [review("02", [finding("R-MINOR", "minor")], touch=["r"], wait=["c"])],
|
|
117
|
+
"02/correctness": [correct([finding("C-IMPORTANT")], touch=["c"], wait=["r"])],
|
|
118
|
+
"02/fixer": [fix("02", 1)]})
|
|
119
|
+
return rig.run(steps)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_reviewer_and_correctness_run_at_the_same_time_on_their_models(pair):
|
|
123
|
+
assert pair.code == 0, pair.output()
|
|
124
|
+
(r,), (c,) = pair.calls("02", "reviewer")[:1], pair.calls("02", "correctness")[:1]
|
|
125
|
+
assert r["t0"] < c["t1"] and c["t0"] < r["t1"], "each started before the other ended"
|
|
126
|
+
assert pair.led("reviewer", OPUS) and pair.led("correctness", "gpt-sol@medium")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_findings_of_both_reports_are_merged_with_their_severities(pair):
|
|
130
|
+
assert len(pair.calls("02", "fixer")) == 1, "the important finding of the correctness pass starts a round"
|
|
131
|
+
assert pair.led("R-MINOR", "minor") and pair.led("C-IMPORTANT", "important")
|
|
132
|
+
assert pair.state()["tickets"]["02"]["phase"] == "merged"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Ticket 08: the pass is never folded into the reviewer. A correctness entry with no CLI on PATH stops the run at the
|
|
136
|
+
# start: test_amendments.py test_a_correctness_entry_whose_cli_is_not_on_path_stops_the_run_before_any_agent.
|
|
137
|
+
|
|
138
|
+
def test_an_installed_skill_without_review_diff_fails_clearly_before_any_agent(tmp_path):
|
|
139
|
+
rig = Review(tmp_path, RISKY)
|
|
140
|
+
copy = tmp_path / "installed/skills/implement-spec"
|
|
141
|
+
shutil.copytree(SKILL, copy, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache", "test_*"))
|
|
142
|
+
(rig.rig / "plan.json").write_text(json.dumps(plan()))
|
|
143
|
+
args = ["--tickets", ".agent/tickets", "--gate", str(rig.rig / "gate"), "--spec", "specs/csv/spec.md",
|
|
144
|
+
"--models", str(rig.models), "--runner", str(rig.rig / "run-agent")]
|
|
145
|
+
proc = subprocess.run(["sh", str(copy / "run.sh"), *args], cwd=rig.repo, env=rig.env, text=True,
|
|
146
|
+
capture_output=True, timeout=120)
|
|
147
|
+
assert proc.returncode == 3, proc.stdout + proc.stderr
|
|
148
|
+
assert "review-diff/correctness.md" in proc.stdout + proc.stderr
|
|
149
|
+
assert not rig.calls() and not (rig.repo / ".agent/implement/summary.md").exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.fixture(scope="module")
|
|
153
|
+
def plain(tmp_path_factory):
|
|
154
|
+
"""A ticket without a Risk line whose reviewer reports two minor findings."""
|
|
155
|
+
rig = Review(tmp_path_factory.mktemp("plain"), ONE, {"correctness": [SOL, OPUS]})
|
|
156
|
+
return rig.run(plan(**{"01/reviewer": [review("01", [finding("M-ONE", "minor"), finding("M-TWO", "minor")])]}))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_a_ticket_without_a_risk_line_runs_no_correctness_pass(plain):
|
|
160
|
+
assert plain.code == 0, plain.output()
|
|
161
|
+
assert len(plain.calls("01", "reviewer")) == 1 and not plain.calls("01", "correctness")
|
|
162
|
+
assert not plain.led("folded")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_a_review_with_only_minor_findings_passes_and_writes_them_to_the_ledger(plain):
|
|
166
|
+
assert not plain.calls("01", "fixer") and plain.state()["tickets"]["01"]["phase"] == "merged"
|
|
167
|
+
assert plain.state()["tickets"]["01"]["round"] == 0
|
|
168
|
+
assert plain.led("M-ONE", "minor") and plain.led("M-TWO", "minor")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------- fix loop
|
|
172
|
+
|
|
173
|
+
STUCK = finding("STUCK")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.fixture(scope="module")
|
|
177
|
+
def escalated(tmp_path_factory):
|
|
178
|
+
"""One important finding survives the re-reviews after rounds 1 and 2; round 3 is clean."""
|
|
179
|
+
rig = Review(tmp_path_factory.mktemp("esc"), ONE, {"implementer": [OPUS, FABLE]})
|
|
180
|
+
bad = review("01", [STUCK])
|
|
181
|
+
return rig.run(plan(**{"01/reviewer": [bad, bad, bad, review("01")],
|
|
182
|
+
"01/fixer": [fix("01", n) for n in (1, 2, 3)]}))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_round_3_starts_a_fresh_implementer_on_the_next_model(escalated):
|
|
186
|
+
assert escalated.code == 0, escalated.output()
|
|
187
|
+
assert escalated.led("01 round 3: escalated to claude-fable-5-1@high")
|
|
188
|
+
assert not escalated.led("round 1: escalated") and not escalated.led("round 2: escalated")
|
|
189
|
+
assert len(escalated.led("fixer", OPUS)) == 2 and len(escalated.led("fixer", FABLE)) == 1
|
|
190
|
+
assert escalated.state()["tickets"]["01"]["round"] == 3
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_each_round_ends_with_one_gate_record_and_a_re_review_scoped_to_the_fix(escalated):
|
|
194
|
+
tips = [escalated.sha("01", "implementer")] + [c["sha"] for c in escalated.calls("01", "fixer")]
|
|
195
|
+
commits = [g["commit"] for g in escalated.gates()]
|
|
196
|
+
assert len(tips) == 4 and all(commits.count(t) == 1 for t in tips), commits
|
|
197
|
+
assert all((escalated.folder() / f"gate-{t[:7]}.log").exists() for t in tips)
|
|
198
|
+
reviews = escalated.calls("01", "reviewer")
|
|
199
|
+
assert len(reviews) == 4
|
|
200
|
+
for k in (1, 2, 3):
|
|
201
|
+
assert tips[k - 1][:7] in reviews[k]["prompt"] and tips[k][:7] in reviews[k]["prompt"], k
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_reports_are_named_by_role_and_round(escalated):
|
|
205
|
+
for name in ("reviewer-0", "reviewer-1", "reviewer-2", "reviewer-3", "fixer-1", "fixer-2", "fixer-3"):
|
|
206
|
+
assert (escalated.folder() / f"{name}.json").exists(), name
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.fixture(scope="module")
|
|
210
|
+
def capped(tmp_path_factory):
|
|
211
|
+
"""The three-ticket default; 01's reviewer keeps the same finding through round 4."""
|
|
212
|
+
rig = Review(tmp_path_factory.mktemp("cap"), three(), {"implementer": [OPUS, FABLE]})
|
|
213
|
+
return rig.run(plan(**{"01/reviewer": [review("01", [STUCK])] * 5,
|
|
214
|
+
"01/fixer": [fix("01", n) for n in (1, 2, 3, 4)]}))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_a_finding_open_after_round_4_blocks_the_ticket_and_the_run_goes_on(capped):
|
|
218
|
+
assert capped.code == 1, capped.output()
|
|
219
|
+
assert capped.led("01", "BLOCKED", "STUCK")
|
|
220
|
+
assert len(capped.calls("01", "fixer")) == 4 and len(capped.calls("01", "reviewer")) == 5
|
|
221
|
+
assert not capped.calls("02"), "02 is blocked by 01"
|
|
222
|
+
assert capped.state()["tickets"]["01"]["phase"] == "blocked"
|
|
223
|
+
assert capped.state()["tickets"]["03"]["phase"] == "merged" and "feat: 03 rows" in capped.pr_subjects()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------- fixture correction
|
|
227
|
+
|
|
228
|
+
def test_a_fixture_finding_runs_one_test_writer_correction_outside_the_rounds(tmp_path):
|
|
229
|
+
rig = Review(tmp_path)
|
|
230
|
+
new = "def test_x():\n assert '01' == 'x'\n"
|
|
231
|
+
correction = {**tw("01"), "files": {"tests/test_01.py": new}, "commit": "test: 01 fixture fix"}
|
|
232
|
+
stale = finding("STALEMARK stale path", file="tests/test_01.py", kind="fixture", line=1)
|
|
233
|
+
rig.run(plan(**{"01/test-writer": [tw("01"), correction], "01/reviewer": [review("01", [stale])]}))
|
|
234
|
+
assert rig.code == 0, rig.output()
|
|
235
|
+
assert len(rig.calls("01", "test-writer")) == 2 and not rig.calls("01", "fixer")
|
|
236
|
+
assert rig.led("tests/test_01.py", "STALEMARK")
|
|
237
|
+
assert rig.state()["tickets"]["01"]["round"] == 0 and rig.state()["tickets"]["01"]["phase"] == "merged"
|
|
238
|
+
assert git(rig.repo, "show", "feat/csv:tests/test_01.py") == new
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------- mutation proofs
|
|
242
|
+
|
|
243
|
+
def scoped(tmp, lines, fixes, last_lines):
|
|
244
|
+
"""01's implementer writes 40 lines; the reviewer proves `lines`; each fix replaces the file."""
|
|
245
|
+
rig = Review(tmp)
|
|
246
|
+
bad = review("01", [STUCK], lines=lines)
|
|
247
|
+
reviews = [bad] * len(fixes) + [review("01", lines=last_lines)]
|
|
248
|
+
rig.run(plan(**{"01/implementer": [impl("01", text=LONG)], "01/reviewer": reviews,
|
|
249
|
+
"01/fixer": [fix("01", n, text=t) for n, t in enumerate(fixes, 1)]}))
|
|
250
|
+
return rig
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_a_re_review_asks_the_mutation_proof_only_for_the_lines_the_fix_changed(tmp_path):
|
|
254
|
+
first = edit(LONG, l12="v12 = 'a'", l30="v30 = 'a'")
|
|
255
|
+
rig = scoped(tmp_path, (12, 30), [first, edit(first, l30="v30 = 'b'")], (30,))
|
|
256
|
+
assert rig.code == 0, rig.output()
|
|
257
|
+
after1, after2 = (c["prompt"] for c in rig.calls("01", "reviewer")[1:])
|
|
258
|
+
assert "demo/export.py:12" in after1 and "demo/export.py:30" in after1
|
|
259
|
+
assert "demo/export.py:30" in after2 and "demo/export.py:12" not in after2
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_a_mutation_proof_follows_its_production_line_when_a_fix_moves_it(tmp_path):
|
|
263
|
+
lines = LONG.splitlines(keepends=True)
|
|
264
|
+
moved = "".join(lines[:19] + ["i1 = 1\n", "i2 = 2\n"] + lines[19:29] + ["v30 = 'changed'\n"] + lines[30:])
|
|
265
|
+
rig = scoped(tmp_path, (12, 30, 35), [moved], (32,))
|
|
266
|
+
assert rig.code == 0, rig.output()
|
|
267
|
+
prompt = rig.calls("01", "reviewer")[1]["prompt"]
|
|
268
|
+
assert "demo/export.py:32" in prompt, "line 30 is line 32 after two inserted lines"
|
|
269
|
+
for gone in (":30", ":12", ":35", ":37"): # the old number, and the lines that did not change
|
|
270
|
+
assert f"demo/export.py{gone}" not in prompt, gone
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_a_first_review_without_a_mutation_for_a_locked_test_is_a_violation(tmp_path):
|
|
274
|
+
rig = Review(tmp_path)
|
|
275
|
+
rig.run(plan(**{"01/reviewer": [review("01", lines=()), review("01")]}))
|
|
276
|
+
assert rig.code == 0, rig.output()
|
|
277
|
+
first, second = rig.calls("01", "reviewer")
|
|
278
|
+
appended = second["prompt"].replace(first["prompt"].strip(), "")
|
|
279
|
+
assert first["prompt"].strip() in second["prompt"] and "tests/test_01.py" in appended
|
|
280
|
+
assert "mutation" in appended.lower()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.parametrize("second_matches", [True, False])
|
|
284
|
+
def test_a_red_line_missing_from_the_reviewer_log_reruns_once_then_blocks(tmp_path, second_matches):
|
|
285
|
+
rig = Review(tmp_path)
|
|
286
|
+
bad = review("01")
|
|
287
|
+
bad["report"]["mutations"][0]["red"] = "NEVER PRINTED"
|
|
288
|
+
rig.run(plan(**{"01/reviewer": [bad, review("01") if second_matches else bad]}))
|
|
289
|
+
first, second = rig.calls("01", "reviewer")
|
|
290
|
+
assert "red line not found in log" in second["prompt"] and "red line not found" not in first["prompt"]
|
|
291
|
+
assert rig.code == (0 if second_matches else 1), rig.output()
|
|
292
|
+
assert len(rig.calls("01", "reviewer")) == 2
|
|
293
|
+
if not second_matches:
|
|
294
|
+
assert rig.led("01", "BLOCKED") and "feat: 01 rows" not in rig.pr_subjects()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------- conflict agent
|
|
298
|
+
|
|
299
|
+
def cli(text):
|
|
300
|
+
return f"def main():\n return {text!r}\n"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def conflicted(tmp, moves_again=False):
|
|
304
|
+
"""01 and 03 both add demo/cli.py; 03 merges first, so 01's merge conflicts."""
|
|
305
|
+
rig = Review(tmp, {k: v for k, v in three().items() if not k.startswith("02")})
|
|
306
|
+
resolve = {"files": {"demo/cli.py": cli("resolved")}, "commit": "merge: resolve cli",
|
|
307
|
+
"report": {"status": "DONE", "commits": ["$SHA"], "files": ["demo/cli.py"], "concerns": []}}
|
|
308
|
+
if moves_again: # while the agent works, another commit lands on the PR branch and changes the same file
|
|
309
|
+
third = str(tmp / "third")
|
|
310
|
+
resolve["git"] = [["worktree", "add", "--detach", third, "feat/csv"]]
|
|
311
|
+
resolve["files"][third + "/demo/cli.py"] = cli("third")
|
|
312
|
+
resolve["git_after"] = [["-C", third, "add", "-A"], ["-C", third, "commit", "-q", "-m", "feat: third"],
|
|
313
|
+
["-C", third, "branch", "-f", "feat/csv", "HEAD"],
|
|
314
|
+
["worktree", "remove", "--force", third]]
|
|
315
|
+
one = impl("01", files={"demo/export.py": "def fn_01():\n return '01'\n", "demo/cli.py": cli("one")})
|
|
316
|
+
return rig.run(plan(**{
|
|
317
|
+
"01/test-writer": [{**tw("01"), "touch": ["s1"]}],
|
|
318
|
+
"03/implementer": [{**impl("03", "demo/cli.py", cli("three")), "wait": ["s1"]}],
|
|
319
|
+
"01/implementer": [{**one, "wait_pr_moves": True}],
|
|
320
|
+
"01/conflict": [resolve]}))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_a_conflicting_merge_runs_the_conflict_agent_once_then_the_gate_on_the_merge_commit(tmp_path):
|
|
324
|
+
rig = conflicted(tmp_path)
|
|
325
|
+
assert rig.code == 0, rig.output()
|
|
326
|
+
(call,) = rig.calls("01", "conflict")
|
|
327
|
+
assert call["cwd"] == rig.calls("01", "implementer")[0]["cwd"], "in the ticket's slot"
|
|
328
|
+
assert rig.tip() in [g["commit"] for g in rig.gates()], "the merge commit is gated"
|
|
329
|
+
assert git(rig.repo, "show", "feat/csv:demo/cli.py") == cli("resolved")
|
|
330
|
+
assert rig.led("demo/cli.py", "onflict")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_a_second_conflict_on_the_same_merge_blocks_the_ticket(tmp_path):
|
|
334
|
+
rig = conflicted(tmp_path, moves_again=True)
|
|
335
|
+
assert rig.code == 1, rig.output()
|
|
336
|
+
assert len(rig.calls("01", "conflict")) == 1
|
|
337
|
+
assert rig.led("01", "BLOCKED") and rig.state()["tickets"]["01"]["phase"] == "blocked"
|
|
338
|
+
assert rig.state()["tickets"]["03"]["phase"] == "merged"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Ticket 05 acceptance tests for `driver_review_page.render(decisions) -> html`.
|
|
2
|
+
|
|
3
|
+
Interface choice: `decisions` is the dict of decisions.json (`revision`, `questions[]` with `id`, `title`, `why`,
|
|
4
|
+
`evidence` (list of strings), `options` (list of `{id, description}`), `recommended` (an option id)); the result is the
|
|
5
|
+
whole page as a string. The checks are only those that skills/review-page/SKILL.md and schema.md state.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import driver_review_page
|
|
11
|
+
|
|
12
|
+
REVIEW_PAGE = Path(__file__).resolve().parents[2] / "review-page"
|
|
13
|
+
TEMPLATE = (REVIEW_PAGE / "template.html").read_text()
|
|
14
|
+
ID = re.compile(r"[a-z0-9_-]{1,64}")
|
|
15
|
+
WHAT = "<b>&\"quoted\"</b>"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def question(qid, options, recommended, title=None):
|
|
19
|
+
return {"id": qid, "title": title or f"Title of {qid}", "why": f"Why {qid} matters",
|
|
20
|
+
"evidence": [".agent/implement/01/implementer.json", ".agent/implement/ledger.md:3"],
|
|
21
|
+
"options": [{"id": o, "description": f"Trade-off of {o}"} for o in options], "recommended": recommended}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DECISIONS = {"revision": "ab" * 32, "questions": [
|
|
25
|
+
question("01-locked-test-1", ["overrule-test", "overrule-implementer"], "overrule-implementer"),
|
|
26
|
+
question("02-finding-1", ["accept", "split", "redesign", "other"], "split", title=WHAT)]}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def blocks(html):
|
|
30
|
+
"""(data-q value, block html) per question block, in page order."""
|
|
31
|
+
return re.findall(r'<div class="q" data-q="([^"]*)">(.*?)</textarea>', html, re.S)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def check_page(html, decisions):
|
|
35
|
+
"""Every check the review-page skill states, for a page in questionnaire mode."""
|
|
36
|
+
questions = decisions["questions"]
|
|
37
|
+
assert 'name="agent-ops-review"' in html and 'content="1"' in html, "the dispatcher looks for the marker"
|
|
38
|
+
assert '<body data-mode="questionnaire"' in html
|
|
39
|
+
assert html.count('data-q="') == len(questions) == len(blocks(html)), "one data-q block per question"
|
|
40
|
+
for style_or_script in re.findall(r"<(?:style|script)>.*?</(?:style|script)>", TEMPLATE, re.S):
|
|
41
|
+
assert style_or_script in html, "script and CSS are never edited"
|
|
42
|
+
for slot in ("tickets", "questions", "corrections", "track"):
|
|
43
|
+
assert f'<section data-slot="{slot}">' in html, "the wrappers stay"
|
|
44
|
+
for forbidden in ("<link", "src=", "http:", "https:", "<button", 'data-q="track"', 'data-q="format"'):
|
|
45
|
+
assert forbidden not in html, forbidden
|
|
46
|
+
for (qid, block), want in zip(blocks(html), questions):
|
|
47
|
+
assert qid == want["id"] and ID.fullmatch(qid), qid
|
|
48
|
+
assert f'id="note-{qid}"' in block and 'id="state"' not in block, "the script owns id state"
|
|
49
|
+
radios = re.findall(r'<input type="radio" name="([^"]*)" value="([^"]*)"', block)
|
|
50
|
+
assert radios == [(qid, o["id"]) for o in want["options"]], "name is the question id, value is the option id"
|
|
51
|
+
labels = block.split('<label class="opt">')[1:]
|
|
52
|
+
recommended = [i for i, label in enumerate(labels) if "chip rec" in label]
|
|
53
|
+
assert block.count("chip rec") == 1 and len(recommended) == 1, "exactly one recommended option"
|
|
54
|
+
assert f'value="{want["recommended"]}"' in labels[recommended[0]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_page_carries_the_marker_questionnaire_mode_and_one_block_per_question():
|
|
58
|
+
check_page(driver_review_page.render(DECISIONS), DECISIONS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_titles_whys_and_descriptions_show_up_and_markup_in_them_is_escaped():
|
|
62
|
+
html = driver_review_page.render(DECISIONS)
|
|
63
|
+
for want in DECISIONS["questions"]:
|
|
64
|
+
assert f"Why {want['id']} matters" in html
|
|
65
|
+
assert all(f"Trade-off of {o['id']}" in html for o in want["options"])
|
|
66
|
+
assert "Title of 01-locked-test-1" in html
|
|
67
|
+
assert WHAT not in html and "<b>" in html, "a title is text, never markup"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Slots 2..N: created once, prepared once, reused only when clean."""
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import driver_slots
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_a_slot_is_created_and_prepared_once_then_reused_off_any_branch(repo, tmp_path):
|
|
8
|
+
repo()
|
|
9
|
+
log = tmp_path.parent / f"{tmp_path.name}-prepare.log"
|
|
10
|
+
assert driver_slots.setup(3, f"pwd >> {log}") == []
|
|
11
|
+
assert len(log.read_text().splitlines()) == 2 and driver_slots.path(1) == "."
|
|
12
|
+
slot = driver_slots.path(3)
|
|
13
|
+
subprocess.run(["git", "-C", slot, "checkout", "-q", "-b", "ticket/07"], check=True)
|
|
14
|
+
assert driver_slots.setup(3, f"pwd >> {log}") == [] and len(log.read_text().splitlines()) == 2
|
|
15
|
+
assert driver_slots.git(slot, "symbolic-ref", "-q", "HEAD").returncode != 0 # detached: the branch is free
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_a_slot_with_uncommitted_work_stops_the_run_and_keeps_the_work(repo, tmp_path):
|
|
19
|
+
repo()
|
|
20
|
+
assert driver_slots.setup(2, None) == []
|
|
21
|
+
(tmp_path / ".agent/worktrees/slot-2/wip.txt").write_text("half done\n")
|
|
22
|
+
assert driver_slots.setup(3, None) == [
|
|
23
|
+
"slot 2: .agent/worktrees/slot-2 has uncommitted changes: commit or discard them"]
|
|
24
|
+
assert (tmp_path / ".agent/worktrees/slot-2/wip.txt").exists() and not (tmp_path / ".agent/worktrees/slot-3").exists()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_a_slot_that_cannot_be_created_is_an_error(repo, tmp_path):
|
|
28
|
+
repo()
|
|
29
|
+
(tmp_path / ".agent/worktrees").mkdir(parents=True)
|
|
30
|
+
(tmp_path / ".agent/worktrees/slot-2").write_text("a file is in the way\n")
|
|
31
|
+
(error,) = driver_slots.setup(2, None)
|
|
32
|
+
assert error.startswith("slot 2: cannot create .agent/worktrees/slot-2: ")
|