@jesdi/skills 0.0.30 → 0.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +12 -6
  2. package/package.json +2 -2
  3. package/skills/implement-spec/ARCHITECTURE.md +113 -0
  4. package/skills/implement-spec/README.md +215 -0
  5. package/skills/implement-spec/SKILL.md +18 -70
  6. package/skills/implement-spec/driver.py +580 -0
  7. package/skills/implement-spec/driver_agents.py +145 -0
  8. package/skills/implement-spec/driver_answers.py +79 -0
  9. package/skills/implement-spec/driver_decisions.py +152 -0
  10. package/skills/implement-spec/driver_final.py +534 -0
  11. package/skills/implement-spec/driver_fixloop.py +170 -0
  12. package/skills/implement-spec/driver_gate.py +47 -0
  13. package/skills/implement-spec/driver_graph.py +42 -0
  14. package/skills/implement-spec/driver_merge.py +105 -0
  15. package/skills/implement-spec/driver_report.py +115 -0
  16. package/skills/implement-spec/driver_review.py +126 -0
  17. package/skills/implement-spec/driver_review_page.py +51 -0
  18. package/skills/implement-spec/driver_run.py +262 -0
  19. package/skills/implement-spec/driver_slots.py +64 -0
  20. package/skills/implement-spec/driver_state.py +108 -0
  21. package/skills/implement-spec/driver_summary.py +40 -0
  22. package/skills/implement-spec/driver_tickets.py +132 -0
  23. package/skills/implement-spec/driver_tidy.py +57 -0
  24. package/skills/implement-spec/models.json +11 -0
  25. package/skills/implement-spec/prompts/conflict.md +32 -0
  26. package/skills/implement-spec/prompts/correction.md +4 -0
  27. package/skills/implement-spec/prompts/correctness.md +34 -0
  28. package/skills/implement-spec/prompts/final-fixer.md +36 -0
  29. package/skills/implement-spec/prompts/final-reviewer.md +36 -0
  30. package/skills/implement-spec/prompts/final-scoped.md +1 -0
  31. package/skills/implement-spec/prompts/fixer.md +35 -0
  32. package/skills/implement-spec/prompts/implementer.md +33 -0
  33. package/skills/implement-spec/prompts/interrupted.md +1 -0
  34. package/skills/implement-spec/prompts/moved.md +2 -0
  35. package/skills/implement-spec/prompts/overruled.md +5 -0
  36. package/skills/implement-spec/prompts/pr-body.md +19 -0
  37. package/skills/implement-spec/prompts/pr-writer.md +31 -0
  38. package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
  39. package/skills/implement-spec/prompts/rereview.md +3 -0
  40. package/skills/implement-spec/prompts/rerun.md +2 -0
  41. package/skills/implement-spec/prompts/reviewer.md +34 -0
  42. package/skills/implement-spec/prompts/stands.md +2 -0
  43. package/skills/implement-spec/prompts/test-writer.md +34 -0
  44. package/skills/implement-spec/prompts/timeout-restored.md +2 -0
  45. package/skills/implement-spec/prompts/timeout-shared.md +2 -0
  46. package/skills/implement-spec/prompts/timeout.md +2 -0
  47. package/skills/implement-spec/run.sh +101 -0
  48. package/skills/implement-spec/tests/conftest.py +50 -0
  49. package/skills/implement-spec/tests/test_agents.py +358 -0
  50. package/skills/implement-spec/tests/test_amendments.py +117 -0
  51. package/skills/implement-spec/tests/test_cli.py +221 -0
  52. package/skills/implement-spec/tests/test_decisions.py +434 -0
  53. package/skills/implement-spec/tests/test_driver.py +767 -0
  54. package/skills/implement-spec/tests/test_final.py +442 -0
  55. package/skills/implement-spec/tests/test_fixloop.py +523 -0
  56. package/skills/implement-spec/tests/test_graph.py +32 -0
  57. package/skills/implement-spec/tests/test_landing.py +355 -0
  58. package/skills/implement-spec/tests/test_merge.py +144 -0
  59. package/skills/implement-spec/tests/test_models.py +47 -0
  60. package/skills/implement-spec/tests/test_report.py +162 -0
  61. package/skills/implement-spec/tests/test_review.py +338 -0
  62. package/skills/implement-spec/tests/test_review_page.py +67 -0
  63. package/skills/implement-spec/tests/test_slots.py +32 -0
  64. package/skills/implement-spec/tests/test_state.py +68 -0
  65. package/skills/implement-spec/tests/test_stops.py +272 -0
  66. package/skills/implement-spec/tests/test_summary.py +28 -0
  67. package/skills/implement-spec/tests/test_tickets.py +72 -0
  68. package/skills/implement-spec/tests/test_tidy.py +604 -0
  69. package/skills/implement-spec/tests/test_walk.py +459 -0
  70. package/skills/review-page/template.html +1 -1
  71. package/skills/to-tickets/SKILL.md +19 -2
  72. package/skills-manifest.json +7 -7
  73. package/skills/implement-spec/history.md +0 -52
@@ -0,0 +1,221 @@
1
+ """Driver acceptance tests, through `sh run.sh` in a throwaway fixture repository."""
2
+ import json
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ SKILL = Path(__file__).resolve().parents[1]
12
+ RUN = SKILL / "run.sh"
13
+ GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"]
14
+ TOOLS = ("sh", "uname", "dirname", "readlink", "git", "env", "mktemp", "curl", "tar", "gzip", "mv", "rm", "mkdir",
15
+ "sha256sum", "shasum", "cat", "chmod", "ln", "date", "basename")
16
+ ROLES = ("explorer", "test-writer", "implementer", "reviewer", "correctness", "fixer", "final-reviewer", "conflict",
17
+ "pr-writer")
18
+ NONE = "None (can start immediately)"
19
+
20
+
21
+ def ticket(number, title, blocked=NONE, *, seam="`export_csv()`", touches="`demo/export.py: export_csv`",
22
+ criteria=("It works",), risk=None):
23
+ lines = [f"# {number}: {title}" if title else "", "", "**What to build:** Something.", "",
24
+ f"**Blocked by:** {blocked}", "", f"**Seam:** {seam}", ""]
25
+ if touches:
26
+ lines += [f"**Touches:** {touches}", ""]
27
+ if risk:
28
+ lines += [f"**Risk:** {risk}", ""]
29
+ return "\n".join(lines + [f"- [ ] {c}" for c in criteria] + [""])
30
+
31
+
32
+ def three(**changes):
33
+ files = {
34
+ "01-export-rows.md": ticket("01", "Export rows", criteria=("Rows export", "Header exports")),
35
+ "02-quote-fields.md": ticket("02", "Quote fields", "01", risk="persistence"),
36
+ "03-cli-flag.md": ticket("03", "CLI flag", seam="`demo export` CLI", touches="`demo/cli.py: main`"),
37
+ }
38
+ files.update(changes)
39
+ return files
40
+
41
+
42
+ def git(root, *args):
43
+ return subprocess.run([*GIT, *args], cwd=root, check=True, capture_output=True, text=True).stdout
44
+
45
+
46
+ def snapshot(root):
47
+ """Every file under root (but .git) with its bytes, plus the branch list."""
48
+ files = {p.relative_to(root).as_posix(): p.read_bytes()
49
+ for p in sorted(root.rglob("*")) if p.is_file() and ".git" not in p.relative_to(root).parts}
50
+ return files, git(root, "branch", "--list")
51
+
52
+
53
+ def make_repo(root, files, *, ignore_agent=True, commit_agent=False):
54
+ root.mkdir()
55
+ git(root, "init", "-q", "-b", "main")
56
+ (root / "demo").mkdir()
57
+ (root / "demo/export.py").write_text("def export_csv():\n return ''\n")
58
+ (root / ".gitignore").write_text(".agent/\n" if ignore_agent else ".crap/\n")
59
+ git(root, "add", "-A")
60
+ git(root, "commit", "-q", "-m", "init")
61
+ git(root, "checkout", "-q", "-b", "feat/csv")
62
+ tickets = root / ".agent/tickets"
63
+ tickets.mkdir(parents=True)
64
+ for name, text in files.items():
65
+ (tickets / name).write_text(text)
66
+ if commit_agent:
67
+ git(root, "add", "-f", ".agent")
68
+ git(root, "commit", "-q", "-m", "tickets")
69
+ return root
70
+
71
+
72
+ @pytest.fixture
73
+ def env(tmp_path):
74
+ """A PATH holding only basic tools plus fake claude and codex that log any launch."""
75
+ bin_dir = tmp_path / "bin"
76
+ bin_dir.mkdir()
77
+ for name in TOOLS:
78
+ if shutil.which(name):
79
+ (bin_dir / name).symlink_to(shutil.which(name))
80
+ for cli in ("claude", "codex"):
81
+ (bin_dir / cli).write_text(f'#!/bin/sh\necho {cli} >> "{tmp_path}/launched"\nexit 1\n')
82
+ (bin_dir / cli).chmod(0o755)
83
+ return dict(os.environ, PATH=str(bin_dir)), tmp_path
84
+
85
+
86
+ def models_file(tmp_path, **overrides):
87
+ entries = {role: ["anthropic/claude-opus-5@medium"] for role in ROLES}
88
+ entries["correctness"] = ["openai/gpt-sol@medium"]
89
+ entries.update(overrides)
90
+ path = tmp_path / "models.json"
91
+ path.write_text(json.dumps(entries))
92
+ return path
93
+
94
+
95
+ def drive(env, repo, *args, script=RUN):
96
+ env_vars, _ = env
97
+ args = args or ("--tickets", ".agent/tickets", "--gate", "make check", "--dry-run")
98
+ return subprocess.run(["sh", str(script), *args], cwd=repo, env=env_vars,
99
+ text=True, capture_output=True, timeout=300)
100
+
101
+
102
+ def assert_untouched(proc, repo, before, tmp_path):
103
+ assert snapshot(repo) == before
104
+ assert not (tmp_path / "launched").exists()
105
+
106
+
107
+ def test_missing_touches_names_file_and_seam_line_and_creates_nothing(env, tmp_path):
108
+ files = three(**{"02-quote-fields.md": ticket("02", "Quote fields", "01", touches=None)})
109
+ repo = make_repo(tmp_path / "demo", files)
110
+ before = snapshot(repo)
111
+ seam_line = files["02-quote-fields.md"].splitlines().index("**Seam:** `export_csv()`") + 1
112
+ proc = drive(env, repo)
113
+ assert proc.returncode == 3, proc.stderr
114
+ (line,) = [l for l in proc.stdout.splitlines() if "missing Touches" in l]
115
+ assert line.startswith("02-quote-fields.md") and re.search(rf"\b{seam_line}\b", line)
116
+ assert_untouched(proc, repo, before, tmp_path)
117
+
118
+
119
+ def test_every_error_is_listed_in_one_run(env, tmp_path):
120
+ files = three(**{"01-export-rows.md": ticket("01", None), "03-cli-flag.md": ticket("03", "CLI flag", criteria=())})
121
+ repo = make_repo(tmp_path / "demo", files)
122
+ proc = drive(env, repo)
123
+ assert proc.returncode == 3, proc.stderr
124
+ lines = proc.stdout.splitlines()
125
+ assert any("01-export-rows.md" in l and "missing title" in l for l in lines)
126
+ assert any("03-cli-flag.md" in l and "missing criterion" in l for l in lines)
127
+
128
+
129
+ def test_single_ticket_without_touches_is_accepted(env, tmp_path):
130
+ repo = make_repo(tmp_path / "demo", {"01-export-rows.md": ticket("01", "Export rows", touches=None)})
131
+ proc = drive(env, repo)
132
+ assert proc.returncode == 0, proc.stdout + proc.stderr
133
+ assert "max frontier width: 1" in proc.stdout
134
+ assert "missing" not in proc.stdout
135
+
136
+
137
+ def test_refactor_seam_is_accepted_with_no_criterion(env, tmp_path):
138
+ files = {"01-export-rows.md": ticket("01", "Export rows", seam="none — refactor", criteria=())}
139
+ repo = make_repo(tmp_path / "demo", files)
140
+ proc = drive(env, repo)
141
+ assert proc.returncode == 0, proc.stdout + proc.stderr
142
+ assert "max frontier width: 1" in proc.stdout
143
+
144
+
145
+ def test_blocked_by_cycle_prints_one_error_naming_both_files(env, tmp_path):
146
+ files = three(**{"01-export-rows.md": ticket("01", "Export rows", "03"),
147
+ "03-cli-flag.md": ticket("03", "CLI flag", "01", touches="`demo/cli.py: main`")})
148
+ repo = make_repo(tmp_path / "demo", files)
149
+ proc = drive(env, repo)
150
+ assert proc.returncode == 3, proc.stderr
151
+ both = [l for l in proc.stdout.splitlines() if "01-export-rows.md" in l and "03-cli-flag.md" in l]
152
+ assert len(both) == 1
153
+
154
+
155
+ def test_blocked_by_unknown_number_names_file_and_number(env, tmp_path):
156
+ files = three(**{"02-quote-fields.md": ticket("02", "Quote fields", "07")})
157
+ repo = make_repo(tmp_path / "demo", files)
158
+ proc = drive(env, repo)
159
+ assert proc.returncode == 3, proc.stderr
160
+ assert any("02-quote-fields.md" in l and "07" in l for l in proc.stdout.splitlines())
161
+
162
+
163
+ @pytest.mark.parametrize("files, code", [
164
+ (three(**{"02-quote-fields.md": ticket("02", "Quote fields", "07")}), 3),
165
+ (three(), 0),
166
+ ])
167
+ def test_driver_never_modifies_a_ticket_file(env, tmp_path, files, code):
168
+ repo = make_repo(tmp_path / "demo", files)
169
+ before = {p.name: p.read_bytes() for p in (repo / ".agent/tickets").iterdir()}
170
+ proc = drive(env, repo)
171
+ assert proc.returncode == code, proc.stdout + proc.stderr
172
+ assert {p.name: p.read_bytes() for p in (repo / ".agent/tickets").iterdir()} == before
173
+
174
+
175
+ def test_run_without_gate_exits_3_naming_the_flag(env, tmp_path):
176
+ repo = make_repo(tmp_path / "demo", three())
177
+ proc = drive(env, repo, "--tickets", ".agent/tickets", "--dry-run")
178
+ assert proc.returncode == 3, proc.stderr
179
+ assert "--gate" in proc.stdout
180
+
181
+
182
+ def test_agent_dir_not_ignored_exits_3(env, tmp_path):
183
+ repo = make_repo(tmp_path / "demo", three(), ignore_agent=False, commit_agent=True)
184
+ proc = drive(env, repo)
185
+ assert proc.returncode == 3, proc.stderr
186
+ assert ".agent is not gitignored" in proc.stdout
187
+
188
+
189
+ def test_dirty_worktree_exits_3_naming_the_paths(env, tmp_path):
190
+ repo = make_repo(tmp_path / "demo", three())
191
+ (repo / "demo/export.py").write_text("def export_csv():\n return 'edited'\n")
192
+ (repo / "notes.txt").write_text("untracked\n")
193
+ proc = drive(env, repo)
194
+ assert proc.returncode == 3, proc.stderr
195
+ assert "demo/export.py" in proc.stdout and "notes.txt" in proc.stdout
196
+
197
+
198
+ def test_models_file_naming_a_provider_without_its_cli_exits_3(env, tmp_path):
199
+ (tmp_path / "bin/codex").unlink()
200
+ repo = make_repo(tmp_path / "demo", three())
201
+ models = models_file(tmp_path, implementer=["openai/gpt-sol@medium"])
202
+ proc = drive(env, repo, "--tickets", ".agent/tickets", "--gate", "make check", "--models", str(models), "--dry-run")
203
+ assert proc.returncode == 3, proc.stderr
204
+ assert any("implementer" in l and "codex" in l for l in proc.stdout.splitlines())
205
+
206
+
207
+ @pytest.mark.parametrize("link", ["directory", "file"])
208
+ def test_valid_set_prints_frontier_width_and_dry_run_creates_nothing(env, tmp_path, link):
209
+ repo = make_repo(tmp_path / "demo", three())
210
+ script = RUN
211
+ if link == "directory": # an install through ~/.my-skills links the whole skill directory
212
+ (tmp_path / "skills link").symlink_to(SKILL)
213
+ script = tmp_path / "skills link/run.sh"
214
+ elif link == "file":
215
+ (tmp_path / "run link.sh").symlink_to(RUN)
216
+ script = tmp_path / "run link.sh"
217
+ before = snapshot(repo)
218
+ proc = drive(env, repo, "--tickets", ".agent/tickets", "--gate", "make check", "--dry-run", script=script)
219
+ assert proc.returncode == 0, proc.stdout + proc.stderr
220
+ assert "max frontier width: 2" in proc.stdout
221
+ assert_untouched(proc, repo, before, tmp_path)
@@ -0,0 +1,434 @@
1
+ """In-process tests of the human stops: the scenarios of test_stops.py without the launcher, so coverage sees the
2
+ driver, plus the cases that only a direct call reaches (the answers file as input from outside, the question ids,
3
+ a template that is not there)."""
4
+ import json
5
+ import shutil
6
+
7
+ import pytest
8
+
9
+ import driver_answers
10
+ import driver_decisions
11
+ import driver_review
12
+ import driver_review_page
13
+ from test_cli import git
14
+ from test_fixloop import Inside, inside # noqa: F401 (the autouse fixture puts the rig's CLIs on PATH)
15
+ from test_tidy import Inside as Pushing, tools # noqa: F401 (an unattended run pushes: the rig with an origin and a fake gh)
16
+ from test_review import ONE, finding, fix, review
17
+ from test_stops import CONTRA, NEW_TEST, Q1, UNATTENDED, blocked, correction, decisions, stage, steps, write_answers
18
+ from test_walk import impl, plan, tw
19
+
20
+ PAGE = ".agent/implement-review.html"
21
+
22
+
23
+ def stopped(tmp_path, *extra, then=(impl("01"),)):
24
+ return Inside(tmp_path, ONE).run(steps(blocked(), *then), *extra)
25
+
26
+
27
+ def doc(*questions, run="run-1"):
28
+ """decisions.json of a run whose state.json holds `questions`."""
29
+ return driver_decisions.document({"revision": run, "questions": list(questions)})
30
+
31
+
32
+ def asked(kind="locked-test", ticket="01", subject="tests/test_a.py::test_x", before=(), stop=1):
33
+ return driver_decisions.question(kind, ticket, subject, ["report.json", "ledger.md:3"], list(before), stop)
34
+
35
+
36
+ # ---------------------------------------------------------------- the stop and its answer
37
+
38
+ def test_a_stop_prints_the_question_and_an_answer_is_applied_once(tmp_path, capsys):
39
+ rig = stopped(tmp_path)
40
+ out = capsys.readouterr().out
41
+ assert rig.code == 2 and f"Question {Q1}: Ticket 01: the implementer reports" in out
42
+ assert " overrule-implementer (recommended): " in out and "--answer <question id>=<option id>" in out
43
+ (q,) = rig.state()["run"]["questions"]
44
+ assert (q["ticket"], q["kind"], q["subject"], q["stop"], q["answer"]) == (
45
+ "01", "locked-test", "tests/test_01.py::test_x", 1, None)
46
+ assert q["evidence"][0] == ".agent/implement/01/implementer-0.json" and rig.state()["run"]["stops"] == 1
47
+ assert rig.led("Ticket 01: the operator decides on 1 question of kind locked-test in .agent/implement/decisions.json.")
48
+
49
+ rig.run(steps(blocked(), impl("01")), "--answer", f"{Q1}=overrule-test")
50
+ assert rig.code == 0, rig.ledger()
51
+ assert len(rig.led("Ruling: 01 locked test overruled: the test-writer corrects tests/test_01.py::test_x")) == 1
52
+ prompt = rig.prompt("test-writer-0")
53
+ assert "This call is a correction after a ruling" in prompt and "read `concerns` in .agent/implement/01/implementer-0.json" in prompt
54
+ assert "ruling" not in rig.prompt("implementer-0") and prompt.rstrip().endswith("one ref store.")
55
+ entry = rig.state()["tickets"]["01"]
56
+ assert (entry["phase"], entry["note"], entry["locked"]) == ("merged", "", ["tests/test_01.py"])
57
+ assert rig.state()["run"]["questions"][0]["answer"] == "overrule-test"
58
+ assert decisions(rig)["questions"] == [] and not (rig.repo / PAGE).exists()
59
+ assert "Question" not in capsys.readouterr().out
60
+
61
+ # The question is closed: its answer cannot be given again, and the old answers file is history.
62
+ rig.run({}, "--answer", f"{Q1}=overrule-implementer")
63
+ assert rig.code == 3 and f"--answer {Q1}=overrule-implementer: no open question {Q1}" in capsys.readouterr().out
64
+ rig.run({})
65
+ assert rig.code == 0 and not rig.led("ignored") and len(rig.led("Ruling")) == 1
66
+
67
+
68
+ def test_overrule_implementer_runs_the_implementer_again_with_the_ruling(tmp_path, capsys):
69
+ rig = stopped(tmp_path)
70
+ rig.run(steps(blocked(), impl("01")), "--answer", f"{Q1}=nope")
71
+ assert rig.code == 3 and f"--answer {Q1}=nope: {Q1} offers overrule-test, overrule-implementer" in capsys.readouterr().out
72
+ assert not (rig.repo / driver_answers.FILE).exists()
73
+ rig.run(steps(blocked(), impl("01")), "--answer", f"{Q1}=overrule-implementer")
74
+ assert rig.code == 0, rig.ledger()
75
+ assert rig.led("Ruling: 01 locked test stands: the implementer is overruled on tests/test_01.py::test_x")
76
+ assert len(rig.calls("01", "test-writer")) == 1 and len(rig.calls("01", "implementer")) == 2
77
+ assert "the operator ruled that the locked tests stand as they are" in rig.prompt("implementer-0")
78
+
79
+
80
+ def test_an_answer_needs_a_question_and_an_option(tmp_path, capsys):
81
+ with pytest.raises(SystemExit) as usage:
82
+ stopped(tmp_path, "--answer", "no-equals-sign")
83
+ assert usage.value.code == 3 and "--answer" in capsys.readouterr().out
84
+
85
+
86
+ def test_a_second_stop_counts_on_and_its_page_holds_only_the_open_question(tmp_path, capsys):
87
+ rig = stopped(tmp_path, then=(blocked(),))
88
+ rig.run(steps(blocked(), blocked()), "--answer", f"{Q1}=overrule-test")
89
+ assert rig.code == 2 and [q["id"] for q in decisions(rig)["questions"]] == ["01-locked-test-2"]
90
+ html = (rig.repo / PAGE).read_text()
91
+ assert 'data-q="01-locked-test-2"' in html and Q1 not in html and rig.state()["run"]["stops"] == 2
92
+
93
+
94
+ # ---------------------------------------------------------------- what a stop keeps for the rerun
95
+
96
+ def test_an_answers_file_of_an_earlier_run_is_not_applied_to_the_same_question_of_a_new_run(tmp_path, capsys):
97
+ rig = stopped(tmp_path)
98
+ old = decisions(rig)
99
+ write_answers(rig, {Q1: "overrule-test"})
100
+ # The operator starts again: the run directory goes, the answers file is outside it and stays.
101
+ shutil.rmtree(rig.repo / ".agent/implement")
102
+ git(rig.repo, "branch", "-D", "ticket/01")
103
+ again = steps(blocked(), blocked(), impl("01"))
104
+ rig.run(again)
105
+ new = decisions(rig)
106
+ assert rig.code == 2 and new["questions"] == old["questions"], "the same question, word for word"
107
+ assert new["run"] == rig.state()["run"]["revision"] != old["run"] and new["revision"] != old["revision"]
108
+ rig.run(again)
109
+ assert rig.code == 2 and rig.led("is ignored: its revision") and not rig.led("Ruling")
110
+ assert len(rig.calls("01", "implementer")) == 2, "no agent ran on the old answer"
111
+ assert doc(asked(), run="a")["revision"] != doc(asked(), run="b")["revision"]
112
+
113
+
114
+ def partial():
115
+ """An implementer that commits a part of its work, then reports the contradicted locked test."""
116
+ step = {**blocked(), "files": {"demo/export.py": "def partial():\n return 1\n"}, "commit": "feat: 01 partial work"}
117
+ step["report"] = {**step["report"], "commits": ["$SHA"], "files": ["demo/export.py"]}
118
+ return step
119
+
120
+
121
+ @pytest.mark.parametrize("option, role", [("overrule-implementer", "implementer"), ("overrule-test", "test-writer")])
122
+ def test_after_a_ruling_the_ticket_goes_on_from_the_commits_of_the_blocked_implementer(tmp_path, capsys, option, role):
123
+ rig = Inside(tmp_path, ONE).run(steps(partial(), impl("01")))
124
+ stop = rig.calls("01", "implementer")[0]["sha"]
125
+ assert rig.code == 2 and rig.state()["tickets"]["01"]["tip"] == stop
126
+ rig.run(steps(partial(), impl("01")), "--answer", f"{Q1}={option}")
127
+ assert rig.code == 0, rig.ledger()
128
+ after = rig.calls("01", role)[1]
129
+ assert after["head"] == stop and "feat: 01 partial work" in after["log"].splitlines()
130
+ # After overrule-test the implementer starts from the correction, which is on top of its own commit.
131
+ assert "feat: 01 partial work" in rig.calls("01", "implementer")[1]["log"].splitlines()
132
+ assert rig.state()["tickets"]["01"]["locked"] == ["tests/test_01.py"]
133
+ assert "feat: 01 partial work" in rig.pr_subjects() and "feat: 01 rows" in rig.pr_subjects()
134
+
135
+
136
+ def test_a_stop_with_uncommitted_work_keeps_its_slot_and_takes_the_answer(tmp_path, capsys):
137
+ dirty = {**blocked(), "files": {"demo/export.py": "half = 1\n"}}
138
+ rig = Pushing(tmp_path, ONE).run(steps(dirty, impl("01")), *UNATTENDED)
139
+ assert rig.code == 2 and git(rig.repo, "status", "--porcelain") == " M demo/export.py\n"
140
+ rig.run(steps(dirty, impl("01")), *UNATTENDED)
141
+ assert rig.code == 2 and len(rig.calls()) == 2, "the stop repeats: the work in the slot is no error"
142
+ assert (rig.repo / "demo/export.py").read_text() == "half = 1\n"
143
+ write_answers(rig, {Q1: "overrule-implementer"})
144
+ rig.run(steps(dirty, impl("01")), *UNATTENDED)
145
+ assert rig.code == 0, rig.ledger()
146
+ second = rig.calls("01", "implementer")[1]
147
+ assert second["status"] == "M demo/export.py" and second["branch"] == "ticket/01", "the work is in the tree of the rerun"
148
+ prompt = (rig.folder() / "implementer-0.prompt.md").read_text()
149
+ assert "the locked tests stand as they are" in prompt and "uncommitted work" in prompt
150
+ assert "feat: 01 rows" in rig.pr_subjects() and not git(rig.repo, "status", "--porcelain")
151
+
152
+
153
+ # ---------------------------------------------------------------- the correction after overrule-test
154
+
155
+ OTHER = "tests/test_01b.py"
156
+ OTHER_TEST = "def test_y():\n assert 1 == 'y'\n"
157
+
158
+
159
+ def two_locked():
160
+ """A test-writer that locks two files; the contradiction is about the first one."""
161
+ step = tw("01")
162
+ step["files"][OTHER] = OTHER_TEST
163
+ step["report"] = {**step["report"], "locked": ["tests/test_01.py", OTHER], "files": ["tests/test_01.py", OTHER]}
164
+ return step
165
+
166
+
167
+ def restored(path):
168
+ return f"Ticket 01: {path} was not the locked version at the correction: the driver restored it in the tree"
169
+
170
+
171
+ TWO_TESTS = "def test_x():\n assert 01 == 'x'\n\n\ndef test_y():\n assert 1 == 'y'\n"
172
+ ONLY_X = TWO_TESTS.split("\n\n\n")[0] + "\n"
173
+ # The edit of an agent: the one line of test_x, in place, in the file as the tree has it.
174
+ EDIT = "--- a/tests/test_01.py\n+++ b/tests/test_01.py\n@@ -2 +2 @@\n- assert 01 == 'x'\n+ assert '01' == '01'\n"
175
+
176
+
177
+ @pytest.mark.parametrize("committed", [True, False])
178
+ def test_the_correction_starts_from_the_locked_version_of_a_file_the_implementer_changed(tmp_path, capsys, committed):
179
+ writer = {**tw("01"), "files": {"tests/test_01.py": TWO_TESTS}}
180
+ deletes = {**(partial() if committed else blocked()), "files": {"tests/test_01.py": ONLY_X}}
181
+ rig = Inside(tmp_path, ONE)
182
+ (rig.rig / "edit.patch").write_text(EDIT)
183
+ edits = {**correction(), "files": {}, "git": [["apply", "--unidiff-zero", str(rig.rig / "edit.patch")]]}
184
+ scenario = plan(**{"01/test-writer": [writer, edits], "01/implementer": [deletes, impl("01")]})
185
+ rig.run(scenario)
186
+ assert rig.code == 2, rig.ledger()
187
+ rig.run(scenario, "--answer", f"{Q1}=overrule-test")
188
+ assert rig.code == 0, rig.ledger()
189
+ assert git(rig.repo, "show", "feat/csv:tests/test_01.py") == TWO_TESTS.replace("01 == 'x'", "'01' == '01'")
190
+ assert len(rig.led(restored("tests/test_01.py"))) == 1 and not rig.led("locked file changed")
191
+ assert rig.state()["tickets"]["01"]["held"] == {}
192
+
193
+
194
+ def test_a_locked_file_the_correction_changes_without_listing_it_is_still_a_finding(tmp_path, capsys):
195
+ gutted = partial()
196
+ gutted["files"] = {**gutted["files"], OTHER: "# gutted\n"}
197
+ # The driver restored the other file before the correction; the correction guts it again and commits it unlisted.
198
+ guts = {**correction(), "files": {"tests/test_01.py": NEW_TEST, OTHER: "# gutted again\n"}}
199
+ restore = fix("01", 1, files={"demo/export.py": "x = 1\n", OTHER: OTHER_TEST})
200
+ scenario = plan(**{"01/test-writer": [two_locked(), guts], "01/implementer": [gutted, impl("01")],
201
+ "01/fixer": [restore]})
202
+ rig = Inside(tmp_path, ONE).run(scenario)
203
+ first = rig.calls("01", "test-writer")[0]["sha"]
204
+ rig.run(scenario, "--answer", f"{Q1}=overrule-test")
205
+ assert rig.code == 0, rig.ledger()
206
+ assert rig.led(restored(OTHER)) and not rig.led(restored("tests/test_01.py"))
207
+ (line,) = rig.led(f"locked file changed: {OTHER}")
208
+ assert "critical" in line and len(rig.calls("01", "fixer")) == 1
209
+ entry = rig.state()["tickets"]["01"]
210
+ assert entry["held"] == {OTHER: first} and entry["test_writer_sha"] == rig.calls("01", "test-writer")[1]["sha"]
211
+ assert git(rig.repo, "show", f"feat/csv:{OTHER}") == OTHER_TEST and git(rig.repo, "show", "feat/csv:tests/test_01.py") == NEW_TEST
212
+
213
+
214
+ def test_a_correction_that_commits_more_than_the_files_it_lists_is_rejected(tmp_path, capsys):
215
+ dirty = {**blocked(), "files": {"demo/export.py": "half = 1\n", OTHER: "# gutted\n"}}
216
+ # The correction commits with `git add -A`, so the implementer's work in the tree goes into its commit. The
217
+ # locked file it gutted is back at the locked version before the correction, so only the other file is a fault.
218
+ scenario = plan(**{"01/test-writer": [two_locked(), correction(), correction()], "01/implementer": [dirty, impl("01")]})
219
+ rig = Inside(tmp_path, ONE).run(scenario)
220
+ rig.run(scenario, "--answer", f"{Q1}=overrule-test")
221
+ assert rig.code == 1, rig.ledger()
222
+ assert rig.led("Ticket 01: BLOCKED at the test-writer: a correction may change only the files it lists in "
223
+ "`locked`: its commits also change demo/export.py.")
224
+ assert rig.led(restored(OTHER)) and git(rig.repo, "show", f"ticket/01:{OTHER}") == OTHER_TEST
225
+ assert len(rig.calls("01", "test-writer")) == 3 and len(rig.calls("01", "implementer")) == 1, "one rerun, no implementer"
226
+ assert "its commits also change demo/export.py" in rig.prompt("test-writer-0")
227
+ assert "feat: 01 rows" not in rig.pr_subjects() and rig.state()["tickets"]["01"]["held"] == {}
228
+
229
+
230
+ def test_a_correction_of_its_own_file_leaves_the_work_in_the_tree_to_the_implementer(tmp_path, capsys):
231
+ dirty = {**blocked(), "files": {"demo/export.py": "half = 1\n"}}
232
+ # This correction commits only its test file: the implementer's file is out of the reach of `git add -A`.
233
+ only = {**correction(), "git_after": [["update-index", "--assume-unchanged", "demo/export.py"]]}
234
+ goes_on = {**impl("01"), "files": {}, "git": [["update-index", "--no-assume-unchanged", "demo/export.py"]]}
235
+ scenario = plan(**{"01/test-writer": [two_locked(), only], "01/implementer": [dirty, goes_on]})
236
+ rig = Inside(tmp_path, ONE).run(scenario)
237
+ rig.run(scenario, "--answer", f"{Q1}=overrule-test")
238
+ assert rig.code == 0, rig.ledger()
239
+ fixed = rig.calls("01", "test-writer")[1]["sha"]
240
+ assert git(rig.repo, "show", "--name-only", "--format=", fixed).split() == ["tests/test_01.py"]
241
+ assert git(rig.repo, "show", "feat/csv:demo/export.py") == "half = 1\n", "the implementer committed its own work"
242
+ entry = rig.state()["tickets"]["01"]
243
+ assert entry["held"] == {} and entry["locked"] == ["tests/test_01.py", OTHER] and not rig.led("locked file changed")
244
+ assert git(rig.repo, "show", f"feat/csv:{OTHER}") == OTHER_TEST
245
+
246
+
247
+ def test_a_locked_file_keeps_its_old_reference_until_a_test_writer_lists_it(repo, tmp_path):
248
+ def commit(name, text):
249
+ (tmp_path / name).write_text(text)
250
+ return git(tmp_path, "add", "-A") + git(tmp_path, "commit", "-q", "-m", name) + git(tmp_path, "rev-parse", "HEAD").strip()
251
+
252
+ repo()
253
+ commit("a.py", "1\n"), commit("c.py", "1\n")
254
+ old = commit("b.py", "1\n")
255
+ commit("b.py", "the implementer changed it\n")
256
+ tip = commit("a.py", "the correction\n")
257
+ first = driver_review.written(".", {"test_writer_sha": None, "locked": [], "held": {}}, ["a.py", "b.py", "c.py"], old)
258
+ assert first == {"test_writer_sha": old, "locked": ["a.py", "b.py", "c.py"], "held": {}}
259
+ after = driver_review.written(".", first, ["a.py"], tip)
260
+ assert after == {"test_writer_sha": tip, "locked": ["a.py", "b.py", "c.py"], "held": {"b.py": old}}
261
+ (finding,) = driver_review.locked_findings(".", after)
262
+ assert (finding["title"], finding["evidence"]) == ("locked file changed: b.py", f"git diff {old}..HEAD -- b.py")
263
+ assert driver_review.written(".", after, ["b.py"], tip)["held"] == {}, "the test-writer corrected it: a new reference"
264
+ # A diff that git cannot make holds each other file, and then the check of the review fails in the open.
265
+ lost = driver_review.written(".", {**first, "test_writer_sha": "0" * 40}, ["a.py"], tip)
266
+ assert lost["held"] == {"b.py": "0" * 40, "c.py": "0" * 40}
267
+ assert [f["title"].split(":")[0] for f in driver_review.locked_findings(".", lost)] == ["locked file check failed"]
268
+
269
+
270
+ # ---------------------------------------------------------------- open findings after round 4
271
+
272
+ def test_round_4_findings_wait_for_every_answer_before_the_ticket_merges(tmp_path, capsys):
273
+ rig = Inside(tmp_path, ONE)
274
+ bad = review("01", [finding("STUCK-A", line=98), finding("STUCK-B", line=99)])
275
+ rig.run(plan(**{"01/reviewer": [bad] * 5, "01/fixer": [fix("01", n) for n in (1, 2, 3, 4)]}))
276
+ out = capsys.readouterr().out
277
+ one, two = rig.state()["run"]["questions"]
278
+ assert rig.code == 1 and (one["id"], two["id"]) == ("01-finding-1", "01-finding-2") and one["stop"] == two["stop"]
279
+ assert one["subject"] == "STUCK-A (demo/export.py:98)" and one["evidence"][0] == ".agent/implement/01/findings-4.json"
280
+ assert (rig.repo / one["evidence"][0]).exists() and "Question 01-finding-2: " in out and "split (recommended)" in out
281
+
282
+ write_answers(rig, {"01-finding-1": "accept", "01-finding-1.note": "known limit", "01-finding-2.note": "?"})
283
+ rig.run({})
284
+ assert rig.code == 1 and rig.state()["tickets"]["01"]["phase"] == "blocked"
285
+ assert rig.led("Ruling: 01 finding closed as it is (accept): STUCK-A (demo/export.py:98). Note of the operator: known limit")
286
+ assert [q["id"] for q in decisions(rig)["questions"]] == ["01-finding-2"]
287
+ # The file was written for the two questions: for the one that is left it is stale.
288
+ rig.run({})
289
+ assert rig.code == 1 and len(rig.led("Ruling")) == 1 and rig.led("is ignored: its revision")
290
+ rig.run({}, "--answer", "01-finding-2=accept")
291
+ assert rig.code == 0 and rig.state()["tickets"]["01"]["phase"] == "merged" and len(rig.calls("01", "fixer")) == 4
292
+
293
+
294
+ def test_one_answer_that_keeps_the_ticket_blocked_wins_over_an_accept():
295
+ one = asked("finding", subject="A")
296
+ two = asked("finding", subject="B", before=[one])
297
+ other = asked("finding", ticket="02", subject="C", before=[one, two], stop=2)
298
+ rulings, moved = driver_decisions.apply([one, two, other], {one["id"]: "accept", two["id"]: "redesign",
299
+ other["id"]: "accept", "09-finding-1": "accept"})
300
+ assert moved == {"02": {"phase": "merge"}} and len(rulings) == 3
301
+ assert rulings[1] == "Ruling: 01 stays BLOCKED, the design changes first (redesign): B."
302
+ assert driver_decisions.apply([one, two, other], {one["id"]: "split"}) == ([], {}), "an answer is applied once"
303
+
304
+
305
+ # ---------------------------------------------------------------- unattended
306
+
307
+ def test_unattended_writes_the_stage_file_and_prints_no_question(tmp_path, capsys):
308
+ rig = Pushing(tmp_path, ONE).run(steps(blocked(), impl("01")), *UNATTENDED)
309
+ assert rig.code == 2 and "Question" not in rig.out
310
+ assert stage(rig) == {"stage": "implement", "status": "awaiting-review", "artifact": PAGE, "open_questions": 1}
311
+ write_answers(rig, {Q1: "overrule-test"})
312
+ rig.run(steps(blocked(), impl("01")), *UNATTENDED)
313
+ assert rig.code == 0 and stage(rig) == {"stage": "implement", "status": "done"}, "no stage file of the earlier run stays"
314
+ assert (rig.repo / "tests/test_01.py").read_text() == NEW_TEST
315
+
316
+
317
+ def test_unattended_names_the_blocked_tickets_and_needs_a_stage_path_to_write(tmp_path):
318
+ rig = Pushing(tmp_path, ONE, gate={"red": ["ticket/01"]})
319
+ rig.run(plan(), "--unattended")
320
+ assert rig.code == 1 and not (rig.repo / ".agent/stage.json").exists()
321
+ rig.run(plan(), "--unattended", "--stage-json", ".agent/out/stage.json")
322
+ assert json.loads((rig.repo / ".agent/out/stage.json").read_text()) == {
323
+ "stage": "implement", "status": "blocked", "blocked": ["01"]}
324
+
325
+
326
+ # ---------------------------------------------------------------- the page
327
+
328
+ def test_a_stop_without_the_review_page_skill_says_so_and_the_next_run_writes_the_page(tmp_path, monkeypatch, capsys):
329
+ template = driver_review_page.TEMPLATE
330
+ monkeypatch.setattr(driver_review_page, "TEMPLATE", tmp_path / "skills/review-page/template.html")
331
+ rig = stopped(tmp_path)
332
+ out = capsys.readouterr().out
333
+ assert rig.code == 3 and f"cannot write {PAGE}" in out and "skills/review-page/template.html" in out
334
+ assert "install the review-page skill next to implement-spec" in out and decisions(rig)["questions"]
335
+ rig.run({})
336
+ assert rig.code == 3 and not rig.calls("01", "reviewer"), "the same error, before any agent"
337
+ monkeypatch.setattr(driver_review_page, "TEMPLATE", template)
338
+ rig.run({})
339
+ assert rig.code == 2 and f'data-q="{Q1}"' in (rig.repo / PAGE).read_text()
340
+
341
+
342
+ def test_a_template_of_another_shape_is_an_error_and_every_text_of_a_question_is_escaped(tmp_path, monkeypatch):
343
+ q = asked(subject='<img onerror="x">')
344
+ q["evidence"], q["options"][0]["description"] = ["<i>.json"], "a & b"
345
+ html = driver_review_page.render(doc(q))
346
+ assert "<img" not in html and "&lt;img onerror=&quot;x&quot;&gt;" in html and "<code>&lt;i&gt;.json</code>" in html
347
+ assert "a &amp; b" in html and "<h1>1 open question of the implementation</h1>" in html
348
+ monkeypatch.setattr(driver_review_page, "TEMPLATE", tmp_path / "template.html")
349
+ (tmp_path / "template.html").write_text(html.replace("<!-- slot: track -->", ""))
350
+ with pytest.raises(ValueError, match="has 0 matches"):
351
+ driver_review_page.render(doc(q))
352
+
353
+
354
+ # ---------------------------------------------------------------- questions
355
+
356
+ def test_only_an_implementer_blocked_on_the_contradiction_concern_is_a_stop():
357
+ report = {"status": "BLOCKED", "concerns": [CONTRA, "other", "locked test contradicts spec: b::c", driver_decisions.CONTRADICTS]}
358
+ assert driver_decisions.contradicted("implementer", report) == "tests/test_01.py::test_x; b::c"
359
+ assert driver_decisions.contradicted("fixer", report) == ""
360
+ assert driver_decisions.contradicted("implementer", {**report, "status": "DONE"}) == ""
361
+ assert driver_decisions.contradicted("implementer", {}) == "", "a rejected report is no report"
362
+ assert driver_decisions.contradicted("implementer", {"status": "BLOCKED", "concerns": [driver_decisions.CONTRADICTS]}) == ""
363
+
364
+
365
+ def test_question_ids_count_per_ticket_and_kind_over_the_run_and_only_open_questions_are_published():
366
+ one = asked()
367
+ two, other, found = asked(before=[one]), asked(ticket="02", before=[one]), asked("finding", before=[one])
368
+ assert [q["id"] for q in (one, two, other, found)] == ["01-locked-test-1", "01-locked-test-2", "02-locked-test-1", "01-finding-1"]
369
+ both = doc(one, two)
370
+ assert set(both["questions"][0]) == set(driver_decisions.PUBLIC) and len(both["questions"]) == 2
371
+ one["answer"] = "overrule-test"
372
+ left = doc(one, two)
373
+ assert [q["id"] for q in left["questions"]] == ["01-locked-test-2"] and left["revision"] != both["revision"]
374
+ assert driver_decisions.parked([one, two]) and not driver_decisions.parked([one, found])
375
+ assert driver_decisions.text([], "answers.json", "page.html") == ""
376
+ for kind in driver_decisions.KINDS.values():
377
+ assert 2 <= len(kind.options) <= 4 and kind.recommended in kind.options
378
+
379
+
380
+ # ---------------------------------------------------------------- the answers file
381
+
382
+ @pytest.fixture
383
+ def one(tmp_path, monkeypatch):
384
+ """The decisions file of one open question, in a directory where the answers file goes."""
385
+ monkeypatch.chdir(tmp_path)
386
+ (tmp_path / ".agent").mkdir()
387
+ return doc(asked())
388
+
389
+
390
+ def answers(document, **fields):
391
+ body = {"v": 1, "stage": "implement", "submitted": "approve", "revision": document["revision"],
392
+ "answers": {Q1: "overrule-test"}, **fields}
393
+ driver_answers.FILE.write_text(json.dumps(body))
394
+ return driver_answers.read(document)
395
+
396
+
397
+ @pytest.mark.parametrize("fields, why", [
398
+ ({"revision": "0" * 64}, "its revision 000000000000 is not the revision"),
399
+ ({"revision": None}, "its revision None is not the revision"),
400
+ ({"submitted": None}, "it is a draft: `submitted` is not set"),
401
+ ({"stage": "plan"}, "it is not a version 1 file of the implement stage"),
402
+ ({"v": 2}, "it is not a version 1 file of the implement stage"),
403
+ ({"answers": ["overrule-test"]}, "it is not an object with an `answers` object")])
404
+ def test_a_file_that_is_not_a_submission_for_this_revision_gives_no_answer(one, fields, why):
405
+ found, (ignored,) = answers(one, **fields)
406
+ assert found == {} and ignored.startswith(f"The answers file .agent/implement-answers.json is ignored: {why}")
407
+
408
+
409
+ @pytest.mark.parametrize("content", ["{not json", "[]", '"text"'])
410
+ def test_a_file_that_is_not_a_json_object_gives_no_answer(one, content):
411
+ driver_answers.FILE.write_text(content)
412
+ found, (ignored,) = driver_answers.read(one)
413
+ assert found == {} and "is ignored: it is not" in ignored
414
+
415
+
416
+ def test_each_answer_is_checked_on_its_own_and_a_note_goes_with_its_question(one):
417
+ found, ignored = answers(one, answers={Q1: "overrule-test", f"{Q1}.note": "see ADR 3", "track": "standard",
418
+ "02-finding-1.note": "x"})
419
+ assert found == {Q1: "overrule-test", f"{Q1}.note": "see ADR 3"}
420
+ assert ignored == ["The answer for track in .agent/implement-answers.json is ignored: there is no open question track.",
421
+ "The answer for 02-finding-1.note in .agent/implement-answers.json is ignored: there is no open "
422
+ "question 02-finding-1."]
423
+ found, (ignored,) = answers(one, answers={Q1: ["overrule-test"], f"{Q1}.note": 5})
424
+ assert found == {} and ignored.endswith("the question offers overrule-test, overrule-implementer.")
425
+
426
+
427
+ def test_no_open_question_or_no_file_reads_nothing_and_given_writes_only_valid_answers(one):
428
+ assert driver_answers.read(one) == ({}, [])
429
+ assert driver_answers.given([], one) == [] and not driver_answers.FILE.exists()
430
+ assert driver_answers.given([(Q1, "overrule-test"), ("zz", "accept")], one) == ["--answer zz=accept: no open question zz"]
431
+ assert not driver_answers.FILE.exists()
432
+ assert driver_answers.given([(Q1, "overrule-test")], one) == []
433
+ assert driver_answers.read(one) == ({Q1: "overrule-test"}, [])
434
+ assert driver_answers.read(doc()) == ({}, []), "with no open question the file is history"