@jesdi/skills 0.0.25 → 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 +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/to-openspec/SKILL.md +27 -20
- package/skills/to-openspec/templates/design.md +2 -2
- package/skills/to-openspec/templates/proposal.md +1 -2
- package/skills-manifest.json +6 -6
- package/skills/to-openspec/templates/tasks.md +0 -9
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
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
name: to-openspec
|
|
3
3
|
description: >-
|
|
4
4
|
Turn settled discovery notes and a sketched data model into a spec folder
|
|
5
|
-
in openspec shape (proposal, spec, design
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
5
|
+
in openspec shape (proposal, spec, design) under specs/<slug>/, plus ticket
|
|
6
|
+
files under .agent/tickets/ that implement-spec runs directly. Two stages:
|
|
7
|
+
stage 1 alone by default, with a human review before stage 2, or both at
|
|
8
|
+
once with "all". No interview, just synthesis of decisions already made.
|
|
9
|
+
Trigger on "write the spec", "to-openspec", or
|
|
10
|
+
"/to-openspec <slug> [stage 2 | all]".
|
|
9
11
|
---
|
|
10
12
|
|
|
11
13
|
# To openspec
|
|
@@ -21,13 +23,16 @@ instead of inventing it. Never invent prices, policies, deadlines, or permission
|
|
|
21
23
|
conversation.
|
|
22
24
|
- Shape: read every file in the repo's `specs/_template/`, or in this skill's `templates/` when
|
|
23
25
|
the repo has none. Keep the headings exactly; write "None." rather than dropping a section.
|
|
26
|
+
Ignore a `tasks.md` template: tickets replace it.
|
|
24
27
|
- Check command: the one the repo documents (`make check`, or what AGENTS.md names).
|
|
28
|
+
- Stages: no argument runs stage 1 and stops. `stage 2` runs stage 2 only. `all` runs both
|
|
29
|
+
without the stop in between.
|
|
25
30
|
|
|
26
31
|
## Stage 1: what and why
|
|
27
32
|
|
|
28
33
|
Write `specs/<slug>/proposal.md` and `specs/<slug>/spec.md`.
|
|
29
34
|
|
|
30
|
-
- **Goal** is one or two sentences. Every
|
|
35
|
+
- **Goal** is one or two sentences. Every ticket serves it.
|
|
31
36
|
- **Non-goals** carry over every "later" or "never" verdict from discovery.
|
|
32
37
|
- **Requirements** cover the v1 slice only: the smallest set that meets the goal. Each one is
|
|
33
38
|
testable and maps to at least one scenario.
|
|
@@ -35,13 +40,14 @@ Write `specs/<slug>/proposal.md` and `specs/<slug>/spec.md`.
|
|
|
35
40
|
Every invariant (overselling, expiry, refund permissions, and so on) gets a scenario for the
|
|
36
41
|
boundary and one for the violation.
|
|
37
42
|
|
|
38
|
-
Then **stop
|
|
39
|
-
the spec before the design is written. Don't write code.
|
|
43
|
+
Then **stop**, unless invoked with `all`. Print the requirement list and any open questions so
|
|
44
|
+
the human can cut or correct the spec before the design is written. Don't write code.
|
|
40
45
|
|
|
41
46
|
## Stage 2: how
|
|
42
47
|
|
|
43
|
-
Run only when asked
|
|
44
|
-
stage 1.
|
|
48
|
+
Run only when asked: "/to-openspec <slug> stage 2" after the human has reviewed stage 1, or
|
|
49
|
+
`all` straight after stage 1. On `stage 2`, re-read `proposal.md` and `spec.md` first; the
|
|
50
|
+
human may have edited them.
|
|
45
51
|
|
|
46
52
|
1. Run the `red-team-data-model` skill against the data model. Print the top 5, and its identity
|
|
47
53
|
inventory when the change widens a key; don't write them to a file.
|
|
@@ -50,23 +56,24 @@ stage 1. Re-read `proposal.md` and `spec.md` first; the human may have edited th
|
|
|
50
56
|
- **Data model**: every invariant a DB constraint can enforce (`CHECK`, `UNIQUE`, `NOT NULL`,
|
|
51
57
|
FK, conditional `UPDATE ... WHERE`) goes in the table, not only in app code.
|
|
52
58
|
- **Identities**: when the change widens a key, every item of the red-team's identity
|
|
53
|
-
inventory, marked covered (
|
|
59
|
+
inventory, marked covered (ticket numbers filled in step 3) or out of scope (why). Otherwise "None." Add the
|
|
54
60
|
heading when the repo's template lacks it.
|
|
55
61
|
- **Seams**: the endpoint, function, or command the tests drive through, marked existing or
|
|
56
62
|
new. Prefer one high seam.
|
|
57
63
|
- No implementation code anywhere in the spec folder. `implement-spec`'s test-writer reads it
|
|
58
64
|
and must not know the solution. A decision's shape (a type, a schema, a state machine, a
|
|
59
|
-
seam's signature) may appear; the function bodies that make the
|
|
60
|
-
3.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
seam's signature) may appear; the function bodies that make the tickets pass may not.
|
|
66
|
+
3. Run the `to-tickets` skill on `specs/<slug>/`, skipping its quiz (step 4): it writes
|
|
67
|
+
`.agent/tickets/<NN>-<slug>.md`, the task graph `implement-spec` runs directly. Don't write a
|
|
68
|
+
`tasks.md`. On top of its rules:
|
|
69
|
+
- Every scenario in `spec.md` is an acceptance criterion of some ticket.
|
|
70
|
+
- Each ticket's **Seam** is one of the seams from `design.md`.
|
|
65
71
|
- A slice adds only the tables and columns its own scenarios need, not the whole model up front.
|
|
66
|
-
- No standalone "run the checks"
|
|
67
|
-
- Then fill the
|
|
68
|
-
the
|
|
72
|
+
- No standalone "run the checks" ticket: every ticket already ends with the check command green.
|
|
73
|
+
- Then fill the ticket numbers into `design.md`'s **Identities**: every identity marked covered
|
|
74
|
+
names the ticket that rekeys it. A covered identity with no ticket is a missing ticket; add it.
|
|
69
75
|
|
|
70
|
-
Print the
|
|
76
|
+
Print the ticket list (number, title, blocked by, seam, touches), then **Open questions**: every
|
|
77
|
+
red-team finding the design leaves
|
|
71
78
|
unresolved, and every input the design assumes but the sources don't provide. Then stop. Don't
|
|
72
79
|
write code.
|
|
@@ -17,10 +17,10 @@ Tables/fields touched or added, and the invariants that go in DB constraints (no
|
|
|
17
17
|
## Identities
|
|
18
18
|
|
|
19
19
|
When this change widens a key, each identity built from the old key (file names, hashes,
|
|
20
|
-
dedupe keys, command args, dict keys): covered by which
|
|
20
|
+
dedupe keys, command args, dict keys): covered by which ticket, or out of scope and why. Otherwise
|
|
21
21
|
"None."
|
|
22
22
|
|
|
23
|
-
- `<identity>` — covered by <
|
|
23
|
+
- `<identity>` — covered by <ticket> | out of scope: <why>.
|
|
24
24
|
|
|
25
25
|
## Seams
|
|
26
26
|
|
|
@@ -10,8 +10,7 @@ The actor(s) this serves (e.g. customer, staff, owner).
|
|
|
10
10
|
|
|
11
11
|
## Goal
|
|
12
12
|
|
|
13
|
-
What "done" looks like for this slice. One or two sentences — this is what every
|
|
14
|
-
to.
|
|
13
|
+
What "done" looks like for this slice. One or two sentences — this is what every ticket serves.
|
|
15
14
|
|
|
16
15
|
## Non-goals
|
|
17
16
|
|
package/skills-manifest.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-06T10:03:53.513Z",
|
|
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
|
{
|
|
@@ -76,9 +76,9 @@
|
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
"name": "to-openspec",
|
|
79
|
-
"version": "0.1.
|
|
80
|
-
"hash": "sha256-
|
|
81
|
-
"description": "Turn settled discovery notes and a sketched data model into a spec folder in openspec shape (proposal, spec, design
|
|
79
|
+
"version": "0.1.3",
|
|
80
|
+
"hash": "sha256-fa982c0960a4b07d6ec9c54076f831c1fb16a4e46b8ddd62acf86e5316345be7",
|
|
81
|
+
"description": "Turn settled discovery notes and a sketched data model into a spec folder in openspec shape (proposal, spec, design) under specs/<slug>/, plus ticket files under .agent/tickets/ that implement-spec runs directly. Two stages: stage 1 alone by default, with a human review before stage 2, or both at once with \"all\". No interview, just synthesis of decisions already made. Trigger on \"write the spec\", \"to-openspec\", or \"/to-openspec <slug> [stage 2 | all]\"."
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"name": "to-spec",
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# <Feature title> — Tasks
|
|
2
|
-
|
|
3
|
-
Each task links to the goal in proposal.md. Work the frontier (tasks whose blockers are done);
|
|
4
|
-
commit per task with its ID.
|
|
5
|
-
|
|
6
|
-
- [ ] **T1** — <task>. _Goal: <one line, why this task serves proposal.md's goal>._
|
|
7
|
-
Seam: <endpoint / function / command from design.md>. Blocked by: none.
|
|
8
|
-
- [ ] **T2** — <task>. _Goal: ..._
|
|
9
|
-
Seam: .... Blocked by: T1.
|