@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,170 @@
1
+ """A gated ticket tip in, a reviewed tip out: the review, the fixture corrections and the fix rounds.
2
+
3
+ Reports of a ticket are `<role>-<K>.json`: K is the fix round the agent follows (reviewer, correctness) or is
4
+ (fixer). A fixture correction after the review of round K is `test-writer-<K+1>.json`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from dataclasses import dataclass, field
12
+
13
+ import driver_agents
14
+ import driver_review
15
+ import driver_slots
16
+ from driver_run import OUT, Blocked, Run, during, ledger
17
+ from driver_tickets import Ticket
18
+
19
+ ROUNDS = 4
20
+
21
+
22
+ @dataclass
23
+ class Job:
24
+ """One ticket between its gate and its merge."""
25
+ ticket: Ticket
26
+ slot: str
27
+ run: Run
28
+ values: dict[str, str] # the pointers every prompt of the ticket gets
29
+ round: int # fix rounds started
30
+ tip: str
31
+ previous: str = "" # the tip before the last fix; "" makes the next review a full one
32
+ proofs: set[tuple[str, int]] = field(default_factory=set) # mutation proofs so far, at `tip`
33
+ again: list[str] = field(default_factory=list) # `file:line` of each proof the last fix made due again
34
+ corrected: bool = False # the last step was a fixture correction
35
+
36
+ def pointers(self, review: int) -> dict[str, str]:
37
+ """The values of a prompt; `review` is the round whose findings the agent reads."""
38
+ entry = self.run.state.read()["tickets"][self.ticket.number]
39
+ findings = os.path.relpath(OUT / self.ticket.number / f"findings-{review}.json", self.slot)
40
+ return {**self.values, "start_sha": self.tip, "from_sha": self.previous, "round": str(self.round),
41
+ "locked": " ".join(entry["locked"]), "test_writer_sha": str(entry["test_writer_sha"] or ""),
42
+ "findings": findings, "proofs": " ".join(self.again)}
43
+
44
+
45
+ def entry(models: dict[str, list[str]], round: int) -> str:
46
+ """The implementer entry of a fix round: the second one from round 3, when the list has one."""
47
+ entries = models["implementer"]
48
+ return entries[1] if round > 2 and len(entries) > 1 else entries[0]
49
+
50
+
51
+ def _log_findings(job: Job, findings: list[dict]) -> list[dict]:
52
+ for finding in findings:
53
+ ledger(f"Ticket {job.ticket.number}: {finding['severity']} finding of kind {finding['kind']} from the "
54
+ f"{finding['source']} at {finding['file']}:{finding['line']}: {finding['title']}.")
55
+ return findings
56
+
57
+
58
+ def _run_reviewers(job: Job, cast: list[tuple[str, str]], locked: list[str]) -> list[dict]:
59
+ """Run the (role, model entry) pairs of `cast` at the same time in the one slot. Returns their reports."""
60
+ values, running = job.pointers(job.round - 1), [role for role, _ in cast]
61
+ errors: dict[str, str] = {}
62
+
63
+ def call(role: str, entry: str) -> dict:
64
+ # The first review of a tip must give a mutation proof for each locked test.
65
+ first_review = not job.previous and role == "reviewer"
66
+ try:
67
+ return job.run.agent(role, job.ticket.number, job.slot, values, entry=entry, fix_round=job.round,
68
+ notes=["rereview"] if job.previous else [],
69
+ check={"round": 1, "locked": locked} if first_review else {},
70
+ alone=lambda: len(running) == 1)
71
+ except Blocked as blocked:
72
+ errors[role] = f"the {role}: {blocked}"
73
+ return {}
74
+ finally:
75
+ running.remove(role)
76
+
77
+ with ThreadPoolExecutor(len(cast)) as pool:
78
+ reports = list(pool.map(lambda agent: call(*agent), cast))
79
+ if errors:
80
+ raise Blocked("; ".join(errors[role] for role, _ in cast if role in errors))
81
+ return reports
82
+
83
+
84
+ def _review(job: Job) -> list[dict]:
85
+ """Review `job.tip`. Returns the findings of the driver and of each reviewer."""
86
+ run, number = job.run, job.ticket.number
87
+ state = run.state.ticket(number, phase="review")
88
+ found = _log_findings(job, driver_review.locked_findings(job.slot, state))
89
+ cast = [("reviewer", "")]
90
+ if job.ticket.risk:
91
+ cast.append(("correctness", driver_agents.correctness_entry(run.models)))
92
+ reports = run.read_only(number, job.slot, job.values["branch"], job.tip,
93
+ lambda: _run_reviewers(job, cast, state["locked"]))
94
+ for (role, _), report in zip(cast, reports):
95
+ found += _log_findings(job, [{**finding, "source": role} for finding in report["findings"]])
96
+ job.proofs |= {(proof["file"], proof["line"]) for proof in report.get("mutations", [])}
97
+ (OUT / number / f"findings-{job.round}.json").write_text(json.dumps(found, indent=1) + "\n")
98
+ return found
99
+
100
+
101
+ def _correct_fixture(job: Job, fixture: list[dict]) -> None:
102
+ """One test-writer correction for the fixture findings. It is outside the fix rounds."""
103
+ run, number = job.run, job.ticket.number
104
+ if job.corrected:
105
+ titles = "; ".join(finding["title"] for finding in fixture)
106
+ raise Blocked(f"the review: a fixture finding is open after the test-writer correction: {titles}")
107
+ run.restore_locked_files(number, job.slot)
108
+ with during("the fixture correction"):
109
+ report = run.agent("test-writer", number, job.slot, job.pointers(job.round), notes=["correction"],
110
+ fix_round=job.round + 1)
111
+ job.tip, job.previous, job.corrected = driver_slots.head(job.slot), "", True
112
+ entry = run.state.read()["tickets"][number]
113
+ run.state.ticket(number, tip=job.tip, **driver_review.written(job.slot, entry, report["locked"], job.tip))
114
+ for finding in fixture:
115
+ ledger(f"Ticket {number}: fixture correction in {finding['file']}, outside the fix rounds: "
116
+ f"{finding['title']}: {finding['evidence']}. The test-writer corrected it at {job.tip[:7]}.")
117
+
118
+
119
+ def _fix(job: Job) -> None:
120
+ """Run the next fix round: a fixer on the implementer's list."""
121
+ run, number = job.run, job.ticket.number
122
+ job.round += 1
123
+ on = entry(run.models, job.round)
124
+ run.state.ticket(number, phase="fix", round=job.round)
125
+ if job.round == 3 and on != run.models["implementer"][0]:
126
+ ledger(f"Ticket {number} round 3: escalated to {on}.")
127
+ elif job.round == 3:
128
+ ledger(f"Ticket {number} round 3: the implementer has no next entry, so the round stays on {on}.")
129
+ with during(f"fix round {job.round}"):
130
+ run.agent("fixer", number, job.slot, job.pointers(job.round - 1), entry=on, fix_round=job.round)
131
+ before, job.tip = job.tip, driver_slots.head(job.slot)
132
+ # No review has seen a fixture correction, so the review after a fix on a corrected tip is a full one.
133
+ job.previous, job.corrected = "" if job.corrected else before, False
134
+ run.state.ticket(number, tip=job.tip)
135
+ followed = driver_review.follow(job.slot, before, job.tip, job.proofs)
136
+ job.proofs = set(followed)
137
+ job.again = [f"{file}:{line}" for (file, line), changed in sorted(followed.items()) if changed]
138
+
139
+
140
+ def _act_on(job: Job, found: list[dict]) -> bool:
141
+ """Do what the findings of a review ask for: a fixture correction, a fix round, or a stop. True: none is open."""
142
+ fixture, to_fix = driver_review.route(found)
143
+ # A changed locked file is fixed first: a correction on top of it would make the changed file the reference.
144
+ locked_file_changed = any(finding["source"] == "driver" for finding in found)
145
+ if fixture and not locked_file_changed:
146
+ _correct_fixture(job, fixture)
147
+ if not to_fix:
148
+ return False
149
+ if not to_fix:
150
+ return True
151
+ if job.round == ROUNDS:
152
+ titles = [f"{finding['title']} ({finding['file']}:{finding['line']})" for finding in to_fix]
153
+ evidence = OUT / job.ticket.number / f"findings-{ROUNDS}.json"
154
+ job.run.ask(job.ticket.number, "finding", titles, str(evidence))
155
+ raise Blocked(f"the review: after round {ROUNDS} these findings are open: {'; '.join(titles)}")
156
+ _fix(job)
157
+ return False
158
+
159
+
160
+ def run(ticket: Ticket, slot: str, run: Run, values: dict[str, str]) -> None:
161
+ """Gate, review and fix the ticket tip until a review leaves no finding open."""
162
+ state = run.state.read()["tickets"][ticket.number]
163
+ job = Job(ticket, slot, run, values, state["round"], state["tip"])
164
+ while True:
165
+ with during("the gate"):
166
+ run.gate(ticket.number, slot, job.tip)
167
+ with during("the review"):
168
+ found = _review(job)
169
+ if _act_on(job, found):
170
+ return
@@ -0,0 +1,47 @@
1
+ """The gate: the repo's one check command, run by the driver on the commit it is about to trust.
2
+
3
+ A gate record is `<folder>/gate-<first 7 hex of the commit>.log`. It is green only for exit 0 on a tree that is
4
+ clean before the command and after it, with HEAD still on the commit.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import subprocess
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ from driver_slots import git, head
14
+
15
+
16
+ def _now() -> str:
17
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
18
+
19
+
20
+ def _after(slot: str, commit: str) -> str:
21
+ """What the command left behind: "clean", or why the record cannot be green."""
22
+ if head(slot) != commit:
23
+ return f"HEAD moved to {head(slot)}"
24
+ status = git(slot, "status", "--short").stdout
25
+ return "not clean\n" + "".join(f" {line}\n" for line in status.splitlines()).rstrip("\n") if status else "clean"
26
+
27
+
28
+ def run(command: str, slot: str, folder: Path, pr_branch: str) -> tuple[bool, Path]:
29
+ """Gate the HEAD of `slot`. Returns (green, the record)."""
30
+ commit = head(slot)
31
+ status = git(slot, "status", "--short").stdout
32
+ folder.mkdir(parents=True, exist_ok=True)
33
+ record = folder / f"gate-{commit[:7]}.log"
34
+ # The cache of compiled Python stays out of the slot, so the gate leaves the tree clean.
35
+ env = dict(os.environ, IMPLEMENT_SPEC_BASE=pr_branch, PYTHONPYCACHEPREFIX=str(folder.parent.resolve() / "pycache"))
36
+ with record.open("w") as out:
37
+ out.write(f"start: {_now()}\ncommit: {commit}\nstatus: {'not clean' if status else 'clean'}\n")
38
+ out.writelines(f" {line}\n" for line in status.splitlines())
39
+ out.write(f"command: {command}\n\n")
40
+ out.flush()
41
+ # shell=True: the command is the operator's own shell line, as `make check` is.
42
+ code = "not run" if status else subprocess.run(
43
+ command, shell=True, cwd=slot, env=env, stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT
44
+ ).returncode
45
+ after = "not run" if status else _after(slot, commit)
46
+ out.write(f"\nexit: {code}\nafter: {after}\nend: {_now()}\n")
47
+ return code == 0 and after == "clean", record
@@ -0,0 +1,42 @@
1
+ """The frontier: which tickets may start now, and which wait on a running ticket's Touches."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from driver_tickets import Ticket
7
+
8
+
9
+ def touches(ticket: Ticket) -> set[tuple[str, str]]:
10
+ """(file, function) per backticked entry of the Touches line; function "" is the whole file."""
11
+ entries = re.findall(r"`([^`]+)`", ticket.touches or "")
12
+ return {(file.strip(), function.strip()) for file, _, function in (entry.partition(":") for entry in entries)}
13
+
14
+
15
+ def overlap(one: Ticket, other: Ticket) -> str:
16
+ """The first place both tickets name, "" when there is none. A whole file overlaps each function in it."""
17
+ for file, function in sorted(touches(one)):
18
+ for other_file, other_function in touches(other):
19
+ if file == other_file and (not function or not other_function or function == other_function):
20
+ return f"{file}: {function or other_function}".rstrip(": ")
21
+ return ""
22
+
23
+
24
+ def _waits(ticket: Ticket, phases: dict[str, str], running: set[str]) -> bool:
25
+ return (ticket.number not in running and phases[ticket.number] not in ("merged", "blocked")
26
+ and all(phases[blocker] == "merged" for blocker in ticket.blocked_by))
27
+
28
+
29
+ def frontier(tickets: list[Ticket], phases: dict[str, str], running: set[str]) -> tuple[list[Ticket], dict[str, str]]:
30
+ """(ready tickets in file order, held number -> why). Two ready tickets can overlap: start one, then ask again."""
31
+ by = {t.number: t for t in tickets}
32
+ ready: list[Ticket] = []
33
+ holds: dict[str, str] = {}
34
+ for ticket in tickets:
35
+ if not _waits(ticket, phases, running):
36
+ continue
37
+ holder = next((n for n in sorted(running) if overlap(ticket, by[n])), None)
38
+ if holder:
39
+ holds[ticket.number] = f"held while {holder} runs: both touch {overlap(ticket, by[holder])}"
40
+ else:
41
+ ready.append(ticket)
42
+ return ready, holds
@@ -0,0 +1,105 @@
1
+ """Merges into the PR branch through git plumbing: no checkout, so no slot's tree changes. Only a conflicting merge
2
+ gets a tree: `begin` opens it in the ticket's own slot for the conflict agent.
3
+
4
+ `refused` is the locked-file check of a landing. The caller holds the merge lock.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import subprocess
10
+
11
+ from driver_slots import git, names
12
+
13
+
14
+ def ancestor(commit: str, of: str) -> bool:
15
+ return git(".", "merge-base", "--is-ancestor", commit, of).returncode == 0
16
+
17
+
18
+ def identity(tip: str, slot: str = ".") -> dict[str, str]:
19
+ """git's own identity when it has one; else the identity of the ticket's last commit, so the merge can be made."""
20
+ if git(slot, "var", "GIT_COMMITTER_IDENT").returncode == 0:
21
+ return {}
22
+ name, email = git(slot, "log", "-1", "--format=%an%n%ae", tip).stdout.splitlines()
23
+ return {"GIT_AUTHOR_NAME": name, "GIT_AUTHOR_EMAIL": email, "GIT_COMMITTER_NAME": name, "GIT_COMMITTER_EMAIL": email}
24
+
25
+
26
+ def commit(pr_tip: str, tip: str, message: str) -> tuple[str, str]:
27
+ """(the commit the PR branch gets, "") or ("", the error): `pr_tip` when the ticket is merged already, `tip`
28
+ for a fast-forward, else a new merge commit."""
29
+ if ancestor(tip, pr_tip):
30
+ return pr_tip, ""
31
+ if ancestor(pr_tip, tip):
32
+ return tip, ""
33
+ tree = git(".", "merge-tree", "--write-tree", "--name-only", "--no-messages", pr_tip, tip)
34
+ if tree.returncode:
35
+ files = ", ".join(tree.stdout.splitlines()[1:]) or tree.stderr.strip()
36
+ return "", f"the merge into the PR branch conflicts in {files}"
37
+ made = subprocess.run(["git", "commit-tree", tree.stdout.strip(), "-p", pr_tip, "-p", tip, "-m", message],
38
+ capture_output=True, text=True, env={**os.environ, **identity(tip)})
39
+ return (made.stdout.strip(), "") if made.returncode == 0 else ("", f"git commit-tree failed: {made.stderr.strip()}")
40
+
41
+
42
+ def begin(slot: str, pr_tip: str) -> list[str]:
43
+ """Open the merge of `pr_tip` into the branch of `slot`. Returns the files in conflict; with none, the slot is
44
+ as it was."""
45
+ # git merge needs a committer even with --no-commit.
46
+ env = {**os.environ, **identity("HEAD", slot)}
47
+ subprocess.run(["git", "-C", slot, "merge", "--no-commit", "--no-ff", pr_tip], capture_output=True, env=env)
48
+ files = git(slot, "diff", "--name-only", "--diff-filter=U").stdout.splitlines()
49
+ if not files:
50
+ abort(slot)
51
+ return files
52
+
53
+
54
+ def abort(slot: str) -> None:
55
+ """Close the merge that `begin` opened."""
56
+ git(slot, "merge", "--abort")
57
+
58
+
59
+ def protected(tickets: dict, number: str) -> list[str]:
60
+ """The locked files of the merged tickets that ticket `number` does not lock itself. `tickets` is that of
61
+ state.json; a record with no `locked` locked nothing."""
62
+ merged = {path for entry in tickets.values() if entry.get("phase") == "merged" for path in entry.get("locked") or []}
63
+ return sorted(merged - set(tickets.get(number, {}).get("locked") or []))
64
+
65
+
66
+ def _entries(commit: str, paths: list[str]) -> tuple[dict[str, str], str]:
67
+ """({path: "mode type object-id"} for each of `paths` that `commit` has, "") or ({}, why git cannot tell)."""
68
+ tree = git(".", "ls-tree", "-z", f"{commit}^{{commit}}", "--", *paths)
69
+ if tree.returncode:
70
+ return {}, f"cannot read the locked files at {commit}: {tree.stderr.strip() or f'git ls-tree exit {tree.returncode}'}"
71
+ return {path: entry for entry, _, path in (line.partition("\t") for line in names(tree.stdout))}, ""
72
+
73
+
74
+ def refused(tickets: dict, number: str, old: str, new: str) -> str:
75
+ """"" or why ticket `number` may not land `new` on the PR tip `old`: each locked file of a merged ticket must
76
+ be the same blob with the same mode in both."""
77
+ paths = protected(tickets, number)
78
+ if not paths:
79
+ return "" # `git ls-tree` with no path lists the whole tree
80
+ (before, error), (after, failed) = _entries(old, paths), _entries(new, paths)
81
+ return error or failed or "; ".join(f"locked file changed: {path}" for path in paths
82
+ if before.get(path) != after.get(path))
83
+
84
+
85
+ def restore(slot: str, pr_tip: str, paths: list[str]) -> list[str]:
86
+ """Put each of `paths` that the ticket changed back to the version of the PR tip, in the tree of `slot` only; a
87
+ file the PR tip does not have is removed. Returns those paths."""
88
+ # Against the merge base: a file that only the PR branch changed since then is not the ticket's change.
89
+ base = git(slot, "merge-base", "HEAD", pr_tip).stdout.strip() or pr_tip
90
+ changed = names(git(slot, "diff", "--name-only", "-z", base, "--", *paths).stdout) if paths else []
91
+ for path in changed:
92
+ if git(slot, "checkout", "-q", pr_tip, "--", path).returncode:
93
+ git(slot, "rm", "-q", "-f", "--", path)
94
+ return changed
95
+
96
+
97
+ def land(pr_branch: str, new: str, old: str) -> str:
98
+ """Move the PR branch from `old` to `new`; "" or the error. git refuses when the branch is no longer at `old`."""
99
+ moved = git(".", "update-ref", "-m", "implement-spec: merge", f"refs/heads/{pr_branch}", new, old)
100
+ return moved.stderr.strip() if moved.returncode else ""
101
+
102
+
103
+ def delete(branch: str, pr_branch: str) -> bool:
104
+ """Delete a ticket branch whose commits are all in the PR branch. git refuses one a worktree has checked out."""
105
+ return ancestor(branch, pr_branch) and git(".", "branch", "-q", "-D", branch).returncode == 0
@@ -0,0 +1,115 @@
1
+ """Report validation: validate(role, path, context) -> list of errors, empty when the report is accepted.
2
+
3
+ context: repo (slot path), branch, start (HEAD when the role started), log, round (1 = first review), locked,
4
+ correction (a test-writer corrects locked tests), restored (locked files the driver put back before it),
5
+ owners (ticket numbers, for the final fix batch), merged (locked files of merged tickets, not its own), pr (tip)."""
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ import jsonschema
14
+
15
+ PROMPTS = Path(__file__).resolve().parent / "prompts"
16
+ RED = "red line not found in log"
17
+
18
+
19
+ def schema(role: str) -> dict:
20
+ return json.loads(re.search(r"```json\n(.*?)\n```", (PROMPTS / f"{role}.md").read_text(), re.S).group(1))
21
+
22
+
23
+ def _git(repo: str, *args: str, end: str = "\n") -> list[str]:
24
+ """The lines of a git answer. For a list of paths, pass `-z` and `end="\\0"`: git quotes no path there."""
25
+ out = subprocess.run(["git", "-C", repo, "-c", "core.quotePath=false", *args], capture_output=True, text=True).stdout
26
+ return [line for line in out.split(end) if line]
27
+
28
+
29
+ def _seen(line: str, log: str) -> bool:
30
+ # A stream-json log holds tool output as JSON strings, so the escaped form of the line counts too.
31
+ return line in log or json.dumps(line)[1:-1] in log
32
+
33
+
34
+ def _shas(report: dict) -> list[str]:
35
+ """`commits` as SHAs: the fix batch of the final review gives each commit with its ticket."""
36
+ return [commit["sha"] if isinstance(commit, dict) else commit for commit in report["commits"]]
37
+
38
+
39
+ def _owner_errors(report: dict, context: dict) -> list[str]:
40
+ """The fix batch of the final review: each new commit on the branch is in the report with a ticket of the run."""
41
+ if "owners" not in context:
42
+ return []
43
+ given, branch = _shas(report), context["branch"]
44
+ new = _git(context["repo"], "rev-list", f"{context['start']}..{branch}")
45
+ errors = [f"commit {sha[:7]} is on {branch} and not in `commits`: each commit of the batch needs its ticket"
46
+ for sha in new if not any(sha.startswith(one) for one in given)]
47
+ return errors + [f"commit {commit['sha'][:7]}: {commit['ticket']} is not a ticket of this run"
48
+ for commit in report["commits"] if commit["ticket"] not in context["owners"]]
49
+
50
+
51
+ def _commit_errors(report: dict, context: dict) -> list[str]:
52
+ """Every commit is new since the role started and on the branch; every locked path is in such a commit."""
53
+ repo, branch, commits = context["repo"], context["branch"], _shas(report)
54
+ new = _git(repo, "rev-list", f"{context['start']}..{branch}")
55
+ errors = [f"commit {sha[:7]} is not a new commit on {branch}"
56
+ for sha in commits if not any(n.startswith(sha) for n in new)]
57
+ if locked := report.get("locked"):
58
+ changed = _git(repo, "log", "--name-only", "-z", "--format=", f"{context['start']}..{branch}", end="\0")
59
+ errors += [f"locked path {path} is not in the role's commits" for path in locked if path not in changed]
60
+ return errors
61
+
62
+
63
+ def _correction_errors(report: dict, context: dict) -> list[str]:
64
+ """A correction changes only the files it lists in `locked` and the files the driver `restored`."""
65
+ if not context.get("correction"):
66
+ return []
67
+ changed = _git(context["repo"], "diff", "--name-only", "-z", context["start"], context["branch"], end="\0")
68
+ more = [path for path in changed if path not in report.get("locked", []) + context.get("restored", [])]
69
+ return [f"a correction may change only the files it lists in `locked`: its commits also change {', '.join(more)}"
70
+ ] if more else []
71
+
72
+
73
+ def _merged_errors(report: dict, context: dict) -> list[str]:
74
+ """A test-writer changes a locked file of a merged ticket only when it lists the file in `locked`."""
75
+ def blob(commit: str, path: str) -> list[str]:
76
+ return _git(context["repo"], "rev-parse", "-q", "--verify", f"{commit}:{path}")
77
+
78
+ return [f"{path} is a locked file of a merged ticket, and `locked` does not list it: list it in `locked`, or "
79
+ "leave the file as it was" for path in context.get("merged", []) if path not in report.get("locked", [])
80
+ and blob(context["branch"], path) not in (blob(context["start"], path), blob(context["pr"], path))]
81
+
82
+
83
+ def _proof_errors(report: dict, context: dict) -> list[str]:
84
+ """Every claimed red line (a test-writer `red`, a reviewer `mutations`) is in the agent's log."""
85
+ lines = [r["line"] for r in report.get("red", [])] + [m["red"] for m in report.get("mutations", [])]
86
+ errors = []
87
+ if lines and not all(_seen(line, Path(context["log"]).read_text(errors="replace")) for line in lines):
88
+ errors.append(RED)
89
+ if context.get("round") == 1 and "findings" in report and "mutations" not in report:
90
+ errors.append("mutations: required in round 1")
91
+ return errors
92
+
93
+
94
+ def _locked_errors(report: dict, context: dict) -> list[str]:
95
+ """In the first review, each locked test file has a mutation proof (`mutations[].test` is `<file>::<test>`)."""
96
+ if context.get("round") != 1 or "findings" not in report:
97
+ return []
98
+ tests = [proof["test"] for proof in report.get("mutations", [])]
99
+ return [f"mutations: no mutation proof for the locked test file {path}" for path in context.get("locked", [])
100
+ if not any(test.startswith(path) for test in tests)]
101
+
102
+
103
+ def validate(role: str, path: str, context: dict) -> list[str]:
104
+ try:
105
+ report = json.loads(Path(path).read_text())
106
+ except (OSError, ValueError) as exc:
107
+ return [f"report {path} is not readable JSON: {exc}"]
108
+ rules = schema(role)
109
+ found = sorted(jsonschema.Draft202012Validator(rules).iter_errors(report), key=lambda e: str(list(e.path)))
110
+ if found:
111
+ return [f"{'.'.join(map(str, e.path)) or 'report'}: {e.message}" for e in found]
112
+ # The schema types only the role's own fields: any other field of the report is dropped.
113
+ report = {name: value for name, value in report.items() if name in rules["properties"]}
114
+ return (_commit_errors(report, context) + _owner_errors(report, context) + _correction_errors(report, context)
115
+ + _merged_errors(report, context) + _proof_errors(report, context) + _locked_errors(report, context))
@@ -0,0 +1,126 @@
1
+ """The driver's own part of a review: the locked-file check of the ticket's own tests, the routing of the findings,
2
+ and the mutation proofs that a fix makes due again.
3
+
4
+ A finding is the dict of the reviewer schema plus `source`: the role that reported it, or `driver`.
5
+ A mutation proof is keyed by (production file, line); the line is the one the file has at the ticket tip.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import driver_agents
11
+ from driver_slots import git, head, names
12
+ from driver_tickets import Ticket
13
+
14
+ # The correctness pass reads this file of the review-diff skill, which is installed next to this one.
15
+ CORRECTNESS = driver_agents.SKILL.parent / "review-diff" / "correctness.md"
16
+ OPEN = ("critical", "important") # the severities that start a fix round
17
+ _HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", re.M)
18
+
19
+
20
+ def precondition_errors(tickets: list[Ticket]) -> list[str]:
21
+ """[] or the error that stops the run before any agent: a ticket with a Risk line needs CORRECTNESS."""
22
+ risky = [ticket.number for ticket in tickets if ticket.risk]
23
+ if CORRECTNESS.exists() or not risky:
24
+ return []
25
+ return [f"{CORRECTNESS} is missing: ticket {', '.join(risky)} has a Risk line and its correctness pass reads "
26
+ "that file; install the review-diff skill next to implement-spec"]
27
+
28
+
29
+ def locked_changes(slot: str, sha: str | None, locked: list[str]) -> list[dict]:
30
+ """One critical finding per locked file that differs from the test-writer's commit `sha`, or for a diff that
31
+ git cannot make."""
32
+ if not (sha and locked):
33
+ return [] # a refactor ticket has no test-writer
34
+ diff = git(slot, "diff", "--name-only", "-z", f"{sha}..HEAD", "--", *locked)
35
+ titles = ({path: f"locked file changed: {path}" for path in names(diff.stdout)} if diff.returncode == 0
36
+ else {locked[0]: f"locked file check failed: {diff.stderr.strip()}"})
37
+ return [{"severity": "critical", "file": path, "line": 1, "title": title, "kind": "code", "source": "driver",
38
+ "evidence": f"git diff {sha}..HEAD -- {path}"} for path, title in titles.items()]
39
+
40
+
41
+ def _references(entry: dict) -> list[tuple[str | None, list[str]]]:
42
+ """(reference commit, the locked files it is the reference of): `test_writer_sha`, or the older commit that
43
+ `held` names for a file."""
44
+ held = entry["held"]
45
+ groups = [(entry["test_writer_sha"], [path for path in entry["locked"] if path not in held])]
46
+ return groups + [(sha, [path for path, at in held.items() if at == sha]) for sha in dict.fromkeys(held.values())]
47
+
48
+
49
+ def locked_findings(slot: str, entry: dict) -> list[dict]:
50
+ """The locked-file check for the ticket record `entry`."""
51
+ return [finding for sha, paths in _references(entry) for finding in locked_changes(slot, sha, paths)]
52
+
53
+
54
+ def restore_locked(slot: str, entry: dict) -> list[str]:
55
+ """Put each locked file that differs from its reference back to the reference version, in the tree only.
56
+ Returns those paths."""
57
+ restored = []
58
+ for sha, paths in _references(entry):
59
+ if sha and paths:
60
+ restored += names(git(slot, "diff", "--name-only", "-z", sha, "--", *paths).stdout)
61
+ for path in restored:
62
+ git(slot, "checkout", "-q", entry["held"].get(path, entry["test_writer_sha"]), "--", path)
63
+ return restored
64
+
65
+
66
+ def written(slot: str, entry: dict, listed: list[str], tip: str) -> dict:
67
+ """The locked-file fields of the ticket record after a test-writer commit at `tip` that lists `listed`.
68
+ `tip` is the new reference. A locked file that a correction does not list and that differs from the old
69
+ reference keeps the old one in `held`, so a change the implementer made before stays a finding."""
70
+ held = {path: sha for path, sha in entry["held"].items() if path not in listed}
71
+ others = [path for path in entry["locked"] if path not in listed and path not in held]
72
+ if entry["test_writer_sha"] and others:
73
+ diff = git(slot, "diff", "--name-only", "-z", entry["test_writer_sha"], tip, "--", *others)
74
+ # A diff that git cannot make holds each file.
75
+ held.update(dict.fromkeys(names(diff.stdout) if diff.returncode == 0 else others, entry["test_writer_sha"]))
76
+ return {"test_writer_sha": tip, "locked": list(dict.fromkeys(entry["locked"] + listed)), "held": held}
77
+
78
+
79
+ def route(findings: list[dict]) -> tuple[list[dict], list[dict]]:
80
+ """(the fixture findings, whatever their severity; the other findings that are severe enough for a fix round)."""
81
+ fixture = [finding for finding in findings if finding["kind"] == "fixture"]
82
+ return fixture, [finding for finding in findings if finding["kind"] != "fixture" and finding["severity"] in OPEN]
83
+
84
+
85
+ def _hunks(slot: str, old: str, new: str, file: str) -> list[tuple[int, int, int, int]]:
86
+ """(first old line, old count, first new line, new count) per hunk of `file`, with no context lines."""
87
+ diff = git(slot, "diff", "-U0", old, new, "--", file).stdout
88
+ return [(int(a), int(b or 1), int(c), int(d or 1)) for a, b, c, d in _HUNK.findall(diff)]
89
+
90
+
91
+ def _follow(line: int, hunks: list[tuple[int, int, int, int]]) -> tuple[int, bool]:
92
+ """(the number old `line` has after the hunks, whether a hunk changed the line)."""
93
+ shift = 0
94
+ for a, b, c, d in hunks:
95
+ if a <= line < a + b:
96
+ return c + min(line - a, max(d - 1, 0)), True
97
+ if line >= a + max(b, 1): # a hunk with no old line is an insert after line `a`
98
+ shift += d - b
99
+ return line + shift, False
100
+
101
+
102
+ def follow(slot: str, old: str, new: str, proofs: set[tuple[str, int]]) -> dict[tuple[str, int], bool]:
103
+ """The proofs of commit `old` as they are at `new`: (file, new line) -> whether the fix changed that line, so
104
+ its proof is due again."""
105
+ # ponytail: a renamed file reads as deleted, so each of its proofs is due again; follow renames if that costs.
106
+ hunks = {file: _hunks(slot, old, new, file) for file in {file for file, _ in proofs}}
107
+ found: dict[tuple[str, int], bool] = {}
108
+ for file, line in sorted(proofs):
109
+ at, changed = _follow(line, hunks[file])
110
+ found[file, at] = changed or found.get((file, at), False)
111
+ return found
112
+
113
+
114
+ def restore(slot: str, branch: str, tip: str) -> None:
115
+ """Put the slot back on `branch` at `tip` with a clean tree. Only for a read-only role: its edits, commits and
116
+ untracked files go. Ignored files stay."""
117
+ git(slot, "checkout", "-q", "-f", "-B", branch, tip)
118
+ git(slot, "clean", "-q", "-fd")
119
+
120
+
121
+ def disturbed(slot: str, branch: str, tip: str) -> str:
122
+ """"" when the slot is still on `branch` at `tip` with a clean tree, else what the read-only agents left."""
123
+ if git(slot, "symbolic-ref", "-q", "--short", "HEAD").stdout.strip() != branch or head(slot) != tip:
124
+ return f"the slot is no longer on {branch} at {tip[:7]}"
125
+ left = [line[3:] for line in git(slot, "status", "--porcelain").stdout.splitlines()]
126
+ return f"the tree is not as the review found it: {', '.join(left)}" if left else ""