@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.
Files changed (72) 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 +214 -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 +505 -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 +105 -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 +10 -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/rebase-conflict.md +33 -0
  38. package/skills/implement-spec/prompts/rereview.md +3 -0
  39. package/skills/implement-spec/prompts/rerun.md +2 -0
  40. package/skills/implement-spec/prompts/reviewer.md +34 -0
  41. package/skills/implement-spec/prompts/stands.md +2 -0
  42. package/skills/implement-spec/prompts/test-writer.md +34 -0
  43. package/skills/implement-spec/prompts/timeout-restored.md +2 -0
  44. package/skills/implement-spec/prompts/timeout-shared.md +2 -0
  45. package/skills/implement-spec/prompts/timeout.md +2 -0
  46. package/skills/implement-spec/run.sh +101 -0
  47. package/skills/implement-spec/tests/conftest.py +38 -0
  48. package/skills/implement-spec/tests/test_agents.py +358 -0
  49. package/skills/implement-spec/tests/test_amendments.py +117 -0
  50. package/skills/implement-spec/tests/test_cli.py +220 -0
  51. package/skills/implement-spec/tests/test_decisions.py +434 -0
  52. package/skills/implement-spec/tests/test_driver.py +767 -0
  53. package/skills/implement-spec/tests/test_final.py +442 -0
  54. package/skills/implement-spec/tests/test_fixloop.py +523 -0
  55. package/skills/implement-spec/tests/test_graph.py +32 -0
  56. package/skills/implement-spec/tests/test_landing.py +355 -0
  57. package/skills/implement-spec/tests/test_merge.py +144 -0
  58. package/skills/implement-spec/tests/test_models.py +47 -0
  59. package/skills/implement-spec/tests/test_report.py +162 -0
  60. package/skills/implement-spec/tests/test_review.py +338 -0
  61. package/skills/implement-spec/tests/test_review_page.py +67 -0
  62. package/skills/implement-spec/tests/test_slots.py +32 -0
  63. package/skills/implement-spec/tests/test_state.py +68 -0
  64. package/skills/implement-spec/tests/test_stops.py +272 -0
  65. package/skills/implement-spec/tests/test_summary.py +28 -0
  66. package/skills/implement-spec/tests/test_tickets.py +72 -0
  67. package/skills/implement-spec/tests/test_tidy.py +582 -0
  68. package/skills/implement-spec/tests/test_walk.py +457 -0
  69. package/skills/review-page/template.html +1 -1
  70. package/skills/to-tickets/SKILL.md +19 -2
  71. package/skills-manifest.json +7 -7
  72. package/skills/implement-spec/history.md +0 -52
@@ -0,0 +1,358 @@
1
+ """Agent launch acceptance tests, through `sh run.sh` with a fake runner script or fake claude/codex on PATH.
2
+
3
+ Interface choices (ticket 02): `--runner <script>` is called as `<script> <role> <prompt> <report>` in the slot
4
+ directory. A plain run (no --dry-run) branches ticket/NN from the PR tip in slot 1, runs the test-writer (unless the
5
+ Seam is `none — refactor`) then the implementer; from ticket 03 on an accepted ticket goes on to the driver's gate (`true` here) and a
6
+ merge into the PR branch, which deletes `ticket/NN`. Exit 0 when the ticket merges, 1 on BLOCKED.
7
+ `--only <role>` runs that one role on the first ticket and stops. `--spec <path>` names the spec for the prompts.
8
+ `--timeout <role>=<minutes>` (repeatable, fractions allowed) overrides a role timeout.
9
+ Ticket 08: the fixture has no `origin`, and a run that merges its ticket ends with the tidy and the final review, so
10
+ `ticket_commits()` reads the ticket's commits from `refs/implement/recovery`, the PR tip from before the tidy.
11
+ """
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+ from test_cli import GIT, ROLES, RUN, SKILL, TOOLS, drive, git, make_repo, models_file, ticket
23
+
24
+ RED = "FAILED tests/test_export.py::test_rows - AttributeError"
25
+ RUNNER = f"""#!{sys.executable}
26
+ import json, os, subprocess, sys, time
27
+ from pathlib import Path
28
+ here = Path(__file__).resolve().parent
29
+ role, prompt, report = sys.argv[1:4]
30
+ def git(*a):
31
+ cmd = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", *a]
32
+ return subprocess.run(cmd, capture_output=True, text=True).stdout.strip()
33
+ calls = here / "calls.jsonl"
34
+ past = [json.loads(l) for l in calls.read_text().splitlines()] if calls.exists() else []
35
+ n = sum(1 for c in past if c["role"] == role)
36
+ rec = dict(role=role, argv=sys.argv[1:], cwd=os.path.realpath(os.getcwd()), prompt=Path(prompt).read_text(),
37
+ branch=git("rev-parse", "--abbrev-ref", "HEAD"), head=git("rev-parse", "HEAD"),
38
+ status=git("status", "--porcelain"))
39
+ with calls.open("a") as f:
40
+ f.write(json.dumps(rec) + "\\n")
41
+ plan = json.loads((here / "plan.json").read_text()).get(role, [])
42
+ step = plan[n] if n < len(plan) else {{}}
43
+ if role in ("reviewer", "correctness", "final-reviewer") and "report" not in step: # a role the test does not script reports no findings
44
+ reds = [f"FAILED {{p}}::test_x - AttributeError" for p in git("diff", "--name-only", "feat/csv...HEAD", "--", "tests").split()]
45
+ proofs = [dict(test=r.split()[1], file="demo/export.py", line=1, red=r) for r in reds]
46
+ step = dict(step, stdout="".join(r + "\\n" for r in reds), report=dict(
47
+ status="DONE", commits=[], files=[], concerns=[], findings=[], mutations=proofs))
48
+ for cmd in step.get("git", []):
49
+ git(*cmd)
50
+ for path, text in step.get("files", {{}}).items():
51
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
52
+ Path(path).write_text(text)
53
+ sys.stdout.write(step.get("stdout", ""))
54
+ sys.stderr.write(step.get("stderr", ""))
55
+ sys.stdout.flush()
56
+ sys.stderr.flush()
57
+ time.sleep(step.get("sleep", 0))
58
+ sha = ""
59
+ if "commit" in step:
60
+ git("add", "-A")
61
+ git("commit", "-q", "-m", step["commit"])
62
+ sha = git("rev-parse", "HEAD")
63
+ if step.get("report") is not None:
64
+ Path(report).parent.mkdir(parents=True, exist_ok=True)
65
+ Path(report).write_text(json.dumps(step["report"]).replace("$SHA", sha))
66
+ sys.exit(step.get("exit", 0))
67
+ """
68
+
69
+
70
+ @pytest.fixture
71
+ def env(tmp_path):
72
+ """A restricted PATH whose fake claude and codex append their argv (NUL-separated) and exit 1."""
73
+ bin_dir = tmp_path / "bin"
74
+ bin_dir.mkdir()
75
+ for name in TOOLS:
76
+ if shutil.which(name):
77
+ (bin_dir / name).symlink_to(shutil.which(name))
78
+ for cli in ("claude", "codex"):
79
+ log = f'"{tmp_path}/argv-{cli}"'
80
+ (bin_dir / cli).write_text(f"#!/bin/sh\nprintf '%s\\0' \"$@\" >> {log}\nprintf '\\036' >> {log}\nexit 1\n")
81
+ (bin_dir / cli).chmod(0o755)
82
+ return dict(os.environ, PATH=str(bin_dir)), tmp_path
83
+
84
+
85
+ def launches(tmp_path, cli):
86
+ path = tmp_path / f"argv-{cli}"
87
+ records = path.read_bytes().decode().split("\x1e")[:-1] if path.exists() else []
88
+ return [record.split("\0")[:-1] for record in records]
89
+
90
+
91
+ def value_after(argv, *flags):
92
+ return next((argv[i + 1] for i, arg in enumerate(argv[:-1]) if arg in flags), None)
93
+
94
+
95
+ class World:
96
+ def __init__(self, env, *, refactor=False, **models):
97
+ self.env, self.tmp = env, env[1]
98
+ seam = {"seam": "none — refactor", "criteria": ()} if refactor else {}
99
+ self.repo = make_repo(self.tmp / "demo", {"01-export-rows.md": ticket("01", "Export rows", **seam)})
100
+ (self.repo / "specs/csv").mkdir(parents=True)
101
+ (self.repo / "specs/csv/spec.md").write_text("# csv spec\n")
102
+ git(self.repo, "add", "specs")
103
+ git(self.repo, "commit", "-q", "-m", "spec")
104
+ self.base = git(self.repo, "rev-parse", "HEAD").strip()
105
+ self.models = models_file(self.tmp, **models)
106
+ self.runner = self.tmp / "runner/run-agent"
107
+ self.runner.parent.mkdir()
108
+ self.runner.write_text(RUNNER)
109
+ self.runner.chmod(0o755)
110
+
111
+ def run(self, plan=None, *extra, runner=True):
112
+ (self.runner.parent / "plan.json").write_text(json.dumps(plan or {}))
113
+ args = ["--tickets", ".agent/tickets", "--gate", "true", "--spec", "specs/csv/spec.md",
114
+ "--models", str(self.models), *(["--runner", str(self.runner)] if runner else []), *extra]
115
+ return drive(self.env, self.repo, *args)
116
+
117
+ def calls(self, role):
118
+ path = self.runner.parent / "calls.jsonl"
119
+ rows = [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
120
+ return [row for row in rows if row["role"] == role]
121
+
122
+ def ledger(self):
123
+ path = self.repo / ".agent/implement/ledger.md"
124
+ return path.read_text() if path.exists() else ""
125
+
126
+ def ticket_commits(self):
127
+ made = subprocess.run(["git", "rev-parse", "--verify", "-q", "refs/implement/recovery"], cwd=self.repo,
128
+ capture_output=True, text=True).stdout.strip() or "feat/csv"
129
+ return git(self.repo, "rev-list", "--reverse", "--no-merges", f"{self.base}..{made}").split()
130
+
131
+
132
+ def ok(proc):
133
+ assert proc.returncode == 0, proc.stdout + proc.stderr
134
+
135
+
136
+ def blocked(proc):
137
+ assert proc.returncode == 1, proc.stdout + proc.stderr
138
+
139
+
140
+ def tw(n=0, stdout=RED + "\n", **changes):
141
+ step = {"files": {"tests/test_export.py": f"def test_rows():\n assert {n} == 'rows'\n"},
142
+ "stdout": stdout, "commit": "test: red",
143
+ "report": {"status": "DONE", "commits": ["$SHA"], "files": ["tests/test_export.py"], "concerns": [],
144
+ "locked": ["tests/test_export.py"],
145
+ "red": [{"test": "tests/test_export.py::test_rows", "line": RED}]}}
146
+ return {**step, **changes}
147
+
148
+
149
+ def impl(n=0, **changes):
150
+ step = {"files": {"demo/export.py": f"def export_csv():\n return 'rows{n}'\n"}, "commit": "feat: rows",
151
+ "report": {"status": "DONE", "commits": ["$SHA"], "files": ["demo/export.py"], "concerns": [],
152
+ "green": ["1 passed"]}}
153
+ return {**step, **changes}
154
+
155
+
156
+ def with_report(step, **changes):
157
+ return {**step, "report": {**step["report"], **changes}}
158
+
159
+
160
+ def test_runner_is_called_with_role_prompt_report_in_the_slot_directory(env):
161
+ w = World(env, refactor=True)
162
+ ok(w.run({"implementer": [impl()]}))
163
+ (call,) = w.calls("implementer")
164
+ role, prompt, report = call["argv"]
165
+ assert role == "implementer" and call["prompt"].strip()
166
+ assert Path(call["cwd"], report).resolve().as_posix().endswith(".agent/implement/01/implementer-0.json")
167
+ assert Path(call["cwd"], report).exists()
168
+ assert call["cwd"] == str(w.repo.resolve()) and call["branch"] == "ticket/01"
169
+
170
+
171
+ def test_default_models_file_names_every_role():
172
+ models = json.loads((SKILL / "models.json").read_text())
173
+ assert set(models) == set(ROLES) and all(models[role] for role in ROLES)
174
+
175
+
176
+ def test_help_lists_the_timeout_flag_and_default_minutes():
177
+ proc = subprocess.run(["sh", str(RUN), "--help"], capture_output=True, text=True)
178
+ assert "--timeout" in proc.stdout and re.search(r"implementer\W+90", proc.stdout), proc.stdout
179
+
180
+
181
+ @pytest.mark.parametrize("entry", ["openai/gpt-sol@medium"])
182
+ def test_openai_implementer_launches_codex_exec_in_workspace_write(env, entry):
183
+ # ticket 08: the correctness list needs an entry on another provider than the implementer's
184
+ w = World(env, refactor=True, implementer=[entry], correctness=["claude-opus-5@medium"])
185
+ w.run(runner=False)
186
+ argv = launches(w.tmp, "codex")[0]
187
+ assert argv[0] == "exec" and "--ephemeral" not in argv
188
+ assert value_after(argv, "--model", "-m") == "gpt-sol"
189
+ assert any("model_reasoning_effort" in a and "medium" in a for a in argv)
190
+ assert value_after(argv, "--sandbox", "-s") == "workspace-write"
191
+ assert not launches(w.tmp, "claude")
192
+
193
+
194
+ @pytest.mark.parametrize("role", ["reviewer", "correctness"])
195
+ def test_reviewing_roles_launch_codex_exec_read_only(env, role):
196
+ w = World(env, refactor=True, **{role: ["openai/gpt-sol@medium"]})
197
+ w.run(None, "--only", role, runner=False)
198
+ argv = launches(w.tmp, "codex")[0]
199
+ assert argv[0] == "exec" and "--ephemeral" not in argv
200
+ assert value_after(argv, "--sandbox", "-s") == "read-only"
201
+ assert value_after(argv, "--model", "-m") == "gpt-sol"
202
+
203
+
204
+ @pytest.mark.parametrize("entry", ["claude-opus-5@medium", "anthropic/claude-opus-5@medium"])
205
+ def test_claude_entry_launches_claude_p_and_bare_means_anthropic(env, entry):
206
+ w = World(env, refactor=True, implementer=[entry])
207
+ w.run(runner=False)
208
+ argv = launches(w.tmp, "claude")[0]
209
+ assert ("-p" in argv or "--print" in argv)
210
+ assert value_after(argv, "--model") == "claude-opus-5"
211
+ assert value_after(argv, "--effort") == "medium"
212
+ assert not launches(w.tmp, "codex")
213
+
214
+
215
+ @pytest.mark.parametrize("entry", ["mystery/model@medium", "anthropic/claude-opus-5@turbo", "openai/gpt-sol@turbo"])
216
+ def test_unknown_provider_or_effort_exits_3_naming_role_and_entry_before_any_agent(env, entry):
217
+ w = World(env, reviewer=[entry])
218
+ proc = drive(w.env, w.repo, "--tickets", ".agent/tickets", "--gate", "true", "--models", str(w.models), "--dry-run")
219
+ assert proc.returncode == 3, proc.stdout + proc.stderr
220
+ assert any("reviewer" in l and entry in l for l in proc.stdout.splitlines()), proc.stdout
221
+ proc = w.run({"test-writer": [tw()]})
222
+ assert proc.returncode == 3 and not w.calls("test-writer")
223
+ assert not launches(w.tmp, "claude") and not launches(w.tmp, "codex")
224
+ assert "ticket/01" not in git(w.repo, "branch", "--list")
225
+
226
+
227
+ def test_implementer_prompt_carries_pointers_and_only_template_sentences(env):
228
+ w = World(env)
229
+ ok(w.run({"test-writer": [tw()], "implementer": [impl()]}))
230
+ (call,) = w.calls("implementer")
231
+ prompt, report = call["prompt"], call["argv"][2]
232
+ sha = w.ticket_commits()[0]
233
+ for pointer in ("specs/csv/spec.md", ".agent/tickets/01-export-rows.md", "`export_csv()`", sha,
234
+ "tests/test_export.py", report):
235
+ assert pointer in prompt, pointer
236
+ template = (SKILL / "prompts/implementer.md").read_text()
237
+ for prefix in (str(w.repo.resolve()) + "/", str(w.repo) + "/"):
238
+ prompt = prompt.replace(prefix, "")
239
+ for value in (report, "specs/csv/spec.md", ".agent/tickets/01-export-rows.md", "`export_csv()`", sha,
240
+ "tests/test_export.py"):
241
+ prompt = prompt.replace(value, "")
242
+ lines = lambda text: {" ".join(l.split()) for l in text.splitlines() if l.strip()}
243
+ assert lines(prompt) <= lines(re.sub(r"\{[a-z_]+\}", "", template))
244
+
245
+
246
+ @pytest.mark.parametrize("role, extra, refactor", [
247
+ ("test-writer", (), False), ("implementer", (), True),
248
+ ("reviewer", ("--only", "reviewer"), True), ("correctness", ("--only", "correctness"), True)])
249
+ def test_every_prompt_ends_with_report_path_schema_and_the_never_rule(env, role, extra, refactor):
250
+ w = World(env, refactor=refactor)
251
+ w.run(None, *extra)
252
+ call = w.calls(role)[0]
253
+ prompt, report = call["prompt"].rstrip(), call["argv"][2]
254
+ last = prompt.splitlines()[-1].lower()
255
+ assert "never" in last and "stash" in last and "reset" in last and "check out" in last, last
256
+ assert prompt.rfind(report) < prompt.rfind("DONE_WITH_CONCERNS") < prompt.rfind(prompt.splitlines()[-1])
257
+ assert report in prompt
258
+
259
+
260
+ def test_no_report_is_a_violation_with_one_rerun_then_blocked(env):
261
+ w = World(env, refactor=True)
262
+ blocked(w.run({"implementer": [{}, {}, impl()]}))
263
+ first, second = w.calls("implementer")
264
+ assert first["prompt"].strip() in second["prompt"] and len(second["prompt"]) > len(first["prompt"])
265
+ assert "report" in second["prompt"][len(first["prompt"].rstrip()):].lower()
266
+ assert len(w.calls("implementer")) == 2
267
+ assert any("01" in l and "BLOCKED" in l for l in w.ledger().splitlines()), w.ledger()
268
+
269
+
270
+ def test_finished_status_reruns_the_implementer_once_with_the_error_appended(env):
271
+ w = World(env)
272
+ ok(w.run({"test-writer": [tw()], "implementer": [with_report(impl(0), status="FINISHED"), impl(1)]}))
273
+ first, second = w.calls("implementer")
274
+ assert first["prompt"].strip() in second["prompt"] and "FINISHED" in second["prompt"]
275
+ assert "FINISHED" not in first["prompt"]
276
+
277
+
278
+ def test_second_schema_violation_blocks_the_ticket_with_the_error_in_the_ledger(env):
279
+ w = World(env)
280
+ bad = lambda n: with_report(impl(n), status="FINISHED")
281
+ blocked(w.run({"test-writer": [tw()], "implementer": [bad(0), bad(1), impl(2)]}))
282
+ assert len(w.calls("implementer")) == 2
283
+ assert any("01" in l and "BLOCKED" in l and "FINISHED" in l for l in w.ledger().splitlines()), w.ledger()
284
+
285
+
286
+ @pytest.mark.parametrize("second_matches", [True, False])
287
+ def test_red_line_missing_from_the_log_reruns_once_then_blocks(env, second_matches):
288
+ w = World(env)
289
+ plan = {"test-writer": [tw(0, "no such line\n"), tw(1, RED + "\n" if second_matches else "still none\n")],
290
+ "implementer": [impl()]}
291
+ proc = w.run(plan)
292
+ second = w.calls("test-writer")[1]["prompt"]
293
+ assert "red line not found in log" in second and "red line not found" not in w.calls("test-writer")[0]["prompt"]
294
+ if second_matches:
295
+ ok(proc)
296
+ assert len(w.calls("implementer")) == 1
297
+ else:
298
+ blocked(proc)
299
+ assert len(w.calls("test-writer")) == 2 and not w.calls("implementer")
300
+ assert any("01" in l and "BLOCKED" in l for l in w.ledger().splitlines()), w.ledger()
301
+
302
+
303
+ def test_implementer_report_with_no_new_commit_is_a_violation(env):
304
+ w = World(env)
305
+ nothing = with_report(impl(), commits=[])
306
+ nothing.pop("commit"), nothing.pop("files")
307
+ blocked(w.run({"test-writer": [tw()], "implementer": [nothing, nothing]}))
308
+ assert len(w.calls("implementer")) == 2
309
+ assert any("01" in l and "BLOCKED" in l and "commit" in l for l in w.ledger().splitlines()), w.ledger()
310
+
311
+
312
+ def test_timeout_keeps_the_tree_and_reruns_once_with_double_the_limit(env):
313
+ w = World(env, refactor=True)
314
+ hang = {"files": {"wip.txt": "half done\n"}, "sleep": 60}
315
+ # 0.04 min = 2.4 s: the rerun sleeps 3.6 s, which passes only under the doubled 4.8 s limit.
316
+ ok(w.run({"implementer": [hang, impl(sleep=3.6)]}, "--timeout", "implementer=0.04"))
317
+ first, second = w.calls("implementer")
318
+ assert "wip.txt" in second["status"]
319
+ prompt = second["prompt"].lower()
320
+ assert "timed out" in prompt and "in the tree" in prompt and "timed out" not in first["prompt"].lower()
321
+ assert git(w.repo, "show", "feat/csv:wip.txt") == "half done\n"
322
+
323
+
324
+ def test_second_timeout_blocks_the_ticket(env):
325
+ w = World(env, refactor=True)
326
+ hang = {"files": {"wip.txt": "half done\n"}, "sleep": 60}
327
+ blocked(w.run({"implementer": [hang, hang, impl()]}, "--timeout", "implementer=0.04"))
328
+ assert len(w.calls("implementer")) == 2
329
+ assert any("BLOCKED" in l and "timed out" in l for l in w.ledger().splitlines()), w.ledger()
330
+ assert (w.repo / "wip.txt").exists()
331
+
332
+
333
+ def test_head_moved_to_the_pr_branch_resets_the_slot_and_reruns_once(env):
334
+ w = World(env)
335
+ ok(w.run({"test-writer": [tw()], "implementer": [{"git": [["checkout", "feat/csv"]]}, impl()]}))
336
+ first, second = w.calls("implementer")
337
+ tests_sha = w.ticket_commits()[0]
338
+ assert (second["branch"], second["head"]) == ("ticket/01", tests_sha)
339
+ assert any("implementer" in l and "feat/csv" in l for l in w.ledger().splitlines()), w.ledger()
340
+
341
+
342
+ def test_a_missing_ticket_branch_is_a_violation_named_in_the_ledger(env):
343
+ w = World(env)
344
+ gone = {"git": [["checkout", "--detach"], ["branch", "-D", "ticket/01"]]}
345
+ proc = w.run({"test-writer": [tw()], "implementer": [gone, impl()]})
346
+ assert "Traceback" not in proc.stderr
347
+ assert any("ticket/01" in l and "branch" in l for l in w.ledger().splitlines()), w.ledger()
348
+
349
+
350
+ def test_log_next_to_report_and_ledger_records_role_entry_duration_and_cost(env):
351
+ w = World(env, refactor=True, implementer=["claude-opus-5@medium"])
352
+ step = impl(stdout='OUT-MARK\n{"total_cost_usd": 0.4242}\n', stderr="ERR-MARK\n")
353
+ ok(w.run({"implementer": [step]}))
354
+ folder = w.repo / ".agent/implement/01"
355
+ log = (folder / "implementer-0.log").read_text()
356
+ assert "OUT-MARK" in log and "ERR-MARK" in log and (folder / "implementer-0.json").exists()
357
+ (line,) = [l for l in w.ledger().splitlines() if "implementer" in l and "claude-opus-5@medium" in l]
358
+ assert re.search(r"\d+(\.\d+)?s\b", line) and "0.4242" in line, line
@@ -0,0 +1,117 @@
1
+ """Ticket 08 acceptance tests: a run with no `origin` gets its final review; the correctness CLI is mandatory.
2
+
3
+ All through `sh run.sh --runner <script> [--push] [--unattended]` on the fixture repository: the rig of test_final
4
+ with and without its bare `origin`, and the rig of test_review with and without the fake `codex` on PATH.
5
+
6
+ Interface choices (ticket 08):
7
+ - with no `origin`, the tidied commits sit on the commit the PR branch had when the run started (nothing to rebase
8
+ onto), and `refs/implement/recovery` holds the PR tip from before the tidy, as with an `origin`.
9
+ - the one ledger sentence about the missing remote names `origin` and says that the rebase and the push were skipped.
10
+ - a refused start (exit 3) prints its reason on stdout or stderr: `origin` for a push that has no remote; for the
11
+ correctness role one line that names `correctness` and `codex`, or `correctness` and each entry of its list.
12
+ """
13
+ import pytest
14
+
15
+ from test_cli import git, ticket
16
+ from test_final import Final, ref, stop_lines
17
+ from test_review import FABLE, ONE, OPUS, RISKY, SOL, Review, correct, review
18
+ from test_stops import UNATTENDED
19
+ from test_walk import plan
20
+
21
+ TWO = {"01-export-rows.md": ticket("01", "Export rows"), "02-quote-fields.md": ticket("02", "Quote fields", "01")}
22
+
23
+
24
+ # ---------------------------------------------------------------- no origin
25
+
26
+ @pytest.fixture(scope="module")
27
+ def bare(tmp_path_factory):
28
+ """Two tickets in a chain, no `origin`, no `--push`: both merge as fast-forwards, four commits in all."""
29
+ rig = Final(tmp_path_factory.mktemp("bare"), TWO, remote=False)
30
+ rig.start = rig.branch()
31
+ return rig.run(plan())
32
+
33
+
34
+ def test_with_no_origin_the_branch_is_tidied_gated_and_reviewed_with_no_rebase_and_no_push(bare):
35
+ rig = bare
36
+ assert rig.code == 0, rig.output()
37
+ tip, recovery = rig.branch(), rig.recovery()
38
+ assert recovery and len(rig.commits(rig.start, recovery)) == 4, "the commits of the walk are at the recovery point"
39
+ rows = rig.commits(rig.start)
40
+ assert [r["subject"] for r in rows] == ["feat(01): Export rows", "feat(02): Quote fields"]
41
+ assert ref(rig.repo, f"{rows[0]['sha']}~1") == rig.start, "no rebase: the ticket commits sit on the branch's start"
42
+ assert ref(rig.repo, "feat/csv^{tree}") == ref(rig.repo, "refs/implement/recovery^{tree}")
43
+ records = sorted(p.name for p in (rig.repo / ".agent/implement/final").glob("gate-*.log"))
44
+ assert records == [f"gate-{tip[:7]}.log"], records
45
+ (gate,) = [g for g in rig.gates() if g["commit"] == tip]
46
+ (call,) = rig.final_calls("final-reviewer")
47
+ assert call["head"] == tip and gate["t0"] < call["t0"], "the final review reads the gated, tidied tip"
48
+ assert not rig.final_calls("conflict") and not rig.prs() and git(rig.repo, "remote") == "", "nothing is pushed"
49
+ branch_tip, reviewed, _ = stop_lines(rig.out)
50
+ assert branch_tip and reviewed, rig.out
51
+ assert tip.startswith(branch_tip.group(1)) and tip.startswith(reviewed.group(1))
52
+
53
+
54
+ def test_with_no_origin_one_ledger_sentence_says_why_and_the_summary_does_not_deny_the_final_review(bare):
55
+ (line,) = bare.led("origin")
56
+ low = line.lower()
57
+ assert "rebase" in low and "push" in low and "skipped" in low and "no origin" in low, line
58
+ assert "did not run" not in (bare.repo / ".agent/implement/summary.md").read_text()
59
+
60
+
61
+ @pytest.mark.parametrize("flags", [UNATTENDED, ("--push",)], ids=["unattended", "push"])
62
+ def test_a_push_with_no_origin_is_refused_at_the_start_before_any_agent(tmp_path, flags):
63
+ rig = Final(tmp_path, ONE, remote=False).run(plan(), *flags)
64
+ assert rig.code == 3, rig.output()
65
+ assert "origin" in rig.out + rig.err
66
+ assert not rig.calls() and not rig.gates() and not rig.branches()
67
+
68
+
69
+ def test_with_an_origin_the_end_of_run_still_rebases_onto_the_fetched_base_and_pushes(tmp_path):
70
+ rig = Final(tmp_path, ONE)
71
+ rig.move_base({"docs.md": "base docs\n"})
72
+ rig.run(plan(), "--push")
73
+ assert rig.code == 0, rig.output()
74
+ git(rig.repo, "merge-base", "--is-ancestor", ref(rig.origin, "refs/heads/main"), "feat/csv")
75
+ assert rig.pushes() == [f"refs/heads/feat/csv {rig.branch()}"] and len(rig.prs()) == 1
76
+ assert len(rig.final_calls("final-reviewer")) == 1 and not [l for l in rig.ledger() if "skipped" in l.lower()]
77
+
78
+
79
+ # ---------------------------------------------------------------- the correctness CLI
80
+
81
+ @pytest.mark.parametrize("files", [RISKY, ONE], ids=["risk-line", "no-risk-line"])
82
+ def test_a_correctness_entry_whose_cli_is_not_on_path_stops_the_run_before_any_agent(tmp_path, files):
83
+ rig = Review(tmp_path, files, {"correctness": [SOL, OPUS], "implementer": [OPUS]})
84
+ (tmp_path / "bin/codex").unlink()
85
+ rig.run(plan())
86
+ assert rig.code == 3, rig.output()
87
+ assert [l for l in (rig.out + rig.err).splitlines() if "correctness" in l and "codex" in l], rig.output()
88
+ assert not rig.calls() and not rig.gates()
89
+
90
+
91
+ def test_a_correctness_list_all_on_the_implementers_provider_stops_the_run_before_any_agent(tmp_path):
92
+ rig = Review(tmp_path, RISKY, {"correctness": [OPUS, FABLE], "implementer": [OPUS]})
93
+ rig.run(plan())
94
+ assert rig.code == 3, rig.output()
95
+ assert [l for l in (rig.out + rig.err).splitlines() if "correctness" in l and OPUS in l and FABLE in l], rig.output()
96
+ assert not rig.calls() and not rig.gates()
97
+
98
+
99
+ @pytest.mark.parametrize("entries, codex", [([SOL, OPUS], False), ([OPUS], True)], ids=["no-codex", "one-provider"])
100
+ def test_no_run_folds_the_correctness_pass_into_the_reviewer(tmp_path, entries, codex):
101
+ rig = Review(tmp_path, RISKY, {"correctness": entries, "implementer": [OPUS]})
102
+ if not codex:
103
+ (tmp_path / "bin/codex").unlink()
104
+ rig.run(plan())
105
+ assert not [c for c in rig.calls(role="reviewer") if "correctness.md" in c["prompt"]], "a reviewer got the pass"
106
+ assert not rig.led("folded"), rig.ledger()
107
+
108
+
109
+ def test_a_risky_ticket_still_gets_reviewer_and_correctness_together_on_the_entry_of_another_provider(tmp_path):
110
+ rig = Review(tmp_path, RISKY, {"correctness": [OPUS, SOL], "implementer": [OPUS]})
111
+ rig.run(plan(**{"02/reviewer": [review("02", touch=["r"], wait=["c"])],
112
+ "02/correctness": [correct(touch=["c"], wait=["r"])]}))
113
+ assert rig.code == 0, rig.output()
114
+ (r,), (c,) = rig.calls("02", "reviewer"), rig.calls("02", "correctness")
115
+ assert r["t0"] < c["t1"] and c["t0"] < r["t1"], "each started before the other ended"
116
+ assert rig.led("correctness", "gpt-sol@medium") and not rig.led("correctness", OPUS)
117
+ assert "correctness.md" not in r["prompt"] and not rig.led("folded")