@jesdi/skills 0.0.42 → 0.0.44
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.
- package/package.json +1 -1
- package/skills/implement-spec/ARCHITECTURE.md +20 -6
- package/skills/implement-spec/README.md +11 -10
- package/skills/implement-spec/driver.py +3 -2
- package/skills/implement-spec/driver_fixloop.py +23 -5
- package/skills/implement-spec/driver_merge.py +10 -5
- package/skills/implement-spec/driver_report.py +9 -2
- package/skills/implement-spec/driver_review.py +8 -0
- package/skills/implement-spec/driver_run.py +14 -3
- package/skills/implement-spec/prompts/final-fixer.md +1 -1
- package/skills/implement-spec/prompts/fixer.md +1 -1
- package/skills/implement-spec/prompts/implementer.md +2 -2
- package/skills/implement-spec/prompts/restored-red.md +3 -0
- package/skills/implement-spec/prompts/restored.md +3 -0
- package/skills/implement-spec/tests/test_decisions.py +17 -1
- package/skills/implement-spec/tests/test_fixloop.py +71 -19
- package/skills/implement-spec/tests/test_landing.py +14 -7
- package/skills/implement-spec/tests/test_review.py +6 -3
- package/skills-manifest.json +3 -3
package/package.json
CHANGED
|
@@ -60,12 +60,23 @@ and do not know about the run, except `driver_run.py`, which the three deciding
|
|
|
60
60
|
- The commit of the test-writer is the reference of the ticket's locked files. Before each review, a locked file
|
|
61
61
|
that differs from its reference is a critical finding (`driver_review.locked_findings`).
|
|
62
62
|
- A correction of the test-writer is a new reference. For that reason the driver first puts each locked file back
|
|
63
|
-
to its locked version (`Run.restore_locked_files`), and a correction may change only the files it
|
|
64
|
-
`overrule-test` and in the fix loop. Its commit becomes the reference of each file it lists. A locked
|
|
65
|
-
does not list and that differs from the old reference keeps the old reference in the ticket's `held`,
|
|
66
|
-
change of the implementer stays a finding (`driver_review.written`).
|
|
67
|
-
- The
|
|
68
|
-
|
|
63
|
+
to its locked version and commits that (`Run.restore_locked_files`), and a correction may change only the files it
|
|
64
|
+
lists, after `overrule-test` and in the fix loop. Its commit becomes the reference of each file it lists. A locked
|
|
65
|
+
file that it does not list and that differs from the old reference keeps the old reference in the ticket's `held`,
|
|
66
|
+
so a change of the implementer stays a finding (`driver_review.written`).
|
|
67
|
+
- The restore is a commit, not a tree change: an agent commits only its own files, and a gate refuses a tree that
|
|
68
|
+
is not clean, so a restore left in the tree would turn the next gate red and use a fix round. The correction's
|
|
69
|
+
prompt names the restored files (`prompts/restored.md`): this is the one path for a change of an earlier ticket's
|
|
70
|
+
locked test that a later ticket's interface needs.
|
|
71
|
+
- The driver, not the correction, decides whether a restored file fails with the ticket's code: in the fix loop it
|
|
72
|
+
gates the restore commit, whose parent has a green gate. A red gate goes to the correction as evidence
|
|
73
|
+
(`prompts/restored-red.md`), and a DONE correction must then list a restored file. An agent's judgement of
|
|
74
|
+
"this test does not fail" missed a red file and led to a refused merge (issue #90). After `overrule-test` the
|
|
75
|
+
parent has no green gate, so a red gate proves nothing there: the next review catches the file instead.
|
|
76
|
+
- The locked files of merged tickets are checked before each review (`driver_review.merged_changes`): a change is a
|
|
77
|
+
`fixture` finding, so it goes to a correction, where the restore and its gate decide. They are checked again when
|
|
78
|
+
a ticket lands (`driver_merge.refused`): the tree that lands must have each of them as the PR tip has it. A
|
|
79
|
+
ticket's test-writer may change one only when it lists it.
|
|
69
80
|
- A conflict resolution, of a merge or of the rebase, may not change a locked file.
|
|
70
81
|
|
|
71
82
|
### The PR branch
|
|
@@ -114,6 +125,9 @@ and do not know about the run, except `driver_run.py`, which the three deciding
|
|
|
114
125
|
it blocks only after round 4. A red gate on a test-writer correction blocks.
|
|
115
126
|
- The implementer and each fixer get the `--gate` command and run it before they report. The gate of the driver
|
|
116
127
|
stays the source of truth: an agent's run of the gate counts for nothing.
|
|
128
|
+
- No test-writer correction runs after round 4 (`driver_fixloop._act_on`): its findings become `finding` questions
|
|
129
|
+
with the others. A correction there would add red locked tests that no fix round makes green, and `accept` could
|
|
130
|
+
not merge the ticket.
|
|
117
131
|
|
|
118
132
|
### Questions
|
|
119
133
|
|
|
@@ -95,12 +95,13 @@ Everything is under `.agent/`, which is never committed.
|
|
|
95
95
|
A ticket is ready when each ticket in its **Blocked by** line is merged and no running ticket touches the same place. The places are the backticked entries of the **Touches** lines: `file` or `file: function`. Two entries are the same place when they name the same file and the same function, or when one of them names the whole file. Each ready ticket takes the lowest free slot.
|
|
96
96
|
|
|
97
97
|
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
|
-
2. **implementer** (`prompts/implementer.md`). It starts from that commit and makes the locked tests green.
|
|
98
|
+
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
99
|
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>`. 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.
|
|
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.
|
|
101
101
|
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
|
-
**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. 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
|
-
**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
|
|
102
|
+
**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
|
+
**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`.
|
|
104
|
+
**The gate of the restore.** In the fix loop, the tip before the restore has a green gate, so the driver gates the restore commit and does not leave the question to the correction. A red gate means that a restored file fails with the ticket's code: the correction gets the gate record (`prompts/restored-red.md`), and a DONE correction must list a restored file in `locked`; else it reports BLOCKED with `locked test contradicts spec:`. A green gate with only driver findings to correct closes them with the restore: no test-writer runs. After `overrule-test` the driver does not gate the restore, because the tip before it has no green gate; a change that the next implementer makes to a merged ticket's file is a finding of the next review.
|
|
104
105
|
|
|
105
106
|
Rules for each agent:
|
|
106
107
|
|
|
@@ -113,18 +114,18 @@ Rules for each agent:
|
|
|
113
114
|
|
|
114
115
|
| Finding | What the driver does |
|
|
115
116
|
|---|---|
|
|
116
|
-
| kind `fixture`, any severity, or kind `test`, `critical` or `important`, on a locked file of the ticket | One test-writer correction, outside the fix rounds. The fixer may not edit a locked file, so a fix round cannot close a `test` finding there. The corrected files are the new locked versions, and the next review is a full review. A finding of this kind that is open after the correction blocks the ticket. |
|
|
117
|
+
| kind `fixture`, any severity, or kind `test`, `critical` or `important`, on a locked file of the ticket | One test-writer correction, outside the fix rounds. The fixer may not edit a locked file, so a fix round cannot close a `test` finding there. The corrected files are the new locked versions, and the next review is a full review. A finding of this kind that is open after the correction blocks the ticket. No correction runs after round 4. |
|
|
117
118
|
| `critical` or `important` | One fix round. |
|
|
118
119
|
| `minor` | It stays in the ledger and in the findings file. The final reviewer gets the list. |
|
|
119
120
|
|
|
120
|
-
A fix round
|
|
121
|
+
A fix round has three steps: the **fixer** (`prompts/fixer.md`) makes the gate green on its final commit, as the implementer does; the driver runs the gate on the new tip; a re-review reads the range of the fix. A mutation proof is repeated only when the fix changed its production line.
|
|
121
122
|
|
|
122
|
-
A red gate on the commit of the implementer or of a fixer is a critical finding with source `gate`. Its file is the gate record, and the record holds the output. No review runs on that tip, and the next round starts: a red gate on the commit of the implementer starts round 1. The review after that round covers each fix since the last review and repeats each proof that one of them made due.
|
|
123
|
+
A red gate on the commit of the implementer or of a fixer is a critical finding with source `gate`. Its file is the gate record, and the record holds the output. No review runs on that tip, and the next round starts: a red gate on the commit of the implementer starts round 1. The review after that round covers each fix since the last review and repeats each proof that one of them made due. Two red gates block the ticket: one after round 4, and one on the commit of a test-writer correction, because the correction is outside the fix rounds and a fixer may not edit the locked files.
|
|
123
124
|
|
|
124
125
|
- Rounds 1 and 2 run on the first entry of the implementer list.
|
|
125
126
|
- Rounds 3 and 4 run on the second entry of the implementer list. When the list has one entry, they stay on it and the ledger says so.
|
|
126
127
|
- There is no ruling at round 2.
|
|
127
|
-
- Findings that are open after round 4 make the ticket BLOCKED. The run ends with exit 1, and the driver writes one question of kind `finding` for each.
|
|
128
|
+
- Findings that are open after round 4 make the ticket BLOCKED. The run ends with exit 1, and the driver writes one question of kind `finding` for each. A finding that would get a test-writer correction is one of them: no correction runs after round 4, because its new locked tests would have no fix round to make them green and the merge gate would be red. So `accept` on each question merges the reviewed tip.
|
|
128
129
|
|
|
129
130
|
### End of run
|
|
130
131
|
|
|
@@ -172,7 +173,7 @@ Every report has `status` (`DONE`, `DONE_WITH_CONCERNS`, `NEEDS_CONTEXT`, `BLOCK
|
|
|
172
173
|
|
|
173
174
|
| Role | Template | More fields | Checks after the schema |
|
|
174
175
|
|---|---|---|---|
|
|
175
|
-
| test-writer | `prompts/test-writer.md` | `locked` (test file paths), `red` (`test`, `line`) | The driver reads a test node id `<file>::<test>` in `locked` as its file. With DONE: one commit, one locked file and one red line or more. Each locked path is in a commit that the role made. Each `line` is in the log. A locked file of a merged ticket that the role changed is in `locked`. A correction (after `overrule-test`, or for a review finding) changes only the files that it lists in `locked` and the files that the driver restored before it. |
|
|
176
|
+
| test-writer | `prompts/test-writer.md` | `locked` (test file paths), `red` (`test`, `line`) | The driver reads a test node id `<file>::<test>` in `locked` as its file. With DONE: one commit, one locked file and one red line or more. Each locked path is in a commit that the role made. Each `line` is in the log. A locked file of a merged ticket that the role changed is in `locked`. A correction (after `overrule-test`, or for a review finding) changes only the files that it lists in `locked` and the files that the driver restored before it. When the gate is red on the restore commit, a DONE correction lists a restored file in `locked`. |
|
|
176
177
|
| implementer | `prompts/implementer.md` | `green` | With DONE: one commit or more. |
|
|
177
178
|
| reviewer | `prompts/reviewer.md` | `findings` (`severity`, `file`, `line`, `title`, `evidence`, `kind`), `mutations` (`test`, `file`, `line`, `red`); `commits` is empty | In the first review of a tip: `mutations` is required, with a proof for each locked test file (`test` is `<locked file>::<test name>`). Each `red` is in the log. |
|
|
178
179
|
| correctness | `prompts/correctness.md` | the same as the reviewer; `commits` is empty | `mutations` is optional. |
|
|
@@ -202,7 +203,7 @@ Answer with `--answer <question id>=<option id>` on the next run, or through the
|
|
|
202
203
|
|
|
203
204
|
| Check | Where it lives |
|
|
204
205
|
|---|---|
|
|
205
|
-
| Locked tests with a demonstrated red | `driver_report.py` `_proof_errors`: each red line of the test-writer report must be in its log. `driver_review.py` `locked_findings`: a locked file of the ticket that differs from the test-writer commit is a critical finding, before each review. `driver_merge.py` `refused`, called from `driver.py` `_checked_landing` under the merge lock: a locked file of a merged ticket that differs between the PR tip and the tree that is about to land refuses the landing, so a later ticket cannot change the locked test of an earlier one. `driver_report.py` `_merged_errors` rejects a test-writer report that changes such a file and does not list it. |
|
|
206
|
+
| Locked tests with a demonstrated red | `driver_report.py` `_proof_errors`: each red line of the test-writer report must be in its log. `driver_review.py` `locked_findings`: a locked file of the ticket that differs from the test-writer commit is a critical finding, before each review. `driver_review.py` `merged_changes`: a locked file of a merged ticket that the ticket changed is a critical `fixture` finding, before each review. `driver_merge.py` `refused`, called from `driver.py` `_checked_landing` under the merge lock: a locked file of a merged ticket that differs between the PR tip and the tree that is about to land refuses the landing, so a later ticket cannot change the locked test of an earlier one. `driver_report.py` `_merged_errors` rejects a test-writer report that changes such a file and does not list it. |
|
|
206
207
|
| One mutation proof for each locked test | `prompts/reviewer.md` asks for one proof for each locked test. `driver_report.py` `_locked_errors` and `_proof_errors` reject a first review with no proof for a locked test file, or with a red line that is not in the log. `driver_review.py` `follow` finds the proofs that a fix makes due again. |
|
|
207
208
|
| One independent correctness pass for each risky ticket | `driver_fixloop.py` `_review` adds the pass for a ticket with a **Risk** line. `driver_agents.py` `correctness_entry` picks the entry of another provider, and `load_models` stops the run when there is none or its CLI is not on PATH. |
|
|
208
209
|
| One full gate for each candidate | `driver_gate.py` `run`, called from `driver_run.py` `Run.gate`: one record for each commit the driver is about to trust. Green is exit 0 on a tree that is clean before and after, with HEAD not moved. |
|
|
@@ -107,8 +107,9 @@ def _run_roles(ticket: Ticket, slot_number: int, run: Run) -> None:
|
|
|
107
107
|
values["start_sha"] = start
|
|
108
108
|
# A test-writer on a ticket that has locked tests is the correction after a ruling.
|
|
109
109
|
check = {"correction": role == "test-writer" and bool(entry["test_writer_sha"])}
|
|
110
|
-
if check["correction"]:
|
|
111
|
-
|
|
110
|
+
if check["correction"] and (restored := run.restore_locked_files(number, slot)):
|
|
111
|
+
values.update(start_sha=driver_slots.head(slot), restored=" ".join(restored))
|
|
112
|
+
notes, check["restored"] = [*notes, "restored"], restored
|
|
112
113
|
with during(f"the {role}"):
|
|
113
114
|
report = run.agent(role, number, slot, values, notes=notes, check=check)
|
|
114
115
|
facts = {"tip": driver_slots.head(slot)}
|
|
@@ -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_merge
|
|
14
15
|
import driver_review
|
|
15
16
|
import driver_slots
|
|
16
17
|
from driver_run import OUT, Blocked, Run, during, ledger
|
|
@@ -86,7 +87,8 @@ def _review(job: Job) -> list[dict]:
|
|
|
86
87
|
"""Review `job.tip`. Returns the findings of the driver and of each reviewer."""
|
|
87
88
|
run, number = job.run, job.ticket.number
|
|
88
89
|
state = run.state.ticket(number, phase="review")
|
|
89
|
-
|
|
90
|
+
merged = driver_merge.changed(job.slot, run.tip(), driver_merge.protected(run.state.read()["tickets"], number))
|
|
91
|
+
found = _log_findings(job, driver_review.locked_findings(job.slot, state) + driver_review.merged_changes(merged))
|
|
90
92
|
cast = [("reviewer", "")]
|
|
91
93
|
if job.ticket.risk:
|
|
92
94
|
cast.append(("correctness", driver_agents.correctness_entry(run.models)))
|
|
@@ -105,10 +107,23 @@ def _correct(job: Job, findings: list[dict]) -> None:
|
|
|
105
107
|
if job.corrected:
|
|
106
108
|
titles = "; ".join(finding["title"] for finding in findings)
|
|
107
109
|
raise Blocked(f"the review: a locked-test finding is open after the test-writer correction: {titles}")
|
|
108
|
-
|
|
110
|
+
restored = run.restore_locked_files(number, job.slot)
|
|
111
|
+
job.tip = driver_slots.head(job.slot) # the restore is a commit of the driver
|
|
112
|
+
# The gate was green on the tip before the restore, so a red gate here is a restored file that fails with the
|
|
113
|
+
# ticket's code. The driver decides that, not the correction (issue #90).
|
|
114
|
+
with during("the gate of the restore"):
|
|
115
|
+
record = run.red_gate(number, job.slot, job.tip) if restored else ""
|
|
116
|
+
if not record and all(finding["source"] == "driver" for finding in findings):
|
|
117
|
+
job.previous = "" # no review has seen the restore commit
|
|
118
|
+
ledger(f"Ticket {number}: the code does not need the change to {', '.join(restored)}: the restore closes it, "
|
|
119
|
+
"with no correction.")
|
|
120
|
+
return
|
|
121
|
+
values = {**job.pointers(job.round), "restored": " ".join(restored),
|
|
122
|
+
"record": record and os.path.relpath(record, job.slot)}
|
|
123
|
+
notes = ["correction"] + ["restored"] * bool(restored) + ["restored-red"] * bool(record)
|
|
109
124
|
with during("the test-writer correction"):
|
|
110
|
-
report = run.agent("test-writer", number, job.slot,
|
|
111
|
-
|
|
125
|
+
report = run.agent("test-writer", number, job.slot, values, notes=notes, fix_round=job.round + 1,
|
|
126
|
+
check={"correction": True, "restored": restored, "restore_red": bool(record)})
|
|
112
127
|
job.tip, job.previous, job.corrected = driver_slots.head(job.slot), "", True
|
|
113
128
|
entry = run.state.read()["tickets"][number]
|
|
114
129
|
run.state.ticket(number, tip=job.tip, **driver_review.written(job.slot, entry, report["locked"], job.tip))
|
|
@@ -146,8 +161,11 @@ def _act_on(job: Job, found: list[dict]) -> bool:
|
|
|
146
161
|
"""Do what the findings of a review ask for: a correction, a fix round, or a stop. True: none is open."""
|
|
147
162
|
locked = job.run.state.read()["tickets"][job.ticket.number]["locked"]
|
|
148
163
|
to_correct, to_fix = driver_review.route(found, locked)
|
|
164
|
+
if job.round == ROUNDS:
|
|
165
|
+
# No correction after round 4: no fix round could make its new locked tests green, so `accept` could not merge.
|
|
166
|
+
to_correct, to_fix = [], [finding for finding in found if finding in to_correct + to_fix]
|
|
149
167
|
# A changed locked file is fixed first: a correction on top of it would make the changed file the reference.
|
|
150
|
-
locked_file_changed = any(finding["source"] == "driver" for finding in
|
|
168
|
+
locked_file_changed = any(finding["source"] == "driver" for finding in to_fix)
|
|
151
169
|
if to_correct and not locked_file_changed:
|
|
152
170
|
_correct(job, to_correct)
|
|
153
171
|
if not to_fix:
|
|
@@ -82,16 +82,21 @@ def refused(tickets: dict, number: str, old: str, new: str) -> str:
|
|
|
82
82
|
if before.get(path) != after.get(path))
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def changed(slot: str, pr_tip: str, paths: list[str]) -> list[str]:
|
|
86
|
+
"""Each of `paths` that the ticket changed in the tree of `slot`."""
|
|
87
|
+
# Against the merge base: a file that only the PR branch changed since then is not the ticket's change.
|
|
88
|
+
base = git(slot, "merge-base", "HEAD", pr_tip).stdout.strip() or pr_tip
|
|
89
|
+
return names(git(slot, "diff", "--name-only", "-z", base, "--", *paths).stdout) if paths else []
|
|
90
|
+
|
|
91
|
+
|
|
85
92
|
def restore(slot: str, pr_tip: str, paths: list[str]) -> list[str]:
|
|
86
93
|
"""Put each of `paths` that the ticket changed back to the version of the PR tip, in the tree of `slot` only; a
|
|
87
94
|
file the PR tip does not have is removed. Returns those paths."""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
changed = names(git(slot, "diff", "--name-only", "-z", base, "--", *paths).stdout) if paths else []
|
|
91
|
-
for path in changed:
|
|
95
|
+
restored = changed(slot, pr_tip, paths)
|
|
96
|
+
for path in restored:
|
|
92
97
|
if git(slot, "checkout", "-q", pr_tip, "--", path).returncode:
|
|
93
98
|
git(slot, "rm", "-q", "-f", "--", path)
|
|
94
|
-
return
|
|
99
|
+
return restored
|
|
95
100
|
|
|
96
101
|
|
|
97
102
|
def land(pr_branch: str, new: str, old: str) -> str:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
context: repo (slot path), branch, start (HEAD when the role started), log, round (1 = first review), locked,
|
|
4
4
|
correction (a test-writer corrects locked tests), restored (locked files the driver put back before it),
|
|
5
|
+
restore_red (the gate is red on the commit of that restore),
|
|
5
6
|
owners (ticket numbers, for the final fix batch), merged (locked files of merged tickets, not its own), pr (tip)."""
|
|
6
7
|
from __future__ import annotations
|
|
7
8
|
|
|
@@ -66,8 +67,14 @@ def _correction_errors(report: dict, context: dict) -> list[str]:
|
|
|
66
67
|
return []
|
|
67
68
|
changed = _git(context["repo"], "diff", "--name-only", "-z", context["start"], context["branch"], end="\0")
|
|
68
69
|
more = [path for path in changed if path not in report.get("locked", []) + context.get("restored", [])]
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
errors = [f"a correction may change only the files it lists in `locked`: its commits also change {', '.join(more)}"
|
|
71
|
+
] if more else []
|
|
72
|
+
if context.get("restore_red") and report["status"] in ("DONE", "DONE_WITH_CONCERNS") and not (
|
|
73
|
+
set(report["locked"]) & set(context["restored"])):
|
|
74
|
+
errors.append("the gate is red on the restore commit, so a restored file fails with the code of this ticket: "
|
|
75
|
+
"update that file and list it in `locked`, or report BLOCKED with a concern that starts with "
|
|
76
|
+
"`locked test contradicts spec:`")
|
|
77
|
+
return errors
|
|
71
78
|
|
|
72
79
|
|
|
73
80
|
def _merged_errors(report: dict, context: dict) -> list[str]:
|
|
@@ -38,6 +38,14 @@ def locked_changes(slot: str, sha: str | None, locked: list[str]) -> list[dict]:
|
|
|
38
38
|
"evidence": f"git diff {sha}..HEAD -- {path}"} for path, title in titles.items()]
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def merged_changes(paths: list[str]) -> list[dict]:
|
|
42
|
+
"""One critical `fixture` finding per locked file of a merged ticket that the ticket changed. A fixture finding
|
|
43
|
+
goes to a test-writer correction: the driver puts the file back before it and gates that commit."""
|
|
44
|
+
return [{"severity": "critical", "file": path, "line": 1, "kind": "fixture", "source": "driver",
|
|
45
|
+
"title": f"the ticket changed the locked file of a merged ticket: {path}",
|
|
46
|
+
"evidence": f"git diff <merge base with the PR branch> -- {path}"} for path in paths]
|
|
47
|
+
|
|
48
|
+
|
|
41
49
|
def _references(entry: dict) -> list[tuple[str | None, list[str]]]:
|
|
42
50
|
"""(reference commit, the locked files it is the reference of): `test_writer_sha`, or the older commit that
|
|
43
51
|
`held` names for a file."""
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
import argparse
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
+
import subprocess
|
|
11
12
|
import threading
|
|
12
13
|
import time
|
|
13
14
|
from contextlib import contextmanager
|
|
@@ -23,7 +24,7 @@ import driver_report
|
|
|
23
24
|
import driver_review
|
|
24
25
|
import driver_slots
|
|
25
26
|
import driver_state
|
|
26
|
-
from driver_slots import git
|
|
27
|
+
from driver_slots import git, names
|
|
27
28
|
|
|
28
29
|
OUT = Path(".agent/implement")
|
|
29
30
|
FINAL = "final" # the end of run has its reports and gate records under this name, as a ticket has under its number
|
|
@@ -156,13 +157,23 @@ class Run:
|
|
|
156
157
|
|
|
157
158
|
def restore_locked_files(self, number: str, slot: str) -> list[str]:
|
|
158
159
|
"""Before a test-writer correction: put each locked file the implementer changed back to its locked
|
|
159
|
-
version in the
|
|
160
|
+
version in one commit of the driver, so the correction starts from the locked versions on a clean index and
|
|
161
|
+
its commit carries no change of the implementer. Other work in the tree stays. Returns the paths."""
|
|
160
162
|
tickets = self.state.read()["tickets"]
|
|
161
163
|
paths = (driver_review.restore_locked(slot, tickets[number])
|
|
162
164
|
+ driver_merge.restore(slot, self.tip(), driver_merge.protected(tickets, number)))
|
|
165
|
+
# A path whose change was never committed is back at HEAD already, and `git commit -- <path>` refuses it.
|
|
166
|
+
changed = names(git(slot, "diff", "--name-only", "-z", "HEAD", "--", *paths).stdout) if paths else []
|
|
167
|
+
if changed:
|
|
168
|
+
words = "implement-spec: restore the locked files before the correction"
|
|
169
|
+
made = subprocess.run(["git", "-C", slot, "commit", "-q", "--no-verify", "-m", words, "--", *changed],
|
|
170
|
+
capture_output=True, text=True, env={**os.environ, **driver_merge.identity("HEAD", slot)})
|
|
171
|
+
if made.returncode:
|
|
172
|
+
raise Blocked(f"the driver cannot commit the restored locked files: {made.stderr.strip()}")
|
|
173
|
+
self.state.ticket(number, tip=driver_slots.head(slot))
|
|
163
174
|
for path in paths:
|
|
164
175
|
ledger(f"Ticket {number}: {path} was not the locked version at the correction: the driver restored it in "
|
|
165
|
-
"
|
|
176
|
+
"a commit, so the correction starts from the locked version.")
|
|
166
177
|
return paths
|
|
167
178
|
|
|
168
179
|
|
|
@@ -12,7 +12,7 @@ Fix each finding in the list as one batch, in one commit or in several. Leave th
|
|
|
12
12
|
Each commit belongs to one ticket: the ticket whose criterion or code the commit changes. A fix for two tickets is two commits.
|
|
13
13
|
Never edit a locked file: each one stays as it is at {start_sha}, and a locked file that differs is a critical finding of the next review.
|
|
14
14
|
Use targeted tests while you work.
|
|
15
|
-
Before you report DONE or DONE_WITH_CONCERNS,
|
|
15
|
+
Before you report DONE or DONE_WITH_CONCERNS, make the gate green on your final commit: run `{gate}` with IMPLEMENT_SPEC_BASE={pr_branch} in the environment, then fix, commit, and run it again until it passes. The driver runs the gate again on that commit, and a scoped review reads your commits.
|
|
16
16
|
Commit your work on the checked-out PR branch and change no commit that is on it now. Do not dispatch subagents.
|
|
17
17
|
In `commits`, give every commit you made with the number of its ticket: the driver names each commit `fix(NN): <your subject>` from it, and a commit with no ticket is a violation. In `green`, give the summary lines of the passing test output.
|
|
18
18
|
|
|
@@ -11,7 +11,7 @@ Fix each finding of severity critical or important in the findings file. Leave t
|
|
|
11
11
|
Never edit a locked file. The one exception is a finding `locked file changed`: make that file equal to its content at {test_writer_sha}.
|
|
12
12
|
If a locked test contradicts the spec or the ticket, so that no fix can make it green, stop and report BLOCKED with a concern that starts with `locked test contradicts spec:`, then the test and the evidence.
|
|
13
13
|
Use targeted tests while you work.
|
|
14
|
-
Before you report DONE or DONE_WITH_CONCERNS,
|
|
14
|
+
Before you report DONE or DONE_WITH_CONCERNS, make the gate green on your final commit: run `{gate}` with IMPLEMENT_SPEC_BASE={pr_branch} in the environment, then fix, commit, and run it again until it passes. The driver runs the gate again on that commit, and a re-review reads the lines you changed.
|
|
15
15
|
A finding from the `gate` is a red gate: its file is the gate record. Read the output there and make the gate green.
|
|
16
16
|
Commit your work on the checked-out ticket branch. Do not dispatch subagents.
|
|
17
17
|
In `ticket`, give the number of the ticket that owns your commits. In `green`, give the summary lines of the passing test output.
|
|
@@ -9,8 +9,8 @@ Locked files: {locked}
|
|
|
9
9
|
Make the locked tests green and meet each criterion of the ticket. For internal code, work in vertical test-first slices: one failing test, the smallest code that passes it, then the next slice.
|
|
10
10
|
Never edit a locked file. If a locked test contradicts the spec or the ticket, stop and report BLOCKED with a concern that starts with `locked test contradicts spec:`, then the test and the evidence.
|
|
11
11
|
Commit your work on the checked-out ticket branch. Do not dispatch subagents.
|
|
12
|
-
|
|
13
|
-
Before you report DONE or DONE_WITH_CONCERNS,
|
|
12
|
+
Use targeted tests while you work.
|
|
13
|
+
Before you report DONE or DONE_WITH_CONCERNS, make the gate green on your final commit: run `{gate}` with IMPLEMENT_SPEC_BASE={pr_branch} in the environment, then fix, commit, and run it again until it passes. The driver runs the gate again on that commit.
|
|
14
14
|
In `green`, give the summary lines of the passing test output.
|
|
15
15
|
|
|
16
16
|
Write one JSON report at {report} that matches this schema:
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
|
|
2
|
+
The driver ran the gate on that commit, {start_sha}, and the gate is red: read the output in {record}. The gate was green before the restore, so a restored file fails with the code of this ticket.
|
|
3
|
+
Update each restored file that fails so that it works with the new interface: keep what each test asserts, and list the file in `locked`. When no such update can keep what a test asserts, report BLOCKED with a concern that starts with `locked test contradicts spec:`, then the test and the evidence.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
|
|
2
|
+
The driver put back these files at their locked versions, in a commit before this call, because the implementer or a fixer had changed them: {restored}.
|
|
3
|
+
A fixer may not change a locked file. When a test in one of these files now fails only because this ticket changed an interface that the test uses, update that file so it works with the new interface: keep what each test asserts, and list the file in `locked`. Else leave the file as it is.
|
|
@@ -165,7 +165,7 @@ def two_locked():
|
|
|
165
165
|
|
|
166
166
|
|
|
167
167
|
def restored(path):
|
|
168
|
-
return f"Ticket 01: {path} was not the locked version at the correction: the driver restored it in
|
|
168
|
+
return f"Ticket 01: {path} was not the locked version at the correction: the driver restored it in a commit"
|
|
169
169
|
|
|
170
170
|
|
|
171
171
|
TWO_TESTS = "def test_x():\n assert 01 == 'x'\n\n\ndef test_y():\n assert 1 == 'y'\n"
|
|
@@ -330,6 +330,22 @@ def test_round_4_findings_wait_for_every_answer_before_the_ticket_merges(tmp_pat
|
|
|
330
330
|
assert rig.code == 0 and rig.state()["tickets"]["01"]["phase"] == "merged" and len(rig.calls("01", "fixer")) == 4
|
|
331
331
|
|
|
332
332
|
|
|
333
|
+
def test_a_test_finding_after_round_4_is_a_question_and_accepting_every_question_merges(tmp_path):
|
|
334
|
+
"""Issue 84: after round 4 no test-writer correction runs. Its red locked tests would make `accept` a lie."""
|
|
335
|
+
locks = {**tw("01"), "files": {"tests/test_01.py": "# red lock\n"}, "commit": "test: 01 lock"}
|
|
336
|
+
rig = Inside(tmp_path, ONE, gate={"subjects": ["test: 01 lock"]})
|
|
337
|
+
vacuous = finding("VACUOUS", "critical", kind="test", file="tests/test_01.py", line=1)
|
|
338
|
+
last = review("01", [finding("STUCK"), vacuous])
|
|
339
|
+
rig.run(plan(**{"01/test-writer": [tw("01"), locks], "01/reviewer": [review("01", [finding("STUCK")])] * 4 + [last],
|
|
340
|
+
"01/fixer": [fix("01", n) for n in (1, 2, 3, 4)]}))
|
|
341
|
+
assert rig.code == 1 and len(rig.calls("01", "test-writer")) == 1
|
|
342
|
+
assert [q["subject"] for q in rig.state()["run"]["questions"]] == ["STUCK (demo/export.py:99)",
|
|
343
|
+
"VACUOUS (tests/test_01.py:1)"]
|
|
344
|
+
rig.run({}, "--answer", "01-finding-1=accept", "--answer", "01-finding-2=accept")
|
|
345
|
+
assert rig.code == 0, rig.ledger()
|
|
346
|
+
assert rig.state()["tickets"]["01"]["phase"] == "merged"
|
|
347
|
+
|
|
348
|
+
|
|
333
349
|
def test_one_answer_that_keeps_the_ticket_blocked_wins_over_an_accept():
|
|
334
350
|
one = asked("finding", subject="A")
|
|
335
351
|
two = asked("finding", subject="B", before=[one])
|
|
@@ -238,7 +238,6 @@ def test_a_fixture_correction_that_is_rejected_blocks_the_ticket(tmp_path):
|
|
|
238
238
|
|
|
239
239
|
WEAK = "def test_x():\n assert True\n"
|
|
240
240
|
LOCKED_01 = tw("01")["files"]["tests/test_01.py"]
|
|
241
|
-
REFUSED_01 = "Ticket 02: BLOCKED at the merge: locked file changed: tests/test_01.py."
|
|
242
241
|
CHEAT = impl("02", files={"demo/export.py": "def fn_02():\n return '02'\n", "tests/test_01.py": WEAK})
|
|
243
242
|
|
|
244
243
|
|
|
@@ -246,14 +245,15 @@ def on_pr(rig, path):
|
|
|
246
245
|
return subprocess.run(["git", "-C", str(rig.repo), "show", f"feat/csv:{path}"], capture_output=True, text=True).stdout
|
|
247
246
|
|
|
248
247
|
|
|
249
|
-
def
|
|
248
|
+
def test_a_later_ticket_that_weakens_a_locked_test_of_a_merged_ticket_lands_only_with_the_test_put_back(tmp_path):
|
|
250
249
|
"""Final review, correctness 3: ticket 02 replaced the locked assertion of ticket 01, merged and was pushed.
|
|
251
|
-
|
|
250
|
+
Issue #90: the review finds the change; the restore keeps the gate green, so it closes the finding alone."""
|
|
252
251
|
rig = Inside(tmp_path, three()).run(plan(**{"02/implementer": [CHEAT]}))
|
|
253
|
-
assert
|
|
254
|
-
assert
|
|
255
|
-
assert
|
|
256
|
-
assert
|
|
252
|
+
assert rig.code == 0, rig.ledger()
|
|
253
|
+
assert rig.led("Ticket 02: critical finding of kind fixture from the driver at tests/test_01.py:1")
|
|
254
|
+
assert rig.led("Ticket 02:", "the restore closes it") and not rig.led("locked file changed")
|
|
255
|
+
assert len(rig.calls("02", "test-writer")) == 1 and not rig.calls("02", "fixer")
|
|
256
|
+
assert on_pr(rig, "tests/test_01.py") == LOCKED_01 and "feat: 02 rows" in rig.pr_subjects()
|
|
257
257
|
|
|
258
258
|
|
|
259
259
|
def extends(text, listed):
|
|
@@ -286,25 +286,27 @@ def test_the_test_writer_of_a_later_ticket_may_extend_the_locked_file_of_a_merge
|
|
|
286
286
|
|
|
287
287
|
|
|
288
288
|
def test_a_refactor_ticket_may_not_change_a_locked_test_of_a_merged_ticket(tmp_path):
|
|
289
|
-
"""It has no test-writer and no locked file of its own: the
|
|
289
|
+
"""It has no test-writer and no locked file of its own: the review finds the change, and the restore closes it."""
|
|
290
290
|
files = three(**{"02-quote-fields.md": test_review.ticket("02", "Quote fields", "01", seam="none — refactor", criteria=())})
|
|
291
291
|
rig = Inside(tmp_path, files).run(plan(**{"02/implementer": [CHEAT]}))
|
|
292
|
-
assert
|
|
293
|
-
assert on_pr(rig, "tests/test_01.py") == LOCKED_01
|
|
292
|
+
assert rig.code == 0 and not rig.calls("02", "test-writer"), rig.ledger()
|
|
293
|
+
assert rig.led("Ticket 02:", "the restore closes it") and on_pr(rig, "tests/test_01.py") == LOCKED_01
|
|
294
294
|
clean = Inside(under(tmp_path, "clean"), files).run(plan())
|
|
295
295
|
assert clean.code == 0 and not clean.led("locked file changed"), clean.ledger()
|
|
296
296
|
|
|
297
297
|
|
|
298
298
|
# A correction is a test-writer: a file that it lists is its ticket's own. It must start from the locked version.
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
299
|
+
# The edit has no context lines, so it applies to the file whatever version the tree holds.
|
|
300
|
+
APPEND = "--- a/tests/test_01.py\n+++ b/tests/test_01.py\n@@ -2,0 +3 @@\n+# corrected\n"
|
|
301
|
+
CORRECTED_01 = LOCKED_01 + "# corrected\n"
|
|
302
|
+
RESTORED_01 = ("Ticket 02: tests/test_01.py was not the locked version at the correction: the driver restored it in a "
|
|
303
|
+
"commit, so the correction starts from the locked version.")
|
|
302
304
|
|
|
303
305
|
|
|
304
306
|
def appending(tmp_path, **changes):
|
|
305
|
-
"""A correction of 02 that appends a line to tests/test_01.py as the
|
|
307
|
+
"""A correction of 02 that appends a line to tests/test_01.py as the tree holds it, and lists it."""
|
|
306
308
|
(tmp_path / "append.patch").write_text(APPEND)
|
|
307
|
-
step = {**tw("02"), "git": [["apply", str(tmp_path / "append.patch")]], "commit": "test: 02 corrected", **changes}
|
|
309
|
+
step = {**tw("02"), "git": [["apply", "--unidiff-zero", str(tmp_path / "append.patch")]], "commit": "test: 02 corrected", **changes}
|
|
308
310
|
step["report"] = {**step["report"], "locked": ["tests/test_02.py", "tests/test_01.py"]}
|
|
309
311
|
return step
|
|
310
312
|
|
|
@@ -325,7 +327,7 @@ def test_an_overrule_correction_starts_from_the_locked_file_of_the_merged_ticket
|
|
|
325
327
|
rig = Inside(tmp_path, three()).run(steps)
|
|
326
328
|
assert rig.code == 2, rig.ledger()
|
|
327
329
|
rig.run(steps, "--answer", "02-locked-test-1=overrule-test")
|
|
328
|
-
assert on_pr(rig, "tests/test_01.py") ==
|
|
330
|
+
assert on_pr(rig, "tests/test_01.py") == CORRECTED_01, rig.ledger()
|
|
329
331
|
assert rig.led("Ticket 02:", "restored") == [RESTORED_01] and rig.code == 0
|
|
330
332
|
|
|
331
333
|
|
|
@@ -335,10 +337,60 @@ def test_a_fixture_correction_starts_from_the_locked_file_of_the_merged_ticket(t
|
|
|
335
337
|
correction = appending(tmp_path, files={"tests/test_02.py": "def test_x():\n assert '02' == 'x' # corrected\n"})
|
|
336
338
|
rig = Inside(tmp_path, three()).run(plan(**{"02/implementer": [CHEAT], "02/test-writer": [tw("02"), correction],
|
|
337
339
|
"02/reviewer": [review("02", [stale]), proves("02", "tests/test_01.py")]}))
|
|
338
|
-
assert on_pr(rig, "tests/test_01.py") ==
|
|
340
|
+
assert on_pr(rig, "tests/test_01.py") == CORRECTED_01, rig.ledger()
|
|
339
341
|
assert rig.led("Ticket 02:", "restored") == [RESTORED_01] and rig.code == 0
|
|
340
342
|
|
|
341
343
|
|
|
344
|
+
def test_the_restore_before_a_correction_is_committed_so_the_next_fixer_and_its_gate_find_a_clean_tree(tmp_path):
|
|
345
|
+
"""Issue #86: the correction and the fixer each commit only their own files. The restore of the merged ticket's
|
|
346
|
+
file must not stay in the tree, where the gate of the fixer's commit is `not clean` and burns a round."""
|
|
347
|
+
weak = finding("WEAK", kind="test", file="tests/test_02.py")
|
|
348
|
+
stronger = "def test_x():\n assert '02' == 'x' # stronger\n"
|
|
349
|
+
correction = {**tw("02"), "files": {"tests/test_02.py": stronger}, "commit": "test: 02 stronger",
|
|
350
|
+
"only": ["tests/test_02.py"]}
|
|
351
|
+
rig = Inside(tmp_path, three()).run(plan(**{
|
|
352
|
+
"02/implementer": [CHEAT], "02/test-writer": [tw("02"), correction],
|
|
353
|
+
"02/reviewer": [review("02", [weak, STUCK]), review("02")], "02/fixer": [fix("02", 1, only=["demo/export.py"])]}))
|
|
354
|
+
assert rig.code == 0, rig.ledger()
|
|
355
|
+
assert rig.led("Ticket 02:", "restored") == [RESTORED_01]
|
|
356
|
+
(fixer,) = rig.calls("02", "fixer")
|
|
357
|
+
assert fixer["status"] == "" and fixer["head"] == rig.calls("02", "test-writer")[1]["sha"]
|
|
358
|
+
assert not [gate for gate in rig.folder("02").glob("gate-*.log") if "not clean" in gate.read_text()]
|
|
359
|
+
assert rig.state()["tickets"]["02"]["round"] == 1 and on_pr(rig, "tests/test_01.py") == LOCKED_01
|
|
360
|
+
# The correction is told which files the driver put back, so it may update one compatibly when it lists it.
|
|
361
|
+
prompt = rig.prompt("test-writer-1", "02")
|
|
362
|
+
assert "The driver put back these files at their locked versions" in prompt and "tests/test_01.py" in prompt
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
ADAPTED_01 = LOCKED_01 + "# adapted to the interface of 02\n"
|
|
366
|
+
ADAPTS = impl("02", files={"demo/export.py": "def fn_02():\n return '02'\n", "tests/test_01.py": ADAPTED_01})
|
|
367
|
+
RESTORE = "implement-spec: restore the locked files before the correction"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def writes(nn, text, subject):
|
|
371
|
+
"""A test-writer correction that writes tests/test_<nn>.py and lists it."""
|
|
372
|
+
return {**tw(nn), "files": {f"tests/test_{nn}.py": text}, "commit": subject}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_a_restored_locked_file_of_a_merged_ticket_that_fails_with_the_new_code_must_be_updated_by_the_correction(tmp_path):
|
|
376
|
+
"""Issue #90: ticket 02 changes an interface that the locked file of merged ticket 01 uses, and its implementer
|
|
377
|
+
adapts that file. The review finds the change, the driver puts the file back in a commit and gates it: red. The
|
|
378
|
+
correction gets that gate record, and a correction that leaves the file as it is is rejected."""
|
|
379
|
+
leaves = writes("02", "def test_x():\n assert '02' == 'x' # stronger\n", "test: 02 stronger")
|
|
380
|
+
updates = writes("01", ADAPTED_01, "test: 01 adapted to 02")
|
|
381
|
+
updates["report"] = {**updates["report"], "locked": ["tests/test_01.py", "tests/test_02.py"]} # both attempts' files
|
|
382
|
+
rig = Inside(tmp_path, three(), gate={"subjects": [RESTORE]}).run(
|
|
383
|
+
plan(**{"02/implementer": [ADAPTS], "02/test-writer": [tw("02"), leaves, updates]}))
|
|
384
|
+
assert rig.code == 0, rig.ledger()
|
|
385
|
+
assert rig.led("Ticket 02: critical finding of kind fixture from the driver at tests/test_01.py:1")
|
|
386
|
+
assert not rig.calls("02", "fixer")
|
|
387
|
+
(restore,) = [gate["commit"] for gate in rig.gates() if gate["exit"]]
|
|
388
|
+
first, second = rig.calls("02", "test-writer")[1:]
|
|
389
|
+
assert f".agent/implement/02/gate-{restore[:7]}.log" in first["prompt"] and first["head"] == restore
|
|
390
|
+
assert "the gate is red on the restore commit" in second["prompt"] # the rejection of the first attempt
|
|
391
|
+
assert on_pr(rig, "tests/test_01.py") == ADAPTED_01 and "tests/test_01.py" in rig.state()["tickets"]["02"]["locked"]
|
|
392
|
+
|
|
393
|
+
|
|
342
394
|
ACCENT = "tests/tést.py"
|
|
343
395
|
|
|
344
396
|
|
|
@@ -351,7 +403,7 @@ def accented(tmp_path, name, **steps):
|
|
|
351
403
|
return Inside(under(tmp_path, name), three()).run(plan(**{"01/test-writer": [writer], "01/reviewer": proofs, **steps}))
|
|
352
404
|
|
|
353
405
|
|
|
354
|
-
def
|
|
406
|
+
def test_a_locked_file_with_a_name_that_git_quotes_is_protected_at_review_for_its_ticket_and_a_later_one(tmp_path):
|
|
355
407
|
"""Review round 1, I2: the quoted form of the path matched nothing, so no check saw the file."""
|
|
356
408
|
own = impl("01", files={"demo/export.py": "def fn_01():\n return '01'\n", ACCENT: WEAK})
|
|
357
409
|
rig = accented(tmp_path, "review", **{"01/implementer": [own], "01/fixer": [{}, {}]})
|
|
@@ -360,7 +412,7 @@ def test_a_locked_file_with_a_name_that_git_quotes_is_protected_at_review_and_at
|
|
|
360
412
|
later = impl("02", files={"demo/export.py": "def fn_02():\n return '02'\n", ACCENT: WEAK})
|
|
361
413
|
rig = accented(tmp_path, "landing", **{"02/implementer": [later]})
|
|
362
414
|
assert rig.state()["tickets"]["01"]["locked"] == ["tests/test_01.py", ACCENT], rig.ledger()
|
|
363
|
-
assert
|
|
415
|
+
assert rig.code == 0 and rig.led(f"Ticket 02: critical finding of kind fixture from the driver at {ACCENT}:1")
|
|
364
416
|
assert "assert 1 == 'y'" in on_pr(rig, ACCENT)
|
|
365
417
|
|
|
366
418
|
|
|
@@ -31,7 +31,7 @@ import time
|
|
|
31
31
|
import pytest
|
|
32
32
|
|
|
33
33
|
from test_cli import RUN, git, models_file, snapshot, three, ticket
|
|
34
|
-
from test_final import Final
|
|
34
|
+
from test_final import Final
|
|
35
35
|
from test_review import HOOKED, Review, cli
|
|
36
36
|
from test_stops import UNATTENDED, blocked as contradicted
|
|
37
37
|
from test_walk import GATE, Rig, impl, make_env, plan, tw
|
|
@@ -74,6 +74,13 @@ def refused(rig, nn, path):
|
|
|
74
74
|
assert rig.led(nn, f"locked file changed: {path}"), rig.ledger()
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def restored(rig, nn, path):
|
|
78
|
+
"""The review of ticket `nn` found its change to the locked file `path` of a merged ticket, and the restore
|
|
79
|
+
closed it."""
|
|
80
|
+
return (rig.led(f"Ticket {nn}: critical finding of kind fixture from the driver at {path}:1")
|
|
81
|
+
and rig.led(f"Ticket {nn}:", "the restore closes it"))
|
|
82
|
+
|
|
83
|
+
|
|
77
84
|
def writer(nn, path, text, listed=True):
|
|
78
85
|
"""The test-writer of `nn` that also writes `path`, and lists it as locked or not."""
|
|
79
86
|
step = tw(nn)
|
|
@@ -92,13 +99,13 @@ def seeded(rig):
|
|
|
92
99
|
|
|
93
100
|
# ---------------------------------------------------------------- the implementer of a later ticket
|
|
94
101
|
|
|
95
|
-
def
|
|
102
|
+
def test_an_implementer_that_weakens_the_locked_test_of_a_merged_ticket_lands_only_with_the_test_put_back(tmp_path):
|
|
103
|
+
"""The review finds the change and the driver restores the file; the gate stays green, so no correction runs."""
|
|
96
104
|
rig = Final(tmp_path, three())
|
|
97
105
|
weak = impl("02", files={"demo/export.py": "def fn_02():\n return '02'\n", T01: WEAK})
|
|
98
106
|
rig.run(plan(**{"02/implementer": [weak], "02/fixer": NO_REPORT}), *UNATTENDED)
|
|
99
|
-
|
|
100
|
-
assert
|
|
101
|
-
assert rig.pushes() == [] and not rig.prs() and not ref(rig.origin, "refs/heads/feat/csv")
|
|
107
|
+
assert rig.code == 0 and restored(rig, "02", T01), rig.output()
|
|
108
|
+
assert len(rig.calls("02", "test-writer")) == 1 and on_pr(rig, T01) == LOCKED_01
|
|
102
109
|
|
|
103
110
|
|
|
104
111
|
# ---------------------------------------------------------------- a ticket branch that takes the PR branch in
|
|
@@ -117,12 +124,12 @@ def test_a_ticket_that_merged_the_pr_branch_into_itself_lands_with_the_tests_of_
|
|
|
117
124
|
assert phase(rig, "03") == "merged" and on_pr(rig, T01) == LOCKED_01 and on_pr(rig, T03) == LOCKED_03
|
|
118
125
|
|
|
119
126
|
|
|
120
|
-
def
|
|
127
|
+
def test_a_ticket_that_merged_the_pr_branch_and_deleted_a_locked_test_of_the_merged_ticket_lands_with_it_put_back(tmp_path):
|
|
121
128
|
rig = Review(tmp_path, three())
|
|
122
129
|
rig.run(plan(**{"03/implementer": [takes_in(["rm", "-q", T01])], "03/fixer": NO_REPORT}))
|
|
123
130
|
(call,) = rig.calls("03", "implementer")
|
|
124
131
|
assert holds(rig, rig.sha("01", "implementer"), call["sha"]), "01 is in ticket/03"
|
|
125
|
-
|
|
132
|
+
assert rig.code == 0 and restored(rig, "03", T01), rig.output()
|
|
126
133
|
assert phase(rig, "01") == "merged" and on_pr(rig, T01) == LOCKED_01
|
|
127
134
|
|
|
128
135
|
|
|
@@ -24,13 +24,16 @@ import pytest
|
|
|
24
24
|
from test_cli import SKILL, ticket, three, git, models_file
|
|
25
25
|
from test_walk import RUNNER, Rig, fix, impl, plan, tw
|
|
26
26
|
|
|
27
|
-
# The fake runner of test_walk plus
|
|
27
|
+
# The fake runner of test_walk plus three hooks: `git` runs before the step's files, `git_after` right after them,
|
|
28
|
+
# and `only` names the paths the commit takes, as an agent that commits its own files only.
|
|
28
29
|
HOOKED = RUNNER.replace(
|
|
29
30
|
'for path, text in step.get("files", {}).items():',
|
|
30
31
|
'for cmd in step.get("git", []):\n git(*cmd)\nfor path, text in step.get("files", {}).items():', 1).replace(
|
|
31
32
|
'sys.stdout.write(step.get("stdout", ""))',
|
|
32
|
-
'for cmd in step.get("git_after", []):\n git(*cmd)\nsys.stdout.write(step.get("stdout", ""))', 1)
|
|
33
|
-
|
|
33
|
+
'for cmd in step.get("git_after", []):\n git(*cmd)\nsys.stdout.write(step.get("stdout", ""))', 1).replace(
|
|
34
|
+
'git("commit", "-q", "-m", step["commit"])',
|
|
35
|
+
'git("commit", "-q", "-m", step["commit"], *(["--", *step["only"]] if "only" in step else []))', 1)
|
|
36
|
+
assert HOOKED.count("git_after") == 1 and HOOKED.count('step.get("git", [])') == 1 and HOOKED.count('step["only"]') == 1
|
|
34
37
|
|
|
35
38
|
ONE = {"01-export-rows.md": ticket("01", "Export rows")}
|
|
36
39
|
RISKY = {"02-quote-fields.md": ticket("02", "Quote fields", risk="persistence")}
|
package/skills-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-16T23:46:48.180Z",
|
|
4
4
|
"skills": [
|
|
5
5
|
{
|
|
6
6
|
"name": "backlog",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"name": "implement-spec",
|
|
49
|
-
"version": "0.1.
|
|
50
|
-
"hash": "sha256-
|
|
49
|
+
"version": "0.1.20",
|
|
50
|
+
"hash": "sha256-9c85f3c37deb0c2e9715172ccdfbb71ef65c066aa8e8c1505d9d2e968b84f0fd",
|
|
51
51
|
"description": "Implement a specification in code."
|
|
52
52
|
},
|
|
53
53
|
{
|