@jesdi/skills 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jesdi/skills",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-14T10:05:20.198Z",
3
+ "generatedAt": "2026-07-15T09:33:14.993Z",
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",