@jesdi/skills 0.0.4 → 0.0.6
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 +1 -1
- package/skills/backlog/SKILL.md +5 -1
- package/skills/backlog/rank.py +51 -6
- package/skills/backlog/test_rank.py +99 -0
- package/skills-manifest.json +3 -3
package/package.json
CHANGED
package/skills/backlog/SKILL.md
CHANGED
|
@@ -151,7 +151,11 @@ Runs the pure engine; makes no writes. Run **from the project root** (so the cwd
|
|
|
151
151
|
walk-up finds `.backlog/project-meta.json`); do **not** `cd` into the skill dir:
|
|
152
152
|
|
|
153
153
|
```bash
|
|
154
|
-
python3 .claude/skills/backlog/rank.py
|
|
154
|
+
python3 .claude/skills/backlog/rank.py # human-readable table
|
|
155
|
+
python3 .claude/skills/backlog/rank.py --json # machine-readable rows for
|
|
156
|
+
# dispatchers: number, title,
|
|
157
|
+
# url, status, labels, blocked,
|
|
158
|
+
# score
|
|
155
159
|
```
|
|
156
160
|
|
|
157
161
|
(Use whichever agent-skills path the store symlinked the skill into — e.g.
|
package/skills/backlog/rank.py
CHANGED
|
@@ -29,14 +29,31 @@ def _as_int(value):
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def merge_sources(project_items, issue_rows):
|
|
32
|
-
"""Join Project field values (item-list) with issue bodies/state (issue list).
|
|
32
|
+
"""Join Project field values (item-list) with issue bodies/state (issue list).
|
|
33
|
+
|
|
34
|
+
Join key is the issue number when the token can expand item content; when
|
|
35
|
+
content is redacted (project-scope token + private repo), fall back to the
|
|
36
|
+
title — GitHub syncs linked-item titles to issue titles. Ambiguous titles
|
|
37
|
+
(duplicates) and titles matching no issue are skipped, as are drafts.
|
|
38
|
+
"""
|
|
33
39
|
bodies = {row["number"]: row for row in issue_rows}
|
|
40
|
+
by_title, dup_titles = {}, set()
|
|
41
|
+
for row in issue_rows:
|
|
42
|
+
title = row.get("title")
|
|
43
|
+
if title in by_title:
|
|
44
|
+
dup_titles.add(title)
|
|
45
|
+
elif title is not None:
|
|
46
|
+
by_title[title] = row
|
|
34
47
|
merged = []
|
|
35
48
|
for item in project_items:
|
|
36
49
|
content = item.get("content") or {}
|
|
37
50
|
number = content.get("number")
|
|
38
51
|
if number is None:
|
|
39
|
-
|
|
52
|
+
row = by_title.get(item.get("title"))
|
|
53
|
+
if row is None or item.get("title") in dup_titles:
|
|
54
|
+
continue # draft, board-only, or ambiguous — not joinable
|
|
55
|
+
number = row["number"]
|
|
56
|
+
content = {"title": row.get("title", ""), "url": row.get("url", "")}
|
|
40
57
|
row = bodies.get(number, {})
|
|
41
58
|
merged.append({
|
|
42
59
|
"number": number,
|
|
@@ -48,6 +65,7 @@ def merge_sources(project_items, issue_rows):
|
|
|
48
65
|
"impact": _as_int(item.get("impact")),
|
|
49
66
|
"effort": _as_int(item.get("effort")),
|
|
50
67
|
"area": item.get("area"),
|
|
68
|
+
"labels": [l["name"] for l in row.get("labels") or []],
|
|
51
69
|
})
|
|
52
70
|
return merged
|
|
53
71
|
|
|
@@ -111,6 +129,27 @@ def rank_issues(issues):
|
|
|
111
129
|
}
|
|
112
130
|
|
|
113
131
|
|
|
132
|
+
def to_json_rows(result):
|
|
133
|
+
"""Flat machine-readable rows for `--json` consumers (e.g. dispatchers):
|
|
134
|
+
available issues in rank order, then blocked, then in-progress."""
|
|
135
|
+
rows = []
|
|
136
|
+
for issue, blocked in (
|
|
137
|
+
[(i, False) for i in result["available"]]
|
|
138
|
+
+ [(i, True) for i in result["blocked"]]
|
|
139
|
+
+ [(i, False) for i in result["in_progress"]]
|
|
140
|
+
):
|
|
141
|
+
rows.append({
|
|
142
|
+
"number": issue["number"],
|
|
143
|
+
"title": issue["title"],
|
|
144
|
+
"url": issue["url"],
|
|
145
|
+
"status": issue.get("status"),
|
|
146
|
+
"labels": issue.get("labels", []),
|
|
147
|
+
"blocked": blocked,
|
|
148
|
+
"score": score(issue),
|
|
149
|
+
})
|
|
150
|
+
return rows
|
|
151
|
+
|
|
152
|
+
|
|
114
153
|
def render(result):
|
|
115
154
|
lines = []
|
|
116
155
|
if result["in_progress"]:
|
|
@@ -153,7 +192,7 @@ def _project_env():
|
|
|
153
192
|
return {**os.environ, "GH_TOKEN": token} if token else None
|
|
154
193
|
|
|
155
194
|
|
|
156
|
-
def main(owner, project_number, repo):
|
|
195
|
+
def main(owner, project_number, repo, as_json=False):
|
|
157
196
|
payload = _gh_json([
|
|
158
197
|
"gh", "project", "item-list", str(project_number),
|
|
159
198
|
"--owner", owner, "--format", "json", "--limit", "200",
|
|
@@ -161,10 +200,14 @@ def main(owner, project_number, repo):
|
|
|
161
200
|
project_items = payload.get("items", [])
|
|
162
201
|
issue_rows = _gh_json([
|
|
163
202
|
"gh", "issue", "list", "--repo", repo, "--state", "all",
|
|
164
|
-
"--limit", "500", "--json", "number,body,state",
|
|
203
|
+
"--limit", "500", "--json", "number,title,url,body,state,labels",
|
|
165
204
|
])
|
|
166
205
|
issues = merge_sources(project_items, issue_rows)
|
|
167
|
-
|
|
206
|
+
result = rank_issues(issues)
|
|
207
|
+
if as_json:
|
|
208
|
+
print(json.dumps(to_json_rows(result)))
|
|
209
|
+
else:
|
|
210
|
+
print(render(result))
|
|
168
211
|
|
|
169
212
|
|
|
170
213
|
def find_project_meta(start=None):
|
|
@@ -189,7 +232,9 @@ def find_project_meta(start=None):
|
|
|
189
232
|
|
|
190
233
|
|
|
191
234
|
if __name__ == "__main__":
|
|
235
|
+
import sys
|
|
192
236
|
config_path = find_project_meta()
|
|
193
237
|
with open(config_path) as handle:
|
|
194
238
|
meta = json.load(handle)
|
|
195
|
-
main(meta["owner"], meta["projectNumber"], meta["repo"]
|
|
239
|
+
main(meta["owner"], meta["projectNumber"], meta["repo"],
|
|
240
|
+
as_json="--json" in sys.argv[1:])
|
|
@@ -37,6 +37,46 @@ def test_merge_sources_joins_fields_and_bodies():
|
|
|
37
37
|
assert thirtyfour["impact"] is None and thirtyfour["status"] is None
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def test_merge_sources_falls_back_to_title_join_when_content_redacted():
|
|
41
|
+
# A project-scope token can list items and field values but cannot expand
|
|
42
|
+
# the linked issue of a private repo: no "content", empty "repository".
|
|
43
|
+
# GitHub syncs linked-item titles to issue titles, so title is the join key.
|
|
44
|
+
project_items = [
|
|
45
|
+
{"id": "PVTI_a", "title": "Add X", "repository": "",
|
|
46
|
+
"status": "Ready", "impact": 4, "effort": 2},
|
|
47
|
+
]
|
|
48
|
+
issue_rows = [
|
|
49
|
+
{"number": 12, "title": "Add X", "url": "u/12",
|
|
50
|
+
"body": "Blocked by: #34", "state": "open",
|
|
51
|
+
"labels": [{"name": "auto"}]},
|
|
52
|
+
]
|
|
53
|
+
out = rank.merge_sources(project_items, issue_rows)
|
|
54
|
+
assert len(out) == 1
|
|
55
|
+
twelve = out[0]
|
|
56
|
+
assert twelve["number"] == 12
|
|
57
|
+
assert twelve["url"] == "u/12"
|
|
58
|
+
assert twelve["impact"] == 4 and twelve["status"] == "Ready"
|
|
59
|
+
assert twelve["body"] == "Blocked by: #34"
|
|
60
|
+
assert twelve["labels"] == ["auto"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_merge_sources_redacted_item_without_matching_title_is_skipped():
|
|
64
|
+
out = rank.merge_sources(
|
|
65
|
+
[{"id": "PVTI_a", "title": "board-only note", "status": "Ready"}],
|
|
66
|
+
[{"number": 1, "title": "Other", "url": "u/1", "body": "", "state": "open"}],
|
|
67
|
+
)
|
|
68
|
+
assert out == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_merge_sources_title_join_skips_ambiguous_duplicate_titles():
|
|
72
|
+
project_items = [{"id": "PVTI_a", "title": "Dup", "status": "Ready"}]
|
|
73
|
+
issue_rows = [
|
|
74
|
+
{"number": 1, "title": "Dup", "url": "u/1", "body": "", "state": "open"},
|
|
75
|
+
{"number": 2, "title": "Dup", "url": "u/2", "body": "", "state": "open"},
|
|
76
|
+
]
|
|
77
|
+
assert rank.merge_sources(project_items, issue_rows) == []
|
|
78
|
+
|
|
79
|
+
|
|
40
80
|
def test_merge_sources_missing_body_row_defaults_open_empty():
|
|
41
81
|
out = rank.merge_sources(
|
|
42
82
|
[{"content": {"number": 7, "title": "T", "url": "u/7"}, "impact": 3, "effort": 1}],
|
|
@@ -206,6 +246,65 @@ def test_find_project_meta_nearest_ancestor_wins(tmp_path, monkeypatch):
|
|
|
206
246
|
assert rank.find_project_meta() == str(inner_meta)
|
|
207
247
|
|
|
208
248
|
|
|
249
|
+
def test_merge_sources_carries_label_names():
|
|
250
|
+
out = rank.merge_sources(
|
|
251
|
+
[{"content": {"number": 7, "title": "T", "url": "u/7"}}],
|
|
252
|
+
[{"number": 7, "body": "", "state": "open",
|
|
253
|
+
"labels": [{"name": "auto"}, {"name": "bug"}]}],
|
|
254
|
+
)
|
|
255
|
+
assert out[0]["labels"] == ["auto", "bug"]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_merge_sources_defaults_labels_empty():
|
|
259
|
+
out = rank.merge_sources(
|
|
260
|
+
[{"content": {"number": 7, "title": "T", "url": "u/7"}}], [])
|
|
261
|
+
assert out[0]["labels"] == []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_json_rows_flags_blocked_and_keeps_rank_order():
|
|
265
|
+
issues = [
|
|
266
|
+
_scored(1, 2, 2), # score 1.0
|
|
267
|
+
_scored(2, 5, 1), # score 5.0 -> first
|
|
268
|
+
_scored(3, 9, 1, body="Blocked by: #2"), # blocked
|
|
269
|
+
_scored(4, 4, 1, status="In progress"), # claimed
|
|
270
|
+
]
|
|
271
|
+
for i in issues:
|
|
272
|
+
i["labels"] = ["auto"]
|
|
273
|
+
rows = rank.to_json_rows(rank.rank_issues(issues))
|
|
274
|
+
assert [(r["number"], r["blocked"]) for r in rows] == [
|
|
275
|
+
(2, False), (1, False), (3, True), (4, False)]
|
|
276
|
+
row = rows[0]
|
|
277
|
+
assert row["title"] == "i2" and row["status"] == "Ready"
|
|
278
|
+
assert row["labels"] == ["auto"]
|
|
279
|
+
assert "url" in row
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_main_json_emits_machine_readable_rows(monkeypatch, capsys):
|
|
283
|
+
import json as jsonlib
|
|
284
|
+
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
285
|
+
def fake_run(args, capture_output, text, check, env=None):
|
|
286
|
+
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"}]}])
|
|
298
|
+
return R()
|
|
299
|
+
|
|
300
|
+
monkeypatch.setattr(rank.subprocess, "run", fake_run)
|
|
301
|
+
rank.main("acme", 1, "acme/private-repo", as_json=True)
|
|
302
|
+
rows = jsonlib.loads(capsys.readouterr().out)
|
|
303
|
+
assert rows == [{"number": 5, "title": "Task", "url": "u/5",
|
|
304
|
+
"status": "Ready", "labels": ["auto"],
|
|
305
|
+
"blocked": False, "score": 2.0}]
|
|
306
|
+
|
|
307
|
+
|
|
209
308
|
def test_project_env_absent_without_token(monkeypatch):
|
|
210
309
|
monkeypatch.delenv("GH_PROJECT_TOKEN", raising=False)
|
|
211
310
|
assert rank._project_env() is None
|
package/skills-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-23T15:49:56.550Z",
|
|
4
4
|
"skills": [
|
|
5
5
|
{
|
|
6
6
|
"name": "backlog",
|
|
7
|
-
"version": "0.1.
|
|
8
|
-
"hash": "sha256-
|
|
7
|
+
"version": "0.1.3",
|
|
8
|
+
"hash": "sha256-afe5ef66a86a4bbb1ce654e9ec861be80873e36e1cb45fbe132564a25dc26c1d",
|
|
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
|
{
|