@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,505 @@
1
+ """The end of a run, when every ticket is merged and every slot is idle: tidy the PR branch into one commit per
2
+ ticket, rebase it onto the fetched base tip, gate it, run the final review, let one fix batch land as `fix(NN)`
3
+ commits with a scoped review, then stop before the push, or push and open the PR.
4
+
5
+ `run.final` of state.json is the resume contract (`driver_state._final` makes it with every key):
6
+ skipped why the end-of-run work did not start
7
+ recovery the PR tip before the rewrite; the ref RECOVERY holds it. None when origin had the history already
8
+ tidied the tip the rewrite made, or the tip as the tickets left it when the history is kept
9
+ base, onto the default branch of origin and its tip under the history; with no origin: "" and the start of the run
10
+ commits {ticket: its commit}
11
+ reviewed the last tip a review covered
12
+ round the reviews done
13
+ open the findings the fix batch is due for
14
+ batch the tip of the fix batch that the driver validated and named
15
+ green_gates, pushed, pr
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import shlex
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+
27
+ import driver_agents
28
+ import driver_decisions
29
+ import driver_merge
30
+ import driver_review
31
+ import driver_tidy
32
+ from driver_run import FINAL, OUT, Blocked, Run, during, ledger
33
+ from driver_slots import git, head
34
+ from driver_state import State
35
+ from driver_tickets import Ticket
36
+
37
+ RECOVERY = "refs/implement/recovery"
38
+ KIND = "final-finding"
39
+ ROUNDS = 2 # the full review and the scoped one after the fix batch
40
+ # The final review runs through the review-diff skill, which is installed next to this one.
41
+ REVIEW_DIFF = driver_agents.SKILL.parent / "review-diff" / "SKILL.md"
42
+
43
+
44
+ def _has_origin() -> bool:
45
+ return git(".", "remote", "get-url", "origin").returncode == 0
46
+
47
+
48
+ def precondition_errors(push: bool) -> list[str]:
49
+ """[] or the errors that stop the run before any agent."""
50
+ errors = []
51
+ if not REVIEW_DIFF.exists():
52
+ errors.append(f"{REVIEW_DIFF} is missing: the final review runs through it; install the review-diff skill "
53
+ "next to implement-spec")
54
+ if push and not _has_origin():
55
+ errors.append("the repository has no origin remote: --unattended and --push push the PR branch to origin; "
56
+ "add the remote, or run without them to stop before the push")
57
+ return errors
58
+
59
+
60
+ def finish_interrupted_move(state: State, pr: str) -> None:
61
+ """A run that stopped between the record of the tidied tip and the move of the PR branch: move it now."""
62
+ final = state.read()["run"]["final"]
63
+ if final["tidied"] and git(".", "rev-parse", f"refs/heads/{pr}").stdout.strip() == final["recovery"]:
64
+ git(".", "checkout", "-q", "--detach")
65
+ driver_merge.land(pr, final["tidied"], final["recovery"])
66
+ git(".", "checkout", "-q", pr)
67
+
68
+
69
+ def _prompt_values(run: Run) -> dict[str, str]:
70
+ """The pointers every prompt of the end of run gets. Its agents work in slot 1, the PR worktree."""
71
+ return {"spec": run.args.spec, "tickets": run.args.tickets, "pr_branch": run.pr, "branch": run.pr}
72
+
73
+
74
+ def _title(finding: dict) -> str:
75
+ return f"{finding['title']} ({finding['file']}:{finding['line']})"
76
+
77
+
78
+ def _locked_files(run: Run) -> list[str]:
79
+ return sorted({path for entry in run.state.read()["tickets"].values() for path in entry["locked"]})
80
+
81
+
82
+ def _last(commits: dict[str, str]) -> str:
83
+ return list(commits.values())[-1]
84
+
85
+
86
+ # ---------------------------------------------------------------- the rewrite: tidy, rebase, move the branch
87
+
88
+ def _fetch_base() -> tuple[str, str]:
89
+ """Fetch every ref of origin. Returns (its default branch, the tip of that branch)."""
90
+ shown = git(".", "ls-remote", "--symref", "origin", "HEAD")
91
+ name = re.search(r"^ref: refs/heads/(\S+)\s+HEAD$", shown.stdout, re.M)
92
+ if not name:
93
+ raise Blocked(f"origin names no default branch: {shown.stderr.strip() or 'git ls-remote shows no HEAD'}")
94
+ fetched = git(".", "fetch", "-q", "origin")
95
+ onto = git(".", "rev-parse", "-q", "--verify", f"refs/remotes/origin/{name[1]}^{{commit}}").stdout.strip()
96
+ if fetched.returncode or not onto:
97
+ raise Blocked(f"git fetch origin did not give refs/remotes/origin/{name[1]}: {fetched.stderr.strip()}")
98
+ return name[1], onto
99
+
100
+
101
+ def _base(run: Run) -> tuple[str, str]:
102
+ """(the default branch of origin, its tip): what the tidied history sits on. With no origin there is no base:
103
+ ("", the commit the run started from)."""
104
+ if not _has_origin():
105
+ ledger("End of run: the repository has no origin remote, so the rebase and the push are skipped; the "
106
+ "tidy, the gate and the final review run.")
107
+ return "", run.state.read()["run"]["start"]
108
+ base, onto = _fetch_base()
109
+ if base == run.pr:
110
+ raise Blocked(f"{run.pr} is the default branch of origin: the end of run needs a PR branch")
111
+ return base, onto
112
+
113
+
114
+ def _origin_has_commits(pr: str, tip: str, onto: str) -> bool:
115
+ """origin has the PR branch, or a ref of origin holds a commit of `tip` that the base tip `onto` does not."""
116
+ def commits_not_in(*others: str) -> str:
117
+ return git(".", "rev-list", "--count", tip, "--not", *others).stdout.strip()
118
+
119
+ return (commits_not_in(onto) != commits_not_in("--remotes=origin")
120
+ or bool(git(".", "ls-remote", "origin", f"refs/heads/{pr}").stdout.strip()))
121
+
122
+
123
+ def _keep_published_history(run: Run, tip: str, base: str, onto: str) -> None:
124
+ """Record the history as it is: a history that origin has is never rewritten."""
125
+ commits = dict(run.state.read()["run"]["landed"])
126
+ run.state.update(lambda state: state["run"]["final"].update(tidied=tip, onto=onto, base=base, commits=commits))
127
+ ledger(f"End of run: origin has commits of {run.pr}, so its history is kept as it is: no tidy and no rebase, "
128
+ f"because a published history is not rewritten. The gate and the final review run on {tip[:7]}.")
129
+
130
+
131
+ def _rebase_is_open() -> bool:
132
+ return any(Path(git(".", "rev-parse", "--git-path", name).stdout.strip()).exists()
133
+ for name in ("rebase-merge", "rebase-apply"))
134
+
135
+
136
+ def _files_in_conflict() -> list[str]:
137
+ return git(".", "diff", "--name-only", "--diff-filter=U").stdout.split()
138
+
139
+
140
+ def _resolve_rebase_conflict(run: Run, env: dict, base: str, onto: str) -> None:
141
+ """Run the conflict agent, once, on the rebase that is open in slot 1, then gate the rebased tip. No rebase is
142
+ open afterwards, also when this raises."""
143
+ files = _files_in_conflict()
144
+ values = {**_prompt_values(run), "branch": "HEAD", "start_sha": onto, "base": base, "files": " ".join(files)}
145
+ error = ""
146
+ try:
147
+ run.agent("conflict", FINAL, ".", values, template="rebase-conflict", detached=True)
148
+ except Blocked as blocked:
149
+ error = str(blocked)
150
+ if not error and _rebase_is_open(): # the agent resolved the files and did not go on
151
+ subprocess.run(["git", "-c", "core.editor=true", "rebase", "--continue"], capture_output=True, env=env)
152
+ if _rebase_is_open():
153
+ again = _files_in_conflict()
154
+ git(".", "rebase", "--abort")
155
+ raise Blocked(f"the conflict agent: {error}" if error else
156
+ f"the rebase onto {base} conflicts a second time, in {', '.join(again)}")
157
+ # The resolution may not change a locked test: each one is as the recovery point has it.
158
+ changed = driver_review.locked_changes(".", git(".", "rev-parse", RECOVERY).stdout.strip(), _locked_files(run))
159
+ if error := error or "; ".join(finding["title"] for finding in changed):
160
+ raise Blocked(f"the conflict agent: {error}")
161
+ ledger(f"Rebase: the conflict agent resolved the conflict with {base} in {', '.join(files)} at {head('.')[:7]}.")
162
+ with during("the gate after the rebase"):
163
+ run.gate(FINAL, ".", head("."))
164
+
165
+
166
+ def _rebase(run: Run, tip: str, base: str, onto: str) -> None:
167
+ """`git rebase` of `tip` onto `onto` in slot 1, on a detached HEAD: no ref moves. HEAD is the rebased tip."""
168
+ env = {**os.environ, **driver_merge.identity(tip)}
169
+ git(".", "checkout", "-q", "--detach", tip)
170
+ done = subprocess.run(["git", "-c", "core.editor=true", "rebase", onto], capture_output=True, text=True, env=env)
171
+ if _rebase_is_open():
172
+ _resolve_rebase_conflict(run, env, base, onto)
173
+ elif done.returncode:
174
+ raise Blocked(f"git rebase: {done.stderr.strip()}")
175
+
176
+
177
+ def _rebased(run: Run, commits: dict[str, str], base: str, onto: str) -> dict[str, str]:
178
+ """Rebase the tidied history onto `onto`, the tip of `base`. Returns {ticket: its commit on the new base}."""
179
+ tip = _last(commits)
180
+ before = git(".", "rev-list", "--reverse", f"{onto}..{tip}").stdout.split()
181
+ if not before: # no ticket left a change, and the base tip holds the commit under them
182
+ return dict.fromkeys(commits, onto)
183
+ _rebase(run, tip, base, onto)
184
+ after = git(".", "rev-list", "--reverse", f"{onto}..HEAD").stdout.split()
185
+ if len(after) != len(before):
186
+ raise Blocked(f"the rebase onto {base} left {len(after)} commits for {len(before)}")
187
+ # A conflict resolution may have changed a message: each rebased commit gets its tidied message again.
188
+ made = driver_tidy.rewrite(onto, [(new, driver_tidy.text(old)) for new, old in zip(after, before)])
189
+ if not driver_tidy.same_tree(made[-1], head(".")):
190
+ raise Blocked(f"the renamed commits do not have the tree of the rebased tip {head('.')[:7]}")
191
+ moved = dict(zip(before, made))
192
+ return {number: moved.get(commit, commit) for number, commit in commits.items()}
193
+
194
+
195
+ def _new_history(run: Run, tickets: list[Ticket], old: str, base: str, onto: str) -> dict[str, str]:
196
+ """Make the new history of the PR branch, with no ref moved. Returns {ticket: its commit}."""
197
+ record = run.state.read()["run"]
198
+ if not driver_merge.ancestor(record["start"], old):
199
+ raise Blocked(f"{run.pr} no longer holds {record['start'][:7]}, the commit the run started from")
200
+ # The tidy comes before the rebase: its tree check is exact, and the rebase moves one commit per ticket.
201
+ commits = driver_tidy.tidy(record["start"], record["landed"], {ticket.number: ticket for ticket in tickets})
202
+ if not driver_tidy.same_tree(_last(commits), old):
203
+ raise Blocked(f"the tidied history does not have the tree of {old[:7]}")
204
+ if driver_merge.ancestor(onto, _last(commits)):
205
+ return commits
206
+ return _rebased(run, commits, base, onto)
207
+
208
+
209
+ def _move_branch(run: Run, commits: dict[str, str], old: str, base: str, onto: str) -> None:
210
+ """Record the new history, then move the PR branch to it."""
211
+ new = _last(commits)
212
+ old_tips = {number: entry["tip"] for number, entry in run.state.read()["tickets"].items()}
213
+
214
+ def record(state: dict, tips: dict, **fields) -> None:
215
+ state["run"]["final"].update(fields)
216
+ for number, tip in tips.items():
217
+ state["tickets"][number]["tip"] = tip
218
+
219
+ # The record comes first and names each ticket's new commit as its tip: a resume confirms a merged ticket by
220
+ # that tip, and `finish_interrupted_move` moves the branch when the run stopped between the two writes.
221
+ run.state.update(lambda state: record(state, commits, recovery=old, tidied=new, onto=onto, base=base,
222
+ commits=commits))
223
+ if error := driver_merge.land(run.pr, new, old):
224
+ run.state.update(lambda state: record(state, old_tips, tidied=None))
225
+ raise Blocked(f"cannot move {run.pr} to the tidied tip {new[:7]}: {error}")
226
+ each = ", ".join(f"{number} is {commit[:7]}" for number, commit in commits.items())
227
+ ledger(f"Tidy: {run.pr} moved from {old[:7]} to {new[:7]} on {base or 'the start of the run'} at {onto[:7]}, "
228
+ f"with one commit per ticket in merge order: {each}.")
229
+
230
+
231
+ def _rewrite_once(run: Run, tickets: list[Ticket]) -> None:
232
+ """The one rewrite of the PR branch: recovery point, tidy, rebase, then the move. When it raises, the branch
233
+ is at its recovery point."""
234
+ final = run.state.read()["run"]["final"]
235
+ if final["tidied"]:
236
+ if not driver_merge.ancestor(final["tidied"], run.tip()):
237
+ raise Blocked(f"{run.pr} does not hold the tidied tip {final['tidied'][:7]}; {RECOVERY} holds the "
238
+ "history before the tidy")
239
+ return
240
+ base, onto = _base(run)
241
+ old = run.tip()
242
+ if base and _origin_has_commits(run.pr, old, onto):
243
+ _keep_published_history(run, old, base, onto)
244
+ return
245
+ tree = git(".", "rev-parse", f"{old}^{{tree}}").stdout.strip()
246
+ git(".", "update-ref", "-m", "implement-spec: recovery point", RECOVERY, old)
247
+ ledger(f"Recovery point: {run.pr} was at {old} with the tree {tree} before the rebase and the tidy; "
248
+ f"{RECOVERY} holds that commit.")
249
+ git(".", "checkout", "-q", "--detach") # slot 1 is off the branch while the history is made
250
+ try:
251
+ commits = _new_history(run, tickets, old, base, onto)
252
+ _move_branch(run, commits, old, base, onto)
253
+ except Blocked as blocked:
254
+ raise Blocked(f"the rewrite: {blocked}; {run.pr} is unrewritten at its recovery point {old[:7]}") from None
255
+ finally:
256
+ git(".", "checkout", "-q", run.pr)
257
+
258
+
259
+ # ---------------------------------------------------------------- the final review and the fix batch
260
+
261
+ def _minor_findings() -> str:
262
+ """One line per minor finding that a ticket review left for the final review, with its findings file."""
263
+ lines = []
264
+ for path in sorted(OUT.glob("[0-9]*/findings-*.json")):
265
+ lines += [f"- {path}: {_title(finding)}" for finding in json.loads(path.read_text())
266
+ if finding["severity"] == "minor"]
267
+ return "\n".join(dict.fromkeys(lines))
268
+
269
+
270
+ def _check_review_is_due(run: Run, final: dict, tip: str) -> None:
271
+ """Only a tip the driver made gets a review: the tidied tip, then the tip of the validated fix batch."""
272
+ if final["round"] >= ROUNDS:
273
+ raise Blocked(f"the two review rounds are done and the tip {tip[:7]} of {run.pr} is not the reviewed tip "
274
+ f"{final['reviewed'][:7]}")
275
+ made = final["batch"] or final["tidied"]
276
+ if tip != made:
277
+ raise Blocked(f"the tip {tip[:7]} of {run.pr} is not a tip the driver made: only {made[:7]} gets a review, "
278
+ f"so the run goes on when {run.pr} is at {made[:7]} again")
279
+
280
+
281
+ def _record_review(state: dict, tip: str, to_fix: list[dict], evidence: list[str]) -> None:
282
+ """The open findings of the first round start the fix batch; those of the second become one question each."""
283
+ record, final = state["run"], state["run"]["final"]
284
+ first_round = final["round"] == 0
285
+ final.update(reviewed=tip, round=final["round"] + 1, open=to_fix if first_round else [])
286
+ if not first_round:
287
+ driver_decisions.stop(record, KIND, "", [_title(finding) for finding in to_fix], evidence)
288
+
289
+
290
+ def _settle_review(run: Run, done: int, tip: str, covered: str, found: list[dict]) -> None:
291
+ """An accepted review of the range `covered`, after `done` earlier rounds: its findings go to their file, the
292
+ ledger and state.json."""
293
+ folder = OUT / FINAL
294
+ file = folder / f"findings-{done}.json"
295
+ file.write_text(json.dumps(found, indent=1) + "\n")
296
+ for finding in found:
297
+ ledger(f"Final review round {done + 1}: {finding['severity']} finding of kind {finding['kind']} at "
298
+ f"{finding['file']}:{finding['line']}: {finding['title']}.")
299
+ to_fix = [finding for finding in found if finding["severity"] in driver_review.OPEN]
300
+ evidence = [str(file), str(folder / f"final-reviewer-{done}.json")]
301
+ run.state.update(lambda state: _record_review(state, tip, to_fix, evidence))
302
+ ledger(f"Final review round {done + 1}: the range {covered} is reviewed, the reviewed tip is {tip[:7]}, and "
303
+ f"{len(to_fix)} critical or important finding{'s are' if len(to_fix) != 1 else ' is'} open.")
304
+ if to_fix and done:
305
+ ledger(f"End of run: {driver_decisions.decides(KIND, len(to_fix))} in {OUT / 'decisions.json'}: a third "
306
+ "review round is never started.")
307
+
308
+
309
+ def _review(run: Run, final: dict, tip: str) -> None:
310
+ """One round of the final review on `tip`: the full review, or after it the scoped one."""
311
+ done, folder = final["round"], OUT / FINAL
312
+ values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['reviewed'] or final['onto']}..{tip}",
313
+ "gate": str(folder / f"gate-{tip[:7]}.log"), "review_diff": str(REVIEW_DIFF),
314
+ "ledger": str(OUT / "ledger.md"), "minors": _minor_findings(), "reviewed": str(final["reviewed"]),
315
+ "rulings": "\n".join(driver_decisions.rulings(run.state.read()["run"]["questions"])),
316
+ "findings": str(folder / f"findings-{done - 1}.json")}
317
+ # A locked file that differs from the tidied tip is a critical finding. The driver made that tip, and since
318
+ # the tickets' own checks only the conflict agent of the rebase ran, which has the same check.
319
+ found = driver_review.locked_changes(".", final["tidied"], _locked_files(run))
320
+ with during("the final review"):
321
+ report = run.read_only(FINAL, ".", run.pr, tip, lambda: run.agent(
322
+ "final-reviewer", FINAL, ".", values, notes=["final-scoped"] if done else [], fix_round=done))
323
+ _settle_review(run, done, tip, values["range"], found + report["findings"])
324
+
325
+
326
+ def _fix_commit_message(commit: str, report: dict) -> str:
327
+ """`fix(NN): ` and the fixer's own message; NN is the ticket the report gives for the commit."""
328
+ owner = next(one["ticket"] for one in report["commits"] if commit.startswith(one["sha"]))
329
+ words = driver_tidy.text(commit)
330
+ return words if words.startswith(f"fix({owner}): ") else f"fix({owner}): {words}"
331
+
332
+
333
+ def _undo_rejected_batch(run: Run, tip: str) -> str:
334
+ """Move the PR branch back to the reviewed tip `tip`: commits the driver did not validate get no review and
335
+ no push. The files stay in slot 1, where the next run stops on them as uncommitted changes.
336
+ Returns "" or, for the error message, why the branch did not move."""
337
+ left = run.tip()
338
+ if left != tip and (error := driver_merge.land(run.pr, tip, left)):
339
+ return f"; {run.pr} stays at {left[:7]}: {error}"
340
+ if left != tip:
341
+ commits = f"the commits of the batch end at {left} and stay in the reflog"
342
+ else:
343
+ commits = "the fixer made no commit"
344
+ ledger(f"End of run: the fix batch was rejected, so the driver moved {run.pr} back to its reviewed tip "
345
+ f"{tip[:7]}; {commits}. The files of the fixer stay in slot 1 as they are.")
346
+ return ""
347
+
348
+
349
+ def _run_fixer(run: Run, tickets: list[Ticket], final: dict, tip: str) -> dict:
350
+ """Run the fixer on the PR branch in slot 1 and return its report. A rejected batch is undone."""
351
+ folder = OUT / FINAL
352
+ values = {**_prompt_values(run), "start_sha": tip, "findings": str(folder / f"findings-{final['round'] - 1}.json"),
353
+ "locked": " ".join(_locked_files(run)),
354
+ "findings_list": "\n".join(f"- {finding['severity']}: {_title(finding)}" for finding in final["open"])}
355
+ try:
356
+ return run.agent("fixer", FINAL, ".", values, template="final-fixer", fix_round=final["round"],
357
+ check={"owners": [ticket.number for ticket in tickets]})
358
+ except Blocked as blocked:
359
+ raise Blocked(f"{blocked}{_undo_rejected_batch(run, tip)}") from None
360
+
361
+
362
+ def _fix_batch(run: Run, tickets: list[Ticket], final: dict, tip: str) -> None:
363
+ """Run the one fix batch, then name its commits `fix(NN)`."""
364
+ with during("the fix batch"):
365
+ report = _run_fixer(run, tickets, final, tip)
366
+ # The commits are named before the gate and the scoped review see them: nothing is rewritten after a review.
367
+ made = git(".", "rev-list", "--reverse", f"{tip}..HEAD").stdout.split()
368
+ named = driver_tidy.rewrite(tip, [(commit, _fix_commit_message(commit, report)) for commit in made])
369
+ if not driver_tidy.same_tree(named[-1], made[-1]):
370
+ raise Blocked(f"the named commits do not have the tree of {made[-1][:7]}")
371
+ # Slot 1 has the branch checked out: the new tip has the same tree, so the index and the files stay right.
372
+ if error := driver_merge.land(run.pr, named[-1], made[-1]):
373
+ raise Blocked(error)
374
+ run.state.update(lambda state: state["run"]["final"].update(open=[], batch=named[-1]))
375
+ subjects = ", ".join(f"{commit[:7]} {driver_tidy.text(commit).splitlines()[0]}" for commit in named)
376
+ ledger(f"Fix batch of the final review: {len(named)} commit{'s' if len(named) > 1 else ''} on {tip[:7]}: "
377
+ f"{subjects}.")
378
+
379
+
380
+ def _review_until_reviewed(run: Run, tickets: list[Ticket]) -> None:
381
+ """Gate and review the tip, and run the fix batch, until the tip is the reviewed tip."""
382
+ while True:
383
+ final, tip = run.state.read()["run"]["final"], run.tip()
384
+ if final["reviewed"] != tip:
385
+ _check_review_is_due(run, final, tip)
386
+ with during("the gate"):
387
+ run.gate(FINAL, ".", tip)
388
+ _review(run, final, tip)
389
+ elif final["open"]:
390
+ _fix_batch(run, tickets, final, tip)
391
+ else:
392
+ return
393
+
394
+
395
+ # ---------------------------------------------------------------- the push
396
+
397
+ def _name(run: Run) -> str:
398
+ """The title of the PR: the first heading of the spec, else the PR branch."""
399
+ spec = Path(run.args.spec)
400
+ lines = spec.read_text(errors="replace").splitlines() if spec.is_file() else []
401
+ return next((line[2:].strip() for line in lines if line.startswith("# ")), run.pr)
402
+
403
+
404
+ def _body(run: Run, final: dict, tickets: list[Ticket]) -> str:
405
+ """The PR body: the ticket-to-commit map, the fix commits, the rulings and the review coverage."""
406
+ titles, tip = {ticket.number: ticket.title for ticket in tickets}, final["reviewed"]
407
+ fixes = git(".", "log", "--reverse", "--format=- %h %s", f"{final['tidied']}..{tip}").stdout.strip()
408
+ rulings = driver_decisions.rulings(run.state.read()["run"]["questions"])
409
+ return driver_agents.render("pr-body", {
410
+ "map": "\n".join(f"- {number}: {commit[:7]} {titles[number]}" for number, commit in final["commits"].items()),
411
+ "fixes": fixes or "None.", "rulings": "\n".join(f"- {ruling}" for ruling in rulings) or "None.",
412
+ "tip": tip, "onto": final["onto"], "tidied": final["tidied"],
413
+ "scoped": f"{final['tidied']}..{tip}" if fixes else "not needed"})
414
+
415
+
416
+ def _open_pr(run: Run, final: dict, tickets: list[Ticket]) -> str:
417
+ """Open the PR, ready for review, and return its URL."""
418
+ body = OUT / FINAL / "pr-body.md"
419
+ body.write_text(_body(run, final, tickets))
420
+ made = subprocess.run(["gh", "pr", "create", "--base", final["base"], "--head", run.pr, "--title", _name(run),
421
+ "--body-file", str(body)], capture_output=True, text=True, stdin=subprocess.DEVNULL)
422
+ if made.returncode:
423
+ raise Blocked(f"gh pr create failed: {(made.stderr or made.stdout).strip()}")
424
+ url = (made.stdout.split() or [""])[-1]
425
+ run.state.update(lambda state: state["run"]["final"].update(pr=url))
426
+ ledger(f"The PR of {run.pr} into {final['base']} is open: {url}.")
427
+ return url
428
+
429
+
430
+ def _push(run: Run, final: dict, tickets: list[Ticket]) -> list[str]:
431
+ """Push the reviewed tip to origin, once and with no force, then open the PR, once. Returns the lines for the
432
+ operator."""
433
+ tip = final["reviewed"]
434
+ if tip != run.tip() or tip not in final["green_gates"]:
435
+ raise Blocked(f"the reviewed tip {tip[:7]} is not the tip of {run.pr} with a green gate record: nothing is "
436
+ "pushed")
437
+ if not final["base"]:
438
+ raise Blocked(f"the end of run tidied {run.pr} when the repository had no origin remote, so it is on no base "
439
+ "branch of origin: nothing is pushed")
440
+ if not shutil.which("gh"):
441
+ raise Blocked("gh is not on PATH: nothing is pushed")
442
+ if final["pushed"] != tip:
443
+ # The commit, not the branch name: what leaves the machine is the tip the review covered.
444
+ sent = git(".", "push", "-q", "origin", f"{tip}:refs/heads/{run.pr}")
445
+ if sent.returncode:
446
+ raise Blocked(f"git push origin {run.pr} failed: {sent.stderr.strip()}")
447
+ run.state.update(lambda state: state["run"]["final"].update(pushed=tip))
448
+ ledger(f"Pushed {run.pr} at {tip[:7]} to origin.")
449
+ url = final["pr"] or _open_pr(run, final, tickets)
450
+ return [f"Pushed {run.pr} at {tip} to origin.", f"PR: {url}"]
451
+
452
+
453
+ def again(argv: list[str]) -> str:
454
+ """The command of this run with `--push` and without its answers: an answer is given once."""
455
+ kept, skip = [], False
456
+ for word in argv:
457
+ if not (skip or word.startswith("--answer")):
458
+ kept.append(word)
459
+ skip = word == "--answer"
460
+ return shlex.join(["sh", str(driver_agents.SKILL / "run.sh"), *kept, "--push"])
461
+
462
+
463
+ def _close(run: Run, tickets: list[Ticket]) -> list[str]:
464
+ """The tip is reviewed and its gate is green: push when the run may. Returns the lines for the operator."""
465
+ record = run.state.read()["run"]
466
+ answers = [question["answer"] for question in record["questions"] if question["kind"] == KIND]
467
+ if None in answers:
468
+ return [] # parked: the driver prints the questions
469
+ if kept := answers.count("fix"):
470
+ raise Blocked(f"the operator keeps {kept} finding{'s' if kept > 1 else ''} of the final review open (fix): "
471
+ "nothing is pushed")
472
+ final = record["final"]
473
+ if run.args.push or run.args.unattended:
474
+ return _push(run, final, tickets)
475
+ if final["base"]:
476
+ how = f"To push {run.pr} and open the PR, run: {again(run.args.argv)}"
477
+ else:
478
+ how = "The repository has no origin remote."
479
+ return [f"Branch tip: {run.tip()}", f"Reviewed tip: {final['reviewed']}", f"Nothing is pushed. {how}"]
480
+
481
+
482
+ def _unmerged_tickets(state: dict) -> str:
483
+ """"" or why the end-of-run work does not start. A ticket that is not merged can still merge after an answer,
484
+ and its branch starts from the history as it is: a tidy would rewrite its branch point."""
485
+ waiting = [number for number, entry in state["tickets"].items() if entry["phase"] != "merged"]
486
+ return f"these tickets are not merged: {', '.join(waiting)}" if waiting else ""
487
+
488
+
489
+ def run(run: Run, tickets: list[Ticket]) -> tuple[bool, list[str]]:
490
+ """Do the end of the run, from where state.json says it is. Call it only when no slot runs a ticket.
491
+ Returns (the end of run is BLOCKED, the lines the operator reads last)."""
492
+ state = run.state.read()
493
+ reason, before = _unmerged_tickets(state), state["run"]["final"]["skipped"]
494
+ run.state.update(lambda new: new["run"]["final"].update(skipped=reason))
495
+ if reason:
496
+ if reason != before:
497
+ ledger(f"End-of-run work skipped: {reason}.")
498
+ return False, []
499
+ try:
500
+ _rewrite_once(run, tickets)
501
+ _review_until_reviewed(run, tickets)
502
+ return False, _close(run, tickets)
503
+ except Blocked as blocked:
504
+ ledger(f"End of run: BLOCKED at {blocked}.")
505
+ return True, [f"End of run: BLOCKED at {blocked}"]