@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 +6 -9
- package/skills/backlog/SKILL.md +170 -0
- package/skills/backlog/meta.py +18 -0
- package/skills/backlog/project-meta.example.json +19 -0
- package/skills/backlog/rank.py +186 -0
- package/skills/backlog/references/graphql.md +121 -0
- package/skills/backlog/test_meta.py +21 -0
- package/skills/backlog/test_rank.py +214 -0
- package/skills/branch-audit/scripts/branch-audit.sh +0 -0
- package/skills-manifest.json +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jesdi/skills",
|
|
3
|
-
"version": "0.0.
|
|
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
|
package/skills-manifest.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
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",
|