@jesdi/skills 0.0.30 → 0.0.32

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 (73) hide show
  1. package/README.md +12 -6
  2. package/package.json +2 -2
  3. package/skills/implement-spec/ARCHITECTURE.md +113 -0
  4. package/skills/implement-spec/README.md +215 -0
  5. package/skills/implement-spec/SKILL.md +18 -70
  6. package/skills/implement-spec/driver.py +580 -0
  7. package/skills/implement-spec/driver_agents.py +145 -0
  8. package/skills/implement-spec/driver_answers.py +79 -0
  9. package/skills/implement-spec/driver_decisions.py +152 -0
  10. package/skills/implement-spec/driver_final.py +534 -0
  11. package/skills/implement-spec/driver_fixloop.py +170 -0
  12. package/skills/implement-spec/driver_gate.py +47 -0
  13. package/skills/implement-spec/driver_graph.py +42 -0
  14. package/skills/implement-spec/driver_merge.py +105 -0
  15. package/skills/implement-spec/driver_report.py +115 -0
  16. package/skills/implement-spec/driver_review.py +126 -0
  17. package/skills/implement-spec/driver_review_page.py +51 -0
  18. package/skills/implement-spec/driver_run.py +262 -0
  19. package/skills/implement-spec/driver_slots.py +64 -0
  20. package/skills/implement-spec/driver_state.py +108 -0
  21. package/skills/implement-spec/driver_summary.py +40 -0
  22. package/skills/implement-spec/driver_tickets.py +132 -0
  23. package/skills/implement-spec/driver_tidy.py +57 -0
  24. package/skills/implement-spec/models.json +11 -0
  25. package/skills/implement-spec/prompts/conflict.md +32 -0
  26. package/skills/implement-spec/prompts/correction.md +4 -0
  27. package/skills/implement-spec/prompts/correctness.md +34 -0
  28. package/skills/implement-spec/prompts/final-fixer.md +36 -0
  29. package/skills/implement-spec/prompts/final-reviewer.md +36 -0
  30. package/skills/implement-spec/prompts/final-scoped.md +1 -0
  31. package/skills/implement-spec/prompts/fixer.md +35 -0
  32. package/skills/implement-spec/prompts/implementer.md +33 -0
  33. package/skills/implement-spec/prompts/interrupted.md +1 -0
  34. package/skills/implement-spec/prompts/moved.md +2 -0
  35. package/skills/implement-spec/prompts/overruled.md +5 -0
  36. package/skills/implement-spec/prompts/pr-body.md +19 -0
  37. package/skills/implement-spec/prompts/pr-writer.md +31 -0
  38. package/skills/implement-spec/prompts/rebase-conflict.md +33 -0
  39. package/skills/implement-spec/prompts/rereview.md +3 -0
  40. package/skills/implement-spec/prompts/rerun.md +2 -0
  41. package/skills/implement-spec/prompts/reviewer.md +34 -0
  42. package/skills/implement-spec/prompts/stands.md +2 -0
  43. package/skills/implement-spec/prompts/test-writer.md +34 -0
  44. package/skills/implement-spec/prompts/timeout-restored.md +2 -0
  45. package/skills/implement-spec/prompts/timeout-shared.md +2 -0
  46. package/skills/implement-spec/prompts/timeout.md +2 -0
  47. package/skills/implement-spec/run.sh +101 -0
  48. package/skills/implement-spec/tests/conftest.py +50 -0
  49. package/skills/implement-spec/tests/test_agents.py +358 -0
  50. package/skills/implement-spec/tests/test_amendments.py +117 -0
  51. package/skills/implement-spec/tests/test_cli.py +221 -0
  52. package/skills/implement-spec/tests/test_decisions.py +434 -0
  53. package/skills/implement-spec/tests/test_driver.py +767 -0
  54. package/skills/implement-spec/tests/test_final.py +442 -0
  55. package/skills/implement-spec/tests/test_fixloop.py +523 -0
  56. package/skills/implement-spec/tests/test_graph.py +32 -0
  57. package/skills/implement-spec/tests/test_landing.py +355 -0
  58. package/skills/implement-spec/tests/test_merge.py +144 -0
  59. package/skills/implement-spec/tests/test_models.py +47 -0
  60. package/skills/implement-spec/tests/test_report.py +162 -0
  61. package/skills/implement-spec/tests/test_review.py +338 -0
  62. package/skills/implement-spec/tests/test_review_page.py +67 -0
  63. package/skills/implement-spec/tests/test_slots.py +32 -0
  64. package/skills/implement-spec/tests/test_state.py +68 -0
  65. package/skills/implement-spec/tests/test_stops.py +272 -0
  66. package/skills/implement-spec/tests/test_summary.py +28 -0
  67. package/skills/implement-spec/tests/test_tickets.py +72 -0
  68. package/skills/implement-spec/tests/test_tidy.py +604 -0
  69. package/skills/implement-spec/tests/test_walk.py +459 -0
  70. package/skills/review-page/template.html +1 -1
  71. package/skills/to-tickets/SKILL.md +19 -2
  72. package/skills-manifest.json +7 -7
  73. package/skills/implement-spec/history.md +0 -52
@@ -0,0 +1,51 @@
1
+ """The review page of the open questions: decisions.json in, HTML out.
2
+
3
+ The page is the template of the review-page skill in questionnaire mode with its slots filled. Every text is escaped.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from html import escape
9
+
10
+ import driver_agents
11
+
12
+ TEMPLATE = driver_agents.SKILL.parent / "review-page" / "template.html"
13
+ SLOTS = ("header", "tickets", "questions", "corrections", "track")
14
+
15
+
16
+ def _option(question: str, option: dict, recommended: bool) -> str:
17
+ name, chip = escape(option["id"]), ' <span class="chip rec">recommended</span>' * recommended
18
+ return (f'<label class="opt"><input type="radio" name="{question}" value="{name}"><span><span class="t">{name}'
19
+ f'{chip}</span><br><span class="d">{escape(option["description"])}</span></span></label>')
20
+
21
+
22
+ def _question(q: dict) -> str:
23
+ name = escape(q["id"])
24
+ options = "\n".join(_option(name, option, option["id"] == q["recommended"]) for option in q["options"])
25
+ evidence = " · ".join(f"<code>{escape(pointer)}</code>" for pointer in q["evidence"])
26
+ return (f'<div class="q" data-q="{name}">\n'
27
+ f'<header><h3>{escape(q["title"])}</h3><span class="chip waiting">needs you</span></header>\n'
28
+ f'<p class="why">{escape(q["why"])}</p>\n<p class="why">Evidence: {evidence}</p>\n'
29
+ f'<div class="opts">\n{options}\n</div>\n'
30
+ f'<textarea id="note-{name}" placeholder="Note (optional)"></textarea>\n</div>')
31
+
32
+
33
+ def _filled(page: str, pattern: str, new: str) -> str:
34
+ """`page` with the one match of `pattern` replaced; ValueError when the template has not exactly one."""
35
+ page, count = re.subn(pattern, lambda match: new, page, flags=re.S)
36
+ if count != 1:
37
+ raise ValueError(f"{TEMPLATE} has {count} matches of {pattern}, not 1")
38
+ return page
39
+
40
+
41
+ def render(decisions: dict) -> str:
42
+ """The page for decisions.json. Raises OSError with no template, ValueError when it has another shape."""
43
+ questions = decisions["questions"]
44
+ header = (f'<div class="eyebrow">implement-spec · decisions {escape(decisions["revision"][:12])}</div>\n'
45
+ f"<h1>{len(questions)} open question{'s' * (len(questions) != 1)} of the implementation</h1>\n"
46
+ '<p class="lede">Pick an option under each question, then send.</p>')
47
+ slots = {"header": header, "questions": "\n".join(["<h2>Open questions</h2>", *map(_question, questions)])}
48
+ page = _filled(TEMPLATE.read_text(), r'<body data-mode="[a-z]*">', '<body data-mode="questionnaire">')
49
+ for slot in SLOTS:
50
+ page = _filled(page, rf"(?<=<!-- slot: {slot} -->).*?(?=<!-- /slot -->)", f"\n{slots.get(slot, '')}\n")
51
+ return page
@@ -0,0 +1,262 @@
1
+ """What the ticket threads and the end of run share: the Run, one agent with its one rerun, the gate, the ledger.
2
+
3
+ A step that cannot go on raises Blocked. The message says why, and `during` adds the step it happened in.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import threading
11
+ import time
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Callable, Iterator, NamedTuple, TypeVar
16
+
17
+ import driver_agents
18
+ import driver_decisions
19
+ import driver_gate
20
+ import driver_merge
21
+ import driver_report
22
+ import driver_review
23
+ import driver_slots
24
+ import driver_state
25
+ from driver_slots import git
26
+
27
+ OUT = Path(".agent/implement")
28
+ FINAL = "final" # the end of run has its reports and gate records under this name, as a ticket has under its number
29
+ _LEDGER = threading.Lock()
30
+ T = TypeVar("T")
31
+
32
+
33
+ class Blocked(Exception):
34
+ """A ticket, or the end of run, cannot go on. The message says why."""
35
+
36
+
37
+ @contextmanager
38
+ def during(step: str) -> Iterator[None]:
39
+ """Put the step in front of a Blocked that leaves the block: `<step>: <why>`."""
40
+ try:
41
+ yield
42
+ except Blocked as blocked:
43
+ raise Blocked(f"{step}: {blocked}") from None
44
+
45
+
46
+ def ledger(sentence: str) -> int:
47
+ """Append one sentence to ledger.md and return its line number."""
48
+ with _LEDGER:
49
+ OUT.mkdir(parents=True, exist_ok=True)
50
+ with (OUT / "ledger.md").open("a+") as file:
51
+ file.write(" ".join(sentence.split()) + "\n")
52
+ file.seek(0)
53
+ return sum(1 for _ in file)
54
+
55
+
56
+ def _owner(number: str) -> str:
57
+ return "End of run" if number == FINAL else f"Ticket {number}"
58
+
59
+
60
+ def _gate_record(state: dict, number: str) -> dict:
61
+ """The record of state.json that holds the green gates of `number`."""
62
+ return state["run"]["final"] if number == FINAL else state["tickets"][number]
63
+
64
+
65
+ class Ask(NamedTuple):
66
+ """The questions a ticket thread wants the operator to answer when its ticket blocks."""
67
+ kind: str
68
+ subjects: list[str] # one question per subject
69
+ report: str # the evidence
70
+ fields: dict # what the ticket record gets with the stop
71
+
72
+
73
+ @dataclass
74
+ class Run:
75
+ args: argparse.Namespace
76
+ models: dict[str, list[str]]
77
+ state: driver_state.State
78
+ pr: str # the PR branch
79
+ kept: dict[str, int] # ticket number -> the slot that holds its uncommitted work from an earlier run
80
+ merging: threading.Lock = field(default_factory=threading.Lock) # held for one landing: merges are serial
81
+ asks: dict[str, Ask] = field(default_factory=dict) # ticket number -> its pending stop
82
+
83
+ def tip(self) -> str:
84
+ return git(".", "rev-parse", f"refs/heads/{self.pr}").stdout.strip()
85
+
86
+ def ask(self, number: str, kind: str, subjects: list[str], report: str, **fields) -> None:
87
+ self.asks[number] = Ask(kind, subjects, report, fields)
88
+
89
+ def agent(self, role: str, number: str, slot: str, values: dict[str, str], **options) -> dict:
90
+ """Run one role and return its accepted report. `options` are those of `_attempts`."""
91
+ if role == "test-writer":
92
+ # Its report may change a locked file of a merged ticket only when it lists the file.
93
+ merged = driver_merge.protected(self.state.read()["tickets"], number)
94
+ options["check"] = {**(options.get("check") or {}), "merged": merged, "pr": self.tip()}
95
+ report = _attempts(self, role, number, slot, values, **options)
96
+ if subject := driver_decisions.contradicted(role, report):
97
+ # The implementer's commits stay: after the ruling the ticket goes on from them.
98
+ evidence = OUT / number / f"{role}-{options.get('fix_round', 0)}.json"
99
+ self.ask(number, "locked-test", [subject], str(evidence), tip=driver_slots.head(slot))
100
+ if report.get("status") in ("BLOCKED", "NEEDS_CONTEXT"):
101
+ concerns = "; ".join(report["concerns"]) or "no concern given"
102
+ raise Blocked(f"the {role} reported {report['status']}: {concerns}")
103
+ return report
104
+
105
+ def read_only(self, number: str, slot: str, branch: str, tip: str, review: Callable[[], T]) -> T:
106
+ """Run `review` (read-only agents) and return its result. A review that changes the slot runs once more."""
107
+ for last in (False, True):
108
+ error = ""
109
+ try:
110
+ result = review()
111
+ except Blocked as blocked:
112
+ error = str(blocked)
113
+ left = _restore_if_changed(number, slot, branch, tip, runs_again=not (error or last))
114
+ if error or not left:
115
+ break
116
+ if error or left:
117
+ raise Blocked(error or left)
118
+ return result
119
+
120
+ def gate(self, number: str, slot: str, commit: str) -> None:
121
+ """Gate `commit` in the slot, unless a green record names it already."""
122
+ if commit in _gate_record(self.state.read(), number)["green_gates"]:
123
+ return
124
+ at = driver_slots.head(slot)
125
+ if at != commit:
126
+ raise Blocked(f"the slot is at {at[:7]}, not at {commit[:7]}: the gate did not run")
127
+ green, record = driver_gate.run(self.args.gate, slot, OUT / number, self.pr)
128
+
129
+ def count(state: dict) -> None:
130
+ state["run"]["gates"] += 1
131
+ if green:
132
+ _gate_record(state, number)["green_gates"].append(commit)
133
+
134
+ self.state.update(count)
135
+ if not green:
136
+ raise Blocked(f"the gate is red on {commit[:7]}, record {record}")
137
+ ledger(f"{_owner(number)}: the gate is green on {commit[:7]}, record {record}.")
138
+
139
+ def restore_locked_files(self, number: str, slot: str) -> list[str]:
140
+ """Before a test-writer correction: put each locked file the implementer changed back to its locked
141
+ version in the tree, so the correction's commit carries no change of the implementer. Returns the paths."""
142
+ tickets = self.state.read()["tickets"]
143
+ paths = (driver_review.restore_locked(slot, tickets[number])
144
+ + driver_merge.restore(slot, self.tip(), driver_merge.protected(tickets, number)))
145
+ for path in paths:
146
+ ledger(f"Ticket {number}: {path} was not the locked version at the correction: the driver restored it in "
147
+ "the tree, so the correction starts from the locked version.")
148
+ return paths
149
+
150
+
151
+ def _restore_if_changed(number: str, slot: str, branch: str, tip: str, runs_again: bool) -> str:
152
+ """After read-only agents: "" when the slot is as it was; else restore it and return what they left."""
153
+ left = driver_review.disturbed(slot, branch, tip)
154
+ if left:
155
+ driver_review.restore(slot, branch, tip)
156
+ where, what = ("slot 1", "the final review") if number == FINAL else ("the slot", "the review")
157
+ again = f" and runs {what} again" if runs_again else ""
158
+ ledger(f"{_owner(number)}: a read-only role broke its rule: {left}; the driver restored {where} to "
159
+ f"{tip[:7]}{again}.")
160
+ return left
161
+
162
+
163
+ def _reset_if_moved(slot: str, branch: str, tip: str) -> str:
164
+ """After an agent: "" when the slot is on `branch` and still holds `tip`; else reset it and say what was wrong."""
165
+ head = git(slot, "symbolic-ref", "-q", "--short", "HEAD").stdout.strip()
166
+ if git(slot, "rev-parse", "-q", "--verify", f"refs/heads/{branch}").returncode:
167
+ problem = f"branch {branch} is missing"
168
+ elif head != branch:
169
+ problem = f"HEAD is on {head or 'a detached commit'} instead of {branch}"
170
+ elif git(slot, "merge-base", "--is-ancestor", tip, "HEAD").returncode:
171
+ problem = f"{branch} no longer holds {tip[:7]}"
172
+ else:
173
+ return ""
174
+ git(slot, "checkout", "-q", "-f", "-B", branch, tip)
175
+ return f"{problem}; the driver reset the slot to {tip[:7]}"
176
+
177
+
178
+ def _timeout_lines(role: str, minutes: float, context: dict, alone: Callable[[], bool]) -> str:
179
+ """The lines the rerun prompt gets after a timeout. The tree stays, but a read-only role has no work to keep:
180
+ its slot is restored, unless another agent still works there."""
181
+ values = {"minutes": f"{minutes:g}", "tip": context["start"]}
182
+ if role not in driver_agents.READ_ONLY:
183
+ return driver_agents.render("timeout", values)
184
+ if alone():
185
+ driver_review.restore(context["repo"], context["branch"], context["start"])
186
+ return driver_agents.render("timeout-restored", values)
187
+ return driver_agents.render("timeout-shared", values)
188
+
189
+
190
+ def _rejection(role: str, code: int | None, minutes: float, report: Path, context: dict, alone: Callable[[], bool],
191
+ detached: bool) -> tuple[str, str]:
192
+ """(why the attempt is rejected, the lines the rerun prompt gets); ("", ...) when the report is accepted."""
193
+ branch, tip = context["branch"], context["start"]
194
+ # Read-only agents share a slot, and a rebase is on a detached HEAD: there the driver has no branch to reset.
195
+ owns_branch = role not in driver_agents.READ_ONLY and not detached
196
+ if owns_branch and (moved := _reset_if_moved(context["repo"], branch, tip)):
197
+ return moved, driver_agents.render("moved", {"branch": branch, "tip": tip})
198
+ if code is None:
199
+ return f"timed out after {minutes:g} minutes", _timeout_lines(role, minutes, context, alone)
200
+ error = "; ".join(driver_report.validate(context["template"], str(report), context))
201
+ return error, driver_agents.render("rerun", {"error": error})
202
+
203
+
204
+ def _first_prompt(template: str, values: dict[str, str], notes: tuple | list, report: str) -> str:
205
+ """The template of the role, then the lines of each `notes` template and the ending of the prompt again."""
206
+ text = driver_agents.render(template, {**values, "report": report})
207
+ if notes:
208
+ text += "".join(driver_agents.render(note, values) for note in notes) + driver_agents.tail(text, report)
209
+ return text
210
+
211
+
212
+ def _record_attempt(run: Run, role: str, number: str, entry: str, seconds: float, log: Path, error: str) -> None:
213
+ cost = driver_agents.cost(log)
214
+ used = {"ticket": number, "role": role, "entry": entry, "seconds": round(seconds, 1), "cost": float(cost or 0)}
215
+ run.state.update(lambda state: state["run"]["agents"].append(used))
216
+ spent = f", cost {cost} USD" if cost else ""
217
+ ledger(f"{_owner(number)}: {role} ran on {entry} for {seconds:.1f}s{spent}: {error or 'report accepted'}.")
218
+
219
+
220
+ def _command(run: Run, role: str, entry: str, prompt: str, report: str) -> list[str]:
221
+ if run.args.runner: # `--runner` is for tests
222
+ return [run.args.runner, role, prompt, report]
223
+ return driver_agents.command(role, entry, report)
224
+
225
+
226
+ def _attempts(run: Run, role: str, number: str, slot: str, values: dict[str, str], notes: tuple | list = (),
227
+ entry: str = "", fix_round: int = 0, check: dict | None = None, template: str = "",
228
+ alone: Callable[[], bool] = lambda: True, detached: bool = False) -> dict:
229
+ """Run one role in its slot, with at most one rerun, and return the accepted report.
230
+
231
+ notes templates whose lines the first prompt gets after the role's own
232
+ entry the model entry, when it is not the first of the role's list
233
+ fix_round the K of the files `<role>-<K>.json`, `.log` and `.prompt.md`
234
+ check more context for the report validation (driver_report.validate)
235
+ template the prompt and schema, when it is not the one named after the role
236
+ alone whether no other agent works in the slot now
237
+ detached the agent works on a detached HEAD
238
+ """
239
+ folder, entry = OUT / number, entry or run.models[role][0]
240
+ folder.mkdir(parents=True, exist_ok=True)
241
+ report, log, prompt = (folder / f"{role}-{fix_round}{suffix}" for suffix in (".json", ".log", ".prompt.md"))
242
+ report_in_slot = os.path.relpath(report, slot) # the agent works in its slot
243
+ context = {"repo": slot, "branch": values["branch"], "start": values["start_sha"], "log": str(log), "round": 0,
244
+ "template": template or role, **(check or {})}
245
+ argv = _command(run, role, entry, os.path.relpath(prompt, slot), report_in_slot)
246
+ text = _first_prompt(context["template"], values, notes, report_in_slot)
247
+ minutes, error = run.args.timeout[role], ""
248
+ for _ in range(2):
249
+ prompt.write_text(text)
250
+ report.unlink(missing_ok=True)
251
+ start = time.monotonic()
252
+ code = driver_agents.launch(argv, prompt, log, minutes * 60, slot)
253
+ seconds = time.monotonic() - start
254
+ error, rerun_lines = _rejection(role, code, minutes, report, context, alone, detached)
255
+ _record_attempt(run, role, number, entry, seconds, log, error)
256
+ if not error:
257
+ return json.loads(report.read_text())
258
+ # The rerun prompt is the first prompt, the rejection, then the same ending again.
259
+ text += rerun_lines + driver_agents.tail(text, report_in_slot)
260
+ if code is None:
261
+ minutes *= 2
262
+ raise Blocked(error)
@@ -0,0 +1,64 @@
1
+ """Slots: the worktrees agents run in. Slot 1 is the PR worktree, slots 2..N live under .agent/worktrees/.
2
+
3
+ Every path is relative to the PR worktree, which is the driver's working directory for the whole run.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+
11
+ def git(slot: str, *args: str) -> subprocess.CompletedProcess[str]:
12
+ # core.quotePath off: git prints a path with a non-ASCII name as it is, so it equals the path that a report names.
13
+ return subprocess.run(["git", "-C", slot, "-c", "core.quotePath=false", *args], capture_output=True, text=True)
14
+
15
+
16
+ def names(listing: str) -> list[str]:
17
+ """The paths of a listing that git made with `-z`."""
18
+ return [name for name in listing.split("\0") if name]
19
+
20
+
21
+ def head(slot: str) -> str:
22
+ return git(slot, "rev-parse", "HEAD").stdout.strip()
23
+
24
+
25
+ def path(number: int) -> str:
26
+ return "." if number == 1 else f".agent/worktrees/slot-{number}"
27
+
28
+
29
+ def create(number: int, prepare: str | None, keep: bool = False) -> str:
30
+ """Make and prepare slot 2..N once, or reuse it off any branch (`keep`: as it is). Returns "" or the error."""
31
+ slot = path(number)
32
+ git(".", "worktree", "prune") # a slot directory that was removed by hand
33
+ if Path(slot, ".git").exists():
34
+ if not (keep or release(slot)):
35
+ return f"slot {number}: {slot} has uncommitted changes: commit or discard them"
36
+ return ""
37
+ added = git(".", "worktree", "add", "-q", "--detach", slot)
38
+ if added.returncode:
39
+ return f"slot {number}: cannot create {slot}: {added.stderr.strip()}"
40
+ if not prepare:
41
+ return ""
42
+ done = subprocess.run(prepare, shell=True, cwd=slot, capture_output=True, text=True, stdin=subprocess.DEVNULL)
43
+ if done.returncode:
44
+ # An unprepared slot must not look ready to the next run.
45
+ git(".", "worktree", "remove", "--force", slot)
46
+ return (f"slot {number}: --prepare `{prepare}` exited {done.returncode} in {slot}: "
47
+ f"{(done.stderr or done.stdout).strip()}")
48
+ return ""
49
+
50
+
51
+ def setup(count: int, prepare: str | None, keep: set[int] = frozenset()) -> list[str]:
52
+ """Slots 2..count, before any agent. The first failure stops the run: [] or [the error]."""
53
+ for number in range(2, count + 1):
54
+ if error := create(number, prepare, number in keep):
55
+ return [error]
56
+ return []
57
+
58
+
59
+ def release(slot: str) -> bool:
60
+ """Take a clean slot off its branch. False: the slot has uncommitted work, which stays where it is."""
61
+ if git(slot, "status", "--porcelain").stdout.strip():
62
+ return False
63
+ git(slot, "checkout", "-q", "--detach")
64
+ return True
@@ -0,0 +1,108 @@
1
+ """state.json, the resume contract: one lock, every write a read-modify-write, published by rename.
2
+
3
+ {"run": {revision, pr_branch, gates, stops, agents: [{ticket, role, entry, seconds, cost}], questions, start,
4
+ landed: [[ticket, the PR tip its merge made]], final},
5
+ "tickets": {"NN": {phase, round, tip, test_writer_sha, locked, green_gates, slot, note, held}}}"""
6
+ from __future__ import annotations
7
+
8
+ import fcntl
9
+ import json
10
+ import os
11
+ import threading
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import IO
15
+
16
+ from driver_slots import git, path
17
+
18
+ PHASES = ("pending", "test-writer", "implement", "gate", "review", "fix", "merge", "merged", "blocked")
19
+
20
+
21
+ def _ticket() -> dict:
22
+ return {"phase": "pending", "round": 0, "tip": None, "test_writer_sha": None, "locked": [], "green_gates": [],
23
+ "slot": None, "note": "", "held": {}}
24
+
25
+
26
+ def _final() -> dict:
27
+ """The end-of-run record before it starts. Every key is here, so a reader uses [] and a wrong name fails."""
28
+ return {"skipped": "", "recovery": None, "tidied": None, "onto": None, "base": None, "commits": {},
29
+ "reviewed": None, "round": 0, "open": [], "batch": None, "green_gates": [], "pushed": None, "body": None,
30
+ "pr": None}
31
+
32
+
33
+ class State:
34
+ def __init__(self, path: Path):
35
+ self.path = path
36
+ self._lock = threading.Lock()
37
+
38
+ def read(self) -> dict:
39
+ return json.loads(self.path.read_text())
40
+
41
+ def start(self, pr_branch: str, numbers: list[str]) -> dict:
42
+ """Create the file for a new run, or add the tickets a resumed run does not know yet."""
43
+ def change(state: dict) -> None:
44
+ state.setdefault("run", {
45
+ "revision": uuid.uuid4().hex, "pr_branch": pr_branch, "gates": 0, "stops": 0, "agents": [],
46
+ "questions": [], "start": git(".", "rev-parse", f"refs/heads/{pr_branch}").stdout.strip(),
47
+ "landed": [], "final": _final()})
48
+ for key, value in _final().items(): # a run of an older driver lacks the newer keys
49
+ state["run"]["final"].setdefault(key, value)
50
+ tickets = state.setdefault("tickets", {})
51
+ for number in numbers:
52
+ tickets.setdefault(number, _ticket())
53
+ return self.update(change)
54
+
55
+ def update(self, change) -> dict:
56
+ """Apply `change(state)` to the file as it is now. A reader sees the old file or the new one, never a part."""
57
+ with self._lock:
58
+ state = self.read() if self.path.exists() else {}
59
+ change(state)
60
+ self.path.parent.mkdir(parents=True, exist_ok=True)
61
+ scratch = self.path.with_suffix(".json.tmp")
62
+ scratch.write_text(json.dumps(state, indent=1) + "\n")
63
+ os.replace(scratch, self.path)
64
+ return state
65
+
66
+ def ticket(self, number: str, **fields) -> dict:
67
+ return self.update(lambda state: state["tickets"][number].update(fields))["tickets"][number]
68
+
69
+
70
+ def lock(path: Path) -> IO | None:
71
+ """The exclusive kernel lock on `path`, held while the returned file is open; None when another process has it."""
72
+ path.parent.mkdir(parents=True, exist_ok=True)
73
+ held = path.open("a")
74
+ try:
75
+ fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
76
+ except BlockingIOError:
77
+ held.close()
78
+ return None
79
+ return held
80
+
81
+
82
+ def _ancestor(commit: str, of: str) -> bool:
83
+ return git(".", "merge-base", "--is-ancestor", commit, of).returncode == 0
84
+
85
+
86
+ def verified(entry: dict, branch: str, pr_branch: str) -> tuple[dict, str]:
87
+ """(the fields to resume from, why) when git contradicts the entry; ({}, "") when it does not."""
88
+ phase, tip = entry["phase"], str(entry["tip"])
89
+ if phase not in ("gate", "review", "fix", "merge", "merged"):
90
+ return {}, ""
91
+ # The branch of a merged ticket is deleted, so its commits are trusted only while the PR branch has them.
92
+ where, word = (pr_branch, "in") if phase == "merged" else (branch, "on")
93
+ if _ancestor(tip, where):
94
+ return {}, ""
95
+ return {"phase": "implement", "tip": entry["test_writer_sha"]}, f"the recorded tip {tip[:7]} is not {word} {where}"
96
+
97
+
98
+ def kept(state: dict) -> dict[str, int]:
99
+ """ticket number -> slot, per ticket stopped mid-role or at an open question, with uncommitted work in the slot."""
100
+ found = {}
101
+ asking = {question["ticket"] for question in state.get("run", {}).get("questions", []) if question["answer"] is None}
102
+ for number, entry in state.get("tickets", {}).items():
103
+ if (entry["phase"] in ("test-writer", "implement") or number in asking) and entry["slot"]:
104
+ slot = path(entry["slot"])
105
+ on = git(slot, "symbolic-ref", "-q", "--short", "HEAD").stdout.strip()
106
+ if on == f"ticket/{number}" and git(slot, "status", "--porcelain").stdout.strip():
107
+ found[number] = entry["slot"]
108
+ return found
@@ -0,0 +1,40 @@
1
+ """The summary of a run, from state.json: plain sentences and the measurement table."""
2
+ from __future__ import annotations
3
+
4
+ from collections import Counter, defaultdict
5
+
6
+
7
+ def _rows(run: dict, tickets: dict) -> list[tuple[str, object]]:
8
+ agents = Counter((agent["role"], agent["entry"]) for agent in run["agents"])
9
+ seconds: dict[str, float] = defaultdict(float)
10
+ for agent in run["agents"]:
11
+ seconds[agent["role"]] += agent["seconds"]
12
+ rounds = ", ".join(f"{number}: {entry['round']}" for number, entry in tickets.items())
13
+ return [("gates run", run["gates"]),
14
+ *((f"agents, {role} on {entry}", number) for (role, entry), number in agents.items()),
15
+ *((f"wall time, {role}", f"{total:.1f} s") for role, total in seconds.items()),
16
+ ("fix rounds per ticket", rounds),
17
+ ("human stops", run["stops"]),
18
+ ("cost", f"{sum(agent['cost'] for agent in run['agents']):.2f} USD")]
19
+
20
+
21
+ def _end(run: dict) -> list[str]:
22
+ """What the end of run did, from its record."""
23
+ final = run["final"]
24
+ if final["skipped"]:
25
+ return [f"The end-of-run work (rebase, tidy, final review, push) did not run: {final['skipped']}."]
26
+ if final["pushed"]:
27
+ return [f"{run['pr_branch']} is pushed at {final['pushed'][:7]}; its PR is {final['pr'] or 'not open'}."]
28
+ if final["reviewed"]:
29
+ return [f"The final review covers {run['pr_branch']} up to {final['reviewed'][:7]}. Nothing is pushed."]
30
+ return []
31
+
32
+
33
+ def render(state: dict) -> str:
34
+ run, tickets = state["run"], state["tickets"]
35
+ merged = sum(entry["phase"] == "merged" for entry in tickets.values())
36
+ lines = [f"{merged} of {len(tickets)} tickets are merged into {run['pr_branch']}."]
37
+ lines += [f"Ticket {number} is not merged: its phase is {entry['phase']}."
38
+ for number, entry in tickets.items() if entry["phase"] != "merged"] + _end(run)
39
+ table = ["| Measure | Value |", "|---|---|"] + [f"| {name} | {value} |" for name, value in _rows(run, tickets)]
40
+ return "\n".join(lines + ["", *table]) + "\n"
@@ -0,0 +1,132 @@
1
+ """Ticket files in, task graph or error list out.
2
+
3
+ parse(path) -> (tickets, errors): tickets is empty when errors is not; each error starts with the file name.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from collections import Counter
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ _FILE = re.compile(r"(\d+)-.*\.md$")
13
+ _TITLE = re.compile(r"# \d+: *(\S.*)")
14
+ _FIELD = re.compile(r"\*\*(Blocked by|Seam|Touches|Risk|Suspicions):\*\* *(\S.*)")
15
+ _CRITERION = re.compile(r"- \[[ xX]\] *(\S.*)")
16
+ _NONE = re.compile(r"none\b", re.IGNORECASE) # "None (can start immediately)", "none — refactor"
17
+
18
+
19
+ @dataclass
20
+ class Ticket:
21
+ number: str # as in the file name: "01"
22
+ file: str
23
+ title: str | None
24
+ blocked: str | None # the Blocked by text; blocked_by holds the resolved numbers
25
+ seam: str | None
26
+ touches: str | None
27
+ risk: str | None
28
+ suspicions: str | None
29
+ criteria: list[str]
30
+ lines: dict[str, int] # field name -> 1-based line
31
+ blocked_by: list[str] = field(default_factory=list)
32
+
33
+ @property
34
+ def refactor(self) -> bool:
35
+ """Seam `none — refactor`: no criterion and no test-writer."""
36
+ return bool(_NONE.match(self.seam or ""))
37
+
38
+
39
+ def _read(path: Path) -> Ticket:
40
+ found: dict[str, tuple[str, int]] = {}
41
+ criteria: list[str] = []
42
+ for number, line in enumerate(path.read_text().splitlines(), 1):
43
+ if m := _TITLE.match(line):
44
+ found.setdefault("title", (m.group(1).strip(), number))
45
+ elif m := _FIELD.match(line):
46
+ found.setdefault(m.group(1), (m.group(2).strip(), number))
47
+ elif m := _CRITERION.match(line):
48
+ criteria.append(m.group(1).strip())
49
+ text = {name: value for name, (value, _) in found.items()}
50
+ return Ticket(
51
+ number=_FILE.match(path.name).group(1), file=path.name, title=text.get("title"),
52
+ blocked=text.get("Blocked by"), seam=text.get("Seam"), touches=text.get("Touches"),
53
+ risk=text.get("Risk"), suspicions=text.get("Suspicions"), criteria=criteria,
54
+ lines={name: line for name, (_, line) in found.items()},
55
+ )
56
+
57
+
58
+ def _missing(ticket: Ticket, alone: bool) -> list[str]:
59
+ required = (("title", ticket.title), ("Blocked by", ticket.blocked), ("Seam", ticket.seam))
60
+ errors = [f"{ticket.file}: missing {name}" for name, value in required if not value]
61
+ if not ticket.criteria and not ticket.refactor:
62
+ errors.append(f"{ticket.file}: missing criterion")
63
+ if not ticket.touches and not alone:
64
+ where = f" (expected after Seam, line {ticket.lines['Seam']})" if ticket.seam else ""
65
+ errors.append(f"{ticket.file}: missing Touches{where}")
66
+ return errors
67
+
68
+
69
+ def _blockers(ticket: Ticket, numbers: dict[int, str], titles: dict[str, int]) -> list[str]:
70
+ errors: list[str] = []
71
+ # ponytail: entries split on commas, so a title that holds a comma must be named by number.
72
+ for part in (p.strip() for p in ticket.blocked.split(",")):
73
+ m = re.match(r"\d+", part)
74
+ key = int(m.group()) if m else titles.get(part.lower())
75
+ if key in numbers:
76
+ ticket.blocked_by.append(numbers[key])
77
+ else:
78
+ name = m.group() if m else repr(part)
79
+ errors.append(f"{ticket.file}: Blocked by {name} names no ticket (line {ticket.lines['Blocked by']})")
80
+ return errors
81
+
82
+
83
+ def _link(tickets: list[Ticket]) -> list[str]:
84
+ """Fill blocked_by from each Blocked by text: numbers, titles, or both."""
85
+ numbers = {int(t.number): t.number for t in tickets}
86
+ titles = {t.title.lower(): int(t.number) for t in tickets if t.title}
87
+ blocked = [t for t in tickets if t.blocked and not _NONE.match(t.blocked)]
88
+ return [e for t in blocked for e in _blockers(t, numbers, titles)]
89
+
90
+
91
+ def _cycles(tickets: list[Ticket]) -> list[str]:
92
+ by = {t.number: t for t in tickets}
93
+ done: set[str] = set()
94
+ errors: list[str] = []
95
+
96
+ def visit(number: str, path: list[str]) -> None:
97
+ if number in path:
98
+ files = [by[n].file for n in path[path.index(number):]]
99
+ errors.append(f"{files[0]}: Blocked by cycle: {' -> '.join(files + files[:1])}")
100
+ elif number not in done:
101
+ for blocker in by[number].blocked_by:
102
+ visit(blocker, path + [number])
103
+ done.add(number)
104
+
105
+ for number in sorted(by):
106
+ visit(number, [])
107
+ return errors
108
+
109
+
110
+ def parse(path) -> tuple[list[Ticket], list[str]]:
111
+ files = sorted(p for p in Path(path).glob("*.md") if _FILE.match(p.name))
112
+ if not files:
113
+ return [], [f"{path}: no ticket files (NN-name.md)"]
114
+ tickets = [_read(p) for p in files]
115
+ errors = [e for t in tickets for e in _missing(t, alone=len(tickets) == 1)]
116
+ errors += _link(tickets)
117
+ errors += _cycles(tickets)
118
+ return ([], errors) if errors else (tickets, [])
119
+
120
+
121
+ def max_frontier_width(tickets: list[Ticket]) -> int:
122
+ """The most tickets ready at once when every ready ticket merges before the next start."""
123
+ by = {t.number: t for t in tickets}
124
+ depths: dict[str, int] = {}
125
+
126
+ def depth(number: str) -> int:
127
+ if number not in depths:
128
+ depths[number] = 1 + max((depth(b) for b in by[number].blocked_by), default=0)
129
+ return depths[number]
130
+
131
+ # ponytail: blocking edges only; Touches holds belong to the scheduler (ticket 03).
132
+ return max(Counter(depth(n) for n in by).values())