@jesdi/skills 0.0.26 → 0.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +1 -1
- package/skills/backlog/SKILL.md +6 -5
- package/skills/backlog/rank.py +68 -11
- package/skills/backlog/references/graphql.md +33 -20
- package/skills/backlog/test_rank.py +110 -27
- package/skills/implement-spec/SKILL.md +8 -7
- package/skills/implement-spec/history.md +52 -0
- package/skills/review-diff/SKILL.md +20 -1
- package/skills-manifest.json +7 -7
package/README.md
CHANGED
|
@@ -47,8 +47,25 @@ npx @jesdi/skills-cli update [skill] [--all] [--global]
|
|
|
47
47
|
npx @jesdi/skills-cli sync
|
|
48
48
|
npx @jesdi/skills-cli list
|
|
49
49
|
npx @jesdi/skills-cli uninstall <skill> [--global]
|
|
50
|
+
npx @jesdi/skills-cli pin <skill> [--global] # keep the installed version
|
|
51
|
+
npx @jesdi/skills-cli unpin <skill> [--global]
|
|
52
|
+
npx @jesdi/skills-cli hook # register the session update hook
|
|
50
53
|
```
|
|
51
54
|
|
|
55
|
+
## Automatic updates
|
|
56
|
+
|
|
57
|
+
A global install registers a session-start hook in Claude Code
|
|
58
|
+
(`~/.claude/settings.json`) and Codex (`~/.codex/hooks.json`), for each one
|
|
59
|
+
that is present. The first session of a day runs
|
|
60
|
+
`skills-cli update --all --global` in the background; the result is in
|
|
61
|
+
`~/.my-skills/.session-update.log`. Project installs do not change: they stay
|
|
62
|
+
at the versions committed in `.my-skills.json`.
|
|
63
|
+
|
|
64
|
+
To keep a skill at its installed version, pin it: `skills-cli pin <skill>
|
|
65
|
+
--global`. No update touches a pinned skill until `unpin`. To stop all
|
|
66
|
+
automatic updates, remove the hook entry from the two config files; the next
|
|
67
|
+
global install or `skills-cli hook` adds it again.
|
|
68
|
+
|
|
52
69
|
## Third-party skills (not vendored)
|
|
53
70
|
|
|
54
71
|
Skills authored by other people are **not** copied into this repo — they keep
|
package/package.json
CHANGED
package/skills/backlog/SKILL.md
CHANGED
|
@@ -199,10 +199,9 @@ Reuse the **dedup discipline** from the `file-bug-issue` skill, but take the
|
|
|
199
199
|
sink an issue below the unboosted pack. Dispatchers set 99 for "work on this
|
|
200
200
|
next". `--json` rows carry `"boost"`.
|
|
201
201
|
4. Apply, in order, the `references/graphql.md` → Triage commands:
|
|
202
|
-
add to project → set Impact
|
|
203
|
-
`
|
|
204
|
-
`
|
|
205
|
-
to the body if any → `--remove-label inbox`.
|
|
202
|
+
add to project → set Impact, Effort, `Score` = round(Impact ÷ Effort, 1),
|
|
203
|
+
Area and `Status: Ready` in the single mutation given there → append
|
|
204
|
+
`Blocked by: #…` to the body if any → `--remove-label inbox`.
|
|
206
205
|
5. After each, confirm it is now triaged (fields set, `inbox` gone). Report a
|
|
207
206
|
summary of promoted vs. left-in-inbox.
|
|
208
207
|
|
|
@@ -223,7 +222,9 @@ python3 .claude/skills/backlog/rank.py --json # machine-readable rows for
|
|
|
223
222
|
`.agents/skills/backlog/rank.py`. The key is that the working directory stays at
|
|
224
223
|
or below the repo root so `rank.py` can walk up to `.backlog/`.)
|
|
225
224
|
|
|
226
|
-
`rank.py` pulls Project items
|
|
225
|
+
`rank.py` pulls Project items (one narrow GraphQL query per 100 items, so it is
|
|
226
|
+
cheap to run often; it warns on stderr when the hourly GraphQL budget is low)
|
|
227
|
+
+ issue bodies via `gh`, parses `Blocked by:`
|
|
227
228
|
edges, drops closed/`Done` blockers, and prints:
|
|
228
229
|
|
|
229
230
|
- **Available** issues ranked boost-band first, then Impact÷Effort within a band
|
package/skills/backlog/rank.py
CHANGED
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import subprocess
|
|
6
|
+
import sys
|
|
6
7
|
|
|
7
8
|
_BLOCKED_LINE = re.compile(r"^\s*Blocked by:\s*(.+)$", re.IGNORECASE | re.MULTILINE)
|
|
8
9
|
_ISSUE_REF = re.compile(r"#(\d+)")
|
|
@@ -187,8 +188,14 @@ def render(result):
|
|
|
187
188
|
|
|
188
189
|
|
|
189
190
|
def _gh_json(args, env=None):
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
try:
|
|
192
|
+
completed = subprocess.run(args, capture_output=True, text=True,
|
|
193
|
+
check=True, env=env)
|
|
194
|
+
except subprocess.CalledProcessError as error:
|
|
195
|
+
# gh's own message (rate limit, auth, scope) is the diagnosis; a
|
|
196
|
+
# traceback quoting the whole query buries it.
|
|
197
|
+
sys.exit(f"gh {' '.join(args[1:3])} failed: "
|
|
198
|
+
f"{(error.stderr or '').strip() or error}")
|
|
192
199
|
return json.loads(completed.stdout)
|
|
193
200
|
|
|
194
201
|
|
|
@@ -200,12 +207,64 @@ def _project_env():
|
|
|
200
207
|
return {**os.environ, "GH_TOKEN": token} if token else None
|
|
201
208
|
|
|
202
209
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
# One page of board items with only what ranking reads. `gh project item-list`
|
|
211
|
+
# asks for every field and several nested connections per item, which costs
|
|
212
|
+
# on the order of 100 rate-limit points per call on a ~100-item board; this
|
|
213
|
+
# costs about 1 per page. Aliases mirror the item-list JSON keys so
|
|
214
|
+
# merge_sources takes either shape.
|
|
215
|
+
_BOARD_QUERY = """
|
|
216
|
+
query($project: ID!, $cursor: String) {
|
|
217
|
+
rateLimit { remaining }
|
|
218
|
+
node(id: $project) { ... on ProjectV2 { items(first: 100, after: $cursor) {
|
|
219
|
+
pageInfo { hasNextPage endCursor }
|
|
220
|
+
nodes {
|
|
221
|
+
id
|
|
222
|
+
content { ... on Issue { number title url } }
|
|
223
|
+
title: fieldValueByName(name: "Title") { ... on ProjectV2ItemFieldTextValue { text } }
|
|
224
|
+
status: fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } }
|
|
225
|
+
area: fieldValueByName(name: "Area") { ... on ProjectV2ItemFieldSingleSelectValue { name } }
|
|
226
|
+
impact: fieldValueByName(name: "Impact") { ... on ProjectV2ItemFieldNumberValue { number } }
|
|
227
|
+
effort: fieldValueByName(name: "Effort") { ... on ProjectV2ItemFieldNumberValue { number } }
|
|
228
|
+
boost: fieldValueByName(name: "Boost") { ... on ProjectV2ItemFieldNumberValue { number } }
|
|
229
|
+
}
|
|
230
|
+
} } }
|
|
231
|
+
}
|
|
232
|
+
"""
|
|
233
|
+
LOW_BUDGET = 500 # GraphQL points left (of 5000/h) below which main() warns
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _field(node, alias):
|
|
237
|
+
value = node.get(alias) or {}
|
|
238
|
+
return next(iter(value.values()), None)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def fetch_board(project_id):
|
|
242
|
+
"""Every board item as item-list-shaped rows, plus the GraphQL budget left."""
|
|
243
|
+
items, cursor, remaining = [], None, None
|
|
244
|
+
while True:
|
|
245
|
+
args = ["gh", "api", "graphql", "-f", f"query={_BOARD_QUERY}",
|
|
246
|
+
"-f", f"project={project_id}"]
|
|
247
|
+
if cursor:
|
|
248
|
+
args += ["-f", f"cursor={cursor}"]
|
|
249
|
+
data = _gh_json(args, env=_project_env())["data"]
|
|
250
|
+
remaining = data["rateLimit"]["remaining"]
|
|
251
|
+
page = data["node"]["items"]
|
|
252
|
+
for node in page["nodes"]:
|
|
253
|
+
items.append({
|
|
254
|
+
"id": node["id"], "content": node.get("content") or {},
|
|
255
|
+
**{alias: _field(node, alias) for alias in
|
|
256
|
+
("title", "status", "area", "impact", "effort", "boost")},
|
|
257
|
+
})
|
|
258
|
+
if not page["pageInfo"]["hasNextPage"]:
|
|
259
|
+
return items, remaining
|
|
260
|
+
cursor = page["pageInfo"]["endCursor"]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main(project_id, repo, as_json=False):
|
|
264
|
+
project_items, remaining = fetch_board(project_id)
|
|
265
|
+
if remaining < LOW_BUDGET:
|
|
266
|
+
print(f"warning: GitHub GraphQL budget is low ({remaining} points left "
|
|
267
|
+
f"this hour)", file=sys.stderr)
|
|
209
268
|
issue_rows = _gh_json([
|
|
210
269
|
"gh", "issue", "list", "--repo", repo, "--state", "all",
|
|
211
270
|
"--limit", "500", "--json", "number,title,url,body,state,labels",
|
|
@@ -240,9 +299,7 @@ def find_project_meta(start=None):
|
|
|
240
299
|
|
|
241
300
|
|
|
242
301
|
if __name__ == "__main__":
|
|
243
|
-
import sys
|
|
244
302
|
config_path = find_project_meta()
|
|
245
303
|
with open(config_path) as handle:
|
|
246
304
|
meta = json.load(handle)
|
|
247
|
-
main(meta["
|
|
248
|
-
as_json="--json" in sys.argv[1:])
|
|
305
|
+
main(meta["projectId"], meta["repo"], as_json="--json" in sys.argv[1:])
|
|
@@ -11,6 +11,17 @@ gh auth status # confirm login
|
|
|
11
11
|
gh auth refresh -s project # add Projects-v2 write scope (one-time)
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
+
## Rate limit — do not read the whole board for one item
|
|
15
|
+
|
|
16
|
+
GitHub allows 5000 GraphQL points per hour per user, shared by every tool and
|
|
17
|
+
agent on that login. `gh project item-list` costs on the order of 100 points
|
|
18
|
+
per call on a ~100-item board, so a few dozen calls empty the budget. Read the
|
|
19
|
+
true budget with `gh api graphql -f query='{rateLimit{remaining resetAt}}'`
|
|
20
|
+
(`gh api rate_limit` can show a different number). `rank.py` reads the board
|
|
21
|
+
with a narrow query (about 1 point per 100 items); the lookups below go from
|
|
22
|
+
the issue to its item (1 point). Use `item-list` only for the first read of a
|
|
23
|
+
triage session, and reuse its output.
|
|
24
|
+
|
|
14
25
|
## Setup — create the board (idempotent; skip a step if it already exists)
|
|
15
26
|
|
|
16
27
|
```bash
|
|
@@ -77,22 +88,16 @@ gh issue create --repo <repo> \
|
|
|
77
88
|
# Add issue to the project (prints the item id; capture it)
|
|
78
89
|
gh project item-add <projectNumber> --owner <owner> --url <issueUrl>
|
|
79
90
|
|
|
80
|
-
# Set
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
gh
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# Set single-selects (use the option id from .backlog/project-meta.json)
|
|
92
|
-
gh project item-edit --project-id <projectId> --id <itemId> \
|
|
93
|
-
--field-id <fields.Area.id> --single-select-option-id <fields.Area.options[area]>
|
|
94
|
-
gh project item-edit --project-id <projectId> --id <itemId> \
|
|
95
|
-
--field-id <fields.Status.id> --single-select-option-id <fields.Status.options.Ready>
|
|
91
|
+
# Set all five fields in ONE request (one point, one round trip). Values come
|
|
92
|
+
# from .backlog/project-meta.json; Score = round(impact/effort, 1). Add a
|
|
93
|
+
# `boost:` alias with <fields.Boost.id> for a manual priority override.
|
|
94
|
+
gh api graphql -F p=<projectId> -F i=<itemId> -f query='mutation($p:ID!,$i:ID!){
|
|
95
|
+
impact:updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:"<fields.Impact.id>",value:{number:<1-5>}}){clientMutationId}
|
|
96
|
+
effort:updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:"<fields.Effort.id>",value:{number:<1-5>}}){clientMutationId}
|
|
97
|
+
score:updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:"<fields.Score.id>",value:{number:<score>}}){clientMutationId}
|
|
98
|
+
area:updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:"<fields.Area.id>",value:{singleSelectOptionId:"<fields.Area.options[area]>"}}){clientMutationId}
|
|
99
|
+
status:updateProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:"<fields.Status.id>",value:{singleSelectOptionId:"<fields.Status.options.Ready>"}}){clientMutationId}
|
|
100
|
+
}'
|
|
96
101
|
|
|
97
102
|
# Record blocking edges (append a line; keep any existing body)
|
|
98
103
|
gh issue edit <number> --repo <repo> --body "<body>\n\nBlocked by: #<a>, #<b>"
|
|
@@ -121,8 +126,12 @@ gh api graphql -f query='mutation($parent:ID!,$child:ID!){
|
|
|
121
126
|
|
|
122
127
|
```bash
|
|
123
128
|
gh issue view <number> --repo <repo> --json state --jq .state
|
|
124
|
-
gh
|
|
125
|
-
|
|
129
|
+
gh api graphql -f query='query($owner:String!,$repo:String!,$n:Int!){
|
|
130
|
+
repository(owner:$owner,name:$repo){issue(number:$n){projectItems(first:10){nodes{
|
|
131
|
+
id project{id}
|
|
132
|
+
status:fieldValueByName(name:"Status"){... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}}' \
|
|
133
|
+
-F owner=<owner> -F repo=<repoName> -F n=<number> \
|
|
134
|
+
--jq '.data.repository.issue.projectItems.nodes[] | select(.project.id=="<projectId>") | {id, status: .status.name}'
|
|
126
135
|
# repair only if needed:
|
|
127
136
|
gh issue close <number> --repo <repo>
|
|
128
137
|
gh project item-edit --project-id <projectId> --id <itemId> \
|
|
@@ -133,8 +142,12 @@ gh project item-edit --project-id <projectId> --id <itemId> \
|
|
|
133
142
|
|
|
134
143
|
```bash
|
|
135
144
|
# 1. Item id + live status
|
|
136
|
-
gh
|
|
137
|
-
|
|
145
|
+
gh api graphql -f query='query($owner:String!,$repo:String!,$n:Int!){
|
|
146
|
+
repository(owner:$owner,name:$repo){issue(number:$n){projectItems(first:10){nodes{
|
|
147
|
+
id project{id}
|
|
148
|
+
status:fieldValueByName(name:"Status"){... on ProjectV2ItemFieldSingleSelectValue{name}}}}}}}' \
|
|
149
|
+
-F owner=<owner> -F repo=<repoName> -F n=<number> \
|
|
150
|
+
--jq '.data.repository.issue.projectItems.nodes[] | select(.project.id=="<projectId>") | {id, status: .status.name}'
|
|
138
151
|
# 2. If status == "In progress": abort (already claimed).
|
|
139
152
|
# 3. Claim:
|
|
140
153
|
gh project item-edit --project-id <projectId> --id <itemId> \
|
|
@@ -279,30 +279,122 @@ def test_json_rows_flags_blocked_and_keeps_rank_order():
|
|
|
279
279
|
assert "url" in row
|
|
280
280
|
|
|
281
281
|
|
|
282
|
-
def
|
|
282
|
+
def _board_page(nodes, next_cursor=None, remaining=4000):
|
|
283
283
|
import json as jsonlib
|
|
284
|
-
|
|
284
|
+
return jsonlib.dumps({"data": {
|
|
285
|
+
"rateLimit": {"remaining": remaining},
|
|
286
|
+
"node": {"items": {
|
|
287
|
+
"pageInfo": {"hasNextPage": next_cursor is not None,
|
|
288
|
+
"endCursor": next_cursor},
|
|
289
|
+
"nodes": nodes}}}})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _board_node(number, title="Task", **fields):
|
|
293
|
+
"""A GraphQL item node: `content` is None when the token cannot see the repo."""
|
|
294
|
+
node = {"id": f"PVTI_{number}",
|
|
295
|
+
"content": ({"number": number, "title": title, "url": f"u/{number}"}
|
|
296
|
+
if number is not None else None),
|
|
297
|
+
"title": {"text": title}}
|
|
298
|
+
wrap = {"status": "name", "area": "name"}
|
|
299
|
+
for alias in ("status", "area", "impact", "effort", "boost"):
|
|
300
|
+
value = fields.get(alias)
|
|
301
|
+
node[alias] = None if value is None else {wrap.get(alias, "number"): value}
|
|
302
|
+
return node
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _fake_gh(pages, issues="[]", calls=None):
|
|
306
|
+
"""subprocess.run stand-in: board pages in order, then the issue list."""
|
|
307
|
+
pages = list(pages)
|
|
308
|
+
|
|
285
309
|
def fake_run(args, capture_output, text, check, env=None):
|
|
310
|
+
if calls is not None:
|
|
311
|
+
calls.append((args, env))
|
|
312
|
+
|
|
286
313
|
class R:
|
|
287
|
-
if args[1] == "
|
|
288
|
-
stdout = jsonlib.dumps({"items": [
|
|
289
|
-
{"content": {"number": 5, "title": "Task", "url": "u/5"},
|
|
290
|
-
"status": "Ready", "impact": 4, "effort": 2}]})
|
|
291
|
-
else:
|
|
292
|
-
fields = args[args.index("--json") + 1]
|
|
293
|
-
assert "labels" in fields
|
|
294
|
-
assert "title" in fields and "url" in fields # title-join keys
|
|
295
|
-
stdout = jsonlib.dumps([
|
|
296
|
-
{"number": 5, "body": "", "state": "open",
|
|
297
|
-
"labels": [{"name": "auto"}]}])
|
|
314
|
+
stdout = pages.pop(0) if args[1] == "api" else issues
|
|
298
315
|
return R()
|
|
316
|
+
return fake_run
|
|
299
317
|
|
|
300
|
-
|
|
301
|
-
|
|
318
|
+
|
|
319
|
+
def test_main_json_emits_machine_readable_rows(monkeypatch, capsys):
|
|
320
|
+
import json as jsonlib
|
|
321
|
+
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
322
|
+
calls = []
|
|
323
|
+
issues = jsonlib.dumps([{"number": 5, "body": "", "state": "open",
|
|
324
|
+
"labels": [{"name": "auto"}]}])
|
|
325
|
+
monkeypatch.setattr(rank.subprocess, "run", _fake_gh(
|
|
326
|
+
[_board_page([_board_node(5, status="Ready", impact=4.0, effort=2.0)])],
|
|
327
|
+
issues, calls))
|
|
328
|
+
rank.main("PVT_1", "acme/private-repo", as_json=True)
|
|
302
329
|
rows = jsonlib.loads(capsys.readouterr().out)
|
|
303
330
|
assert rows == [{"number": 5, "title": "Task", "url": "u/5",
|
|
304
331
|
"status": "Ready", "labels": ["auto"],
|
|
305
332
|
"blocked": False, "score": 2.0, "boost": 0}]
|
|
333
|
+
fields = calls[1][0][calls[1][0].index("--json") + 1]
|
|
334
|
+
assert "labels" in fields
|
|
335
|
+
assert "title" in fields and "url" in fields # title-join keys
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_board_read_never_uses_item_list(monkeypatch):
|
|
339
|
+
"""`gh project item-list` costs ~100 rate-limit points a call; the board
|
|
340
|
+
is read with one narrow GraphQL query per 100 items instead."""
|
|
341
|
+
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
342
|
+
calls = []
|
|
343
|
+
monkeypatch.setattr(rank.subprocess, "run", _fake_gh([_board_page([])], calls=calls))
|
|
344
|
+
rank.main("PVT_1", "acme/repo")
|
|
345
|
+
board_args = calls[0][0]
|
|
346
|
+
assert board_args[:3] == ["gh", "api", "graphql"]
|
|
347
|
+
assert "project=PVT_1" in board_args
|
|
348
|
+
assert not any(args[1] == "project" for args, _ in calls)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_fetch_board_follows_pagination(monkeypatch):
|
|
352
|
+
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
353
|
+
calls = []
|
|
354
|
+
monkeypatch.setattr(rank.subprocess, "run", _fake_gh(
|
|
355
|
+
[_board_page([_board_node(1)], next_cursor="c1"),
|
|
356
|
+
_board_page([_board_node(2)], remaining=3990)], calls=calls))
|
|
357
|
+
items, remaining = rank.fetch_board("PVT_1")
|
|
358
|
+
assert [i["content"]["number"] for i in items] == [1, 2]
|
|
359
|
+
assert remaining == 3990
|
|
360
|
+
assert "cursor=c1" not in calls[0][0] and "cursor=c1" in calls[1][0]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_fetch_board_redacted_content_keeps_title_for_the_join(monkeypatch):
|
|
364
|
+
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
365
|
+
monkeypatch.setattr(rank.subprocess, "run", _fake_gh(
|
|
366
|
+
[_board_page([_board_node(None, title="Private task", status="Ready")])]))
|
|
367
|
+
items, _ = rank.fetch_board("PVT_1")
|
|
368
|
+
assert items[0]["content"] == {} and items[0]["title"] == "Private task"
|
|
369
|
+
merged = rank.merge_sources(items, [
|
|
370
|
+
{"number": 9, "title": "Private task", "url": "u/9", "state": "open"}])
|
|
371
|
+
assert [(i["number"], i["status"]) for i in merged] == [(9, "Ready")]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_a_failing_gh_call_exits_with_ghs_own_message(monkeypatch):
|
|
375
|
+
def refused(args, capture_output, text, check, env=None):
|
|
376
|
+
raise rank.subprocess.CalledProcessError(
|
|
377
|
+
1, args, stderr="GraphQL: API rate limit exceeded for user ID 1.\n")
|
|
378
|
+
monkeypatch.setattr(rank.subprocess, "run", refused)
|
|
379
|
+
with pytest.raises(SystemExit) as exc:
|
|
380
|
+
rank.main("PVT_1", "acme/repo")
|
|
381
|
+
assert str(exc.value) == ("gh api graphql failed: GraphQL: API rate limit "
|
|
382
|
+
"exceeded for user ID 1.")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_main_warns_when_graphql_budget_is_low(monkeypatch, capsys):
|
|
386
|
+
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
387
|
+
monkeypatch.setattr(rank.subprocess, "run", _fake_gh(
|
|
388
|
+
[_board_page([], remaining=120)]))
|
|
389
|
+
rank.main("PVT_1", "acme/repo")
|
|
390
|
+
assert "120 points left" in capsys.readouterr().err
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_main_is_quiet_when_graphql_budget_is_healthy(monkeypatch, capsys):
|
|
394
|
+
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
395
|
+
monkeypatch.setattr(rank.subprocess, "run", _fake_gh([_board_page([])]))
|
|
396
|
+
rank.main("PVT_1", "acme/repo")
|
|
397
|
+
assert capsys.readouterr().err == ""
|
|
306
398
|
|
|
307
399
|
|
|
308
400
|
def test_project_env_absent_without_token(monkeypatch):
|
|
@@ -319,19 +411,10 @@ def test_project_env_swaps_gh_token(monkeypatch):
|
|
|
319
411
|
def test_main_uses_project_token_only_for_project_call(monkeypatch):
|
|
320
412
|
monkeypatch.setenv("GH_PROJECT_TOKEN", "classic-tok")
|
|
321
413
|
calls = []
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
calls.append((args, env))
|
|
325
|
-
|
|
326
|
-
class R:
|
|
327
|
-
stdout = "{\"items\": []}" if args[1] == "project" else "[]"
|
|
328
|
-
|
|
329
|
-
return R()
|
|
330
|
-
|
|
331
|
-
monkeypatch.setattr(rank.subprocess, "run", fake_run)
|
|
332
|
-
rank.main("acme", 1, "acme/private-repo")
|
|
414
|
+
monkeypatch.setattr(rank.subprocess, "run", _fake_gh([_board_page([])], calls=calls))
|
|
415
|
+
rank.main("PVT_1", "acme/private-repo")
|
|
333
416
|
project_call, issue_call = calls
|
|
334
|
-
assert project_call[0][1] == "
|
|
417
|
+
assert project_call[0][1] == "api"
|
|
335
418
|
assert project_call[1]["GH_TOKEN"] == "classic-tok"
|
|
336
419
|
assert issue_call[0][1] == "issue"
|
|
337
420
|
assert issue_call[1] is None # stored gh auth (repo PAT) stays active
|
|
@@ -15,7 +15,8 @@ a per-ticket gate (check command + crap-gate) before DONE, re-run by the reviewe
|
|
|
15
15
|
already-green locked tests need a demonstrated mutation; a Codex correctness
|
|
16
16
|
pass per risky ticket; tickets touching the same functions run in sequence;
|
|
17
17
|
check command after each merge; final /review-diff on the most capable model, one
|
|
18
|
-
fixer;
|
|
18
|
+
fixer; ticket-grouped history with fixes folded in before review; closes
|
|
19
|
+
tickets only if they are issues; no push and no PR until every
|
|
19
20
|
ticket is merged and the final review has passed. Review loop and ledger adapt
|
|
20
21
|
superpowers' subagent-driven-development (obra/superpowers, MIT).
|
|
21
22
|
-->
|
|
@@ -57,14 +58,14 @@ Run ticket subagents in the background where possible for **maximum concurrency*
|
|
|
57
58
|
|
|
58
59
|
Critical or Important findings start a fix loop of at most 4 rounds, each a fix plus a re-review scoped to that fix: rounds 1-2 resume the implementer; rounds 3-4 go to a fresh implementer one tier up. After round 4, rule on each open finding in the ledger and move on. Minor findings go to the ledger for the final review.
|
|
59
60
|
|
|
60
|
-
7. Once a ticket passes review, a **merger subagent** merges it to the PR branch and runs the repo's check command (lint, typecheck, full suite). If it fails, an implementer fixes the PR branch before the next merge.
|
|
61
|
+
7. Once a ticket passes review, a **merger subagent** merges it to the PR branch and runs the repo's check command (lint, typecheck, full suite). If it fails, an implementer fixes the PR branch before the next merge. Record the ticket's commits and any integration fixes in the ledger. After the merge and checks pass, remove that ticket's worktree and delete its branch with `git branch -d` from the PR branch, while its commits are still ancestors of the PR tip. Cleanup is part of completing the ticket, before any rebase.
|
|
61
62
|
|
|
62
63
|
8. If this changes the **frontier** of available tickets, start the next tickets. This allows for maximum concurrency. Exception: two ready tickets that change the same function never run at once. Compare their **Touches** lines (or ask the exploration subagent where each will land); on overlap, start one and hold the other until the first merges, then branch it from the updated PR branch. When in doubt, hold: a ticket you suspect builds on another ready or running ticket waits for it, even without a declared edge. Record the hold as a ruling.
|
|
63
64
|
|
|
64
|
-
9. Once all tickets are complete,
|
|
65
|
+
9. Once all tickets are complete, fetch and rebase the unpublished PR branch onto the latest base, then tidy its history by ticket using [history.md](history.md). A reviewer on the most capable model runs /review-diff on the resulting PR branch, given the ledger's minor findings and rulings. Record its resolved range and reviewed tip in the ledger. One **implementer subagent** fixes every finding as one batch, which may contain several working commits; it records the owning ticket for each fix. Use targeted checks during the fixes. Once the whole batch is complete, tidy it into the ticket commits, then run /review-diff on the full rewritten branch. If history was preserved, a scoped /review-diff on `<last-reviewed-tip>..HEAD` suffices. Its gate runs once for the completed batch. Record the new review's range and tip, marking superseded coverage, or `not needed` if there were no fixes. Rule on anything left.
|
|
65
66
|
|
|
66
|
-
10.
|
|
67
|
-
spec and tickets are issues, mark the PR as 'closing' them. List every ruling from the ledger in
|
|
68
|
-
your final message.
|
|
67
|
+
10. Before pushing, compare `git rev-parse HEAD` with the last reviewed tip and confirm the worktree is clean. Every commit through the branch tip must be covered by the full review plus any scoped re-reviews. Collect any later commits, including orchestrator changes, into one batch and follow step 9's tidy-and-review procedure. A rebase or history rewrite invalidates the recorded coverage: review the full rewritten branch, with one full gate for that completed batch. The push is ready only when the history is grouped by ticket, the clean branch tip equals the last reviewed tip, and every finding has a fix or a ledger ruling. If the review cannot finish, report the blocker and each unreviewed commit with its subject, and keep the branch local.
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
Now push the PR branch once and open the PR, ready for review (not draft), using /pr for the body when installed. If the spec and tickets are issues, mark the PR as 'closing' them. In your final message, list every ruling, the final ticket-to-commit map, and the review coverage: the latest full review's range and tip, any scoped re-review ranges and tips since it (or `not needed`), the current branch tip, and whether it equals the last reviewed tip. Earlier reviews of superseded history stay in the ledger.
|
|
70
|
+
|
|
71
|
+
11. Confirm every ticket worktree and branch was removed in step 7.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# History by ticket
|
|
2
|
+
|
|
3
|
+
Tidy the unpublished integration branch into one coherent commit per ticket,
|
|
4
|
+
ordered so each ticket follows its dependencies. Each commit contains that
|
|
5
|
+
ticket's acceptance tests, implementation, and all review or integration fixes.
|
|
6
|
+
The reviewer's commit list should tell the story of the tickets, with the
|
|
7
|
+
final behavior at each step.
|
|
8
|
+
|
|
9
|
+
## Before rewriting
|
|
10
|
+
|
|
11
|
+
- Confirm all ticket worktrees and branches were cleaned up after their merges,
|
|
12
|
+
and that the integration worktree is clean.
|
|
13
|
+
- Confirm the branch has never been pushed. Once pushed, preserve its history
|
|
14
|
+
and add reviewed follow-up commits; rewriting a published branch requires
|
|
15
|
+
explicit user authorization.
|
|
16
|
+
- Record the current tip and `HEAD^{tree}` in the ledger as the recovery point.
|
|
17
|
+
Use the ticket reports and ledger to map every working commit and every fix
|
|
18
|
+
to its owning ticket before editing history.
|
|
19
|
+
|
|
20
|
+
## Group the changes
|
|
21
|
+
|
|
22
|
+
Use an interactive rebase or reconstruct the commits in an isolated worktree,
|
|
23
|
+
operating only on branch-owned commits after the base. Fold red-test commits,
|
|
24
|
+
implementation commits, and later fixes into their ticket commit. Flatten
|
|
25
|
+
branch-only merge commits while preserving their conflict-resolution changes.
|
|
26
|
+
Keep the test-writer's acceptance files unchanged from their locked versions.
|
|
27
|
+
|
|
28
|
+
A fix touching several tickets is split by ownership when the resulting
|
|
29
|
+
commits can stand alone in dependency order. If it cannot be split safely,
|
|
30
|
+
combine the affected tickets into one coherent commit, name them in the
|
|
31
|
+
message, and record the reason in the ledger. Every final change has a ticket
|
|
32
|
+
owner; an out-of-scope change needs a ruling before inclusion.
|
|
33
|
+
|
|
34
|
+
Use a commit subject that names the ticket's delivered behavior and put its
|
|
35
|
+
ticket number or file pointer in the body. Keep a map from the old working
|
|
36
|
+
commits to the final ticket commits in the ledger; ticket reports and locked
|
|
37
|
+
test SHAs still refer to the original history.
|
|
38
|
+
|
|
39
|
+
## Completion
|
|
40
|
+
|
|
41
|
+
The rewritten tip's tree must equal the saved tree: history cleanup changes
|
|
42
|
+
the presentation of the work, not its files. A mismatch must be resolved
|
|
43
|
+
before review. Inspect each final commit's diff against its ticket to confirm
|
|
44
|
+
the tests, implementation, and fixes stayed together, dependencies precede
|
|
45
|
+
their consumers, and no temporary red-test or fix-only commits remain.
|
|
46
|
+
|
|
47
|
+
Use targeted checks where needed to verify a regrouped ticket. Run the full
|
|
48
|
+
gate once at the completed branch tip, through /review-diff. Rewriting changes
|
|
49
|
+
the review endpoints, so the next review covers the full rewritten branch;
|
|
50
|
+
mark the old coverage superseded even if the tip's tree is identical. After a
|
|
51
|
+
review requests fixes, finish the whole fix batch, fold it into these same
|
|
52
|
+
ticket commits, and repeat this completion check before the next review.
|
|
@@ -37,6 +37,15 @@ on one axis must not hide a failure on another.
|
|
|
37
37
|
Before anything else, confirm the range resolves and the diff is non-empty. A
|
|
38
38
|
bad ref fails here, not inside three reviewers.
|
|
39
39
|
|
|
40
|
+
Resolve the diff endpoints to commit SHAs once, before the gate, and use that
|
|
41
|
+
immutable range for every reviewer and the commit list. For a base ref, the
|
|
42
|
+
start is its merge-base with the captured `HEAD`; for an explicit range, keep
|
|
43
|
+
its requested diff semantics. Record the reviewed tip (the right endpoint)
|
|
44
|
+
and the branch tip at the start. Run the gate on that reviewed tip in a clean
|
|
45
|
+
worktree; an explicit range ending at another commit needs a worktree at that
|
|
46
|
+
commit. Local changes must be committed or removed before review so the range
|
|
47
|
+
accounts for everything checked.
|
|
48
|
+
|
|
40
49
|
## Step 0 — gate (sequential, a precondition, not a reviewer)
|
|
41
50
|
|
|
42
51
|
1. Run the repo's check command: `make check` if the Makefile has it,
|
|
@@ -70,7 +79,7 @@ paste one reviewer's output into another's prompt.
|
|
|
70
79
|
```bash
|
|
71
80
|
out=$(mktemp) && codex exec -s read-only --ephemeral -o "$out" \
|
|
72
81
|
"Read and follow <skill-dir>/correctness.md. Range: <range>. Commits: <list>.<quick>" \
|
|
73
|
-
>/dev/null 2>&1; cat "$out"
|
|
82
|
+
< /dev/null >/dev/null 2>&1; cat "$out"
|
|
74
83
|
```
|
|
75
84
|
|
|
76
85
|
No Codex → spawn a subagent with the same instruction and note "Correctness:
|
|
@@ -88,6 +97,7 @@ fresh context if you can, and say so.
|
|
|
88
97
|
|
|
89
98
|
```
|
|
90
99
|
Gate: `make check` green · crap-gate green
|
|
100
|
+
Coverage: <resolved range> · reviewed tip: <SHA> · current branch tip: <SHA> · tip matches: yes/no
|
|
91
101
|
|
|
92
102
|
## Spec
|
|
93
103
|
<report, verbatim or lightly cleaned>
|
|
@@ -107,6 +117,15 @@ A skipped step keeps its place with its one-line note (in the gate line, or
|
|
|
107
117
|
as the section body and summary line). `--quick` has no Structure section and
|
|
108
118
|
no Structure summary line. A clean reviewer's summary line is `0 findings`.
|
|
109
119
|
|
|
120
|
+
Read the original branch tip again when reporting. Include the coverage line
|
|
121
|
+
in full and quick reviews, and label a failed gate `review not run` rather
|
|
122
|
+
than calling its endpoint reviewed. Name skipped or failed reviewers so the
|
|
123
|
+
coverage line cannot imply they ran. If the branch tip changed, list each
|
|
124
|
+
commit in `<reviewed-tip>..<current-tip>` with its subject. If the reviewed tip
|
|
125
|
+
is no longer an ancestor (for example, after a rebase or history cleanup), state that this review
|
|
126
|
+
does not cover the current history. Report a dirty worktree as unreviewed
|
|
127
|
+
local changes even when the tips match.
|
|
128
|
+
|
|
110
129
|
Do not merge, dedupe or re-rank findings across reviewers, and do not pick an
|
|
111
130
|
overall winner. A finding two reviewers both raise stays in both reports; that
|
|
112
131
|
is signal.
|
package/skills-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-08T10:41:10.504Z",
|
|
4
4
|
"skills": [
|
|
5
5
|
{
|
|
6
6
|
"name": "backlog",
|
|
7
|
-
"version": "0.1.
|
|
8
|
-
"hash": "sha256-
|
|
7
|
+
"version": "0.1.8",
|
|
8
|
+
"hash": "sha256-5bd474d3b3ffbb3af23380663c24c8a8d3ba263cfd4dbe7e636879595e6995b8",
|
|
9
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
10
|
},
|
|
11
11
|
{
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"name": "implement-spec",
|
|
49
|
-
"version": "0.1.
|
|
50
|
-
"hash": "sha256-
|
|
49
|
+
"version": "0.1.6",
|
|
50
|
+
"hash": "sha256-836f28be9230179d7389ef5ed8e1dacadd43d649a20a06f32adb891d5a4c1357",
|
|
51
51
|
"description": "Implement a specification in code."
|
|
52
52
|
},
|
|
53
53
|
{
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
72
|
"name": "review-diff",
|
|
73
|
-
"version": "0.1.
|
|
74
|
-
"hash": "sha256-
|
|
73
|
+
"version": "0.1.2",
|
|
74
|
+
"hash": "sha256-70beaf1478820d66e1831d45739ce97ced50224b46c5003d887ea62c8438f251",
|
|
75
75
|
"description": "Review a branch before merge: first a gate (the repo's check command plus crap-gate), then three independent reviewers in parallel — spec (did we build what was asked, no more, no less), correctness via Codex (races, idempotency, money, time, authz, indexes, N+1, migrations, error paths, test quality) and structure (deep-quality-review). `--quick` runs only spec and correctness, shortened, for a live interview. Use when the user says \"/review-diff\", \"review this branch\", \"review this slice\", \"review before merge\", or before closing out a spec task."
|
|
76
76
|
},
|
|
77
77
|
{
|