@jesdi/skills 0.0.26 → 0.0.27

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.26",
3
+ "version": "0.0.27",
4
4
  "description": "jesdi's agent skills (content package consumed by @jesdi/skills-cli)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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/Effort (`--number`) → set
203
- `Score` = round(Impact ÷ Effort, 1) (`--number`) → set Area +
204
- `Status: Ready` (`--single-select-option-id`) → append `Blocked by: #…`
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 + issue bodies via `gh`, parses `Blocked by:`
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
@@ -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
- completed = subprocess.run(args, capture_output=True, text=True, check=True,
191
- env=env)
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
- def main(owner, project_number, repo, as_json=False):
204
- payload = _gh_json([
205
- "gh", "project", "item-list", str(project_number),
206
- "--owner", owner, "--format", "json", "--limit", "200",
207
- ], env=_project_env())
208
- project_items = payload.get("items", [])
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["owner"], meta["projectNumber"], meta["repo"],
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 number fields
81
- gh project item-edit --project-id <projectId> --id <itemId> \
82
- --field-id <fields.Impact.id> --number <1-5>
83
- gh project item-edit --project-id <projectId> --id <itemId> \
84
- --field-id <fields.Effort.id> --number <1-5>
85
- gh project item-edit --project-id <projectId> --id <itemId> \
86
- --field-id <fields.Score.id> --number <round(impact/effort, 1)>
87
- # Optional: manual priority override (default 0; positive boosts above unboosted, negative sinks below)
88
- gh project item-edit --project-id <projectId> --id <itemId> \
89
- --field-id <fields.Boost.id> --number <n>
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 project item-list <projectNumber> --owner <owner> --format json \
125
- --jq '.items[] | select(.content.number==<number>) | {id, status}'
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 project item-list <projectNumber> --owner <owner> --format json \
137
- --jq '.items[] | select(.content.number==<number>) | {id, status}'
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 test_main_json_emits_machine_readable_rows(monkeypatch, capsys):
282
+ def _board_page(nodes, next_cursor=None, remaining=4000):
283
283
  import json as jsonlib
284
- monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
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] == "project":
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
- monkeypatch.setattr(rank.subprocess, "run", fake_run)
301
- rank.main("acme", 1, "acme/private-repo", as_json=True)
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
- def fake_run(args, capture_output, text, check, env=None):
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] == "project"
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
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-02T10:20:34.972Z",
3
+ "generatedAt": "2026-10-06T10:03:53.513Z",
4
4
  "skills": [
5
5
  {
6
6
  "name": "backlog",
7
- "version": "0.1.7",
8
- "hash": "sha256-652964aebeadbd2a4ecafd5a588f0bd0c31b90442f3133f64d7d663893a1f7a0",
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
  {