@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,68 @@
1
+ """state.json: no lost update between threads, atomic publish, and the resume check against git."""
2
+ import json
3
+ import subprocess
4
+ import threading
5
+
6
+ import driver_state
7
+ from conftest import GIT
8
+
9
+
10
+ def test_updates_from_many_threads_are_all_kept_and_no_scratch_file_stays(tmp_path):
11
+ state = driver_state.State(tmp_path / "implement/state.json")
12
+ numbers = [f"{n:02d}" for n in range(1, 9)]
13
+ assert state.start("feat/x", numbers)["run"]["pr_branch"] == "feat/x"
14
+
15
+ def work(number):
16
+ for step in range(40):
17
+ state.update(lambda s: s["run"].update(gates=s["run"]["gates"] + 1))
18
+ state.update(lambda s: s["tickets"][number]["green_gates"].append(step))
19
+ state.ticket(number, round=step)
20
+
21
+ threads = [threading.Thread(target=work, args=(number,)) for number in numbers]
22
+ [thread.start() for thread in threads]
23
+ [thread.join() for thread in threads]
24
+ final = json.loads(state.path.read_text())
25
+ assert final["run"]["gates"] == 320
26
+ assert all(final["tickets"][n]["green_gates"] == list(range(40)) and final["tickets"][n]["round"] == 39 for n in numbers)
27
+ assert [p.name for p in state.path.parent.iterdir()] == ["state.json"]
28
+
29
+
30
+ def test_start_keeps_a_recorded_run_and_adds_only_the_new_tickets(tmp_path):
31
+ state = driver_state.State(tmp_path / "state.json")
32
+ revision = state.start("feat/x", ["01"])["run"]["revision"]
33
+ state.ticket("01", phase="merged")
34
+ again = state.start("other", ["01", "02"])
35
+ assert (again["run"]["revision"], again["run"]["pr_branch"]) == (revision, "feat/x")
36
+ assert again["tickets"]["01"]["phase"] == "merged" and again["tickets"]["02"] == {
37
+ "phase": "pending", "round": 0, "tip": None, "test_writer_sha": None, "locked": [], "green_gates": [], "slot": None,
38
+ "note": "", "held": {}}
39
+
40
+
41
+ def test_verified_trusts_a_tip_only_while_it_is_on_the_ticket_branch(repo, tmp_path):
42
+ repo()
43
+ tests = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
44
+ subprocess.run([*GIT, "commit", "-q", "--allow-empty", "-m", "work"], check=True)
45
+ tip = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
46
+ subprocess.run(["git", "branch", "ticket/01", tip], check=True)
47
+ entry = {"phase": "review", "tip": tip, "test_writer_sha": tests}
48
+ assert driver_state.verified(entry, "ticket/01", "feat/x") == ({}, "")
49
+ assert driver_state.verified({**entry, "tip": tests}, "ticket/01", "feat/x") == ({}, "") # an earlier commit of the branch
50
+ back = ({"phase": "implement", "tip": tests}, f"the recorded tip {tip[:7]} is not on ticket/02")
51
+ assert driver_state.verified(entry, "ticket/02", "feat/x") == back # no such branch
52
+ subprocess.run(["git", "branch", "-f", "ticket/01", tests], check=True)
53
+ assert driver_state.verified({**entry, "phase": "merge"}, "ticket/01", "feat/x")[0] == {"phase": "implement", "tip": tests}
54
+ # No accepted implementation yet, or finished: nothing to check.
55
+ for phase in ("pending", "test-writer", "implement", "blocked"):
56
+ assert driver_state.verified({**entry, "phase": phase, "tip": "1" * 40}, "ticket/01", "feat/x") == ({}, "")
57
+
58
+
59
+ def test_verified_trusts_merged_only_while_the_tip_is_in_the_pr_branch(repo):
60
+ repo()
61
+ tests = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
62
+ subprocess.run([*GIT, "commit", "-q", "--allow-empty", "-m", "work"], check=True)
63
+ tip = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
64
+ entry = {"phase": "merged", "tip": tip, "test_writer_sha": tests}
65
+ assert driver_state.verified(entry, "ticket/01", "feat/x") == ({}, "")
66
+ subprocess.run(["git", "branch", "old", tests], check=True)
67
+ assert driver_state.verified(entry, "ticket/01", "old") == (
68
+ {"phase": "implement", "tip": tests}, f"the recorded tip {tip[:7]} is not in old")
@@ -0,0 +1,272 @@
1
+ """Ticket 05 acceptance tests: human stops as decisions, the review page, answers, modes.
2
+
3
+ All through `sh run.sh --runner <script> [--unattended --stage-json <path>] [--answer <qid>=<optid>]` on the fixture
4
+ repository of test_review (a Review rig). Reruns reuse the rig: the fake runner counts calls per role over all runs, so
5
+ a plan lists the steps of every run in order.
6
+
7
+ Interface choices (ticket 05):
8
+ - decisions: `.agent/implement/decisions.json` is `{revision, questions: [{id, title, why, evidence: [str],
9
+ options: [{id, description}], recommended}]}`. `revision` is the hex SHA-256 of
10
+ `json.dumps(file without "revision", sort_keys=True, separators=(",", ":"))` encoded as UTF-8 (ASCII content here).
11
+ - evidence strings are file pointers: `.agent/implement/<NN>/implementer.json` and `.agent/implement/ledger.md:<line>`,
12
+ the line being a ledger line that names the ticket and BLOCKED.
13
+ - question ids are `<NN>-locked-test-<n>` with n counting per run (the first is 1, a second stop on the ticket is 2),
14
+ and `<NN>-finding-<n>` for an open finding after round 4. Options: `overrule-test` / `overrule-implementer`
15
+ (recommended: overrule-implementer), and `accept` / `split` / `redesign`.
16
+ - an implementer is BLOCKED with a stop only when a `concerns` entry starts with "locked test contradicts spec:";
17
+ any other BLOCKED report is an ordinary BLOCKED ticket (exit 1, no decisions file).
18
+ - the answers file is `.agent/implement-answers.json` in the schema.md shape: `v`, `stage: "implement"`, `submitted`
19
+ (`"changes"`), `revision`, `answers`. A rerun applies it only with `submitted` set and `revision` equal to the current
20
+ decisions revision; otherwise a ledger line says "ignored" (and "revision" for a stale file, the option id for an
21
+ option the question does not offer), and the stop repeats with exit 2 and the same decisions file.
22
+ - `--answer` with an unknown question id exits 3 before any agent and names the id on stdout or stderr.
23
+ - the ruling for an overruled test is the ledger line "Ruling: 01 locked test overruled"; a finding ruling line
24
+ names the ticket, the finding title and the chosen option.
25
+ - the stop of the correction is observed in the runner log: a second `test-writer` call on the ticket (before the
26
+ implementer's second call) and a second `implementer` call whose HEAD is the correction commit.
27
+ - stage.json: `{"stage": "implement", "status": "awaiting-review", "artifact": ".agent/implement-review.html",
28
+ "open_questions": N}` at a stop; at a BLOCKED end `{"stage": "implement", "status": "blocked", "blocked": ["01"]}`.
29
+ - ticket 08: `--unattended` pushes at the end, so it is refused in a repository with no `origin`. The unattended tests
30
+ run on the rig of test_final, which has a bare `origin` and a fake `gh` (imported in the test: test_final imports
31
+ this module).
32
+ """
33
+ import hashlib
34
+ import json
35
+ import re
36
+
37
+ import pytest
38
+
39
+ import driver_review_page
40
+ from test_cli import git
41
+ from test_review import ONE, Review, finding, fix, review
42
+ from test_review_page import TEMPLATE, check_page
43
+ from test_walk import impl, plan, tw
44
+
45
+ CONTRA = "locked test contradicts spec: tests/test_01.py::test_x"
46
+ UNATTENDED = ("--unattended", "--stage-json", ".agent/stage.json")
47
+ NEW_TEST = "def test_x():\n assert '01' == '01'\n"
48
+ Q1 = "01-locked-test-1"
49
+
50
+
51
+ def revision(decisions):
52
+ body = {k: v for k, v in decisions.items() if k != "revision"}
53
+ return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
54
+
55
+
56
+ def blocked(*concerns):
57
+ return {"report": {"status": "BLOCKED", "commits": [], "files": [], "concerns": list(concerns or [CONTRA]),
58
+ "green": []}}
59
+
60
+
61
+ def correction():
62
+ return {**tw("01"), "files": {"tests/test_01.py": NEW_TEST}, "commit": "test: 01 overruled"}
63
+
64
+
65
+ def steps(*implementers, **more):
66
+ return plan(**{"01/implementer": list(implementers), "01/test-writer": [tw("01"), correction()], **more})
67
+
68
+
69
+ def decisions(rig):
70
+ return json.loads((rig.repo / ".agent/implement/decisions.json").read_text())
71
+
72
+
73
+ def answers_file(rig):
74
+ return rig.repo / ".agent/implement-answers.json"
75
+
76
+
77
+ def stage(rig):
78
+ return json.loads((rig.repo / ".agent/stage.json").read_text())
79
+
80
+
81
+ def write_answers(rig, answers, **fields):
82
+ body = {"v": 1, "stage": "implement", "submitted": "changes", "revision": decisions(rig)["revision"],
83
+ "answers": answers, **fields}
84
+ answers_file(rig).write_text(json.dumps(body))
85
+
86
+
87
+ def stopped(tmp, *extra, then=(impl("01"),), files=ONE, rig=Review):
88
+ """A rig after run 1: 01's implementer reported the contradicted locked test."""
89
+ rig = rig(tmp, files)
90
+ return rig.run(steps(blocked(), *then), *extra)
91
+
92
+
93
+ @pytest.fixture(scope="module")
94
+ def local(tmp_path_factory):
95
+ return stopped(tmp_path_factory.mktemp("local"))
96
+
97
+
98
+ # ---------------------------------------------------------------- the question
99
+
100
+ def test_a_contradicted_locked_test_writes_one_question_with_pointers(local):
101
+ assert local.code == 2, local.output()
102
+ (q,) = decisions(local)["questions"]
103
+ assert q["id"] == Q1
104
+ assert {o["id"] for o in q["options"]} == {"overrule-test", "overrule-implementer"}
105
+ assert q["recommended"] == "overrule-implementer"
106
+ assert q["title"] and q["why"]
107
+ evidence = "\n".join(q["evidence"])
108
+ assert ".agent/implement/01/implementer-0.json" in evidence and (local.folder() / "implementer-0.json").exists()
109
+ (pointer,) = re.findall(r"\.agent/implement/ledger\.md:(\d+)", evidence)
110
+ line = local.ledger()[int(pointer) - 1]
111
+ assert "01" in line and "BLOCKED" in line, line
112
+
113
+
114
+ def test_revision_is_the_sha256_of_the_file_without_it_and_ids_are_valid_and_unique(local):
115
+ doc = decisions(local)
116
+ assert doc["revision"] == revision(doc)
117
+ ids = [q["id"] for q in doc["questions"]]
118
+ assert all(re.fullmatch(r"[a-z0-9_-]{1,64}", i) for i in ids) and len(ids) == len(set(ids))
119
+ assert all(2 <= len(q["options"]) <= 4 and q["recommended"] in [o["id"] for o in q["options"]]
120
+ for q in doc["questions"])
121
+
122
+
123
+ def test_a_second_stop_on_the_same_ticket_gets_a_new_id_and_a_new_revision(tmp_path):
124
+ rig = stopped(tmp_path, then=(blocked(), impl("01")))
125
+ first = decisions(rig)
126
+ rig.run(steps(blocked(), blocked(), impl("01")), "--answer", f"{Q1}=overrule-test")
127
+ assert rig.code == 2, rig.output()
128
+ second = decisions(rig)
129
+ ids = [q["id"] for q in second["questions"]]
130
+ assert "01-locked-test-2" in ids and len(ids) == len(set(ids))
131
+ assert second["revision"] != first["revision"] == revision(first) and second["revision"] == revision(second)
132
+ assert "01-locked-test-2" in rig.out
133
+
134
+
135
+ # ---------------------------------------------------------------- the page
136
+
137
+ def test_the_stop_renders_the_review_page_from_the_template(local):
138
+ html = (local.repo / ".agent/implement-review.html").read_text()
139
+ check_page(html, decisions(local))
140
+ assert all(part in html for part in re.findall(r"<(?:style|script)>.*?</(?:style|script)>", TEMPLATE, re.S))
141
+
142
+
143
+ # ---------------------------------------------------------------- local mode
144
+
145
+ def test_without_unattended_the_driver_prints_question_and_options_exits_2_and_launches_nothing_more(local):
146
+ assert local.code == 2
147
+ for word in (Q1, "overrule-test", "overrule-implementer"):
148
+ assert word in local.out + local.err, word
149
+ assert [c["role"] for c in local.calls()] == ["test-writer", "implementer"]
150
+ assert "feat: 01 rows" not in local.pr_subjects()
151
+
152
+
153
+ def test_answer_overrule_test_writes_the_answers_file_rules_corrects_the_test_and_resumes(tmp_path):
154
+ rig = stopped(tmp_path)
155
+ rev = decisions(rig)["revision"]
156
+ rig.run(steps(blocked(), impl("01")), "--answer", f"{Q1}=overrule-test")
157
+ assert rig.code == 0, rig.output()
158
+ file = json.loads(answers_file(rig).read_text())
159
+ assert (file["v"], file["stage"], file["submitted"], file["revision"]) == (1, "implement", "changes", rev)
160
+ assert file["answers"] == {Q1: "overrule-test"}
161
+ assert rig.led("Ruling: 01 locked test overruled")
162
+ fixes, resumed = rig.calls("01", "test-writer"), rig.calls("01", "implementer")
163
+ assert len(fixes) == 2 and len(resumed) == 2, "one test-writer correction, then the implementer again"
164
+ assert fixes[1]["t1"] <= resumed[1]["t0"] and resumed[1]["head"] == fixes[1]["sha"]
165
+ assert git(rig.repo, "show", "feat/csv:tests/test_01.py") == NEW_TEST and "feat: 01 rows" in rig.pr_subjects()
166
+
167
+
168
+ def test_an_answers_file_from_another_revision_is_ignored_with_a_ledger_line_and_the_stop_repeats(tmp_path):
169
+ rig = stopped(tmp_path)
170
+ before = decisions(rig)
171
+ write_answers(rig, {Q1: "overrule-test"}, revision="0" * 64)
172
+ rig.run(steps(blocked(), impl("01")))
173
+ assert rig.code == 2, rig.output()
174
+ assert rig.led("ignored", "revision") and not rig.led("Ruling: 01 locked test overruled")
175
+ assert decisions(rig) == before and Q1 in rig.out
176
+ assert [c["role"] for c in rig.calls()] == ["test-writer", "implementer"]
177
+
178
+
179
+ def test_an_answer_naming_an_option_the_question_does_not_offer_is_ignored_with_a_ledger_line(tmp_path):
180
+ rig = stopped(tmp_path)
181
+ write_answers(rig, {Q1: "bogus-option"})
182
+ rig.run(steps(blocked(), impl("01")))
183
+ assert rig.code == 2, rig.output()
184
+ assert rig.led("ignored", "bogus-option") and not rig.led("Ruling: 01 locked test overruled")
185
+ assert [c["role"] for c in rig.calls()] == ["test-writer", "implementer"]
186
+
187
+
188
+ def test_answer_for_an_unknown_question_id_exits_3_naming_it_before_any_agent(tmp_path):
189
+ rig = stopped(tmp_path)
190
+ seen = len(rig.calls())
191
+ rig.run(steps(blocked(), impl("01")), "--answer", "no-such-question=accept")
192
+ assert rig.code == 3, rig.output()
193
+ assert "no-such-question" in rig.out + rig.err and len(rig.calls()) == seen
194
+
195
+
196
+ def test_a_blocked_report_that_is_not_a_contradicted_locked_test_is_not_a_stop(tmp_path):
197
+ rig = Review(tmp_path)
198
+ rig.run(steps(blocked("cannot reach the database")))
199
+ assert rig.code == 1, rig.output()
200
+ assert not (rig.repo / ".agent/implement/decisions.json").exists()
201
+ assert rig.state()["tickets"]["01"]["phase"] == "blocked"
202
+
203
+
204
+ # ---------------------------------------------------------------- open findings after round 4
205
+
206
+ A, B = finding("STUCK-A", line=98), finding("STUCK-B", line=99)
207
+
208
+
209
+ def round4(tmp, *extra):
210
+ rig = Review(tmp)
211
+ bad = review("01", [A, B])
212
+ return rig.run(plan(**{"01/reviewer": [bad] * 5, "01/fixer": [fix("01", n) for n in (1, 2, 3, 4)]}), *extra)
213
+
214
+
215
+ def finding_ids(rig):
216
+ questions = decisions(rig)["questions"]
217
+ return {name: next(q["id"] for q in questions if name in q["title"] + q["why"]) for name in ("STUCK-A", "STUCK-B")}
218
+
219
+
220
+ def test_open_findings_after_round_4_make_one_question_each_and_accept_closes_them(tmp_path):
221
+ rig = round4(tmp_path)
222
+ assert rig.code == 1, rig.output()
223
+ questions = decisions(rig)["questions"]
224
+ assert len(questions) == 2 and rig.state()["tickets"]["01"]["phase"] == "blocked"
225
+ assert all({o["id"] for o in q["options"]} == {"accept", "split", "redesign"} for q in questions)
226
+ check_page((rig.repo / ".agent/implement-review.html").read_text(), decisions(rig))
227
+ ids = finding_ids(rig)
228
+ rig.run({}, "--answer", f"{ids['STUCK-A']}=accept", "--answer", f"{ids['STUCK-B']}=accept")
229
+ assert rig.code == 0, rig.output()
230
+ assert rig.led("Ruling", "STUCK-A", "accept") and rig.led("Ruling", "STUCK-B", "accept")
231
+ assert rig.state()["tickets"]["01"]["phase"] == "merged" and "feat: 01 rows" in rig.pr_subjects()
232
+ assert len(rig.calls("01", "fixer")) == 4
233
+
234
+
235
+ def test_split_and_redesign_keep_the_ticket_blocked_with_the_ruling(tmp_path):
236
+ rig = round4(tmp_path)
237
+ ids = finding_ids(rig)
238
+ rig.run({}, "--answer", f"{ids['STUCK-A']}=split", "--answer", f"{ids['STUCK-B']}=redesign")
239
+ assert rig.code == 1, rig.output()
240
+ assert rig.led("Ruling", "STUCK-A", "split") and rig.led("Ruling", "STUCK-B", "redesign")
241
+ assert rig.state()["tickets"]["01"]["phase"] == "blocked" and "feat: 01 rows" not in rig.pr_subjects()
242
+ assert len(rig.calls("01", "fixer")) == 4
243
+
244
+
245
+ # ---------------------------------------------------------------- unattended
246
+
247
+ def test_unattended_stop_writes_stage_json_and_exits_2(tmp_path):
248
+ from test_final import Final
249
+ rig = stopped(tmp_path, *UNATTENDED, rig=Final)
250
+ assert rig.code == 2, rig.output()
251
+ assert stage(rig) == {"stage": "implement", "status": "awaiting-review",
252
+ "artifact": ".agent/implement-review.html", "open_questions": 1}
253
+ assert (rig.repo / ".agent/implement-review.html").exists()
254
+
255
+
256
+ def test_unattended_rerun_with_a_submitted_answers_file_at_the_current_revision_continues_silently(tmp_path):
257
+ from test_final import Final
258
+ rig = stopped(tmp_path, *UNATTENDED, rig=Final)
259
+ write_answers(rig, {Q1: "overrule-test"})
260
+ rig.run(steps(blocked(), impl("01")), *UNATTENDED)
261
+ assert rig.code == 0, rig.output()
262
+ assert Q1 not in rig.out + rig.err and rig.led("Ruling: 01 locked test overruled")
263
+ assert len(rig.calls("01", "implementer")) == 2 and "feat: 01 rows" in rig.pr_subjects()
264
+ assert stage(rig) == {"stage": "implement", "status": "done"}
265
+
266
+
267
+ def test_unattended_blocked_ticket_ends_with_stage_json_blocked_naming_it_and_exit_1(tmp_path):
268
+ from test_final import Final
269
+ rig = Final(tmp_path, gate={"red": ["ticket/01"]})
270
+ rig.run(plan(), *UNATTENDED)
271
+ assert rig.code == 1, rig.output()
272
+ assert stage(rig) == {"stage": "implement", "status": "blocked", "blocked": ["01"]}
@@ -0,0 +1,28 @@
1
+ """The summary: plain sentences, then the measurement table."""
2
+ import driver_state
3
+ import driver_summary
4
+
5
+
6
+ def test_render_counts_gates_agents_time_rounds_stops_and_cost():
7
+ ticket = lambda phase, rounds: {"phase": phase, "round": rounds}
8
+ agent = lambda role, entry, seconds, cost: {"role": role, "entry": entry, "seconds": seconds, "cost": cost}
9
+ state = {"run": {"pr_branch": "feat/csv", "gates": 4, "stops": 1, "final": driver_state._final(),
10
+ "agents": [agent("test-writer", "sonnet@medium", 2.0, 0.25), agent("implementer", "opus@medium", 3.5, 0.5),
11
+ agent("implementer", "opus@medium", 1.25, 0), agent("implementer", "fable@high", 1, 1.0)]},
12
+ "tickets": {"01": ticket("merged", 1), "02": ticket("pending", 0), "03": ticket("blocked", 0)}}
13
+ assert driver_summary.render(state) == """1 of 3 tickets are merged into feat/csv.
14
+ Ticket 02 is not merged: its phase is pending.
15
+ Ticket 03 is not merged: its phase is blocked.
16
+
17
+ | Measure | Value |
18
+ |---|---|
19
+ | gates run | 4 |
20
+ | agents, test-writer on sonnet@medium | 1 |
21
+ | agents, implementer on opus@medium | 2 |
22
+ | agents, implementer on fable@high | 1 |
23
+ | wall time, test-writer | 2.0 s |
24
+ | wall time, implementer | 5.8 s |
25
+ | fix rounds per ticket | 01: 1, 02: 0, 03: 0 |
26
+ | human stops | 1 |
27
+ | cost | 1.75 USD |
28
+ """
@@ -0,0 +1,72 @@
1
+ """Ticket parser acceptance tests, through driver_tickets.parse only."""
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from driver_tickets import parse
7
+
8
+
9
+ def body(number, title, blocked, *, seam="`export_csv()`", touches="`demo/export.py: export_csv`", extra=()):
10
+ lines = [f"# {number}: {title}", "", "**What to build:** Something.", "",
11
+ f"**Blocked by:** {blocked}", "", f"**Seam:** {seam}", ""]
12
+ if touches:
13
+ lines += [f"**Touches:** {touches}", ""]
14
+ for line in extra:
15
+ lines += [line, ""]
16
+ return "\n".join(lines + ["- [ ] It works", ""])
17
+
18
+
19
+ def write_set(root, files):
20
+ root.mkdir(parents=True)
21
+ for name, text in files.items():
22
+ (root / name).write_text(text)
23
+ return root
24
+
25
+
26
+ @pytest.mark.parametrize("blocked", [
27
+ "01", "01, 03", "Export rows", "01 (Export rows)", "01: Export rows", "01-export-rows.md", "Export rows, 03",
28
+ ])
29
+ def test_blocked_by_accepts_template_forms(tmp_path, blocked):
30
+ root = write_set(tmp_path / "t", {
31
+ "01-export-rows.md": body("01", "Export rows", "None (can start immediately)"),
32
+ "03-cli-flag.md": body("03", "CLI flag", "None (can start immediately)"),
33
+ "04-more.md": body("04", "More", blocked),
34
+ })
35
+ tickets, errors = parse(root)
36
+ assert errors == []
37
+ wanted = {"01": ["01"], "01, 03": ["01", "03"], "Export rows, 03": ["01", "03"]}.get(blocked, ["01"])
38
+ assert sorted(next(t for t in tickets if t.number == "04").blocked_by) == wanted
39
+
40
+
41
+ def test_none_means_no_blockers(tmp_path):
42
+ root = write_set(tmp_path / "t", {"01-a.md": body("01", "A", "None (can start immediately)")})
43
+ tickets, errors = parse(root)
44
+ assert errors == [] and [t.blocked_by for t in tickets] == [[]]
45
+
46
+
47
+ def test_risk_and_suspicions_are_optional_and_exposed(tmp_path):
48
+ root = write_set(tmp_path / "t", {
49
+ "01-a.md": body("01", "A", "None (can start immediately)",
50
+ extra=["**Risk:** persistence", "**Suspicions:** a sibling caller keys on the old name"]),
51
+ "02-b.md": body("02", "B", "01"),
52
+ })
53
+ tickets, errors = parse(root)
54
+ assert errors == []
55
+ first, second = sorted(tickets, key=lambda t: t.number)
56
+ assert first.risk == "persistence"
57
+ assert first.suspicions == "a sibling caller keys on the old name"
58
+ assert second.risk is None and second.suspicions is None
59
+
60
+
61
+ def test_missing_touches_error_names_file_and_seam_line(tmp_path):
62
+ root = write_set(tmp_path / "t", {
63
+ "01-a.md": body("01", "A", "None (can start immediately)"),
64
+ "02-quote-fields.md": body("02", "Quote fields", "01", touches=None),
65
+ "03-c.md": body("03", "C", "None (can start immediately)"),
66
+ })
67
+ tickets, errors = parse(root)
68
+ assert tickets == []
69
+ (error,) = errors
70
+ assert error.startswith("02-quote-fields.md")
71
+ assert "missing Touches" in error
72
+ assert " 7" in error or ":7" in error # the Seam line of the fixture ticket