@jesdi/skills 0.0.1 → 0.0.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jesdi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jesdi/skills",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "jesdi's agent skills (content package consumed by @jesdi/skills-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,13 +12,6 @@
12
12
  "skills",
13
13
  "skills-manifest.json"
14
14
  ],
15
- "workspaces": [
16
- "cli"
17
- ],
18
- "scripts": {
19
- "test": "vitest run",
20
- "generate-manifest": "tsx scripts/generate-manifest.ts"
21
- },
22
15
  "devDependencies": {
23
16
  "@types/node": "^22.0.0",
24
17
  "gray-matter": "^4.0.3",
@@ -28,5 +21,9 @@
28
21
  },
29
22
  "engines": {
30
23
  "node": ">=20"
24
+ },
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "generate-manifest": "tsx scripts/generate-manifest.ts"
31
28
  }
32
- }
29
+ }
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: backlog
3
+ description: >-
4
+ Manage a GitHub-Issues-backed backlog and a Projects-v2 work graph via five
5
+ verbs. Use when the user wants to CAPTURE an idea/research thread ("add to
6
+ backlog", "capture this", "note this idea", "log a task"), TRIAGE the inbox
7
+ ("triage the backlog", "score the inbox"), see what to work on NEXT ("what's
8
+ next", "what should I work on", "ranked backlog", "next actionable task"),
9
+ LAND a finished item ("mark done", "close out #N"), or SETUP the board the
10
+ first time. Ranks open issues by impact÷effort, respecting `Blocked by:`
11
+ edges, so only actionable work surfaces.
12
+ ---
13
+
14
+ # backlog — capture + ranked work graph on GitHub
15
+
16
+ Two needs, one substrate: a frictionless **capture** inbox (loose issues labelled
17
+ `inbox`) and a typed, dependency-aware **work graph** (one user-owned Project v2
18
+ with Impact / Effort / Area / Status). The verbs below operate on both.
19
+
20
+ **Dispatch on the user's intent:**
21
+
22
+ | Intent | Verb |
23
+ |--------|------|
24
+ | "add this idea", "capture", "note this" | `capture` |
25
+ | "triage the inbox", "score these" | `triage` |
26
+ | "what's next", "ranked backlog" | `next` |
27
+ | "I'm starting #N", beginning work on an issue | `start` |
28
+ | "mark #N done", "close it out" | `land` |
29
+ | first-time board creation | `setup` |
30
+
31
+ All exact commands live in `references/graphql.md`. Board IDs come from the
32
+ **consuming project's** `.backlog/project-meta.json` (created by `setup`, found
33
+ by walking up from the current directory). That file is a **static schema
34
+ pointer** — project/field/option IDs only, *not* issue state (Impact/Effort/
35
+ Status live on the GitHub board and `rank.py` fetches them live). Because the IDs
36
+ are non-secret constants that change only on re-provision, the file is
37
+ **committed in the consuming repo** and shared across every branch/worktree;
38
+ `project-meta.example.json` is the shape reference. Reads use the existing `gh`
39
+ login; **writes need the one-time `gh auth refresh -s project`** (Projects v2 is
40
+ not in the default `repo` scope).
41
+
42
+ `<owner>`, `<repo>`, `<projectNumber>` below all come from that
43
+ `.backlog/project-meta.json`.
44
+
45
+ ## `setup` — create the board once (idempotent)
46
+
47
+ Run when `.backlog/project-meta.json` is absent or the board is being
48
+ (re)provisioned. Reconciles rather than duplicates — re-running after adding a
49
+ field is safe. Writes into the **project's** `.backlog/` directory, not the skill
50
+ dir.
51
+
52
+ 1. Ensure the `project` scope: `gh auth status`; if Projects calls 403, run
53
+ `gh auth refresh -s project`.
54
+ 2. Follow `references/graphql.md` → **Setup**: create the Project, link the repo,
55
+ add the four fields, create the `inbox` label. On a brand-new board the `Area`
56
+ single-select is created with the generic default options
57
+ `feature,bug,infra,docs,research`. If any step reports "already exists", treat
58
+ it as done and continue. Projects that want different Areas edit the option set
59
+ on the board (GitHub UI) and re-run `setup` to reconcile the IDs.
60
+ 3. Discover IDs: `gh project view … --format json` (project id) and
61
+ `gh project field-list … --format json` (field + option ids).
62
+ 4. Build `.backlog/project-meta.json` from that discovery using
63
+ `meta.build_project_meta`. Run from the skill directory (so `import meta`
64
+ resolves), writing to the project's `.backlog/` (create it if missing):
65
+
66
+ ```bash
67
+ mkdir -p "$PROJECT_ROOT/.backlog"
68
+ gh project view <projectNumber> --owner <owner> --format json > /tmp/proj.json
69
+ gh project field-list <projectNumber> --owner <owner> --format json > /tmp/fields.json
70
+ python3 - "$PROJECT_ROOT/.backlog/project-meta.json" <<'PY'
71
+ import json, sys, meta
72
+ proj = json.load(open("/tmp/proj.json"))
73
+ fields = json.load(open("/tmp/fields.json"))
74
+ out = meta.build_project_meta(
75
+ "<owner>", "<repo>",
76
+ proj["number"], proj["id"], fields)
77
+ json.dump(out, open(sys.argv[1], "w"), indent=2)
78
+ print("wrote", sys.argv[1])
79
+ PY
80
+ ```
81
+
82
+ 5. Confirm every field + option id in `.backlog/project-meta.json` is non-empty,
83
+ then commit the real file (non-secret schema pointer). Report the Project URL.
84
+
85
+ 6. **Built-in workflows (UI-only — no API exists; ask the user to click):**
86
+ at `https://github.com/users/<owner>/projects/<projectNumber>/workflows`
87
+ enable:
88
+ - *Auto-add to project*: repo `<repo>`, filter `is:issue is:open`
89
+ - *Item added to project*: issues → `Status: Inbox`
90
+ - *Item closed*: issues → `Status: Done`
91
+ - *Item reopened*: issues → `Status: Ready`
92
+ These make the board self-maintaining; `land` is only an escape hatch.
93
+
94
+ ## `land <issue#>` — verify closed + Done (escape hatch)
95
+
96
+ Closing and Done are **automatic**: a PR merged with `Closes #N` closes the
97
+ issue, and the board's "Item closed" built-in workflow sets `Status: Done`.
98
+ `land` only verifies and repairs:
99
+
100
+ 1. `gh issue view N --repo <repo> --json state` — if not `CLOSED`, close it:
101
+ `gh issue close N --repo <repo>`.
102
+ 2. Check board status (`gh project item-list … --jq` per graphql.md → Land);
103
+ if not `Done` after ~1 min (workflows are async), set it via `item-edit`.
104
+ 3. Report: closed + Done, noting whether automation handled it or you repaired.
105
+
106
+ ## `start <N>` — claim an issue (check-and-claim)
107
+
108
+ Run when beginning work on an issue — worktree setup time is the natural
109
+ moment. The claim signal is the board Status column and nothing else: humans
110
+ claim by dragging the card to "In progress"; agents run this verb.
111
+
112
+ 1. Read the item id + current status live (graphql.md → Start).
113
+ 2. If status is already `In progress` → **abort**: report the issue is
114
+ already claimed and offer `next` for an alternative. Not an atomic lock —
115
+ good enough for one human + a few agents.
116
+ 3. Otherwise set `Status: In progress` via `item-edit`.
117
+ 4. `start --release <N>`: same lookup, set `Status: Ready` (abandon a claim).
118
+
119
+ ## `capture <idea>` — file an inbox issue (frictionless)
120
+
121
+ Reuse the **dedup discipline** from the `file-bug-issue` skill, but take the
122
+ *idea* path — no reproduction scaffolding.
123
+
124
+ 1. Shape a concise, specific title from the idea (e.g. "Cache batch
125
+ responses per ticker", not "caching").
126
+ 2. Dedup: `gh issue list --repo <repo> --state open --search "<distinctive terms>" --json number,title,url`.
127
+ If a genuine duplicate exists, add the new angle as a comment instead of a new
128
+ issue and report which. Different-but-related → create new and cross-link `#N`.
129
+ 3. Otherwise create it (see `references/graphql.md` → Capture): `--label inbox`,
130
+ body = the idea as-is (one loose sentence is fine; no required structure).
131
+ 4. Report the new issue URL. If mid-task, treat as a side-quest and return.
132
+
133
+ ## `triage` — turn inbox issues into scored graph nodes
134
+
135
+ 1. List the inbox: `gh issue list --repo <repo> --state open --label inbox --json number,title,url,body`.
136
+ 2. For each, present title + body and ask whether to promote (skip = leave in inbox).
137
+ 3. For a promoted issue, gather `Impact` (1–5), `Effort` (1–5), `Area` (**pick one
138
+ from the options present in `.backlog/project-meta.json`** → `fields.Area.options`),
139
+ and optional blockers.
140
+ 4. Apply, in order, the `references/graphql.md` → Triage commands:
141
+ add to project → set Impact/Effort (`--number`) → set
142
+ `Score` = round(Impact ÷ Effort, 1) (`--number`) → set Area +
143
+ `Status: Ready` (`--single-select-option-id`) → append `Blocked by: #…`
144
+ to the body if any → `--remove-label inbox`.
145
+ 5. After each, confirm it is now triaged (fields set, `inbox` gone). Report a
146
+ summary of promoted vs. left-in-inbox.
147
+
148
+ ## `next` — ranked, dependency-aware view (read-only)
149
+
150
+ Runs the pure engine; makes no writes. Run **from the project root** (so the cwd
151
+ walk-up finds `.backlog/project-meta.json`); do **not** `cd` into the skill dir:
152
+
153
+ ```bash
154
+ python3 .claude/skills/backlog/rank.py
155
+ ```
156
+
157
+ (Use whichever agent-skills path the store symlinked the skill into — e.g.
158
+ `.agents/skills/backlog/rank.py`. The key is that the working directory stays at
159
+ or below the repo root so `rank.py` can walk up to `.backlog/`.)
160
+
161
+ `rank.py` pulls Project items + issue bodies via `gh`, parses `Blocked by:`
162
+ edges, drops closed/`Done` blockers, and prints:
163
+
164
+ - **Available** issues ranked by Impact÷Effort (unscored sort last, flagged
165
+ `[needs triage]`).
166
+ - **Blocked** issues, unranked, annotated with what they wait on.
167
+
168
+ Present the table as-is. If the user asks "why isn't #N at the top", point at its
169
+ score or its blockers from the output. This in-memory ranking is the reason the
170
+ skill exists over a bare `gh issue list`.
@@ -0,0 +1,18 @@
1
+ """Pure builder for project-meta.json from `gh project field-list` output. Stdlib only."""
2
+
3
+
4
+ def build_project_meta(owner, repo, project_number, project_id, fields_json):
5
+ fields = {}
6
+ for field in fields_json.get("fields", []):
7
+ entry = {"id": field["id"]}
8
+ options = field.get("options")
9
+ if options:
10
+ entry["options"] = {opt["name"]: opt["id"] for opt in options}
11
+ fields[field["name"]] = entry
12
+ return {
13
+ "owner": owner,
14
+ "repo": repo,
15
+ "projectNumber": project_number,
16
+ "projectId": project_id,
17
+ "fields": fields,
18
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "owner": "<owner>",
3
+ "repo": "<owner>/<repo>",
4
+ "projectNumber": 0,
5
+ "projectId": "PVT_REPLACE_ME",
6
+ "fields": {
7
+ "Status": {
8
+ "id": "PVTSSF_REPLACE",
9
+ "options": {"Inbox": "...", "Ready": "...", "In progress": "...", "Done": "..."}
10
+ },
11
+ "Impact": {"id": "PVTF_REPLACE"},
12
+ "Effort": {"id": "PVTF_REPLACE"},
13
+ "Score": {"id": "PVTF_REPLACE"},
14
+ "Area": {
15
+ "id": "PVTSSF_REPLACE",
16
+ "options": {"feature": "...", "bug": "...", "infra": "...", "docs": "...", "research": "..."}
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,186 @@
1
+ """Pure ranking engine for `backlog next`. Stdlib only — no third-party deps."""
2
+ import json
3
+ import os
4
+ import re
5
+ import subprocess
6
+
7
+ _BLOCKED_LINE = re.compile(r"^\s*Blocked by:\s*(.+)$", re.IGNORECASE | re.MULTILINE)
8
+ _ISSUE_REF = re.compile(r"#(\d+)")
9
+
10
+
11
+ def parse_blocked_by(body):
12
+ """Return sorted, unique issue numbers from every `Blocked by: #a, #b` line."""
13
+ if not body:
14
+ return []
15
+ nums = set()
16
+ for line in _BLOCKED_LINE.finditer(body):
17
+ for ref in _ISSUE_REF.findall(line.group(1)):
18
+ nums.add(int(ref))
19
+ return sorted(nums)
20
+
21
+
22
+ def _as_int(value):
23
+ if value is None or value == "":
24
+ return None
25
+ try:
26
+ return int(value)
27
+ except (TypeError, ValueError):
28
+ return None
29
+
30
+
31
+ def merge_sources(project_items, issue_rows):
32
+ """Join Project field values (item-list) with issue bodies/state (issue list)."""
33
+ bodies = {row["number"]: row for row in issue_rows}
34
+ merged = []
35
+ for item in project_items:
36
+ content = item.get("content") or {}
37
+ number = content.get("number")
38
+ if number is None:
39
+ continue # draft item (no linked issue) — not part of the work graph
40
+ row = bodies.get(number, {})
41
+ merged.append({
42
+ "number": number,
43
+ "title": content.get("title", ""),
44
+ "url": content.get("url", ""),
45
+ "state": (row.get("state") or "OPEN").upper(),
46
+ "body": row.get("body") or "",
47
+ "status": item.get("status"),
48
+ "impact": _as_int(item.get("impact")),
49
+ "effort": _as_int(item.get("effort")),
50
+ "area": item.get("area"),
51
+ })
52
+ return merged
53
+
54
+
55
+ def _blocker_satisfied(blocker):
56
+ return blocker["state"] == "CLOSED" or blocker.get("status") == "Done"
57
+
58
+
59
+ def blockers_of(issue, by_number):
60
+ """Unsatisfied blocker numbers. Unknown blockers are dropped (mistyped-# tolerance)."""
61
+ waiting = []
62
+ for number in parse_blocked_by(issue["body"]):
63
+ blocker = by_number.get(number)
64
+ if blocker is None:
65
+ continue # dropped edge
66
+ if not _blocker_satisfied(blocker):
67
+ waiting.append(number)
68
+ return waiting
69
+
70
+
71
+ def is_available(issue, by_number):
72
+ return not blockers_of(issue, by_number)
73
+
74
+
75
+ def score(issue):
76
+ impact, effort = issue.get("impact"), issue.get("effort")
77
+ if impact is None or effort is None or effort == 0:
78
+ return None
79
+ return impact / effort
80
+
81
+
82
+ def _sort_key(issue):
83
+ value = score(issue)
84
+ return (
85
+ 0 if value is not None else 1, # scored issues first
86
+ -(value or 0.0), # higher score first
87
+ -(issue.get("impact") or 0), # tiebreak: higher impact
88
+ issue["number"], # stable, deterministic
89
+ )
90
+
91
+
92
+ def rank_issues(issues):
93
+ by_number = {i["number"]: i for i in issues}
94
+ candidates = [
95
+ i for i in issues
96
+ if i["state"] != "CLOSED" and i.get("status") != "Done"
97
+ ]
98
+ in_progress = [i for i in candidates if i.get("status") == "In progress"]
99
+ actionable = [i for i in candidates if i.get("status") != "In progress"]
100
+ available, blocked = [], []
101
+ for issue in actionable:
102
+ (available if is_available(issue, by_number) else blocked).append(issue)
103
+ available.sort(key=_sort_key)
104
+ blocked.sort(key=lambda i: i["number"])
105
+ in_progress.sort(key=lambda i: i["number"])
106
+ return {
107
+ "available": available,
108
+ "blocked": blocked,
109
+ "in_progress": in_progress,
110
+ "by_number": by_number,
111
+ }
112
+
113
+
114
+ def render(result):
115
+ lines = []
116
+ if result["in_progress"]:
117
+ lines.append("In progress (claimed):")
118
+ for issue in result["in_progress"]:
119
+ lines.append(f" #{issue['number']} {issue['title']}")
120
+ lines.append("")
121
+ lines += [
122
+ "Rank Score Issue Area",
123
+ "---- ----- -------------------------------------- ----------",
124
+ ]
125
+ for idx, issue in enumerate(result["available"], start=1):
126
+ value = score(issue)
127
+ score_str = f"{value:.2f}" if value is not None else " — "
128
+ flag = "" if value is not None else " [needs triage]"
129
+ label = f"#{issue['number']} {issue['title']}"[:38].ljust(38)
130
+ area = (issue.get("area") or "-")
131
+ lines.append(f"{idx:>4} {score_str:>5} {label} {area:<10}{flag}")
132
+
133
+ if result["blocked"]:
134
+ lines.append("")
135
+ lines.append("Blocked (not actionable yet):")
136
+ for issue in result["blocked"]:
137
+ waits = ", ".join(f"#{n}" for n in blockers_of(issue, result["by_number"]))
138
+ lines.append(f" #{issue['number']} {issue['title']} — waiting on {waits}")
139
+ return "\n".join(lines)
140
+
141
+
142
+ def _gh_json(args):
143
+ completed = subprocess.run(args, capture_output=True, text=True, check=True)
144
+ return json.loads(completed.stdout)
145
+
146
+
147
+ def main(owner, project_number, repo):
148
+ payload = _gh_json([
149
+ "gh", "project", "item-list", str(project_number),
150
+ "--owner", owner, "--format", "json", "--limit", "200",
151
+ ])
152
+ project_items = payload.get("items", [])
153
+ issue_rows = _gh_json([
154
+ "gh", "issue", "list", "--repo", repo, "--state", "all",
155
+ "--limit", "500", "--json", "number,body,state",
156
+ ])
157
+ issues = merge_sources(project_items, issue_rows)
158
+ print(render(rank_issues(issues)))
159
+
160
+
161
+ def find_project_meta(start=None):
162
+ """Walk up from `start` (default cwd) to the nearest `.backlog/project-meta.json`.
163
+
164
+ Returns its absolute path. Raises FileNotFoundError with an actionable
165
+ message if no ancestor directory contains one.
166
+ """
167
+ origin = os.path.abspath(start or os.getcwd())
168
+ directory = origin
169
+ while True:
170
+ candidate = os.path.join(directory, ".backlog", "project-meta.json")
171
+ if os.path.isfile(candidate):
172
+ return candidate
173
+ parent = os.path.dirname(directory)
174
+ if parent == directory: # reached the filesystem root
175
+ raise FileNotFoundError(
176
+ f"No .backlog/project-meta.json found walking up from {origin}. "
177
+ "Run `backlog setup` to provision the board and write it."
178
+ )
179
+ directory = parent
180
+
181
+
182
+ if __name__ == "__main__":
183
+ config_path = find_project_meta()
184
+ with open(config_path) as handle:
185
+ meta = json.load(handle)
186
+ main(meta["owner"], meta["projectNumber"], meta["repo"])
@@ -0,0 +1,121 @@
1
+ # backlog — gh command reference
2
+
3
+ All `<placeholders>` come from `.backlog/project-meta.json` unless noted:
4
+ `<owner>`, `<repo>` (`owner/name`), `<projectNumber>`, `<projectId>`,
5
+ `<fields.*.id>`, `<fields.*.options.*>`.
6
+
7
+ ## One-time auth (writes need the `project` scope)
8
+
9
+ ```bash
10
+ gh auth status # confirm login
11
+ gh auth refresh -s project # add Projects-v2 write scope (one-time)
12
+ ```
13
+
14
+ ## Setup — create the board (idempotent; skip a step if it already exists)
15
+
16
+ ```bash
17
+ # 1. Create the user-owned Project v2 (prints its number + URL)
18
+ gh project create --owner <owner> --title "<Project Title> Backlog"
19
+
20
+ # 2. Link it to the repo
21
+ gh project link <projectNumber> --owner <owner> --repo <repo>
22
+
23
+ # 3. Add fields (single-select options are comma-separated, order preserved)
24
+ gh project field-create <projectNumber> --owner <owner> --name "Status" \
25
+ --data-type SINGLE_SELECT --single-select-options "Inbox,Ready,In progress,Done"
26
+ gh project field-create <projectNumber> --owner <owner> --name "Impact" --data-type NUMBER
27
+ gh project field-create <projectNumber> --owner <owner> --name "Effort" --data-type NUMBER
28
+ gh project field-create <projectNumber> --owner <owner> --name "Score" --data-type NUMBER
29
+ gh project field-create <projectNumber> --owner <owner> --name "Area" \
30
+ --data-type SINGLE_SELECT \
31
+ --single-select-options "feature,bug,infra,docs,research"
32
+
33
+ # 4. Create the inbox label on the repo
34
+ gh label create inbox --repo <repo> \
35
+ --description "Un-triaged capture" --color BFD4F2 || true
36
+
37
+ # 5. Discover IDs to build .backlog/project-meta.json
38
+ gh project view <projectNumber> --owner <owner> --format json # -> id (projectId)
39
+ gh project field-list <projectNumber> --owner <owner> --format json # -> field + option ids
40
+ ```
41
+
42
+ Reconcile note: `gh project field-create` errors if the field already exists —
43
+ treat "already exists" as success. Projects that want a different Area taxonomy
44
+ edit the option set on the GitHub board and re-run `setup`, which re-discovers the
45
+ IDs into `.backlog/project-meta.json`.
46
+
47
+ ## Capture — file an inbox issue
48
+
49
+ ```bash
50
+ gh issue create --repo <repo> \
51
+ --title "<concise idea title>" --label inbox \
52
+ --body "<one loose sentence is fine>"
53
+ ```
54
+
55
+ ## Triage — promote an inbox issue to a graph node
56
+
57
+ ```bash
58
+ # Add issue to the project (prints the item id; capture it)
59
+ gh project item-add <projectNumber> --owner <owner> --url <issueUrl>
60
+
61
+ # Set number fields
62
+ gh project item-edit --project-id <projectId> --id <itemId> \
63
+ --field-id <fields.Impact.id> --number <1-5>
64
+ gh project item-edit --project-id <projectId> --id <itemId> \
65
+ --field-id <fields.Effort.id> --number <1-5>
66
+ gh project item-edit --project-id <projectId> --id <itemId> \
67
+ --field-id <fields.Score.id> --number <round(impact/effort, 1)>
68
+
69
+ # Set single-selects (use the option id from .backlog/project-meta.json)
70
+ gh project item-edit --project-id <projectId> --id <itemId> \
71
+ --field-id <fields.Area.id> --single-select-option-id <fields.Area.options[area]>
72
+ gh project item-edit --project-id <projectId> --id <itemId> \
73
+ --field-id <fields.Status.id> --single-select-option-id <fields.Status.options.Ready>
74
+
75
+ # Record blocking edges (append a line; keep any existing body)
76
+ gh issue edit <number> --repo <repo> --body "<body>\n\nBlocked by: #<a>, #<b>"
77
+
78
+ # Drop the inbox label
79
+ gh issue edit <number> --repo <repo> --remove-label inbox
80
+ ```
81
+
82
+ ## Sub-issues (native parent/child) — GraphQL, no field IDs needed
83
+
84
+ ```bash
85
+ # Resolve issue node IDs (<repoName> is the repo without the owner/ prefix)
86
+ gh api graphql -f query='query($owner:String!,$repo:String!,$n:Int!){
87
+ repository(owner:$owner,name:$repo){issue(number:$n){id}}}' \
88
+ -F owner=<owner> -F repo=<repoName> -F n=<parentNumber>
89
+
90
+ # Attach child to parent
91
+ gh api graphql -f query='mutation($parent:ID!,$child:ID!){
92
+ addSubIssue(input:{issueId:$parent,subIssueId:$child}){issue{number}}}' \
93
+ -F parent=<parentNodeId> -F child=<childNodeId>
94
+ ```
95
+
96
+ ## Land — verify closed + Done (escape hatch; automation normally does this)
97
+
98
+ ```bash
99
+ gh issue view <number> --repo <repo> --json state --jq .state
100
+ gh project item-list <projectNumber> --owner <owner> --format json \
101
+ --jq '.items[] | select(.content.number==<number>) | {id, status}'
102
+ # repair only if needed:
103
+ gh issue close <number> --repo <repo>
104
+ gh project item-edit --project-id <projectId> --id <itemId> \
105
+ --field-id <fields.Status.id> --single-select-option-id <fields.Status.options.Done>
106
+ ```
107
+
108
+ ## Start — check-and-claim
109
+
110
+ ```bash
111
+ # 1. Item id + live status
112
+ gh project item-list <projectNumber> --owner <owner> --format json \
113
+ --jq '.items[] | select(.content.number==<number>) | {id, status}'
114
+ # 2. If status == "In progress": abort (already claimed).
115
+ # 3. Claim:
116
+ gh project item-edit --project-id <projectId> --id <itemId> \
117
+ --field-id <fields.Status.id> --single-select-option-id <fields.Status.options["In progress"]>
118
+ # --release: set Ready instead
119
+ gh project item-edit --project-id <projectId> --id <itemId> \
120
+ --field-id <fields.Status.id> --single-select-option-id <fields.Status.options.Ready>
121
+ ```
@@ -0,0 +1,21 @@
1
+ import meta
2
+
3
+
4
+ def test_build_project_meta_maps_fields_and_options():
5
+ fields_json = {"fields": [
6
+ {"id": "F_status", "name": "Status", "type": "ProjectV2SingleSelectField",
7
+ "options": [{"id": "o_inbox", "name": "Inbox"}, {"id": "o_ready", "name": "Ready"}]},
8
+ {"id": "F_impact", "name": "Impact", "type": "ProjectV2Field"},
9
+ {"id": "F_area", "name": "Area", "type": "ProjectV2SingleSelectField",
10
+ "options": [{"id": "o_feat", "name": "feature"}]},
11
+ ]}
12
+ out = meta.build_project_meta("acme", "acme/widget", 7, "PVT_x", fields_json)
13
+ assert out["owner"] == "acme"
14
+ assert out["repo"] == "acme/widget"
15
+ assert out["projectNumber"] == 7
16
+ assert out["projectId"] == "PVT_x"
17
+ assert out["fields"]["Status"]["id"] == "F_status"
18
+ assert out["fields"]["Status"]["options"]["Ready"] == "o_ready"
19
+ assert out["fields"]["Impact"]["id"] == "F_impact"
20
+ assert "options" not in out["fields"]["Impact"]
21
+ assert out["fields"]["Area"]["options"]["feature"] == "o_feat"
@@ -0,0 +1,214 @@
1
+ import rank
2
+
3
+
4
+ def test_parse_blocked_by_basic():
5
+ assert rank.parse_blocked_by("Blocked by: #12, #34") == [12, 34]
6
+
7
+
8
+ def test_parse_blocked_by_case_insensitive_and_multiline():
9
+ body = "Some idea.\n\nblocked BY: #5\nMore text\nBlocked by: #5, #9"
10
+ assert rank.parse_blocked_by(body) == [5, 9] # deduped + sorted
11
+
12
+
13
+ def test_parse_blocked_by_none_and_empty():
14
+ assert rank.parse_blocked_by(None) == []
15
+ assert rank.parse_blocked_by("no blockers here") == []
16
+
17
+
18
+ def test_merge_sources_joins_fields_and_bodies():
19
+ project_items = [
20
+ {"content": {"number": 12, "title": "Add X", "url": "u/12"},
21
+ "status": "Ready", "impact": 4, "effort": 2, "area": "feature"},
22
+ {"content": {"number": 34, "title": "Do Y", "url": "u/34"}}, # untriaged, no fields
23
+ {"title": "a draft note"}, # draft item, no content -> skipped
24
+ ]
25
+ issue_rows = [
26
+ {"number": 12, "body": "Blocked by: #34", "state": "open"},
27
+ {"number": 34, "body": "loose idea", "state": "open"},
28
+ ]
29
+ out = rank.merge_sources(project_items, issue_rows)
30
+ assert [i["number"] for i in out] == [12, 34]
31
+ twelve = out[0]
32
+ assert twelve["state"] == "OPEN"
33
+ assert twelve["impact"] == 4 and twelve["effort"] == 2
34
+ assert twelve["area"] == "feature" and twelve["status"] == "Ready"
35
+ assert twelve["body"] == "Blocked by: #34"
36
+ thirtyfour = out[1]
37
+ assert thirtyfour["impact"] is None and thirtyfour["status"] is None
38
+
39
+
40
+ def test_merge_sources_missing_body_row_defaults_open_empty():
41
+ out = rank.merge_sources(
42
+ [{"content": {"number": 7, "title": "T", "url": "u/7"}, "impact": 3, "effort": 1}],
43
+ [], # no matching issue row
44
+ )
45
+ assert out[0]["state"] == "OPEN" and out[0]["body"] == ""
46
+
47
+
48
+ def _issue(number, body="", state="OPEN", status=None):
49
+ return {"number": number, "title": f"i{number}", "url": "", "state": state,
50
+ "body": body, "status": status, "impact": None, "effort": None, "area": None}
51
+
52
+
53
+ def test_availability_and_blockers():
54
+ a = _issue(1, body="Blocked by: #2, #3, #4")
55
+ by_number = {
56
+ 1: a,
57
+ 2: _issue(2, state="CLOSED"), # satisfied — closed
58
+ 3: _issue(3, state="OPEN", status="Done"), # satisfied — Status Done
59
+ 4: _issue(4, state="OPEN", status="Ready"), # NOT satisfied — still open/active
60
+ }
61
+ assert rank.is_available(a, by_number) is False
62
+ assert rank.blockers_of(a, by_number) == [4]
63
+
64
+
65
+ def test_available_when_no_blockers():
66
+ a = _issue(1, body="just an idea")
67
+ assert rank.is_available(a, {1: a}) is True
68
+ assert rank.blockers_of(a, {1: a}) == []
69
+
70
+
71
+ def test_unknown_blocker_is_dropped_edge():
72
+ a = _issue(1, body="Blocked by: #999") # #999 not in the graph
73
+ assert rank.is_available(a, {1: a}) is True # dropped, not blocking
74
+ assert rank.blockers_of(a, {1: a}) == []
75
+
76
+
77
+ def _scored(number, impact, effort, body="", state="OPEN", status="Ready"):
78
+ return {"number": number, "title": f"i{number}", "url": "", "state": state,
79
+ "body": body, "status": status, "impact": impact, "effort": effort, "area": None}
80
+
81
+
82
+ def test_score_ratio_and_unscored():
83
+ assert rank.score(_scored(1, 4, 2)) == 2.0
84
+ assert rank.score(_scored(1, None, 2)) is None
85
+ assert rank.score(_scored(1, 4, None)) is None
86
+ assert rank.score(_scored(1, 4, 0)) is None # guard divide-by-zero
87
+
88
+
89
+ def test_rank_orders_available_and_separates_blocked_and_done():
90
+ issues = [
91
+ _scored(1, 2, 2), # score 1.0
92
+ _scored(2, 5, 1), # score 5.0 -> rank 1
93
+ _scored(3, None, None), # unscored -> last, needs triage
94
+ _scored(4, 9, 1, body="Blocked by: #2"), # blocked (2 is open/Ready)
95
+ _scored(5, 4, 1, status="Done"), # Done -> excluded entirely
96
+ ]
97
+ result = rank.rank_issues(issues)
98
+ assert [i["number"] for i in result["available"]] == [2, 1, 3]
99
+ assert [i["number"] for i in result["blocked"]] == [4]
100
+ assert 5 not in [i["number"] for i in result["available"] + result["blocked"]]
101
+
102
+
103
+ def test_rank_excludes_closed():
104
+ issues = [_scored(1, 3, 1, state="CLOSED"), _scored(2, 1, 1)]
105
+ result = rank.rank_issues(issues)
106
+ assert [i["number"] for i in result["available"]] == [2]
107
+
108
+
109
+ def test_render_contains_ranks_scores_flags_and_blocked_reason():
110
+ issues = [
111
+ _scored(2, 5, 1), # score 5.00, rank 1
112
+ _scored(3, None, None), # needs triage
113
+ _scored(4, 9, 1, body="Blocked by: #2"), # blocked on #2
114
+ ]
115
+ text = rank.render(rank.rank_issues(issues))
116
+ assert "#2 i2" in text
117
+ assert "5.00" in text
118
+ assert "[needs triage]" in text
119
+ assert "Blocked" in text and "waiting on #2" in text
120
+
121
+
122
+ def test_render_no_blocked_section_when_none_blocked():
123
+ text = rank.render(rank.rank_issues([_scored(1, 2, 1)]))
124
+ assert "Blocked" not in text
125
+
126
+
127
+ def test_rank_separates_in_progress_claimed():
128
+ issues = [
129
+ _scored(1, 2, 2), # score 1.0, available
130
+ _scored(2, 5, 1, status="In progress"), # claimed -> never recommended
131
+ ]
132
+ result = rank.rank_issues(issues)
133
+ assert [i["number"] for i in result["available"]] == [1]
134
+ assert [i["number"] for i in result["in_progress"]] == [2]
135
+ assert result["blocked"] == []
136
+
137
+
138
+ def test_in_progress_with_blockers_goes_to_claimed_not_blocked():
139
+ issues = [
140
+ _scored(1, 3, 1),
141
+ _scored(2, 4, 1, status="In progress", body="Blocked by: #1"),
142
+ ]
143
+ result = rank.rank_issues(issues)
144
+ assert [i["number"] for i in result["in_progress"]] == [2]
145
+ assert result["blocked"] == []
146
+
147
+
148
+ def test_in_progress_item_still_blocks_its_dependents():
149
+ # A claimed blocker is not yet Done -> dependents stay blocked.
150
+ issues = [
151
+ _scored(1, 3, 1, status="In progress"),
152
+ _scored(2, 4, 1, body="Blocked by: #1"),
153
+ ]
154
+ result = rank.rank_issues(issues)
155
+ assert [i["number"] for i in result["blocked"]] == [2]
156
+ assert [i["number"] for i in result["in_progress"]] == [1]
157
+
158
+
159
+ def test_render_shows_claimed_section_first():
160
+ issues = [_scored(1, 2, 1), _scored(2, 5, 1, status="In progress")]
161
+ text = rank.render(rank.rank_issues(issues))
162
+ assert text.startswith("In progress (claimed):")
163
+ assert "#2 i2" in text
164
+
165
+
166
+ def test_render_no_claimed_section_when_none_in_progress():
167
+ text = rank.render(rank.rank_issues([_scored(1, 2, 1)]))
168
+ assert "claimed" not in text
169
+
170
+
171
+ import os
172
+
173
+ import pytest
174
+
175
+
176
+ def test_find_project_meta_in_cwd(tmp_path, monkeypatch):
177
+ backlog = tmp_path / ".backlog"
178
+ backlog.mkdir()
179
+ meta_file = backlog / "project-meta.json"
180
+ meta_file.write_text("{}")
181
+ monkeypatch.chdir(tmp_path)
182
+ assert rank.find_project_meta() == str(meta_file)
183
+
184
+
185
+ def test_find_project_meta_walks_up_from_subdir(tmp_path, monkeypatch):
186
+ backlog = tmp_path / ".backlog"
187
+ backlog.mkdir()
188
+ meta_file = backlog / "project-meta.json"
189
+ meta_file.write_text("{}")
190
+ nested = tmp_path / "backend" / "deep"
191
+ nested.mkdir(parents=True)
192
+ monkeypatch.chdir(nested)
193
+ assert rank.find_project_meta() == str(meta_file)
194
+
195
+
196
+ def test_find_project_meta_nearest_ancestor_wins(tmp_path, monkeypatch):
197
+ outer = tmp_path / ".backlog"
198
+ outer.mkdir()
199
+ (outer / "project-meta.json").write_text("{}")
200
+ inner_root = tmp_path / "sub"
201
+ inner_backlog = inner_root / ".backlog"
202
+ inner_backlog.mkdir(parents=True)
203
+ inner_meta = inner_backlog / "project-meta.json"
204
+ inner_meta.write_text("{}")
205
+ monkeypatch.chdir(inner_root)
206
+ assert rank.find_project_meta() == str(inner_meta)
207
+
208
+
209
+ def test_find_project_meta_errors_clearly_when_absent(tmp_path, monkeypatch):
210
+ monkeypatch.chdir(tmp_path)
211
+ with pytest.raises(FileNotFoundError) as exc:
212
+ rank.find_project_meta()
213
+ assert ".backlog/project-meta.json" in str(exc.value)
214
+ assert "backlog setup" in str(exc.value)
File without changes
@@ -0,0 +1,228 @@
1
+ ---
2
+ name: pr-visual-diff
3
+ 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.
4
+ ---
5
+
6
+ # PR Visual Diff
7
+
8
+ Produce before/after screenshots of the views affected by a branch's frontend
9
+ changes, commit them on the PR branch, and embed them in the PR body. Reviewers
10
+ should see what changed visually without checking out the branch.
11
+
12
+ The approach leans on the project's existing Playwright e2e harness with the
13
+ **API mocked**: because the data is stubbed and identical across both runs, any
14
+ pixel difference between "before" and "after" is caused by the code change
15
+ alone — that's the property that makes the comparison trustworthy. Never
16
+ screenshot against a live backend for this purpose.
17
+
18
+ Because every project wires this differently, the skill **discovers** the
19
+ project's setup once and caches it, then reuses the cache on later runs.
20
+
21
+ ## Step 0. Load or build the per-project config
22
+
23
+ The config cache lives beside this skill at
24
+ `<skill-dir>/pr-visual-diff.<slug>.json`, where `<slug>` is the target repo's
25
+ directory name. It is gitignored — local to this machine. Ensure the cache file is ignored in the `.gitignore` of the repo where you install the skill directory (add an entry like `pr-visual-diff.*.json` if it isn't already).
26
+
27
+ - **If the cache exists**, read it and skip to "When NOT to run the capture".
28
+ If it says `{"supported": false}`, tell the user this project has no
29
+ mocked-API harness and create a plain PR (see Preconditions), then stop.
30
+ - **If it does not exist**, run the discovery pass below to build it.
31
+
32
+ ### Discovery pass (first run only)
33
+
34
+ Inspect the target repo and record these values, then write them to the cache
35
+ file as JSON:
36
+
37
+ | Key | How to find it |
38
+ |---|---|
39
+ | `frontendDir` | the dir holding the app + its `src/` (repo root or a subdir like `frontend/`) |
40
+ | `e2eDir`, `specsDir` | the Playwright spec/support tree (look for `playwright.config.*`, a `specs/` dir) |
41
+ | `authFixtureImport`, `authFixtureName`, `authedPageProp` | the support file exporting a `test` fixture that seeds auth; note its import path, the exported name, and the authed-page property (e.g. `authenticatedPage`) |
42
+ | `mockHelperImport`, `mockHelperName` | the helper that stubs `/api` routes with fixture data; its import path + exported name |
43
+ | `port` | the `webServer` port in the Playwright config |
44
+ | `deviceProjects` | the config's `projects` (default to a desktop + a mobile project) |
45
+ | `baseBranch` | the repo's default branch (`main`/`master`; `git symbolic-ref refs/remotes/origin/HEAD`) |
46
+ | `packageManager` | infer from lockfile: `pnpm-lock.yaml`→pnpm, `package-lock.json`→npm, `yarn.lock`→yarn, `bun.lockb`→bun |
47
+ | `screenshotDir` | default `docs/pr-screenshots` |
48
+ | `spinnerSelector` | the app's loading-indicator selector (search for a spinner/`role="status"`/`aria-busy` component) |
49
+
50
+ ### Preconditions gate
51
+
52
+ The discovery pass is also the gate. **If there is no Playwright e2e harness
53
+ that mocks the API** (no mock helper, or no Playwright at all), write
54
+ `{ "supported": false, "reason": "<why>" }` to the cache, tell the user that a
55
+ trustworthy before/after diff isn't possible here (unmocked data would differ
56
+ between runs for reasons other than the code change), and **create a plain PR
57
+ instead** — no screenshots. Then stop.
58
+
59
+ ## When NOT to run the capture
60
+
61
+ If `git diff --name-only $(git merge-base <baseBranch> HEAD)...HEAD` shows no
62
+ changes under `<frontendDir>/src/`, there is nothing visual to diff — create the
63
+ PR normally and say so. Test-only, docs-only, or backend-only diffs don't need
64
+ screenshots.
65
+
66
+ ## Always run the capture in a subagent
67
+
68
+ Steps 2–6 (writing the spec, capturing, **reading every PNG back to eyeball
69
+ it**, committing, embedding) MUST run inside a single dispatched subagent — not
70
+ in the main context. Reading screenshots back is this skill's dominant token
71
+ cost, and those image bytes are worthless to the main thread; keeping them in a
72
+ subagent's throwaway context is the whole savings, with no loss of verification.
73
+
74
+ Main context does only: step 1 (map the diff, get the user's go-ahead), then
75
+ dispatch a subagent with the plan and the config values. The subagent returns a
76
+ compact report: the committed screenshot paths + pinned URLs, the PR-body
77
+ markdown, and any views it flagged. The main context creates/edits the PR from
78
+ that report — it never reads a PNG.
79
+
80
+ ## Workflow
81
+
82
+ ### 1. Map the diff to views, then get the user's go-ahead
83
+
84
+ List changed files against the merge base (`git merge-base <baseBranch> HEAD`).
85
+ For each changed component, trace upward (imports → page/route component → route
86
+ path) to find the route(s) where it renders, and note any interaction needed to
87
+ make it visible (open a modal, select a tab). Classify each view as **changed**
88
+ (existed at the merge base) or **new** (introduced by this branch) — new ones
89
+ get an after-only shot.
90
+
91
+ This capture is heavy (temp worktree, two Playwright passes, a commit pushed to
92
+ the branch, PR body rewritten). ALWAYS confirm with AskUserQuestion before
93
+ running any of it. The question must cover **both** (a) whether to run the whole
94
+ before/after capture at all, and (b) that it will commit the screenshots and
95
+ embed them in the PR. Include the proposed plan: which routes/states, and which
96
+ device projects — default to the config's `deviceProjects`; add more only when
97
+ the change is layout-sensitive. If they decline, create the PR normally and stop.
98
+
99
+ ### 2. Write the capture spec
100
+
101
+ Copy `references/capture-spec-template.ts` from this skill into
102
+ `<specsDir>/__pr-capture__.spec.ts` and substitute the `__PVD_*__` markers from
103
+ the config:
104
+
105
+ - `__PVD_AUTH_FIXTURE_IMPORT__` → `authFixtureImport` (and, if the fixture isn't
106
+ named `test`, adjust the `import { <authFixtureName> as test }` accordingly)
107
+ - `__PVD_MOCK_HELPER_IMPORT__` → `mockHelperImport` (and `mockHelperName` if not
108
+ `mockApi`)
109
+ - `__PVD_SPINNER_SELECTOR__` → `spinnerSelector`
110
+ - the `{ authenticatedPage: page }` destructure → `{ <authedPageProp>: page }`
111
+
112
+ Then fill in the `VIEWS` array. This file is scaffolding: never commit it, and
113
+ delete it when done (it lives in the specs dir, so a forgotten copy would run in
114
+ every future e2e invocation).
115
+
116
+ ### 3. Capture "after" (current branch)
117
+
118
+ From `<frontendDir>`. First make sure nothing is listening on `<port>`
119
+ (`lsof -nP -i :<port>`) and kill any stray server there: if the Playwright
120
+ config has `reuseExistingServer` on locally, a leftover server from another
121
+ worktree would be silently reused and you'd screenshot the *wrong code*.
122
+
123
+ ```bash
124
+ PR_SHOT_DIR=<scratchpad>/after PR_SHOT_LABEL=after \
125
+ <packageManager> exec playwright test <specsDir>/__pr-capture__.spec.ts \
126
+ --project=<deviceProject> [--project=<deviceProject>...]
127
+ ```
128
+
129
+ Playwright boots its own server and tears it down. The template pins
130
+ `deviceScaleFactor: 1` and asserts no spinner is left on screen, so a
131
+ raced/under-mocked view fails the test instead of producing a misleading shot.
132
+ That assertion is a backstop, not a substitute for looking: the subagent still
133
+ Reads each PNG after capture — an empty state, error toast, or wrong content
134
+ passes the spinner check but is still wrong. On a genuine failure, fix the spec
135
+ (add mock stubs, a wait for a stable element, or correct the spinner selector)
136
+ rather than shipping a bad screenshot.
137
+
138
+ ### 4. Capture "before" (merge base, temp worktree)
139
+
140
+ A "before" shot only makes sense for views that already existed. If a view is
141
+ introduced by this branch, capture only its "after" shot and present it as
142
+ **New** (step 6). If *every* captured view is new, skip this whole step.
143
+
144
+ For views that did exist, reconstruct the pre-change app from git:
145
+
146
+ ```bash
147
+ BASE=$(git merge-base <baseBranch> HEAD)
148
+ git worktree add <scratchpad>/pr-before-shots "$BASE"
149
+ cd <scratchpad>/pr-before-shots/<frontendDir> && <packageManager> install
150
+ ```
151
+
152
+ Copy the same filled `__pr-capture__.spec.ts` into that worktree's specs dir,
153
+ then run the same command with `PR_SHOT_DIR=<scratchpad>/before
154
+ PR_SHOT_LABEL=before`. Run it sequentially after the "after" pass — both configs
155
+ use `<port>`.
156
+
157
+ Edge cases:
158
+ - A route that doesn't exist at the base (new view): skip it and label it
159
+ **New** in the PR table.
160
+ - The e2e helpers (auth fixture, mock helper) missing or different at the base:
161
+ adapt the spec copy to what exists there; the goal is the same rendered view,
162
+ not identical spec code.
163
+ - `<packageManager> install` failing on a native binding: rerun once; if it
164
+ persists, report it rather than screenshotting a broken build.
165
+
166
+ Remove the worktree when done:
167
+ `git worktree remove --force <scratchpad>/pr-before-shots` (`--force` because
168
+ install left untracked artifacts).
169
+
170
+ ### 5. Commit the images on the PR branch
171
+
172
+ ```
173
+ <screenshotDir>/<branch-slug>/<view>--<project>--{before|after}.png
174
+ ```
175
+
176
+ `<branch-slug>` = branch name with `/` → `-`. Keep only the shots you'll
177
+ reference. Commit them, delete the temp spec, and push.
178
+
179
+ ### 6. Embed in the PR body
180
+
181
+ After pushing, pin image URLs to the exact commit so they keep rendering after
182
+ later force-pushes or branch deletion:
183
+
184
+ ```bash
185
+ SHA=$(git rev-parse HEAD)
186
+ REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
187
+ # → https://github.com/$REPO/blob/$SHA/<screenshotDir>/<slug>/<file>.png?raw=true
188
+ ```
189
+
190
+ Per view, one table. Put **Before on the left and After on the right** (one
191
+ device per row) so a reviewer sees the original and changed component side by
192
+ side:
193
+
194
+ ```markdown
195
+ ### <View name> (`/route`)
196
+
197
+ | | Before | After |
198
+ |---|---|---|
199
+ | **Desktop** | ![](<blob-url>?raw=true) | ![](<blob-url>?raw=true) |
200
+ | **Mobile** | ![](<blob-url>?raw=true) | ![](<blob-url>?raw=true) |
201
+ ```
202
+
203
+ If the repo is private, GitHub's image proxy may refuse to inline blob URLs in
204
+ the PR body. Add a one-line note under the tables linking to the
205
+ `<screenshotDir>/<slug>/` directory on the branch as a fallback — the images
206
+ always render in the Files-changed tab and in blob view for authenticated
207
+ viewers.
208
+
209
+ The subagent's job ends here: it has pushed the commit and produced the PR-body
210
+ markdown, and returns both. The **main context** creates (or edits) the PR with
211
+ that body, then opens it (`gh pr view --web`) so the user can confirm the embeds
212
+ render.
213
+
214
+ For a **new** view there is no Before, so put a **New view** label in the left
215
+ cell instead of an image:
216
+
217
+ ```markdown
218
+ | | Before | After |
219
+ |---|---|---|
220
+ | **Desktop** | _New view_ | ![](<blob-url>?raw=true) |
221
+ | **Mobile** | _New view_ | ![](<blob-url>?raw=true) |
222
+ ```
223
+
224
+ ## Adding shots to an existing PR
225
+
226
+ Same flow; the only differences: diff against the PR's base branch
227
+ (`gh pr view --json baseRefName`) instead of `<baseBranch>`, and edit the body
228
+ with `gh pr edit --body-file` instead of creating.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * pr-visual-diff capture spec — TEMPORARY SCAFFOLDING, never commit.
3
+ *
4
+ * This is a template. Before running, substitute every __PVD_*__ marker below
5
+ * with the value from this project's discovery cache
6
+ * (<skill-dir>/pr-visual-diff.<slug>.json). The markers exist because
7
+ * each project wires its Playwright auth fixture and API mock differently.
8
+ *
9
+ * Copy the filled result to <specsDir>/__pr-capture__.spec.ts, then run:
10
+ *
11
+ * PR_SHOT_DIR=<abs-out-dir> PR_SHOT_LABEL=after \
12
+ * <packageManager> exec playwright test <specsDir>/__pr-capture__.spec.ts \
13
+ * --project=<deviceProject> [--project=<deviceProject>...]
14
+ *
15
+ * Delete the copy when done — anything left in the specs dir runs on every e2e pass.
16
+ */
17
+ import { expect, type Page } from '@playwright/test';
18
+ // __PVD_AUTH_IMPORT__: import the project's test fixture.
19
+ // From config: import { <authFixtureName> } from '<authFixtureImport>'
20
+ import { test } from '__PVD_AUTH_FIXTURE_IMPORT__';
21
+ // __PVD_MOCK_IMPORT__: import the project's API-mock helper.
22
+ // From config: import { <mockHelperName> } from '<mockHelperImport>'
23
+ import { mockApi } from '__PVD_MOCK_HELPER_IMPORT__';
24
+
25
+ const OUT = process.env.PR_SHOT_DIR;
26
+ const LABEL = process.env.PR_SHOT_LABEL ?? 'after';
27
+ if (!OUT) throw new Error('PR_SHOT_DIR must be set to an absolute output directory');
28
+
29
+ // Selector for this app's loading indicator. From config: <spinnerSelector>.
30
+ const SPINNER = '__PVD_SPINNER_SELECTOR__';
31
+
32
+ // Pin device-pixel-ratio to 1 for every project. Device descriptors (iPhone 14,
33
+ // Pixel 7, iPad) default to DPR 2-3, producing PNGs 4-9x larger for zero review
34
+ // value — and those bytes land in the main context when the shots get read back.
35
+ test.use({ deviceScaleFactor: 1 });
36
+
37
+ interface View {
38
+ /** Slug used in the filename, e.g. 'dashboard' or 'add-position-modal'. */
39
+ name: string;
40
+ /** Route to open, e.g. '/dashboard'. */
41
+ path: string;
42
+ /** Optional interactions to reach the state (open a modal, pick a tab…). */
43
+ prepare?: (page: Page) => Promise<void>;
44
+ /** Full-page scroll capture (default) vs just the viewport. */
45
+ fullPage?: boolean;
46
+ }
47
+
48
+ const VIEWS: View[] = [
49
+ // { name: 'dashboard', path: '/dashboard' },
50
+ // {
51
+ // name: 'add-position-modal',
52
+ // path: '/',
53
+ // prepare: async (page) => {
54
+ // await page.getByRole('button', { name: /add symbol/i }).click();
55
+ // await page.getByRole('dialog').waitFor();
56
+ // },
57
+ // },
58
+ ];
59
+
60
+ for (const view of VIEWS) {
61
+ // __PVD_PAGE_PROP__: destructure the project's authed-page fixture prop.
62
+ // From config: { <authedPageProp>: page }
63
+ test(`pr-capture: ${view.name}`, async ({ authenticatedPage: page }, testInfo) => {
64
+ await mockApi(page);
65
+ await page.goto(view.path);
66
+ await page.waitForLoadState('networkidle');
67
+ await view.prepare?.(page);
68
+ // Guard against shipping a misleading shot: a spinner still on screen means
69
+ // the mock is missing an endpoint or prepare() raced the render. Fail loudly
70
+ // here instead of committing a loading-state PNG.
71
+ await expect(
72
+ page.locator(SPINNER),
73
+ `${view.name}: a spinner is still visible — fix the mock/prepare before capturing`,
74
+ ).toHaveCount(0);
75
+ await page.screenshot({
76
+ path: `${OUT}/${view.name}--${testInfo.project.name}--${LABEL}.png`,
77
+ fullPage: view.fullPage ?? true,
78
+ animations: 'disabled',
79
+ caret: 'hide',
80
+ });
81
+ });
82
+ }
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-14T10:05:20.198Z",
3
+ "generatedAt": "2026-07-16T10:07:19.685Z",
4
4
  "skills": [
5
+ {
6
+ "name": "backlog",
7
+ "version": "0.1.0",
8
+ "hash": "sha256-ac26c0313981666dfc143bb6eabbf603e0b02111c13ea543e1c86c2900e40559",
9
+ "description": "Manage a GitHub-Issues-backed backlog and a Projects-v2 work graph via five verbs. Use when the user wants to CAPTURE an idea/research thread (\"add to backlog\", \"capture this\", \"note this idea\", \"log a task\"), TRIAGE the inbox (\"triage the backlog\", \"score the inbox\"), see what to work on NEXT (\"what's next\", \"what should I work on\", \"ranked backlog\", \"next actionable task\"), LAND a finished item (\"mark done\", \"close out #N\"), or SETUP the board the first time. Ranks open issues by impact÷effort, respecting `Blocked by:` edges, so only actionable work surfaces."
10
+ },
5
11
  {
6
12
  "name": "branch-audit",
7
13
  "version": "0.1.0",
@@ -25,6 +31,12 @@
25
31
  "version": "0.1.0",
26
32
  "hash": "sha256-916ea397cd74d91c4b6060672088b9c54353c915b98bcaef3514815694842e5a",
27
33
  "description": "Minimal example skill proving the distribution pipeline works. Responds to \"test my skills setup\" by confirming the skill is loaded."
34
+ },
35
+ {
36
+ "name": "pr-visual-diff",
37
+ "version": "0.1.0",
38
+ "hash": "sha256-4e386dd175a34e55e89e0e29e37f5d0afd927560b7e3ba4c5f3ef482fb73f722",
39
+ "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."
28
40
  }
29
41
  ]
30
42
  }