@jesdi/skills 0.0.5 → 0.0.7
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
package/skills/backlog/SKILL.md
CHANGED
|
@@ -52,7 +52,7 @@ dir.
|
|
|
52
52
|
1. Ensure the `project` scope: `gh auth status`; if Projects calls 403, run
|
|
53
53
|
`gh auth refresh -s project`.
|
|
54
54
|
2. Follow `references/graphql.md` → **Setup**: create the Project, link the repo,
|
|
55
|
-
add the
|
|
55
|
+
add the six fields, create the `inbox` label. On a brand-new board the `Area`
|
|
56
56
|
single-select is created with the generic default options
|
|
57
57
|
`feature,bug,infra,docs,research`. If any step reports "already exists", treat
|
|
58
58
|
it as done and continue. Projects that want different Areas edit the option set
|
|
@@ -137,6 +137,12 @@ Reuse the **dedup discipline** from the `file-bug-issue` skill, but take the
|
|
|
137
137
|
3. For a promoted issue, gather `Impact` (1–5), `Effort` (1–5), `Area` (**pick one
|
|
138
138
|
from the options present in `.backlog/project-meta.json`** → `fields.Area.options`),
|
|
139
139
|
and optional blockers.
|
|
140
|
+
|
|
141
|
+
- **Boost** (number, optional, default 0): manual priority override. Ranking is
|
|
142
|
+
band-dominant — any issue with higher Boost outranks every issue with lower
|
|
143
|
+
Boost regardless of score; score orders issues within a band. Negative values
|
|
144
|
+
sink an issue below the unboosted pack. Dispatchers set 99 for "work on this
|
|
145
|
+
next". `--json` rows carry `"boost"`.
|
|
140
146
|
4. Apply, in order, the `references/graphql.md` → Triage commands:
|
|
141
147
|
add to project → set Impact/Effort (`--number`) → set
|
|
142
148
|
`Score` = round(Impact ÷ Effort, 1) (`--number`) → set Area +
|
|
@@ -155,7 +161,7 @@ python3 .claude/skills/backlog/rank.py # human-readable table
|
|
|
155
161
|
python3 .claude/skills/backlog/rank.py --json # machine-readable rows for
|
|
156
162
|
# dispatchers: number, title,
|
|
157
163
|
# url, status, labels, blocked,
|
|
158
|
-
# score
|
|
164
|
+
# score, boost
|
|
159
165
|
```
|
|
160
166
|
|
|
161
167
|
(Use whichever agent-skills path the store symlinked the skill into — e.g.
|
|
@@ -165,10 +171,11 @@ or below the repo root so `rank.py` can walk up to `.backlog/`.)
|
|
|
165
171
|
`rank.py` pulls Project items + issue bodies via `gh`, parses `Blocked by:`
|
|
166
172
|
edges, drops closed/`Done` blockers, and prints:
|
|
167
173
|
|
|
168
|
-
- **Available** issues ranked
|
|
169
|
-
`[needs triage]`).
|
|
174
|
+
- **Available** issues ranked boost-band first, then Impact÷Effort within a band
|
|
175
|
+
(unscored sort last within a band, flagged `[needs triage]`).
|
|
170
176
|
- **Blocked** issues, unranked, annotated with what they wait on.
|
|
171
177
|
|
|
172
|
-
Present the table as-is. If the user asks "why isn't #N at the top",
|
|
173
|
-
|
|
178
|
+
Present the table as-is. If the user asks "why isn't #N at the top", check first
|
|
179
|
+
whether another issue has a higher Boost band (shown as `↑n`), then its score, then
|
|
180
|
+
its blockers from the output. This in-memory ranking is the reason the
|
|
174
181
|
skill exists over a bare `gh issue list`.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"Impact": {"id": "PVTF_REPLACE"},
|
|
12
12
|
"Effort": {"id": "PVTF_REPLACE"},
|
|
13
13
|
"Score": {"id": "PVTF_REPLACE"},
|
|
14
|
+
"Boost": {"id": "PVTF_REPLACE"},
|
|
14
15
|
"Area": {
|
|
15
16
|
"id": "PVTSSF_REPLACE",
|
|
16
17
|
"options": {"feature": "...", "bug": "...", "infra": "...", "docs": "...", "research": "..."}
|
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,
|
|
@@ -49,6 +66,7 @@ def merge_sources(project_items, issue_rows):
|
|
|
49
66
|
"effort": _as_int(item.get("effort")),
|
|
50
67
|
"area": item.get("area"),
|
|
51
68
|
"labels": [l["name"] for l in row.get("labels") or []],
|
|
69
|
+
"boost": (lambda v: 0 if v is None else v)(_as_int(item.get("boost"))),
|
|
52
70
|
})
|
|
53
71
|
return merged
|
|
54
72
|
|
|
@@ -83,7 +101,8 @@ def score(issue):
|
|
|
83
101
|
def _sort_key(issue):
|
|
84
102
|
value = score(issue)
|
|
85
103
|
return (
|
|
86
|
-
0
|
|
104
|
+
-issue.get("boost", 0), # band first: higher boost above everything
|
|
105
|
+
0 if value is not None else 1, # scored issues first within a band
|
|
87
106
|
-(value or 0.0), # higher score first
|
|
88
107
|
-(issue.get("impact") or 0), # tiebreak: higher impact
|
|
89
108
|
issue["number"], # stable, deterministic
|
|
@@ -129,6 +148,7 @@ def to_json_rows(result):
|
|
|
129
148
|
"labels": issue.get("labels", []),
|
|
130
149
|
"blocked": blocked,
|
|
131
150
|
"score": score(issue),
|
|
151
|
+
"boost": issue.get("boost", 0),
|
|
132
152
|
})
|
|
133
153
|
return rows
|
|
134
154
|
|
|
@@ -148,6 +168,11 @@ def render(result):
|
|
|
148
168
|
value = score(issue)
|
|
149
169
|
score_str = f"{value:.2f}" if value is not None else " — "
|
|
150
170
|
flag = "" if value is not None else " [needs triage]"
|
|
171
|
+
boost = issue.get("boost", 0)
|
|
172
|
+
if boost > 0:
|
|
173
|
+
flag += f" ↑{boost}"
|
|
174
|
+
elif boost < 0:
|
|
175
|
+
flag += f" ↓{-boost}"
|
|
151
176
|
label = f"#{issue['number']} {issue['title']}"[:38].ljust(38)
|
|
152
177
|
area = (issue.get("area") or "-")
|
|
153
178
|
lines.append(f"{idx:>4} {score_str:>5} {label} {area:<10}{flag}")
|
|
@@ -183,7 +208,7 @@ def main(owner, project_number, repo, as_json=False):
|
|
|
183
208
|
project_items = payload.get("items", [])
|
|
184
209
|
issue_rows = _gh_json([
|
|
185
210
|
"gh", "issue", "list", "--repo", repo, "--state", "all",
|
|
186
|
-
"--limit", "500", "--json", "number,body,state,labels",
|
|
211
|
+
"--limit", "500", "--json", "number,title,url,body,state,labels",
|
|
187
212
|
])
|
|
188
213
|
issues = merge_sources(project_items, issue_rows)
|
|
189
214
|
result = rank_issues(issues)
|
|
@@ -26,6 +26,7 @@ gh project field-create <projectNumber> --owner <owner> --name "Status" \
|
|
|
26
26
|
gh project field-create <projectNumber> --owner <owner> --name "Impact" --data-type NUMBER
|
|
27
27
|
gh project field-create <projectNumber> --owner <owner> --name "Effort" --data-type NUMBER
|
|
28
28
|
gh project field-create <projectNumber> --owner <owner> --name "Score" --data-type NUMBER
|
|
29
|
+
gh project field-create <projectNumber> --owner <owner> --name "Boost" --data-type NUMBER
|
|
29
30
|
gh project field-create <projectNumber> --owner <owner> --name "Area" \
|
|
30
31
|
--data-type SINGLE_SELECT \
|
|
31
32
|
--single-select-options "feature,bug,infra,docs,research"
|
|
@@ -65,6 +66,9 @@ gh project item-edit --project-id <projectId> --id <itemId> \
|
|
|
65
66
|
--field-id <fields.Effort.id> --number <1-5>
|
|
66
67
|
gh project item-edit --project-id <projectId> --id <itemId> \
|
|
67
68
|
--field-id <fields.Score.id> --number <round(impact/effort, 1)>
|
|
69
|
+
# Optional: manual priority override (default 0; positive boosts above unboosted, negative sinks below)
|
|
70
|
+
gh project item-edit --project-id <projectId> --id <itemId> \
|
|
71
|
+
--field-id <fields.Boost.id> --number <n>
|
|
68
72
|
|
|
69
73
|
# Set single-selects (use the option id from .backlog/project-meta.json)
|
|
70
74
|
gh project item-edit --project-id <projectId> --id <itemId> \
|
|
@@ -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}],
|
|
@@ -249,7 +289,9 @@ def test_main_json_emits_machine_readable_rows(monkeypatch, capsys):
|
|
|
249
289
|
{"content": {"number": 5, "title": "Task", "url": "u/5"},
|
|
250
290
|
"status": "Ready", "impact": 4, "effort": 2}]})
|
|
251
291
|
else:
|
|
252
|
-
|
|
292
|
+
fields = args[args.index("--json") + 1]
|
|
293
|
+
assert "labels" in fields
|
|
294
|
+
assert "title" in fields and "url" in fields # title-join keys
|
|
253
295
|
stdout = jsonlib.dumps([
|
|
254
296
|
{"number": 5, "body": "", "state": "open",
|
|
255
297
|
"labels": [{"name": "auto"}]}])
|
|
@@ -260,7 +302,7 @@ def test_main_json_emits_machine_readable_rows(monkeypatch, capsys):
|
|
|
260
302
|
rows = jsonlib.loads(capsys.readouterr().out)
|
|
261
303
|
assert rows == [{"number": 5, "title": "Task", "url": "u/5",
|
|
262
304
|
"status": "Ready", "labels": ["auto"],
|
|
263
|
-
"blocked": False, "score": 2.0}]
|
|
305
|
+
"blocked": False, "score": 2.0, "boost": 0}]
|
|
264
306
|
|
|
265
307
|
|
|
266
308
|
def test_project_env_absent_without_token(monkeypatch):
|
|
@@ -301,3 +343,72 @@ def test_find_project_meta_errors_clearly_when_absent(tmp_path, monkeypatch):
|
|
|
301
343
|
rank.find_project_meta()
|
|
302
344
|
assert ".backlog/project-meta.json" in str(exc.value)
|
|
303
345
|
assert "backlog setup" in str(exc.value)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_merge_sources_parses_boost_and_defaults_zero():
|
|
349
|
+
out = rank.merge_sources(
|
|
350
|
+
[{"content": {"number": 7, "title": "T", "url": "u/7"}, "boost": 2},
|
|
351
|
+
{"content": {"number": 8, "title": "U", "url": "u/8"}, "boost": ""},
|
|
352
|
+
{"content": {"number": 9, "title": "V", "url": "u/9"}},
|
|
353
|
+
{"content": {"number": 10, "title": "W", "url": "u/10"}, "boost": -3}],
|
|
354
|
+
[])
|
|
355
|
+
assert [i["boost"] for i in out] == [2, 0, 0, -3]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_boost_band_dominates_score():
|
|
359
|
+
lo = _scored(1, 5, 1) # score 5.0, unboosted
|
|
360
|
+
hi = _scored(2, 1, 2) # score 0.5, boosted
|
|
361
|
+
hi["boost"] = 1
|
|
362
|
+
result = rank.rank_issues([lo, hi])
|
|
363
|
+
assert [i["number"] for i in result["available"]] == [2, 1]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_score_orders_within_boost_band():
|
|
367
|
+
a = _scored(1, 2, 2) # score 1.0
|
|
368
|
+
b = _scored(2, 5, 1) # score 5.0
|
|
369
|
+
a["boost"] = b["boost"] = 1
|
|
370
|
+
result = rank.rank_issues([a, b])
|
|
371
|
+
assert [i["number"] for i in result["available"]] == [2, 1]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_negative_boost_sinks_below_unboosted():
|
|
375
|
+
demoted = _scored(1, 9, 1) # score 9.0 but demoted
|
|
376
|
+
demoted["boost"] = -1
|
|
377
|
+
plain = _scored(2, 1, 1) # score 1.0
|
|
378
|
+
result = rank.rank_issues([demoted, plain])
|
|
379
|
+
assert [i["number"] for i in result["available"]] == [2, 1]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_unscored_boosted_enters_band_after_scored_in_band():
|
|
383
|
+
scored_boosted = _scored(1, 2, 1)
|
|
384
|
+
scored_boosted["boost"] = 1
|
|
385
|
+
unscored_boosted = _scored(2, None, None)
|
|
386
|
+
unscored_boosted["boost"] = 1
|
|
387
|
+
scored_plain = _scored(3, 5, 1)
|
|
388
|
+
result = rank.rank_issues([scored_plain, unscored_boosted, scored_boosted])
|
|
389
|
+
assert [i["number"] for i in result["available"]] == [1, 2, 3]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_json_rows_include_boost():
|
|
393
|
+
a = _scored(1, 2, 1)
|
|
394
|
+
a["boost"] = 2
|
|
395
|
+
a["labels"] = ["auto"]
|
|
396
|
+
b = _scored(2, 5, 1)
|
|
397
|
+
b["labels"] = ["auto"]
|
|
398
|
+
rows = rank.to_json_rows(rank.rank_issues([a, b]))
|
|
399
|
+
assert [(r["number"], r["boost"]) for r in rows] == [(1, 2), (2, 0)]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def test_render_marks_boost_up_and_down():
|
|
403
|
+
up = _scored(1, 2, 1)
|
|
404
|
+
up["boost"] = 2
|
|
405
|
+
down = _scored(2, 5, 1)
|
|
406
|
+
down["boost"] = -1
|
|
407
|
+
plain = _scored(3, 1, 1)
|
|
408
|
+
text = rank.render(rank.rank_issues([up, down, plain]))
|
|
409
|
+
line_for_up = next(l for l in text.splitlines() if "#1 i1" in l)
|
|
410
|
+
assert "↑2" in line_for_up
|
|
411
|
+
line_for_down = next(l for l in text.splitlines() if "#2 i2" in l)
|
|
412
|
+
assert "↓1" in line_for_down
|
|
413
|
+
line_for_plain = next(l for l in text.splitlines() if "#3 i3" in l)
|
|
414
|
+
assert "↑" not in line_for_plain and "↓" not in line_for_plain
|
package/skills-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-24T16:48:47.031Z",
|
|
4
4
|
"skills": [
|
|
5
5
|
{
|
|
6
6
|
"name": "backlog",
|
|
7
|
-
"version": "0.1.
|
|
8
|
-
"hash": "sha256-
|
|
7
|
+
"version": "0.1.4",
|
|
8
|
+
"hash": "sha256-c5c2ff8482d24eb782b0d5799b1e490733340f604196f42e8051d731bb00dd09",
|
|
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
|
{
|