@jesdi/skills 0.0.44 → 0.0.46

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 (30) hide show
  1. package/package.json +1 -1
  2. package/skills/implement-spec/ARCHITECTURE.md +13 -6
  3. package/skills/implement-spec/README.md +9 -6
  4. package/skills/implement-spec/driver.py +82 -40
  5. package/skills/implement-spec/driver_final.py +9 -8
  6. package/skills/implement-spec/driver_fixloop.py +4 -1
  7. package/skills/implement-spec/driver_gate.py +5 -1
  8. package/skills/implement-spec/driver_legacy.py +92 -0
  9. package/skills/implement-spec/driver_merge.py +1 -5
  10. package/skills/implement-spec/driver_names.py +32 -0
  11. package/skills/implement-spec/driver_review.py +24 -2
  12. package/skills/implement-spec/driver_run.py +24 -13
  13. package/skills/implement-spec/driver_slots.py +13 -0
  14. package/skills/implement-spec/driver_state.py +12 -13
  15. package/skills/implement-spec/prompts/correctness.md +2 -0
  16. package/skills/implement-spec/prompts/final-reviewer.md +3 -1
  17. package/skills/implement-spec/prompts/pr-writer.md +1 -0
  18. package/skills/implement-spec/prompts/reviewer.md +2 -0
  19. package/skills/implement-spec/tests/test_agents.py +10 -10
  20. package/skills/implement-spec/tests/test_amendments.py +2 -2
  21. package/skills/implement-spec/tests/test_decisions.py +4 -4
  22. package/skills/implement-spec/tests/test_driver.py +183 -23
  23. package/skills/implement-spec/tests/test_final.py +5 -5
  24. package/skills/implement-spec/tests/test_fixloop.py +22 -5
  25. package/skills/implement-spec/tests/test_landing.py +26 -23
  26. package/skills/implement-spec/tests/test_legacy.py +139 -0
  27. package/skills/implement-spec/tests/test_stops.py +1 -1
  28. package/skills/implement-spec/tests/test_tidy.py +2 -2
  29. package/skills/implement-spec/tests/test_walk.py +10 -10
  30. package/skills-manifest.json +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jesdi/skills",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "description": "jesdi's agent skills (content package consumed by @jesdi/skills-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,9 @@ A comment in the code is for a line that looks wrong or removable without it.
21
21
  | `driver_merge.py` | Merges by git plumbing, the locked-file check of a landing, the move of the PR ref |
22
22
  | `driver_gate.py` | One run of the gate command and its record |
23
23
  | `driver_slots.py` | The worktrees that tickets run in, and the `git` helper |
24
- | `driver_state.py` | `state.json` (the resume contract) and the lock of one driver per repository |
24
+ | `driver_state.py` | `state.json` (the resume contract) and the kernel lock |
25
+ | `driver_names.py` | The names of one run: its ticket branches, its recovery ref and its lock file |
26
+ | `driver_legacy.py` | The take-over of a run that an older driver started; delete it when no such run is left |
25
27
  | `driver_decisions.py`, `driver_answers.py`, `driver_review_page.py` | Operator questions, their answers, the page that shows them |
26
28
  | `driver_tidy.py`, `driver_summary.py` | The history with one commit per ticket; `summary.md` |
27
29
 
@@ -89,15 +91,17 @@ and do not know about the run, except `driver_run.py`, which the three deciding
89
91
  - The gate runs only on a commit the driver is about to trust, and one green record settles that commit.
90
92
  A gate on a dirty tree, or one that leaves the tree changed or HEAD moved, is red.
91
93
  - The end of run rewrites the branch one time, before the first final review, and never after a review. A history
92
- that origin has is never rewritten. `refs/implement/recovery` holds the tip before the rewrite.
94
+ that origin has is never rewritten. `refs/implement-spec/<key>/recovery` holds the tip before the rewrite.
93
95
  - A rewrite never changes a file: the driver compares trees (`driver_tidy.same_tree`) before it moves the branch.
94
96
  - The push sends the reviewed commit, not the branch name, with no force.
95
97
 
96
98
  ### Work is never dropped
97
99
 
98
100
  - A role that commits keeps its tree after a timeout or an interrupt; its rerun continues from it.
99
- - A read-only role (reviewer, correctness, final-reviewer, pr-writer) has no work to keep. When it changes its slot, the
100
- driver restores the slot and runs the review one more time (`Run.read_only`).
101
+ - A read-only role (reviewer, correctness, final-reviewer, pr-writer) has no work to keep. When it changes its slot or
102
+ the shared git config, the driver restores them and runs the review one more time (`Run.read_only`). The config is
103
+ a byte copy taken before the role (`driver_review.shared_config`): every worktree reads that one file, so a key a
104
+ role writes there, such as a `core.worktree` on a temp directory, breaks git in all of them.
101
105
  - A slot with uncommitted work of an interrupted or parked ticket is kept for that ticket (`driver_state.kept`).
102
106
  - A blocked ticket keeps its slot when the slot is not clean.
103
107
 
@@ -110,8 +114,11 @@ and do not know about the run, except `driver_run.py`, which the three deciding
110
114
  `finish_interrupted_move` completes it at the next start.
111
115
  - At a resume, git must confirm each recorded tip (`driver_state.verified`). A ticket that git contradicts goes
112
116
  back to `implement` from its test-writer commit.
113
- - The lock of one driver per repository is a kernel lock on a file in the git common directory. The kernel frees
114
- it when the process ends, so it is never stale.
117
+ - The lock of one driver per PR branch is a kernel lock on a file in the git common directory. The kernel frees
118
+ it when the process ends, so it is never stale. Every ref that a run writes has the PR branch in its name
119
+ as one escaped name component. `driver_names.Names` (`Run.names`) makes every name of a run, so runs on
120
+ different PR branches share no ref. `driver_legacy.resume` takes over a run that an older driver started, once,
121
+ after the lock and before any check reads a ref.
115
122
 
116
123
  ### Agents
117
124
 
@@ -23,7 +23,9 @@ Before any agent starts, the driver checks these. Each failed check prints one l
23
23
 
24
24
  A run that stopped is continued with the same command. `state.json` is the only input of a resume.
25
25
 
26
- One driver runs in a repository at a time. The driver holds a kernel file lock on `implement-spec.lock` in the git common directory (`git rev-parse --git-common-dir`) for the whole run. Each worktree of the repository shares that directory, so a second driver in the same worktree or in another one ends with exit 3 and names the lock file. The file is not in a worktree, so `git status` never shows it. The lock ends with its process, so a driver that was killed leaves no lock behind. `--dry-run` takes no lock. A wrong call in a worktree that has no `.agent/implement/` takes no lock and makes no file; where an earlier run left that folder, the driver takes the lock first, then makes the checks.
26
+ One driver runs on a PR branch at a time. The driver holds a kernel file lock on `implement-spec/<key>.lock` in the git common directory (`git rev-parse --git-common-dir`) for the whole run. `<key>` is the PR branch as one name component: `%` becomes `%25` and `/` becomes `%2F`, so `feat/x` gives `feat%2Fx`. Each worktree of the repository shares that directory, so a second driver on the same PR branch, in the same worktree or in another one, ends with exit 3 and names the lock file. Drivers on different PR branches run at the same time: each ticket branch is `ticket/<key>/<NN>` and the recovery ref is `refs/implement-spec/<key>/recovery`, so two runs share no ref. Git refs are a tree, and one component per PR branch keeps the refs of one run from being a folder of the refs of another. The file is not in a worktree, so `git status` never shows it. The lock ends with its process, so a driver that was killed leaves no lock behind. `--dry-run` takes no lock. A wrong call in a worktree that has no `.agent/implement/` takes no lock and makes no file; where an earlier run left that folder, the driver takes the lock first, then makes the checks.
27
+
28
+ A driver before this change named the ticket branches `ticket/<NN>` and the recovery ref `refs/implement/recovery`. An older driver holds `implement-spec.lock` in the git common directory alone for its whole run. Each newer driver also holds that file, shared, for its whole run: newer drivers do not stop each other, but while an older driver runs in the repository, a newer one ends with exit 3 and names that lock, and while a newer one runs, no older driver starts. A run that the newer driver started has `schema` 2 in `state.json`. When the newer driver resumes a run with no `schema`, it renames each `ticket/<NN>` that holds the start commit of the run and the tip that the run recorded, and moves `refs/implement/recovery` when it holds the recovery point of the run. It writes one ledger sentence for each, and then `schema`. A ref that git cannot rename ends the run with exit 3 and git's error, and nothing is lost.
27
29
 
28
30
  ## Flags
29
31
 
@@ -56,7 +58,7 @@ Default timeouts in minutes: explorer 30, test-writer 30, implementer 90, review
56
58
  | 0 | Done: every ticket is merged and no question is open. Also the end of a valid `--dry-run`. In local mode without `--push`, nothing is pushed. |
57
59
  | 1 | A ticket is BLOCKED, or the end of run is BLOCKED. The other tickets ran as far as they could. |
58
60
  | 2 | Parked: a question of kind `locked-test` or `final-finding` is open. Exit 2 wins over exit 1. |
59
- | 3 | Invalid input or a failed precondition: a usage error, a ticket error, a model error, an uncommitted change, a slot that cannot be made, another driver that runs in the same repository. No agent starts. The one exception: a review page that cannot be written gives exit 3 at the end of a run, after agents ran. |
61
+ | 3 | Invalid input or a failed precondition: a usage error, a ticket error, a model error, an uncommitted change, a slot that cannot be made, another driver that runs on the same PR branch. No agent starts. The one exception: a review page that cannot be written gives exit 3 at the end of a run, after agents ran. |
60
62
  | 4 | Stopped: the CLI of an agent failed two times and wrote no report (for example, the account cannot use the model). The driver starts no other agent and lets the running agents end. No ticket is BLOCKED by it: fix the CLI or the model entry, then run the same command again to resume. |
61
63
  | 130 | The operator interrupted the run. The driver stops each agent. The work stays in the slots for the next run. |
62
64
 
@@ -75,7 +77,7 @@ Everything is under `.agent/`, which is never committed.
75
77
  | Path | Content |
76
78
  |---|---|
77
79
  | `.agent/implement/state.json` | The resume contract: the run record and one record for each ticket (`driver_state.py`). |
78
- | `<git common directory>/implement-spec.lock` | An empty file, not in the worktree. The driver that runs holds a kernel file lock on it (`driver_state.py` `lock`). Do not remove it during a run. |
80
+ | `<git common directory>/implement-spec/<key>.lock` | An empty file, not in the worktree. The driver that runs holds a kernel file lock on it (`driver_state.py` `lock`). Do not remove it during a run. |
79
81
  | `.agent/implement/ledger.md` | One plain sentence for each event. Only the driver writes it, and it reads no fact from it. |
80
82
  | `.agent/implement/summary.md` | The result in sentences, and the measurement table: gates, agents, wall time, fix rounds, human stops, cost. |
81
83
  | `.agent/implement/decisions.json` | The open questions and their `revision`. Written only after a stop. |
@@ -88,7 +90,8 @@ Everything is under `.agent/`, which is never committed.
88
90
  | `.agent/implement-review.html` | The page with the open questions. |
89
91
  | `.agent/implement-answers.json` | The answers: `{"v": 1, "stage": "implement", "submitted": …, "revision": …, "answers": {…}}`. |
90
92
  | `.agent/worktrees/slot-K/` | Slots 2..N. Slot 1 is the PR worktree. |
91
- | `refs/implement/recovery` | A git ref: the PR tip before the tidy. |
93
+ | `refs/implement-spec/<key>/recovery` | A git ref: the PR tip before the tidy. |
94
+ | `ticket/<key>/<NN>` | The branch of one ticket. The driver deletes it when the ticket is merged. |
92
95
 
93
96
  ## Roles of one ticket
94
97
 
@@ -97,7 +100,7 @@ A ticket is ready when each ticket in its **Blocked by** line is merged and no r
97
100
  1. **test-writer** (`prompts/test-writer.md`). It writes one black-box test for each criterion through the Seam and commits them red. Those files are locked. A ticket with Seam `none — refactor` has no test-writer.
98
101
  2. **implementer** (`prompts/implementer.md`). It starts from that commit and makes the locked tests green. It makes the gate (`--gate`) green on its final commit before it reports DONE or DONE_WITH_CONCERNS.
99
102
  3. **Gate.** The driver runs `--gate` on the ticket tip.
100
- 4. **Review.** The driver compares each locked file of the ticket with the test-writer commit. A file that differs is a critical finding `locked file changed: <path>`. A locked file of a merged ticket that the ticket changed since its merge base with the PR branch is a critical `fixture` finding `the ticket changed the locked file of a merged ticket: <path>`, so it goes to a correction (see **Before a correction**). Then the **reviewer** (`prompts/reviewer.md`) runs, and for a ticket with a **Risk** line the **correctness** pass (`prompts/correctness.md`) runs at the same time in the same slot.
103
+ 4. **Review.** The driver compares each locked file of the ticket with the test-writer commit. A file that differs is a critical finding `locked file changed: <path>`. A locked file of a merged ticket that the ticket changed since its merge base with the PR branch is a critical `fixture` finding `the ticket changed the locked file of a merged ticket: <path>`, so it goes to a correction (see **Before a correction**). Then the **reviewer** (`prompts/reviewer.md`) runs, and for a ticket with a **Risk** line the **correctness** pass (`prompts/correctness.md`) runs at the same time in the same slot. A read-only role (reviewer, correctness, final-reviewer, pr-writer) leaves its slot and the git config as it found them: all worktrees share one config, and one key written there, such as a `core.worktree` on a temp directory, breaks git in each of them. To run a check off the slot, a reviewer reads the driver's gate record or makes its own `git worktree add --detach` copy and removes it. After read-only roles, the driver checks the slot and the shared config (`git rev-parse --git-common-dir`, then `config`) against what they were before. When one of them changed, the driver restores it, writes `a read-only role broke its rule: ...` to the ledger, and runs the review one more time; a second change blocks the ticket.
101
104
  5. **Merge.** The driver merges the reviewed tip into the PR branch with git plumbing, then deletes the ticket branch. A conflict goes to the **conflict** agent (`prompts/conflict.md`) once. Its resolution may change no locked file of the ticket. A second conflict blocks the ticket.
102
105
  **The landing check.** Merges are serial. Before the PR branch moves, the driver compares the tree that is about to land (the ticket tip of a fast-forward, or the merge commit) with the PR tip: each locked file of each merged ticket must be the same tree entry in both: the same blob with the same mode, so a file that became a symlink is a difference. A file that only one of them has is a difference. A file that the landing ticket lists in its own `locked` is not checked: the test-writer of a later ticket can add a test to a file that an earlier ticket locked, and must list that file. A test-writer report that changes such a file and does not list it is rejected, with the usual one rerun. The review finds such a change first; the landing check catches a change that no review saw, for example of a ticket that merged after the last review. A difference refuses the landing: the ticket is BLOCKED with `locked file changed: <path>` in the ledger, the PR branch does not move, and no fix round runs. The PR branch moves only from the tip that the check read; when another hand moved the branch in between, the driver makes the merge, the check and the gate again, one time. Only that case starts the landing again: a refused landing and a red merge gate block the ticket. The second try runs no conflict agent, so a conflict there blocks the ticket, and so does a branch that moved again.
103
106
  **Before a correction.** A test-writer that corrects locked tests (after `overrule-test`, or for a `fixture` finding or a `test` finding on a locked file) starts from the locked versions: the driver puts back each locked file of the ticket and each locked file of a merged ticket that the ticket changed, commits them in one commit of its own, and the ledger names each file. A file of a merged ticket goes back to the version of the PR branch. The commit keeps the tree of the next fixer and of the gate clean, whatever files the correction commits. The correction's prompt names the restored files: when a test in one of them fails only because the ticket changed an interface that the test uses, the correction updates the file to the new interface, keeps what each test asserts, and lists the file in `locked`.
@@ -131,7 +134,7 @@ A red gate on the commit of the implementer or of a fixer is a critical finding
131
134
 
132
135
  The end of run starts only when every ticket is merged and every slot is idle. Its agents work in slot 1.
133
136
 
134
- 1. **Tidy.** The driver saves the PR tip in `refs/implement/recovery`. It then makes one commit for each ticket, in merge order, named `feat(NN): <title>`. Each commit holds the tests, the implementation and the fixes of its ticket. The tree of the new tip must equal the tree of the old tip, or the branch stays as it was.
137
+ 1. **Tidy.** The driver saves the PR tip in `refs/implement-spec/<key>/recovery`. It then makes one commit for each ticket, in merge order, named `feat(NN): <title>`. Each commit holds the tests, the implementation and the fixes of its ticket. The tree of the new tip must equal the tree of the old tip, or the branch stays as it was.
135
138
  2. **Rebase** onto the fetched tip of the default branch of `origin`. A conflict goes to the conflict agent (`prompts/rebase-conflict.md`) once, and the rebased tip is gated.
136
139
  3. **Gate**, then the **final-reviewer** (`prompts/final-reviewer.md`) on the whole range, with the method of the `review-diff` skill. It gets the minor findings of the tickets and the rulings of the operator.
137
140
  4. **Fix batch.** Critical and important findings go to one fixer (`prompts/final-fixer.md`). The driver names each of its commits `fix(NN): …` from the ticket in the report. Then the gate, then one scoped review of those commits.
@@ -14,8 +14,10 @@ from __future__ import annotations
14
14
  import argparse
15
15
  import json
16
16
  import os
17
+ import re
17
18
  import sys
18
19
  from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
20
+ from contextlib import ExitStack
19
21
  from dataclasses import dataclass
20
22
  from pathlib import Path
21
23
 
@@ -24,7 +26,9 @@ import driver_answers
24
26
  import driver_decisions
25
27
  import driver_final
26
28
  import driver_fixloop
29
+ import driver_gate
27
30
  import driver_graph
31
+ import driver_legacy
28
32
  import driver_merge
29
33
  import driver_review
30
34
  import driver_review_page
@@ -32,6 +36,7 @@ import driver_slots
32
36
  import driver_state
33
37
  import driver_summary
34
38
  import driver_tickets
39
+ from driver_names import Names
35
40
  from driver_run import FINAL, OUT, Blocked, Halted, Run, during, ledger
36
41
  from driver_slots import git
37
42
  from driver_tickets import Ticket
@@ -59,7 +64,7 @@ def _roles_left(ticket: Ticket, phase: str, only: str | None) -> list[str]:
59
64
 
60
65
  def _check_out_ticket(ticket: Ticket, slot_number: int, entry: dict, run: Run) -> str:
61
66
  """Put the ticket branch in the slot and return the commit its next role starts from."""
62
- slot, branch = driver_slots.path(slot_number), f"ticket/{ticket.number}"
67
+ slot, branch = driver_slots.path(slot_number), run.names.ticket(ticket.number)
63
68
  if run.kept.get(ticket.number) == slot_number:
64
69
  # The slot holds the uncommitted work of an interrupted role: the branch and the tree stay as they are.
65
70
  return entry["tip"] or git(slot, "merge-base", "HEAD", run.tip()).stdout.strip()
@@ -80,7 +85,8 @@ def _prompt_values(ticket: Ticket, slot: str, run: Run) -> dict[str, str]:
80
85
  """The pointers every prompt of the ticket gets. An agent works in its slot, so paths are relative to it."""
81
86
  folder = OUT / ticket.number
82
87
  return {"spec": os.path.relpath(run.args.spec, slot), "seam": ticket.seam, "pr_branch": run.pr,
83
- "branch": f"ticket/{ticket.number}", "ticket": os.path.relpath(Path(run.args.tickets) / ticket.file, slot),
88
+ "branch": run.names.ticket(ticket.number),
89
+ "ticket": os.path.relpath(Path(run.args.tickets) / ticket.file, slot),
84
90
  "correctness": str(driver_review.CORRECTNESS), "gate": run.args.gate,
85
91
  "blocked_report": os.path.relpath(_blocked_report(ticket.number, run), slot),
86
92
  "writer_report": os.path.relpath(folder / "test-writer-0.json", slot)}
@@ -104,7 +110,7 @@ def _run_roles(ticket: Ticket, slot_number: int, run: Run) -> None:
104
110
  values = {**_prompt_values(ticket, slot, run), "locked": " ".join(entry["locked"])}
105
111
  for role in roles:
106
112
  run.state.ticket(number, phase=DURING.get(role, "review"))
107
- values["start_sha"] = start
113
+ values.update(start_sha=start, gate_record=os.path.relpath(driver_gate.record_path(OUT / number, start), slot))
108
114
  # A test-writer on a ticket that has locked tests is the correction after a ruling.
109
115
  check = {"correction": role == "test-writer" and bool(entry["test_writer_sha"])}
110
116
  if check["correction"] and (restored := run.restore_locked_files(number, slot)):
@@ -152,7 +158,7 @@ def _resolve_conflict(ticket: Ticket, slot: str, run: Run, tip: str, files: list
152
158
 
153
159
  def _merge_commit(ticket: Ticket, slot: str, run: Run, tip: str, may_resolve: bool) -> _Landing:
154
160
  """Merge the ticket tip with the PR tip into a commit that no ref holds yet."""
155
- message = f"Merge ticket/{ticket.number} into {run.pr}"
161
+ message = f"Merge {run.names.ticket(ticket.number)} into {run.pr}"
156
162
  pr_tip = run.tip()
157
163
  commit, error = driver_merge.commit(pr_tip, tip, message)
158
164
  if not error:
@@ -171,7 +177,8 @@ def _merge_commit(ticket: Ticket, slot: str, run: Run, tip: str, may_resolve: bo
171
177
  def _checked_landing(ticket: Ticket, slot: str, run: Run, may_resolve: bool) -> _Landing:
172
178
  """The commit to land, with the locked-file check passed and the gate green. Call it under the merge lock."""
173
179
  number = ticket.number
174
- git(slot, "checkout", "-q", f"ticket/{number}") # an earlier try left the slot on its merge commit
180
+ # An earlier try left the slot on its merge commit.
181
+ git(slot, "checkout", "-q", run.names.ticket(number))
175
182
  tip = run.state.ticket(number, phase="merge")["tip"]
176
183
  landing = _merge_commit(ticket, slot, run, tip, may_resolve)
177
184
  refusal = driver_merge.refused(run.state.read()["tickets"], number, landing.pr_tip, landing.commit)
@@ -208,7 +215,7 @@ def _record_landing(state: dict, number: str, pr_tip: str) -> None:
208
215
 
209
216
  def _merge(ticket: Ticket, slot: str, run: Run) -> None:
210
217
  """Land the reviewed ticket tip on the PR branch, then delete the ticket branch."""
211
- number, branch = ticket.number, f"ticket/{ticket.number}"
218
+ number, branch = ticket.number, run.names.ticket(ticket.number)
212
219
  # The merge gate and the conflict agent run under the lock: the commit the gate saw is the one that lands.
213
220
  with run.merging, during("the merge"):
214
221
  landing = _land(ticket, slot, run)
@@ -348,27 +355,16 @@ def _work(tickets: list[Ticket], slots: int, run: Run) -> tuple[bool, list[str]]
348
355
 
349
356
  # ---------------------------------------------------------------- before the first agent: resume and answers
350
357
 
351
- def _pr_branch(state: driver_state.State) -> tuple[str, str]:
352
- """(the PR branch, "") or ("", why the run cannot start)."""
353
- here = git(".", "symbolic-ref", "-q", "--short", "HEAD").stdout.strip()
354
- # An interrupted run leaves slot 1 on a ticket branch or detached, so the record names the PR branch.
355
- pr = state.read()["run"]["pr_branch"] if state.path.exists() else here
356
- if not pr:
357
- return "", "the PR worktree is on a detached HEAD: check out the PR branch"
358
- if here not in ("", pr) and not here.startswith("ticket/"):
359
- return "", f"{state.path} records a run on {pr}, not on {here}: check out {pr}, or remove {OUT}"
360
- return pr, ""
361
-
362
-
363
358
  def _resume_error(number: str, state: driver_state.State) -> str:
364
359
  """Check the record of one ticket against git. A ticket that git contradicts goes back to its last verified
365
360
  phase; "" or the error that stops the run."""
366
- entry, branch = state.read()["tickets"][number], f"ticket/{number}"
361
+ run, entry = state.read()["run"], state.read()["tickets"][number]
362
+ branch = Names(run["pr_branch"]).ticket(number)
367
363
  if entry["phase"] not in driver_state.PHASES:
368
364
  return f"{state.path}: ticket {number} has the unknown phase {entry['phase']!r}"
369
- if entry["phase"] == "pending" and git(".", "rev-parse", "-q", "--verify", f"refs/heads/{branch}").returncode == 0:
365
+ if entry["phase"] == "pending" and driver_slots.branch_exists(branch):
370
366
  return f"branch {branch} exists and {state.path} does not record it: delete or rename the branch"
371
- fields, why = driver_state.verified(entry, branch, state.read()["run"]["pr_branch"])
367
+ fields, why = driver_state.verified(entry, branch, run["pr_branch"])
372
368
  if fields:
373
369
  state.ticket(number, **fields)
374
370
  start = fields["tip"][:7] if fields["tip"] else "the PR tip"
@@ -376,15 +372,12 @@ def _resume_error(number: str, state: driver_state.State) -> str:
376
372
  return ""
377
373
 
378
374
 
379
- def _resume(tickets: list[Ticket], state: driver_state.State, slots: int, args, kept: dict) -> tuple[str, list[str]]:
380
- """Start or resume state.json and make the slots: (the PR branch, the errors that stop the run)."""
381
- pr, error = _pr_branch(state)
382
- if error:
383
- return "", [error]
375
+ def _resume(tickets: list[Ticket], state: driver_state.State, pr: str, slots: int, args, kept: dict) -> list[str]:
376
+ """Start or resume state.json on the PR branch `pr` and make the slots: the errors that stop the run."""
384
377
  state.start(pr, [ticket.number for ticket in tickets])
385
378
  driver_final.finish_interrupted_move(state, pr)
386
379
  errors = [error for error in (_resume_error(ticket.number, state) for ticket in tickets) if error]
387
- return pr, errors or driver_slots.setup(slots, args.prepare, set(kept.values()))
380
+ return errors or driver_slots.setup(slots, args.prepare, set(kept.values()))
388
381
 
389
382
 
390
383
  def _publish_questions(run: dict) -> str:
@@ -474,10 +467,10 @@ def _exit_code(final: dict, args, end_blocked: bool) -> int:
474
467
  return code
475
468
 
476
469
 
477
- def _run(tickets: list[Ticket], models: dict[str, list[str]], args, kept: dict[str, int]) -> int:
470
+ def _run(tickets: list[Ticket], models: dict[str, list[str]], args, kept: dict[str, int], pr: str) -> int:
478
471
  state = driver_state.State(STATE)
479
472
  slots = 1 if args.only else min(args.parallel, len(tickets))
480
- pr, errors = _resume(tickets, state, slots, args, kept)
473
+ errors = _resume(tickets, state, pr, slots, args, kept)
481
474
  errors = errors or _apply_answers(state, args)
482
475
  for error in errors:
483
476
  print(error)
@@ -564,7 +557,7 @@ def _validated(args: argparse.Namespace) -> tuple[list[Ticket], dict[str, list[s
564
557
  errors.append("--spec is required: the spec file the prompts point at")
565
558
  if args.parallel < 1:
566
559
  errors.append("--parallel must be 1 or more")
567
- kept = driver_state.kept(json.loads(STATE.read_text())) if STATE.exists() else {}
560
+ kept = driver_state.kept(driver_state.State(STATE).read()) if STATE.exists() else {}
568
561
  errors += _worktree_errors(1 in kept.values())
569
562
  models, model_errors = driver_agents.load_models(args.models)
570
563
  tickets, ticket_errors = driver_tickets.parse(args.tickets)
@@ -579,16 +572,61 @@ def _print_width(tickets: list[Ticket]) -> None:
579
572
  print(f"max frontier width: {driver_tickets.max_frontier_width(tickets)}")
580
573
 
581
574
 
582
- def _lock_file(args: argparse.Namespace) -> Path | None:
583
- """The file one driver per repository locks: in the git common directory, which every worktree shares and
584
- `git status` never reports. None, with each error printed, when the call is not valid."""
575
+ def _run_branch() -> str:
576
+ """The PR branch the run in this worktree is on: the one state.json records, else the one checked out."""
577
+ # HEAD first: a driver writes state.json before it moves HEAD, so a HEAD that a run moved has a state.json.
578
+ here = driver_slots.on_branch(".")
579
+ return driver_state.State(STATE).read()["run"]["pr_branch"] if STATE.exists() else here
580
+
581
+
582
+ def _start_errors(pr: str) -> list[str]:
583
+ """Under the lock of `pr`, before any check reads a ref: the errors that stop the run, or [].
584
+
585
+ The run of this worktree must still be on `pr`, a run that an older driver started gets its new names first,
586
+ and slot 1 must be on `pr` or where an interrupted run left it: on one of its ticket branches, or detached."""
587
+ if _run_branch() != pr:
588
+ return [f"the run in this worktree left {pr} while the driver took its lock: run the driver again"]
589
+ if errors := driver_legacy.resume(driver_state.State(STATE)):
590
+ return errors
591
+ here = driver_slots.on_branch(".")
592
+ # An older driver named its ticket branches `ticket/NN`, and a branch that the resume does not rename stays so.
593
+ if here in ("", pr) or here.startswith(Names(pr).ticket("")) or re.fullmatch(r"ticket/\d+", here):
594
+ return []
595
+ return [f"{STATE} records a run on {pr}, not on {here}: check out {pr}, or remove {OUT}"]
596
+
597
+
598
+ def _common_dir_and_branch(args: argparse.Namespace) -> tuple[Path, str] | None:
599
+ """(the git common directory, which every worktree shares and `git status` never reports, and the PR branch of
600
+ the run): what names the lock. None, with each error printed, when the call is not valid."""
585
601
  common = git(".", "rev-parse", "--git-common-dir")
586
602
  # A first call is validated before the lock, so a wrong call makes no file. After an earlier run the lock comes
587
603
  # first: the driver that holds it changes what the validation reads.
588
604
  first_call = common.returncode or not OUT.exists()
589
605
  if first_call and not _validated(args):
590
606
  return None
591
- return Path(common.stdout.strip()).resolve() / "implement-spec.lock"
607
+ if not (pr := _run_branch()):
608
+ print("the PR worktree is on a detached HEAD: check out the PR branch")
609
+ return None
610
+ if pr.startswith("ticket/"): # a slot of another run: its driver owns the worktree and the branch
611
+ print(f"the PR worktree is on the ticket branch {pr} of a run: check out the PR branch")
612
+ return None
613
+ return Path(common.stdout.strip()).resolve(), pr
614
+
615
+
616
+ def _hold(common: Path, pr: str) -> tuple[ExitStack | None, str]:
617
+ """Take the locks of a run on `pr`: (the stack that holds them until it closes, "") or (None, why another
618
+ driver stops this one)."""
619
+ with ExitStack() as stack:
620
+ lock_file = Names(pr).lock_file(common)
621
+ if not (held := driver_state.lock(lock_file)):
622
+ return None, (f"another driver runs on {pr}: it holds the lock {lock_file}. Wait until it ends, then "
623
+ "run the driver again.")
624
+ stack.enter_context(held)
625
+ older, error = driver_legacy.lock(common)
626
+ if not older:
627
+ return None, error
628
+ stack.enter_context(older)
629
+ return stack.pop_all(), ""
592
630
 
593
631
 
594
632
  def main(argv: list[str] | None = None) -> int:
@@ -597,15 +635,19 @@ def main(argv: list[str] | None = None) -> int:
597
635
  if valid := _validated(args):
598
636
  _print_width(valid[0])
599
637
  return 0 if valid else 3
600
- if not (lock_file := _lock_file(args)):
638
+ if not (locking := _common_dir_and_branch(args)):
601
639
  return 3
602
- held = driver_state.lock(lock_file)
640
+ common, pr = locking
641
+ held, error = _hold(common, pr)
603
642
  if not held:
604
- print(f"implement-spec: another driver runs in this repository: it holds the lock {lock_file}. Wait until it "
605
- "ends, then run the driver again.")
643
+ print(f"implement-spec: {error}")
606
644
  return 3
607
645
  with held:
608
- # Only what is read under the lock is trusted: no other driver changes state.json, the slots or the refs.
646
+ # Only what is read under the lock is trusted: no other driver changes state.json, the slots, the PR branch
647
+ # or its ticket branches. Drivers on other PR branches touch none of them.
648
+ if errors := _start_errors(pr):
649
+ print("".join(f"implement-spec: {error}\n" for error in errors), end="")
650
+ return 3
609
651
  if not (valid := _validated(args)):
610
652
  return 3
611
653
  tickets, models, kept = valid
@@ -614,7 +656,7 @@ def main(argv: list[str] | None = None) -> int:
614
656
  print("".join(error + "\n" for error in errors), end="")
615
657
  return 3
616
658
  _print_width(tickets)
617
- return _run(tickets, models, args, kept)
659
+ return _run(tickets, models, args, kept, pr)
618
660
 
619
661
 
620
662
  if __name__ == "__main__":
@@ -4,7 +4,7 @@ commits with a scoped review, then stop before the push, or push and open the PR
4
4
 
5
5
  `run.final` of state.json is the resume contract (`driver_state._final` makes it with every key):
6
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
7
+ recovery the PR tip before the rewrite; Run.names names its ref. None when origin had the history already
8
8
  tidied the tip the rewrite made, or the tip as the tickets left it when the history is kept
9
9
  base, onto the default branch of origin and its tip under the history; with no origin: "" and the start of the run
10
10
  commits {ticket: its commit}
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
 
27
27
  import driver_agents
28
28
  import driver_decisions
29
+ import driver_gate
29
30
  import driver_merge
30
31
  import driver_review
31
32
  import driver_tidy
@@ -34,7 +35,6 @@ from driver_slots import git, head
34
35
  from driver_state import State
35
36
  from driver_tickets import Ticket
36
37
 
37
- RECOVERY = "refs/implement/recovery"
38
38
  KIND = "final-finding"
39
39
  ROUNDS = 2 # the full review and the scoped one after the fix batch
40
40
  # The final review runs through the review-diff skill, which is installed next to this one.
@@ -170,7 +170,8 @@ def _resolve_rebase_conflict(run: Run, env: dict, base: str, onto: str) -> None:
170
170
  raise Blocked(f"the conflict agent: {error}" if error else
171
171
  f"the rebase onto {base} conflicts a second time, in {', '.join(again)}")
172
172
  # The resolution may not change a locked test: each one is as the recovery point has it.
173
- changed = driver_review.locked_changes(".", git(".", "rev-parse", RECOVERY).stdout.strip(), _locked_files(run))
173
+ before = git(".", "rev-parse", run.names.recovery).stdout.strip()
174
+ changed = driver_review.locked_changes(".", before, _locked_files(run))
174
175
  if error := error or "; ".join(finding["title"] for finding in changed):
175
176
  raise Blocked(f"the conflict agent: {error}")
176
177
  ledger(f"Rebase: the conflict agent resolved the conflict with {base} in {', '.join(files)} at {head('.')[:7]}.")
@@ -249,7 +250,7 @@ def _rewrite_once(run: Run, tickets: list[Ticket]) -> None:
249
250
  final = run.state.read()["run"]["final"]
250
251
  if final["tidied"]:
251
252
  if not driver_merge.ancestor(final["tidied"], run.tip()):
252
- raise Blocked(f"{run.pr} does not hold the tidied tip {final['tidied'][:7]}; {RECOVERY} holds the "
253
+ raise Blocked(f"{run.pr} does not hold the tidied tip {final['tidied'][:7]}; {run.names.recovery} holds the "
253
254
  "history before the tidy")
254
255
  return
255
256
  base, onto = _base(run)
@@ -258,9 +259,9 @@ def _rewrite_once(run: Run, tickets: list[Ticket]) -> None:
258
259
  _keep_published_history(run, old, base, onto)
259
260
  return
260
261
  tree = git(".", "rev-parse", f"{old}^{{tree}}").stdout.strip()
261
- git(".", "update-ref", "-m", "implement-spec: recovery point", RECOVERY, old)
262
+ git(".", "update-ref", "-m", "implement-spec: recovery point", run.names.recovery, old)
262
263
  ledger(f"Recovery point: {run.pr} was at {old} with the tree {tree} before the rebase and the tidy; "
263
- f"{RECOVERY} holds that commit.")
264
+ f"{run.names.recovery} holds that commit.")
264
265
  git(".", "checkout", "-q", "--detach") # slot 1 is off the branch while the history is made
265
266
  try:
266
267
  commits = _new_history(run, tickets, old, base, onto)
@@ -325,7 +326,7 @@ def _review(run: Run, final: dict, tip: str) -> None:
325
326
  """One round of the final review on `tip`: the full review, or after it the scoped one."""
326
327
  done, folder = final["round"], OUT / FINAL
327
328
  values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['reviewed'] or final['onto']}..{tip}",
328
- "gate": str(folder / f"gate-{tip[:7]}.log"), "review_diff": str(REVIEW_DIFF),
329
+ "gate": str(driver_gate.record_path(folder, tip)), "review_diff": str(REVIEW_DIFF),
329
330
  "ledger": str(OUT / "ledger.md"), "minors": _minor_findings(), "reviewed": str(final["reviewed"]),
330
331
  "rulings": "\n".join(driver_decisions.rulings(run.state.read()["run"]["questions"])),
331
332
  "findings": str(folder / f"findings-{done - 1}.json")}
@@ -441,7 +442,7 @@ def _write_body(run: Run, final: dict, tickets: list[Ticket]) -> Path:
441
442
  raise Blocked("the pr skill is missing: the PR body follows it; install it with "
442
443
  "`npx skills add mattpocock/skills --skill pr`")
443
444
  values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['onto']}..{tip}",
444
- "gate": str(OUT / FINAL / f"gate-{tip[:7]}.log"), "pr_skill": str(method), "facts": facts}
445
+ "gate": str(driver_gate.record_path(OUT / FINAL, tip)), "pr_skill": str(method), "facts": facts}
445
446
  with during("the PR body"):
446
447
  report = run.read_only(FINAL, ".", run.pr, tip, lambda: run.agent("pr-writer", FINAL, ".", values))
447
448
  body.write_text(f"{report['body'].strip()}\n\n{facts}")
@@ -11,6 +11,7 @@ from concurrent.futures import ThreadPoolExecutor
11
11
  from dataclasses import dataclass, field
12
12
 
13
13
  import driver_agents
14
+ import driver_gate
14
15
  import driver_merge
15
16
  import driver_review
16
17
  import driver_slots
@@ -39,9 +40,11 @@ class Job:
39
40
  """The values of a prompt; `review` is the round whose findings the agent reads."""
40
41
  entry = self.run.state.read()["tickets"][self.ticket.number]
41
42
  findings = os.path.relpath(OUT / self.ticket.number / f"findings-{review}.json", self.slot)
43
+ gate = os.path.relpath(driver_gate.record_path(OUT / self.ticket.number, self.tip), self.slot)
42
44
  return {**self.values, "start_sha": self.tip, "from_sha": self.previous, "round": str(self.round),
43
45
  "locked": " ".join(entry["locked"]), "test_writer_sha": str(entry["test_writer_sha"] or ""),
44
- "findings": findings, "proofs": " ".join(f"{file}:{line}" for file, line in sorted(self.again))}
46
+ "findings": findings, "gate_record": gate,
47
+ "proofs": " ".join(f"{file}:{line}" for file, line in sorted(self.again))}
45
48
 
46
49
 
47
50
  def entry(models: dict[str, list[str]], round: int) -> str:
@@ -25,12 +25,16 @@ def _after(slot: str, commit: str) -> str:
25
25
  return "not clean\n" + "".join(f" {line}\n" for line in status.splitlines()).rstrip("\n") if status else "clean"
26
26
 
27
27
 
28
+ def record_path(folder: Path, commit: str) -> Path:
29
+ return folder / f"gate-{commit[:7]}.log"
30
+
31
+
28
32
  def run(command: str, slot: str, folder: Path, pr_branch: str) -> tuple[bool, Path]:
29
33
  """Gate the HEAD of `slot`. Returns (green, the record)."""
30
34
  commit = head(slot)
31
35
  status = git(slot, "status", "--short").stdout
32
36
  folder.mkdir(parents=True, exist_ok=True)
33
- record = folder / f"gate-{commit[:7]}.log"
37
+ record = record_path(folder, commit)
34
38
  # The cache of compiled Python stays out of the slot, so the gate leaves the tree clean.
35
39
  env = dict(os.environ, IMPLEMENT_SPEC_BASE=pr_branch, PYTHONPYCACHEPREFIX=str(folder.parent.resolve() / "pycache"))
36
40
  with record.open("w") as out:
@@ -0,0 +1,92 @@
1
+ """A run that an older driver started: one lock per repository, ticket branches `ticket/NN` and the recovery ref
2
+ `refs/implement/recovery`.
3
+
4
+ Every run of a newer driver holds `lock` (driver._hold), so a newer and an older driver never run in one repository
5
+ at the same time. The driver calls `resume` once, after the locks and before any check reads a ref: a run with no
6
+ `schema` in state.json gets the new names of its refs.
7
+ ponytail: delete this module, tests/test_legacy.py and its one line in driver._hold and in driver._start_errors
8
+ once no older driver is left.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import IO
14
+
15
+ import driver_slots
16
+ import driver_state
17
+ from driver_names import Names
18
+ from driver_run import ledger
19
+ from driver_slots import git
20
+
21
+ LOCK = "implement-spec.lock" # in the git common directory: an older driver holds it for its whole run
22
+ RECOVERY = "refs/implement/recovery"
23
+
24
+
25
+ def _checked_out_elsewhere(branch: str) -> bool:
26
+ """True when a worktree that is not this one nor one of its slots has `branch` checked out."""
27
+ here = Path.cwd().resolve()
28
+ for block in git(".", "worktree", "list", "--porcelain").stdout.split("\n\n"):
29
+ lines = block.splitlines()
30
+ if lines and f"branch refs/heads/{branch}" in lines:
31
+ where = Path(lines[0].removeprefix("worktree ")).resolve()
32
+ return where != here and here / ".agent" / "worktrees" not in where.parents
33
+ return False
34
+
35
+
36
+ def _this_runs(entry: dict, legacy: str, start: str) -> bool:
37
+ """A `ticket/NN` is this run's when it holds the commit the run started from and the tip the run recorded, and
38
+ no worktree outside this run has it checked out."""
39
+ if not driver_slots.ancestor(start, legacy) or _checked_out_elsewhere(legacy):
40
+ return False
41
+ return not entry["tip"] or driver_slots.ancestor(entry["tip"], legacy)
42
+
43
+
44
+ def _branch(entry: dict, number: str, names: Names, start: str) -> str:
45
+ """Rename one `ticket/NN` of this run: "" or the error."""
46
+ legacy, new = f"ticket/{number}", names.ticket(number)
47
+ if entry["phase"] in ("pending", "merged") or not driver_slots.branch_exists(legacy):
48
+ return ""
49
+ if driver_slots.branch_exists(new) or not _this_runs(entry, legacy, start):
50
+ return ""
51
+ moved = git(".", "branch", "-m", legacy, new) # git also moves the HEAD of a slot that has the branch
52
+ if moved.returncode:
53
+ return f"cannot rename the branch {legacy} of an older driver to {new}: {moved.stderr.strip()}"
54
+ ledger(f"Ticket {number}: renamed the branch {legacy} of an older driver to {new}.")
55
+ return ""
56
+
57
+
58
+ def _recovery(run: dict, names: Names) -> str:
59
+ """Move `refs/implement/recovery` to the name of this run when it holds the recovery point that this run
60
+ recorded: "" or the error."""
61
+ recorded = run.get("final", {}).get("recovery")
62
+ if not recorded or git(".", "rev-parse", "-q", "--verify", RECOVERY).stdout.strip() != recorded:
63
+ return ""
64
+ # The old ref goes only when the new one holds the commit: a move that stopped half way is finished next time.
65
+ made = git(".", "update-ref", "-m", "implement-spec: rename", names.recovery, recorded)
66
+ if made.returncode:
67
+ return f"cannot move {RECOVERY} of an older driver to {names.recovery}: {made.stderr.strip()}"
68
+ git(".", "update-ref", "-d", RECOVERY, recorded)
69
+ ledger(f"Recovery point: moved {RECOVERY} of an older driver to {names.recovery}.")
70
+ return ""
71
+
72
+
73
+ def lock(common_dir: Path) -> tuple[IO | None, str]:
74
+ """The lock of an older driver, shared: (the held file, "") or (None, the error) while an older driver runs in
75
+ the repository. Newer drivers hold it together, so they do not stop each other; an older driver holds it alone,
76
+ so it never runs beside one."""
77
+ if held := driver_state.lock(common_dir / LOCK, shared=True):
78
+ return held, ""
79
+ return None, (f"a driver of an older version runs in this repository: it holds the lock {common_dir / LOCK}. "
80
+ "Wait until it ends, then run the driver again.")
81
+
82
+
83
+ def resume(state: driver_state.State) -> list[str]:
84
+ """Give each ref of a run that an older driver started its new name, then mark the run: the errors, or []."""
85
+ if not state.path.exists() or state.read()["run"].get("schema", 1) >= driver_state.SCHEMA:
86
+ return []
87
+ run, names = state.read()["run"], Names(state.read()["run"]["pr_branch"])
88
+ moves = [_branch(entry, number, names, run["start"]) for number, entry in state.read()["tickets"].items()]
89
+ if errors := [error for error in [*moves, _recovery(run, names)] if error]:
90
+ return errors
91
+ state.update(lambda changed: changed["run"].update(schema=driver_state.SCHEMA))
92
+ return []
@@ -8,11 +8,7 @@ from __future__ import annotations
8
8
  import os
9
9
  import subprocess
10
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
11
+ from driver_slots import ancestor, git, names
16
12
 
17
13
 
18
14
  def identity(tip: str, slot: str = ".") -> dict[str, str]:
@@ -0,0 +1,32 @@
1
+ """The names of one run: its ticket branches, its recovery ref and its lock file.
2
+
3
+ Each name carries the PR branch as one name component, so runs on other PR branches share none of them. One
4
+ component, because git refs are a tree: `ticket/feat/01` and `ticket/feat/01/02` cannot both exist. `%` and `/` are
5
+ escaped, so two PR branches never give the same component: `feat/x` gives `feat%2Fx`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Names:
15
+ pr: str # the PR branch
16
+
17
+ @property
18
+ def _key(self) -> str:
19
+ return self.pr.replace("%", "%25").replace("/", "%2F")
20
+
21
+ def ticket(self, number: str) -> str:
22
+ """The branch of ticket `number`."""
23
+ return f"ticket/{self._key}/{number}"
24
+
25
+ @property
26
+ def recovery(self) -> str:
27
+ """The ref that holds the PR tip before the tidy."""
28
+ return f"refs/implement-spec/{self._key}/recovery"
29
+
30
+ def lock_file(self, common_dir: Path) -> Path:
31
+ """The file one driver per PR branch locks, in the git common directory that every worktree shares."""
32
+ return common_dir / "implement-spec" / f"{self._key}.lock"