@jesdi/skills 0.0.31 → 0.0.33
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/README.md +6 -5
- package/package.json +1 -1
- package/skills/implement-spec/ARCHITECTURE.md +1 -1
- package/skills/implement-spec/README.md +4 -3
- package/skills/implement-spec/driver_agents.py +2 -2
- package/skills/implement-spec/driver_final.py +33 -4
- package/skills/implement-spec/driver_state.py +4 -1
- package/skills/implement-spec/models.json +2 -1
- package/skills/implement-spec/prompts/pr-writer.md +31 -0
- package/skills/implement-spec/tests/conftest.py +12 -0
- package/skills/implement-spec/tests/test_cli.py +2 -1
- package/skills/implement-spec/tests/test_final.py +2 -2
- package/skills/implement-spec/tests/test_tidy.py +25 -3
- package/skills/implement-spec/tests/test_walk.py +2 -0
- package/skills/pr-visual-diff/SKILL.md +85 -2
- package/skills/pr-visual-diff/references/capture-caller.yml +56 -0
- package/skills/pr-visual-diff/references/capture-spec-template.ts +1 -1
- package/skills-manifest.json +5 -5
package/README.md
CHANGED
|
@@ -144,11 +144,12 @@ them afterwards). `external-skills.json` lists them under `forks`.
|
|
|
144
144
|
|
|
145
145
|
### The box set
|
|
146
146
|
|
|
147
|
-
`external-skills.json` → `sets.box` names the skills the
|
|
148
|
-
receives
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
`external-skills.json` → `sets.box.external` names the third-party skills the
|
|
148
|
+
agent-ops box receives from the pinned upstream. `pr` is in this set and is
|
|
149
|
+
installed directly from mattpocock/skills. agent-ops-infra lists the own skills
|
|
150
|
+
of the box (`provision/my-skills.json`) and pins the commit of this file it
|
|
151
|
+
reads (`provision/external-skills.pin`); this repo's job is to publish the set
|
|
152
|
+
and keep the pins resolvable.
|
|
152
153
|
|
|
153
154
|
## Authoring skills
|
|
154
155
|
|
package/package.json
CHANGED
|
@@ -78,7 +78,7 @@ and do not know about the run, except `driver_run.py`, which the three deciding
|
|
|
78
78
|
### Work is never dropped
|
|
79
79
|
|
|
80
80
|
- A role that commits keeps its tree after a timeout or an interrupt; its rerun continues from it.
|
|
81
|
-
- A read-only role (reviewer, correctness, final-reviewer) has no work to keep. When it changes its slot, the
|
|
81
|
+
- A read-only role (reviewer, correctness, final-reviewer, pr-writer) has no work to keep. When it changes its slot, the
|
|
82
82
|
driver restores the slot and runs the review one more time (`Run.read_only`).
|
|
83
83
|
- A slot with uncommitted work of an interrupted or parked ticket is kept for that ticket (`driver_state.kept`).
|
|
84
84
|
- A blocked ticket keeps its slot when the slot is not clean.
|
|
@@ -44,7 +44,7 @@ One driver runs in a repository at a time. The driver holds a kernel file lock o
|
|
|
44
44
|
| `--only ROLE` | Test only. Run one role (`test-writer`, `implementer`, `reviewer` or `correctness`) on the first ticket, with no gate and no merge. |
|
|
45
45
|
| `--help`, `-h` | Print the flags and exit. |
|
|
46
46
|
|
|
47
|
-
Default timeouts in minutes: explorer 30, test-writer 30, implementer 90, reviewer 45, correctness 30, fixer 60, final-reviewer 60, conflict 30.
|
|
47
|
+
Default timeouts in minutes: explorer 30, test-writer 30, implementer 90, reviewer 45, correctness 30, fixer 60, final-reviewer 60, conflict 30, pr-writer 20.
|
|
48
48
|
|
|
49
49
|
`run.sh` downloads `uv`, Python 3.13 and `jsonschema` once into `~/.cache/implement-spec`. `IMPLEMENT_SPEC_CACHE_DIR` moves that cache. `IMPLEMENT_SPEC_OFFLINE=1` forbids the download. `run.sh` supports macOS and Linux only, on x86_64 or ARM64, and needs `curl`, `tar` and `sha256sum` or `shasum` for the first download.
|
|
50
50
|
|
|
@@ -81,7 +81,7 @@ Everything is under `.agent/`, which is never committed.
|
|
|
81
81
|
| `.agent/implement/<NN>/<role>-<K>.json`, `.log`, `.prompt.md` | The report, the log and the prompt of one agent. K is the fix round. |
|
|
82
82
|
| `.agent/implement/<NN>/gate-<sha7>.log` | One gate record: start, commit, status, command, output, exit, the state after the command, end. |
|
|
83
83
|
| `.agent/implement/<NN>/findings-<K>.json` | The merged findings of the review after fix round K. |
|
|
84
|
-
| `.agent/implement/final/` | The same files for the end of run, and `pr-body.md
|
|
84
|
+
| `.agent/implement/final/` | The same files for the end of run, and `pr-body.md`: the pr-writer's body, then the facts of the run. |
|
|
85
85
|
| `.agent/implement/pycache/` | Compiled Python of the gate, kept out of the slots. |
|
|
86
86
|
| `.agent/implement-review.html` | The page with the open questions. |
|
|
87
87
|
| `.agent/implement-answers.json` | The answers: `{"v": 1, "stage": "implement", "submitted": …, "revision": …, "answers": {…}}`. |
|
|
@@ -131,7 +131,7 @@ The end of run starts only when every ticket is merged and every slot is idle. I
|
|
|
131
131
|
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.
|
|
132
132
|
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.
|
|
133
133
|
5. Findings that are open after the second review become questions of kind `final-finding`. There is no third review.
|
|
134
|
-
6. **Push.** With `--push` or `--unattended`, the driver pushes the reviewed tip once, with no force, and opens the PR with `gh`, ready for review. Without them it prints the two tips and the command to run again with `--push`.
|
|
134
|
+
6. **Push.** With `--push` or `--unattended`, the driver pushes the reviewed tip once, with no force, and opens the PR with `gh`, ready for review. The **pr-writer** (`prompts/pr-writer.md`) writes the body with the method of the `pr` skill; the driver puts the facts of the run after it: the ticket-to-commit map, the fix commits, the rulings and the review coverage (`prompts/pr-body.md`). A push stops before any agent when no `pr` skill is next to this skill, in `${CLAUDE_CONFIG_DIR:-~/.claude}/skills` or in `~/.agents/skills`. Without them it prints the two tips and the command to run again with `--push`.
|
|
135
135
|
|
|
136
136
|
History rules:
|
|
137
137
|
|
|
@@ -174,6 +174,7 @@ Every report has `status` (`DONE`, `DONE_WITH_CONCERNS`, `NEEDS_CONTEXT`, `BLOCK
|
|
|
174
174
|
| correctness | `prompts/correctness.md` | the same as the reviewer | `mutations` is optional. |
|
|
175
175
|
| fixer | `prompts/fixer.md` | `green`, `ticket` | With DONE: one commit or more. |
|
|
176
176
|
| final-reviewer | `prompts/final-reviewer.md` | `findings` | None. |
|
|
177
|
+
| pr-writer | `prompts/pr-writer.md` | `body`; `commits` is empty | None. |
|
|
177
178
|
| fixer of the final review | `prompts/final-fixer.md` | `green`; `commits` is a list of (`sha`, `ticket`) | Each new commit on the branch is in `commits`, with a ticket of the run. |
|
|
178
179
|
| conflict | `prompts/conflict.md`, `prompts/rebase-conflict.md` | none | With DONE: one commit or more. No locked file may change. |
|
|
179
180
|
|
|
@@ -18,10 +18,10 @@ from pathlib import Path
|
|
|
18
18
|
SKILL = Path(__file__).resolve().parent
|
|
19
19
|
# Minutes per role.
|
|
20
20
|
TIMEOUTS = {"explorer": 30, "test-writer": 30, "implementer": 90, "reviewer": 45, "correctness": 30, "fixer": 60,
|
|
21
|
-
"final-reviewer": 60, "conflict": 30}
|
|
21
|
+
"final-reviewer": 60, "conflict": 30, "pr-writer": 20}
|
|
22
22
|
PROVIDERS = {"anthropic": ("claude", ("low", "medium", "high", "xhigh", "max")),
|
|
23
23
|
"openai": ("codex", ("minimal", "low", "medium", "high", "xhigh"))}
|
|
24
|
-
READ_ONLY = ("reviewer", "correctness", "final-reviewer")
|
|
24
|
+
READ_ONLY = ("reviewer", "correctness", "final-reviewer", "pr-writer")
|
|
25
25
|
# A process group is here only while its leader is not reaped: then no other process can have its id.
|
|
26
26
|
LIVE: set[int] = set()
|
|
27
27
|
STOPPED = False
|
|
@@ -41,6 +41,14 @@ ROUNDS = 2 # the full review and the scoped one after the fix batch
|
|
|
41
41
|
REVIEW_DIFF = driver_agents.SKILL.parent / "review-diff" / "SKILL.md"
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def pr_skill() -> Path | None:
|
|
45
|
+
"""The SKILL.md of the pr skill, which the PR body follows: next to this skill, else in the skill folder of the
|
|
46
|
+
Claude home or of the shared home of Codex and OpenCode."""
|
|
47
|
+
claude = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
48
|
+
homes = (driver_agents.SKILL.parent, claude / "skills", Path.home() / ".agents" / "skills")
|
|
49
|
+
return next((home / "pr" / "SKILL.md" for home in homes if (home / "pr" / "SKILL.md").is_file()), None)
|
|
50
|
+
|
|
51
|
+
|
|
44
52
|
def _has_origin() -> bool:
|
|
45
53
|
return git(".", "remote", "get-url", "origin").returncode == 0
|
|
46
54
|
|
|
@@ -51,6 +59,9 @@ def precondition_errors(push: bool) -> list[str]:
|
|
|
51
59
|
if not REVIEW_DIFF.exists():
|
|
52
60
|
errors.append(f"{REVIEW_DIFF} is missing: the final review runs through it; install the review-diff skill "
|
|
53
61
|
"next to implement-spec")
|
|
62
|
+
if push and not pr_skill():
|
|
63
|
+
errors.append("the pr skill is missing: the PR body follows it; install it with "
|
|
64
|
+
"`npx skills add mattpocock/skills --skill pr`, or run without --push and --unattended")
|
|
54
65
|
if push and not _has_origin():
|
|
55
66
|
errors.append("the repository has no origin remote: --unattended and --push push the PR branch to origin; "
|
|
56
67
|
"add the remote, or run without them to stop before the push")
|
|
@@ -401,8 +412,8 @@ def _name(run: Run) -> str:
|
|
|
401
412
|
return next((line[2:].strip() for line in lines if line.startswith("# ")), run.pr)
|
|
402
413
|
|
|
403
414
|
|
|
404
|
-
def
|
|
405
|
-
"""The PR body: the ticket-to-commit map, the fix commits, the rulings and the review coverage."""
|
|
415
|
+
def _facts(run: Run, final: dict, tickets: list[Ticket]) -> str:
|
|
416
|
+
"""The end of the PR body: the ticket-to-commit map, the fix commits, the rulings and the review coverage."""
|
|
406
417
|
titles, tip = {ticket.number: ticket.title for ticket in tickets}, final["reviewed"]
|
|
407
418
|
fixes = git(".", "log", "--reverse", "--format=- %h %s", f"{final['tidied']}..{tip}").stdout.strip()
|
|
408
419
|
rulings = driver_decisions.rulings(run.state.read()["run"]["questions"])
|
|
@@ -413,10 +424,28 @@ def _body(run: Run, final: dict, tickets: list[Ticket]) -> str:
|
|
|
413
424
|
"scoped": f"{final['tidied']}..{tip}" if fixes else "not needed"})
|
|
414
425
|
|
|
415
426
|
|
|
427
|
+
def _write_body(run: Run, final: dict, tickets: list[Ticket]) -> Path:
|
|
428
|
+
"""Write the PR body: what the pr-writer wrote with the method of the pr skill, then the facts of the run. A body
|
|
429
|
+
written for the reviewed tip is kept, so a PR that failed to open costs no second agent."""
|
|
430
|
+
body, tip = OUT / FINAL / "pr-body.md", final["reviewed"]
|
|
431
|
+
if final["body"] == tip and body.is_file():
|
|
432
|
+
return body
|
|
433
|
+
method, facts = pr_skill(), _facts(run, final, tickets)
|
|
434
|
+
if not method:
|
|
435
|
+
raise Blocked("the pr skill is missing: the PR body follows it; install it with "
|
|
436
|
+
"`npx skills add mattpocock/skills --skill pr`")
|
|
437
|
+
values = {**_prompt_values(run), "start_sha": tip, "range": f"{final['onto']}..{tip}",
|
|
438
|
+
"gate": str(OUT / FINAL / f"gate-{tip[:7]}.log"), "pr_skill": str(method), "facts": facts}
|
|
439
|
+
with during("the PR body"):
|
|
440
|
+
report = run.read_only(FINAL, ".", run.pr, tip, lambda: run.agent("pr-writer", FINAL, ".", values))
|
|
441
|
+
body.write_text(f"{report['body'].strip()}\n\n{facts}")
|
|
442
|
+
run.state.update(lambda state: state["run"]["final"].update(body=tip))
|
|
443
|
+
return body
|
|
444
|
+
|
|
445
|
+
|
|
416
446
|
def _open_pr(run: Run, final: dict, tickets: list[Ticket]) -> str:
|
|
417
447
|
"""Open the PR, ready for review, and return its URL."""
|
|
418
|
-
body =
|
|
419
|
-
body.write_text(_body(run, final, tickets))
|
|
448
|
+
body = _write_body(run, final, tickets)
|
|
420
449
|
made = subprocess.run(["gh", "pr", "create", "--base", final["base"], "--head", run.pr, "--title", _name(run),
|
|
421
450
|
"--body-file", str(body)], capture_output=True, text=True, stdin=subprocess.DEVNULL)
|
|
422
451
|
if made.returncode:
|
|
@@ -26,7 +26,8 @@ def _ticket() -> dict:
|
|
|
26
26
|
def _final() -> dict:
|
|
27
27
|
"""The end-of-run record before it starts. Every key is here, so a reader uses [] and a wrong name fails."""
|
|
28
28
|
return {"skipped": "", "recovery": None, "tidied": None, "onto": None, "base": None, "commits": {},
|
|
29
|
-
"reviewed": None, "round": 0, "open": [], "batch": None, "green_gates": [], "pushed": None, "
|
|
29
|
+
"reviewed": None, "round": 0, "open": [], "batch": None, "green_gates": [], "pushed": None, "body": None,
|
|
30
|
+
"pr": None}
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class State:
|
|
@@ -44,6 +45,8 @@ class State:
|
|
|
44
45
|
"revision": uuid.uuid4().hex, "pr_branch": pr_branch, "gates": 0, "stops": 0, "agents": [],
|
|
45
46
|
"questions": [], "start": git(".", "rev-parse", f"refs/heads/{pr_branch}").stdout.strip(),
|
|
46
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)
|
|
47
50
|
tickets = state.setdefault("tickets", {})
|
|
48
51
|
for number in numbers:
|
|
49
52
|
tickets.setdefault(number, _ticket())
|
|
@@ -6,5 +6,6 @@
|
|
|
6
6
|
"correctness": ["openai/gpt-astra@medium", "claude-opus-5@medium"],
|
|
7
7
|
"fixer": ["claude-opus-5@medium"],
|
|
8
8
|
"final-reviewer": ["openai/gpt-astra@medium", "claude-opus-5@medium"],
|
|
9
|
-
"conflict": ["claude-opus-5@medium"]
|
|
9
|
+
"conflict": ["claude-opus-5@medium"],
|
|
10
|
+
"pr-writer": ["claude-opus-5@medium"]
|
|
10
11
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# PR writer
|
|
2
|
+
|
|
3
|
+
Spec: {spec}
|
|
4
|
+
Tickets: {tickets}
|
|
5
|
+
PR branch: {pr_branch}, at commit {start_sha}
|
|
6
|
+
Range: {range}
|
|
7
|
+
Gate record of the driver for {start_sha}: {gate}
|
|
8
|
+
PR method: {pr_skill}
|
|
9
|
+
Facts of the run, which the driver puts after your body:
|
|
10
|
+
{facts}
|
|
11
|
+
|
|
12
|
+
Write the body of the PR of the range with the method of the pr skill in the file above: its sections, in its order. Read the spec, the tickets and the range first. The gate record and the tests of the range are the execution-based evidence; you cannot take screenshots. Do not repeat the facts of the run: the driver puts them after your body.
|
|
13
|
+
Do not commit. Leave the branch and the tree as you found them.
|
|
14
|
+
|
|
15
|
+
Write one JSON report at {report} that matches this schema:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["status", "commits", "files", "concerns", "body"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"status": {"enum": ["DONE", "DONE_WITH_CONCERNS", "NEEDS_CONTEXT", "BLOCKED"]},
|
|
23
|
+
"commits": {"type": "array", "maxItems": 0},
|
|
24
|
+
"files": {"type": "array", "items": {"type": "string"}},
|
|
25
|
+
"concerns": {"type": "array", "items": {"type": "string"}},
|
|
26
|
+
"body": {"type": "string", "minLength": 1}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Never run `git stash`, never reset, and never check out another branch: worktrees share one stash and one ref store.
|
|
@@ -16,6 +16,18 @@ def ticket(number, blocked="None (can start immediately)", touches="`demo/x.py:
|
|
|
16
16
|
return f"# {number}: Ticket {number}\n\n**Blocked by:** {blocked}\n\n**Seam:** {seam}\n\n**Touches:** {touches}\n\n- [ ] It works\n"
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
20
|
+
def pr_skill(tmp_path_factory):
|
|
21
|
+
"""The Claude home of every test, module rigs included, has the pr skill, which the PR body follows. Rigs copy
|
|
22
|
+
os.environ."""
|
|
23
|
+
home = tmp_path_factory.mktemp("claude-home")
|
|
24
|
+
(home / "skills/pr").mkdir(parents=True)
|
|
25
|
+
(home / "skills/pr/SKILL.md").write_text("---\nname: pr\n---\n")
|
|
26
|
+
with pytest.MonkeyPatch.context() as patch:
|
|
27
|
+
patch.setenv("CLAUDE_CONFIG_DIR", str(home))
|
|
28
|
+
yield home / "skills/pr/SKILL.md"
|
|
29
|
+
|
|
30
|
+
|
|
19
31
|
@pytest.fixture
|
|
20
32
|
def repo(tmp_path, monkeypatch):
|
|
21
33
|
"""A clean git worktree with .agent ignored; returns a writer for the ticket set."""
|
|
@@ -13,7 +13,8 @@ RUN = SKILL / "run.sh"
|
|
|
13
13
|
GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false"]
|
|
14
14
|
TOOLS = ("sh", "uname", "dirname", "readlink", "git", "env", "mktemp", "curl", "tar", "gzip", "mv", "rm", "mkdir",
|
|
15
15
|
"sha256sum", "shasum", "cat", "chmod", "ln", "date", "basename")
|
|
16
|
-
ROLES = ("explorer", "test-writer", "implementer", "reviewer", "correctness", "fixer", "final-reviewer", "conflict"
|
|
16
|
+
ROLES = ("explorer", "test-writer", "implementer", "reviewer", "correctness", "fixer", "final-reviewer", "conflict",
|
|
17
|
+
"pr-writer")
|
|
17
18
|
NONE = "None (can start immediately)"
|
|
18
19
|
|
|
19
20
|
|
|
@@ -263,10 +263,10 @@ def test_a_rerun_after_the_tidy_runs_no_agent_no_gate_and_sends_no_ticket_back(s
|
|
|
263
263
|
assert all(stop_lines(now.out))
|
|
264
264
|
|
|
265
265
|
|
|
266
|
-
def
|
|
266
|
+
def test_the_push_rerun_pushes_once_opens_the_pr_with_map_and_rulings_and_runs_only_the_pr_writer(story):
|
|
267
267
|
rig, before, now = story.rig, story.again, story.pushed
|
|
268
268
|
assert now.code == 0, rig.output()
|
|
269
|
-
assert (now.calls, now.gates, now.tip) == (before.calls, before.gates, before.tip)
|
|
269
|
+
assert (now.calls, now.gates, now.tip) == (before.calls + 1, before.gates, before.tip), "only the pr-writer runs"
|
|
270
270
|
assert rig.pushes() == [f"refs/heads/feat/csv {now.tip}"] and ref(rig.origin, "refs/heads/feat/csv") == now.tip
|
|
271
271
|
(pr,) = rig.prs()
|
|
272
272
|
assert "--draft" not in pr["argv"]
|
|
@@ -66,7 +66,7 @@ def stray(rig, text="by hand"):
|
|
|
66
66
|
|
|
67
67
|
# ---------------------------------------------------------------- the story: local stop, rerun, push
|
|
68
68
|
|
|
69
|
-
def test_a_local_run_stops_before_the_push_and_a_push_rerun_pushes_and_opens_the_pr_once(tmp_path):
|
|
69
|
+
def test_a_local_run_stops_before_the_push_and_a_push_rerun_pushes_and_opens_the_pr_once(tmp_path, pr_skill):
|
|
70
70
|
rig = Inside(tmp_path, THREE, FINAL_MODELS)
|
|
71
71
|
rig.run(plan())
|
|
72
72
|
assert rig.code == 0, rig.out + "\n".join(rig.ledger())
|
|
@@ -95,7 +95,11 @@ def test_a_local_run_stops_before_the_push_and_a_push_rerun_pushes_and_opens_the
|
|
|
95
95
|
(pr,) = rig.prs()
|
|
96
96
|
assert pr["argv"][:10] == ["pr", "create", "--base", "main", "--head", "feat/csv", "--title", "csv spec",
|
|
97
97
|
"--body-file", ".agent/implement/final/pr-body.md"]
|
|
98
|
-
assert [
|
|
98
|
+
assert pr["body"].startswith("## Summary\n\nCSV export.\n\n## Tickets\n"), "the pr-writer's body, then the facts"
|
|
99
|
+
assert [f"- {nn}: {row['sha'][:7]} {TITLES[nn]}" for nn, row in zip(order, rows)] == pr["body"].splitlines()[8:11]
|
|
100
|
+
(writer,) = rig.final_calls("pr-writer")
|
|
101
|
+
assert f"PR method: {pr_skill}\n" in writer["prompt"]
|
|
102
|
+
assert "Facts of the run, which the driver puts after your body:\n## Tickets" in writer["prompt"]
|
|
99
103
|
assert "## Rulings\n\nNone.\n" in pr["body"] and "Scoped review after the fix batch: not needed" in pr["body"]
|
|
100
104
|
assert f"Pushed feat/csv at {tip} to origin." in rig.out and "PR: https://github.example/pull/1" in rig.out
|
|
101
105
|
assert f"feat/csv is pushed at {tip[:7]}; its PR is https://github.example/pull/1." in rig.out
|
|
@@ -103,7 +107,7 @@ def test_a_local_run_stops_before_the_push_and_a_push_rerun_pushes_and_opens_the
|
|
|
103
107
|
|
|
104
108
|
rig.run(plan(), "--push")
|
|
105
109
|
assert rig.code == 0 and len(rig.pushes()) == 1 and len(rig.prs()) == 1, "a second push rerun sends nothing"
|
|
106
|
-
assert (len(rig.calls()), len(rig.gates())) == (calls, gates)
|
|
110
|
+
assert (len(rig.calls()), len(rig.gates())) == (calls + 1, gates), "the one agent of the pushes is the pr-writer"
|
|
107
111
|
|
|
108
112
|
|
|
109
113
|
def test_the_final_review_does_not_start_while_a_ticket_is_not_merged_and_the_ledger_says_so_once(tmp_path):
|
|
@@ -493,6 +497,24 @@ def test_without_the_review_diff_skill_every_repository_stops_before_any_agent(t
|
|
|
493
497
|
"review-diff skill next to implement-spec\n")
|
|
494
498
|
|
|
495
499
|
|
|
500
|
+
def test_a_push_without_the_pr_skill_stops_before_any_agent_and_a_local_run_does_not_need_it(tmp_path, monkeypatch):
|
|
501
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
502
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "empty-claude-home"))
|
|
503
|
+
rig = Inside(tmp_path, ONE).run(plan(), "--push")
|
|
504
|
+
assert rig.code == 3 and not rig.calls(), rig.out
|
|
505
|
+
assert rig.out.startswith("the pr skill is missing: the PR body follows it; install it with ")
|
|
506
|
+
|
|
507
|
+
rig.run(plan())
|
|
508
|
+
assert rig.code == 0 and not rig.final_calls("pr-writer"), rig.out
|
|
509
|
+
|
|
510
|
+
(tmp_path / "home/.agents/skills/pr").mkdir(parents=True)
|
|
511
|
+
(tmp_path / "home/.agents/skills/pr/SKILL.md").write_text("---\nname: pr\n---\n")
|
|
512
|
+
rig.run(plan(), "--push")
|
|
513
|
+
assert rig.code == 0 and len(rig.prs()) == 1, rig.out
|
|
514
|
+
(writer,) = rig.final_calls("pr-writer")
|
|
515
|
+
assert f"PR method: {tmp_path}/home/.agents/skills/pr/SKILL.md\n" in writer["prompt"]
|
|
516
|
+
|
|
517
|
+
|
|
496
518
|
def test_without_an_origin_the_run_ends_reviewed_and_a_push_is_refused_at_the_start_and_after_the_tidy(tmp_path):
|
|
497
519
|
rig = Inside(tmp_path, ONE, remote=False).run(plan())
|
|
498
520
|
assert rig.code == 0, rig.out
|
|
@@ -66,6 +66,8 @@ if role in ("reviewer", "correctness", "final-reviewer") and "report" not in ste
|
|
|
66
66
|
proofs = [dict(test=r.split()[1], file="demo/export.py", line=1, red=r) for r in reds]
|
|
67
67
|
step = dict(step, stdout="".join(r + "\\n" for r in reds), report=dict(
|
|
68
68
|
status="DONE", commits=[], files=[], concerns=[], findings=[], mutations=proofs))
|
|
69
|
+
if role == "pr-writer" and "report" not in step: # an unscripted pr-writer writes one section
|
|
70
|
+
step = dict(step, report=dict(status="DONE", commits=[], files=[], concerns=[], body="## Summary\\n\\nCSV export."))
|
|
69
71
|
for name in step.get("touch", []):
|
|
70
72
|
(rig / "sig" / name).touch()
|
|
71
73
|
for name in step.get("wait", []):
|
|
@@ -45,7 +45,7 @@ file as JSON:
|
|
|
45
45
|
| `deviceProjects` | the config's `projects` (default to a desktop + a mobile project) |
|
|
46
46
|
| `baseBranch` | the repo's default branch (`main`/`master`; `git symbolic-ref refs/remotes/origin/HEAD`) |
|
|
47
47
|
| `packageManager` | infer from lockfile: `pnpm-lock.yaml`→pnpm, `package-lock.json`→npm, `yarn.lock`→yarn, `bun.lockb`→bun |
|
|
48
|
-
| `spinnerSelector` | the app
|
|
48
|
+
| `spinnerSelector` | every loading indicator of the app, as one selector list: spinners and skeletons (search for a spinner, `role="status"`, `aria-busy`, a skeleton component, `animate-pulse`), e.g. `.animate-spin, .animate-pulse`. A skeleton left out lets a half-loaded view pass as a good shot |
|
|
49
49
|
|
|
50
50
|
### Preconditions gate
|
|
51
51
|
|
|
@@ -77,6 +77,14 @@ compact report: the screenshot file names + pinned URLs, the PR-body
|
|
|
77
77
|
markdown, and any views it flagged. The main context creates/edits the PR from
|
|
78
78
|
that report — it never reads a PNG.
|
|
79
79
|
|
|
80
|
+
## Where the capture runs
|
|
81
|
+
|
|
82
|
+
- **Local** (the default): steps 3–5 below, on this machine.
|
|
83
|
+
- **Remote**: when the session instructions say so (the agent-ops box does),
|
|
84
|
+
or when this machine cannot start a Playwright browser. A GitHub workflow of
|
|
85
|
+
the target repo captures both sides and pushes the hidden ref; see "Remote
|
|
86
|
+
capture". Steps 1, 2 and 6 stay the same.
|
|
87
|
+
|
|
80
88
|
## Workflow
|
|
81
89
|
|
|
82
90
|
### 1. Map the diff to views, then get the user's go-ahead
|
|
@@ -96,6 +104,10 @@ hidden ref on the remote and embed them in the PR. Include the proposed plan: wh
|
|
|
96
104
|
device projects — default to the config's `deviceProjects`; add more only when
|
|
97
105
|
the change is layout-sensitive. If they decline, create the PR normally and stop.
|
|
98
106
|
|
|
107
|
+
In an unattended session (nobody answers questions, as on the agent-ops box),
|
|
108
|
+
do not ask: run the capture with the plan above and name the plan in the PR
|
|
109
|
+
body.
|
|
110
|
+
|
|
99
111
|
### 2. Write the capture spec
|
|
100
112
|
|
|
101
113
|
Copy `references/capture-spec-template.ts` from this skill into
|
|
@@ -133,7 +145,10 @@ That assertion is a backstop, not a substitute for looking: the subagent still
|
|
|
133
145
|
Reads each PNG after capture — an empty state, error toast, or wrong content
|
|
134
146
|
passes the spinner check but is still wrong. On a genuine failure, fix the spec
|
|
135
147
|
(add mock stubs, a wait for a stable element, or correct the spinner selector)
|
|
136
|
-
rather than shipping a bad screenshot.
|
|
148
|
+
rather than shipping a bad screenshot. A fixed element (a mobile bottom nav, a
|
|
149
|
+
sticky header) is drawn where the viewport was, so in a full-page shot it can
|
|
150
|
+
float over the middle of the page: give such a view `fullPage: false`, or one
|
|
151
|
+
shot per scroll position.
|
|
137
152
|
|
|
138
153
|
### 4. Capture "before" (merge base, temp worktree)
|
|
139
154
|
|
|
@@ -239,6 +254,74 @@ cell instead of an image:
|
|
|
239
254
|
| **Mobile** | _New view_ |  |
|
|
240
255
|
```
|
|
241
256
|
|
|
257
|
+
## Remote capture
|
|
258
|
+
|
|
259
|
+
Replaces steps 3–5. The subagent rule above still holds.
|
|
260
|
+
|
|
261
|
+
The steps run in one shared workflow, `.github/workflows/pr-visual-diff.yml` of
|
|
262
|
+
jesdi/general-skills. Each target repo has a short caller of it, made from
|
|
263
|
+
`references/capture-caller.yml` of this skill, which follows the main branch
|
|
264
|
+
of general-skills: GitHub starts `workflow_dispatch` only in the target repo.
|
|
265
|
+
|
|
266
|
+
**Precondition.** The default branch of the target repo has the caller,
|
|
267
|
+
`.github/workflows/pr-visual-diff.yml`:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
|
|
271
|
+
gh api "repos/$REPO/contents/.github/workflows/pr-visual-diff.yml" --silent
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
If it does not, take no screenshots. Say in the PR body that the repo has no
|
|
275
|
+
pr-visual-diff workflow, and that `references/capture-caller.yml` of this
|
|
276
|
+
skill, filled from the discovery cache and merged to the default branch, adds
|
|
277
|
+
it. Do not add it in this PR: `workflow_dispatch` runs only from the default
|
|
278
|
+
branch.
|
|
279
|
+
|
|
280
|
+
**Dispatch.** Push the PR branch first: the workflow checks out commits of
|
|
281
|
+
origin. Write the filled spec of step 2 to a file, and a second copy whose
|
|
282
|
+
`VIEWS` holds only the views that exist at the merge base (none: no Before).
|
|
283
|
+
Then:
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
HEAD_SHA=$(git rev-parse HEAD)
|
|
287
|
+
BASE=$(git merge-base origin/<baseBranch> HEAD)
|
|
288
|
+
SLUG=$(git branch --show-current | tr / -)
|
|
289
|
+
CAPTURE="${HEAD_SHA:0:12}-$(date +%s)"
|
|
290
|
+
gh workflow run pr-visual-diff.yml --ref <baseBranch> \
|
|
291
|
+
-f capture="$CAPTURE" -f slug="$SLUG" -f head="$HEAD_SHA" -f base="$BASE" \
|
|
292
|
+
-f after_spec="$(base64 < <after-spec> | tr -d '\n')" \
|
|
293
|
+
-f before_spec="$(base64 < <before-spec> | tr -d '\n')" \
|
|
294
|
+
-f projects="<deviceProject>,<deviceProject>"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Leave `before_spec` empty when every view is new. `gh workflow run` prints no
|
|
298
|
+
run id: find the run by its name `pr-visual-diff $CAPTURE` (try for up to a
|
|
299
|
+
minute), then wait for it:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
RUN=$(gh run list --workflow pr-visual-diff.yml --limit 20 --json databaseId,displayTitle \
|
|
303
|
+
--jq ".[] | select(.displayTitle == \"pr-visual-diff $CAPTURE\") | .databaseId")
|
|
304
|
+
gh run watch "$RUN" --exit-status
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**A failed run.** Read `gh run view "$RUN" --log-failed | tail -80`. Fix the
|
|
308
|
+
spec as in step 3 (a mock stub, a wait, the spinner selector) and dispatch
|
|
309
|
+
once more with a new `CAPTURE`. When the second run fails too, take no
|
|
310
|
+
screenshots and say why in the PR body.
|
|
311
|
+
|
|
312
|
+
**Read the shots back.** Fetch the hidden ref and look at every PNG, as in
|
|
313
|
+
step 3:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
git fetch origin "refs/pr-screenshots/$SLUG/$CAPTURE"
|
|
317
|
+
SHA=$(git rev-parse FETCH_HEAD)
|
|
318
|
+
mkdir -p <scratchpad>/shots && git archive "$SHA" | tar -x -C <scratchpad>/shots
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
A wrong shot is a failed run: fix the spec and dispatch again, under the same
|
|
322
|
+
limit. Then embed with step 6, with `$SHA` in the URLs. The ref stays: it keeps
|
|
323
|
+
the images alive.
|
|
324
|
+
|
|
242
325
|
## Adding shots to an existing PR
|
|
243
326
|
|
|
244
327
|
Same flow; the only differences: diff against the PR's base branch
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# pr-visual-diff remote capture of this repo. Template of the pr-visual-diff
|
|
2
|
+
# skill (references/capture-caller.yml): copy it to
|
|
3
|
+
# .github/workflows/pr-visual-diff.yml, replace every __PVD_*__ marker with the
|
|
4
|
+
# value of the discovery cache, and merge it to the default branch:
|
|
5
|
+
# workflow_dispatch runs only from there. The steps live in jesdi/general-skills
|
|
6
|
+
# (.github/workflows/pr-visual-diff.yml) and follow its main branch, like every
|
|
7
|
+
# jesdi skill follows its latest release.
|
|
8
|
+
|
|
9
|
+
name: pr-visual-diff
|
|
10
|
+
run-name: pr-visual-diff ${{ inputs.capture }}
|
|
11
|
+
|
|
12
|
+
on:
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
inputs:
|
|
15
|
+
capture:
|
|
16
|
+
description: 'Unique id of this capture; the last part of the hidden ref'
|
|
17
|
+
required: true
|
|
18
|
+
slug:
|
|
19
|
+
description: 'The PR branch with / as -'
|
|
20
|
+
required: true
|
|
21
|
+
head:
|
|
22
|
+
description: 'Commit captured as After'
|
|
23
|
+
required: true
|
|
24
|
+
base:
|
|
25
|
+
description: 'Commit captured as Before (the merge base)'
|
|
26
|
+
required: true
|
|
27
|
+
after_spec:
|
|
28
|
+
description: 'The filled capture spec, base64'
|
|
29
|
+
required: true
|
|
30
|
+
before_spec:
|
|
31
|
+
description: 'The filled capture spec of the views that exist at base, base64; empty: no Before'
|
|
32
|
+
required: false
|
|
33
|
+
default: ''
|
|
34
|
+
projects:
|
|
35
|
+
description: 'Playwright projects, separated by commas'
|
|
36
|
+
required: true
|
|
37
|
+
|
|
38
|
+
jobs:
|
|
39
|
+
capture:
|
|
40
|
+
permissions:
|
|
41
|
+
contents: write # only its publish job uses it, to push the hidden ref
|
|
42
|
+
uses: jesdi/general-skills/.github/workflows/pr-visual-diff.yml@main
|
|
43
|
+
with:
|
|
44
|
+
capture: ${{ inputs.capture }}
|
|
45
|
+
slug: ${{ inputs.slug }}
|
|
46
|
+
head: ${{ inputs.head }}
|
|
47
|
+
base: ${{ inputs.base }}
|
|
48
|
+
after_spec: ${{ inputs.after_spec }}
|
|
49
|
+
before_spec: ${{ inputs.before_spec }}
|
|
50
|
+
projects: ${{ inputs.projects }}
|
|
51
|
+
runner: __PVD_RUNNER__
|
|
52
|
+
frontend_dir: __PVD_FRONTEND_DIR__
|
|
53
|
+
specs_dir: __PVD_SPECS_DIR__
|
|
54
|
+
browsers: __PVD_BROWSERS__
|
|
55
|
+
node_version: '__PVD_NODE_VERSION__'
|
|
56
|
+
pnpm_version: '__PVD_PNPM_VERSION__'
|
|
@@ -26,7 +26,7 @@ const OUT = process.env.PR_SHOT_DIR;
|
|
|
26
26
|
const LABEL = process.env.PR_SHOT_LABEL ?? 'after';
|
|
27
27
|
if (!OUT) throw new Error('PR_SHOT_DIR must be set to an absolute output directory');
|
|
28
28
|
|
|
29
|
-
//
|
|
29
|
+
// Every loading indicator of this app, spinners and skeletons. From config: <spinnerSelector>.
|
|
30
30
|
const SPINNER = '__PVD_SPINNER_SELECTOR__';
|
|
31
31
|
|
|
32
32
|
// Pin device-pixel-ratio to 1 for every project. Device descriptors (iPhone 14,
|
package/skills-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-15T12:44:26.687Z",
|
|
4
4
|
"skills": [
|
|
5
5
|
{
|
|
6
6
|
"name": "backlog",
|
|
@@ -46,14 +46,14 @@
|
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"name": "implement-spec",
|
|
49
|
-
"version": "0.1.
|
|
50
|
-
"hash": "sha256-
|
|
49
|
+
"version": "0.1.9",
|
|
50
|
+
"hash": "sha256-a2864cb6013ba8ee10af5b597b4376b89507efce8c6acecfbeca5db7389623ea",
|
|
51
51
|
"description": "Implement a specification in code."
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
"name": "pr-visual-diff",
|
|
55
|
-
"version": "0.1.
|
|
56
|
-
"hash": "sha256-
|
|
55
|
+
"version": "0.1.2",
|
|
56
|
+
"hash": "sha256-df10fb424564e7721856a88a9bafeeb077041244faa949711ff92d5490fb7854",
|
|
57
57
|
"description": "Capture before/after screenshots of UI views with Playwright and embed them in a PR. Use this skill whenever creating or preparing a pull request that touches frontend code (components, views, styles, layout, Tailwind classes) — even if the user just says \"open a PR\" or \"create the PR\" after UI work, and especially if they mention screenshots, visual diff, before/after, or \"show what changed\". Also use when the user asks to add screenshots to an existing PR."
|
|
58
58
|
},
|
|
59
59
|
{
|