@stylusnexus/work-plan 2026.6.9
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/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +210 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Query GitHub via `gh`."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from typing import Iterable, Optional
|
|
7
|
+
|
|
8
|
+
PRIORITY_LABELS = ("priority/P0", "priority/P1", "priority/P2", "priority/P3")
|
|
9
|
+
DEFAULT_PRIORITY = "P3"
|
|
10
|
+
|
|
11
|
+
MAX_FETCH_WORKERS = 8
|
|
12
|
+
|
|
13
|
+
_GH_ISSUE_FIELDS = "number,state,labels,title,milestone,url,closedAt,body,updatedAt,assignees"
|
|
14
|
+
|
|
15
|
+
_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
|
|
16
|
+
GQL_CHUNK = 100 # issues per GraphQL query; GitHub GraphQL complexity budget ~5000 pts/query, 100 issueOrPullRequest nodes is well within it
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fetch_issue(repo: str, number: int) -> Optional[dict]:
|
|
20
|
+
"""Fetch a single issue via gh. Returns parsed dict on success, None on failure.
|
|
21
|
+
Never raises — a missing `gh` binary or any subprocess error yields None."""
|
|
22
|
+
try:
|
|
23
|
+
proc = subprocess.run(
|
|
24
|
+
["gh", "issue", "view", str(number),
|
|
25
|
+
"--repo", repo,
|
|
26
|
+
"--json", _GH_ISSUE_FIELDS],
|
|
27
|
+
capture_output=True, text=True,
|
|
28
|
+
)
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
if proc.returncode != 0:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(proc.stdout)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_issues(repo: str, issue_numbers: Iterable[int]) -> list[dict]:
|
|
40
|
+
"""Fetch state of multiple issues via gh (sequential). Unchanged semantics."""
|
|
41
|
+
nums = list(issue_numbers)
|
|
42
|
+
if not nums:
|
|
43
|
+
return []
|
|
44
|
+
results = []
|
|
45
|
+
for num in nums:
|
|
46
|
+
result = fetch_issue(repo, num)
|
|
47
|
+
if result is not None:
|
|
48
|
+
results.append(result)
|
|
49
|
+
return results
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch_issues_concurrent(jobs: Iterable[tuple], max_workers: int = MAX_FETCH_WORKERS) -> dict:
|
|
53
|
+
"""Fetch multiple (repo, number) pairs concurrently.
|
|
54
|
+
|
|
55
|
+
Dedupes jobs (first-seen order preserved). Returns a dict keyed by
|
|
56
|
+
(repo, number) containing only successful fetches (None results omitted).
|
|
57
|
+
Empty jobs -> {}.
|
|
58
|
+
"""
|
|
59
|
+
unique_jobs = list(dict.fromkeys(jobs))
|
|
60
|
+
if not unique_jobs:
|
|
61
|
+
return {}
|
|
62
|
+
workers = min(max_workers, len(unique_jobs))
|
|
63
|
+
result: dict[tuple, dict] = {}
|
|
64
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
65
|
+
futures = {pool.submit(fetch_issue, repo, num): (repo, num)
|
|
66
|
+
for repo, num in unique_jobs}
|
|
67
|
+
for future, key in futures.items():
|
|
68
|
+
issue = future.result()
|
|
69
|
+
if issue is not None:
|
|
70
|
+
result[key] = issue
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize_gql_node(node) -> Optional[dict]:
|
|
75
|
+
"""Reshape a GraphQL issueOrPullRequest node into the REST-ish shape export_model
|
|
76
|
+
expects (assignees as [{login}], milestone as {title}|None). None for a null node.
|
|
77
|
+
On success returns a dict with keys: number, title, state, assignees, milestone."""
|
|
78
|
+
if not node:
|
|
79
|
+
return None
|
|
80
|
+
assignees = [{"login": a.get("login")} for a in
|
|
81
|
+
((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
|
|
82
|
+
ms = node.get("milestone")
|
|
83
|
+
return {
|
|
84
|
+
"number": node.get("number"),
|
|
85
|
+
"title": node.get("title", ""),
|
|
86
|
+
"state": node.get("state", "OPEN"),
|
|
87
|
+
"assignees": assignees,
|
|
88
|
+
"milestone": {"title": ms["title"]} if ms and ms.get("title") else None,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _gql_query(owner: str, name: str, numbers: list) -> str:
|
|
93
|
+
fields = ("number title state assignees(first: 10) { nodes { login } } milestone { title }")
|
|
94
|
+
aliases = "\n".join(
|
|
95
|
+
f' i{n}: issueOrPullRequest(number: {int(n)}) {{ '
|
|
96
|
+
f'... on Issue {{ {fields} }} ... on PullRequest {{ {fields} }} }}'
|
|
97
|
+
for n in numbers
|
|
98
|
+
)
|
|
99
|
+
return f'query {{ repository(owner: "{owner}", name: "{name}") {{\n{aliases}\n}} }}'
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
|
|
103
|
+
max_workers: int = MAX_FETCH_WORKERS) -> dict:
|
|
104
|
+
"""Fetch exactly `numbers` from `repo` via batched GraphQL (issueOrPullRequest, so
|
|
105
|
+
PRs are included). Returns {number: normalized_issue} for those found. Never raises;
|
|
106
|
+
missing/null/errored numbers are simply omitted (caller may fall back per-issue)."""
|
|
107
|
+
try:
|
|
108
|
+
nums = list(dict.fromkeys(int(n) for n in numbers))
|
|
109
|
+
except (ValueError, TypeError):
|
|
110
|
+
return {}
|
|
111
|
+
if not nums or not _REPO_RE.match(repo or ""):
|
|
112
|
+
return {}
|
|
113
|
+
owner, name = repo.split("/", 1)
|
|
114
|
+
chunks = [nums[i:i + chunk] for i in range(0, len(nums), chunk)]
|
|
115
|
+
|
|
116
|
+
def _run(batch):
|
|
117
|
+
try:
|
|
118
|
+
proc = subprocess.run(
|
|
119
|
+
["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch)],
|
|
120
|
+
capture_output=True, text=True,
|
|
121
|
+
)
|
|
122
|
+
except Exception:
|
|
123
|
+
return {}
|
|
124
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
125
|
+
return {}
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(proc.stdout)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
return {}
|
|
130
|
+
repo_obj = ((data.get("data") or {}).get("repository") or {})
|
|
131
|
+
out = {}
|
|
132
|
+
for node in repo_obj.values():
|
|
133
|
+
norm = _normalize_gql_node(node)
|
|
134
|
+
if norm and norm.get("number") is not None:
|
|
135
|
+
out[norm["number"]] = norm
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
result = {}
|
|
139
|
+
with ThreadPoolExecutor(max_workers=min(max_workers, len(chunks))) as ex:
|
|
140
|
+
for part in ex.map(_run, chunks):
|
|
141
|
+
result.update(part)
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fetch_export_issues(repo_to_numbers: dict, max_workers: int = MAX_FETCH_WORKERS) -> dict:
|
|
146
|
+
"""Fetch referenced issues for the viewer export with minimal gh calls: batched
|
|
147
|
+
GraphQL per repo (only the referenced numbers; includes PRs), run concurrently
|
|
148
|
+
across repos, with a per-issue fallback for anything GraphQL didn't return.
|
|
149
|
+
Returns {(repo, number): issue_dict}. Never raises."""
|
|
150
|
+
repos = [r for r, nums in repo_to_numbers.items() if r and nums]
|
|
151
|
+
if not repos:
|
|
152
|
+
return {}
|
|
153
|
+
try:
|
|
154
|
+
with ThreadPoolExecutor(max_workers=min(max_workers, len(repos))) as ex:
|
|
155
|
+
gql_by_repo = dict(zip(repos, ex.map(
|
|
156
|
+
lambda r: fetch_repo_issues_graphql(r, repo_to_numbers[r], max_workers=max_workers),
|
|
157
|
+
repos)))
|
|
158
|
+
except Exception:
|
|
159
|
+
gql_by_repo = {r: {} for r in repos}
|
|
160
|
+
result, missing = {}, []
|
|
161
|
+
for repo, numbers in repo_to_numbers.items():
|
|
162
|
+
if not repo or not numbers:
|
|
163
|
+
continue
|
|
164
|
+
got = gql_by_repo.get(repo, {})
|
|
165
|
+
for n in numbers:
|
|
166
|
+
if n in got:
|
|
167
|
+
result[(repo, n)] = got[n]
|
|
168
|
+
else:
|
|
169
|
+
missing.append((repo, n))
|
|
170
|
+
if missing:
|
|
171
|
+
result.update(fetch_issues_concurrent(missing, max_workers=max_workers))
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def fetch_open_issues(repo: str, limit: int = 1000) -> list[dict]:
|
|
176
|
+
"""All OPEN issues for `repo` as gh rows ({number,title,assignees,milestone,state}).
|
|
177
|
+
One `gh issue list` call. Never raises — returns [] on any error/bad repo."""
|
|
178
|
+
if not _REPO_RE.match(repo or ""):
|
|
179
|
+
return []
|
|
180
|
+
try:
|
|
181
|
+
proc = subprocess.run(
|
|
182
|
+
["gh", "issue", "list", "--repo", repo,
|
|
183
|
+
"--state", "open",
|
|
184
|
+
"--json", "number,title,state,assignees,milestone",
|
|
185
|
+
"--limit", str(limit)],
|
|
186
|
+
capture_output=True, text=True,
|
|
187
|
+
)
|
|
188
|
+
except Exception:
|
|
189
|
+
return []
|
|
190
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
191
|
+
return []
|
|
192
|
+
try:
|
|
193
|
+
return json.loads(proc.stdout)
|
|
194
|
+
except json.JSONDecodeError:
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def fetch_recent_issues(repo: str, since_iso: str, extra_labels: list[str] = None) -> list[dict]:
|
|
199
|
+
"""Fetch issues created since `since_iso` (date YYYY-MM-DD)."""
|
|
200
|
+
search = f"created:>={since_iso}"
|
|
201
|
+
cmd = ["gh", "issue", "list", "--repo", repo,
|
|
202
|
+
"--state", "all",
|
|
203
|
+
"--search", search,
|
|
204
|
+
"--limit", "50",
|
|
205
|
+
"--json", "number,title,labels,createdAt,milestone,url"]
|
|
206
|
+
if extra_labels:
|
|
207
|
+
for lab in extra_labels:
|
|
208
|
+
cmd.extend(["--label", lab])
|
|
209
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
210
|
+
if proc.returncode != 0:
|
|
211
|
+
return []
|
|
212
|
+
return json.loads(proc.stdout) if proc.stdout.strip() else []
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_VIS_CACHE: dict = {}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def repo_visibility(repo: str) -> Optional[str]:
|
|
219
|
+
"""Best-effort repo visibility ('PUBLIC'/'PRIVATE') via gh; None if unknown.
|
|
220
|
+
Memoized per process. Never raises — unknown visibility is a valid answer."""
|
|
221
|
+
if not repo:
|
|
222
|
+
return None
|
|
223
|
+
if repo in _VIS_CACHE:
|
|
224
|
+
return _VIS_CACHE[repo]
|
|
225
|
+
proc = subprocess.run(
|
|
226
|
+
["gh", "repo", "view", repo, "--json", "visibility"],
|
|
227
|
+
capture_output=True, text=True,
|
|
228
|
+
)
|
|
229
|
+
vis = None
|
|
230
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
231
|
+
try:
|
|
232
|
+
vis = json.loads(proc.stdout).get("visibility")
|
|
233
|
+
except json.JSONDecodeError:
|
|
234
|
+
vis = None
|
|
235
|
+
_VIS_CACHE[repo] = vis
|
|
236
|
+
return vis
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def extract_priority(labels: list[dict]) -> str:
|
|
240
|
+
label_names = {l["name"] for l in labels}
|
|
241
|
+
for p in PRIORITY_LABELS:
|
|
242
|
+
if p in label_names:
|
|
243
|
+
return p.split("/")[1]
|
|
244
|
+
return DEFAULT_PRIORITY
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def short_milestone(milestone) -> str:
|
|
248
|
+
"""Extract a compact milestone tag from a gh milestone object.
|
|
249
|
+
|
|
250
|
+
gh returns milestone as `{"title": "v0.4.0 — MVP Go-Live Gate", ...}` or null.
|
|
251
|
+
The leading token (e.g. `v0.4.0`) is what tracks declare in
|
|
252
|
+
`milestone_alignment:`, so it's the natural form to show in tight per-issue
|
|
253
|
+
lines. Returns "" when milestone is missing or has no title.
|
|
254
|
+
"""
|
|
255
|
+
if not milestone:
|
|
256
|
+
return ""
|
|
257
|
+
title = milestone.get("title") if isinstance(milestone, dict) else None
|
|
258
|
+
if not title:
|
|
259
|
+
return ""
|
|
260
|
+
return title.split()[0] if title.split() else ""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def format_assignees(issue: dict) -> str:
|
|
264
|
+
"""Render a canonical-table assignee cell from an issue's assignees.
|
|
265
|
+
|
|
266
|
+
Returns `@login, @login` for one or more assignees, or `—` when there are
|
|
267
|
+
none (or the issue dict is missing). Matches the placeholder used by
|
|
268
|
+
canonicalize so appended rows are visually consistent.
|
|
269
|
+
"""
|
|
270
|
+
assignees = (issue or {}).get("assignees") or []
|
|
271
|
+
logins = [f"@{a['login']}" for a in assignees if a.get("login")]
|
|
272
|
+
return ", ".join(logins) if logins else "—"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def state_to_status_label(state: str) -> str:
|
|
276
|
+
"""Map a GitHub issue/PR state to a human-readable status label.
|
|
277
|
+
|
|
278
|
+
CLOSED and MERGED both map to ✅ Shipped (gh treats PRs as a kind of
|
|
279
|
+
issue, so issue-API responses can return MERGED for PR refs).
|
|
280
|
+
"""
|
|
281
|
+
s = (state or "OPEN").upper()
|
|
282
|
+
if s in ("CLOSED", "MERGED"):
|
|
283
|
+
return "✅ Shipped"
|
|
284
|
+
return "🔲 Open"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def create_issue(repo: str, title: str, body: str) -> Optional[str]:
|
|
288
|
+
"""Open a GitHub issue via `gh issue create`. Returns the issue URL, or None
|
|
289
|
+
on failure. Reuses the user's `gh` auth; never touches tokens."""
|
|
290
|
+
proc = subprocess.run(
|
|
291
|
+
["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body],
|
|
292
|
+
capture_output=True, text=True,
|
|
293
|
+
)
|
|
294
|
+
if proc.returncode != 0:
|
|
295
|
+
return None
|
|
296
|
+
return proc.stdout.strip() or None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Pick the docs that need an LLM verdict and gather evidence for the judgment.
|
|
2
|
+
|
|
3
|
+
Two kinds of candidate mechanical scoring can't resolve:
|
|
4
|
+
- manifest-less: prose docs (design specs) with no Create/Modify/Test paths.
|
|
5
|
+
- ambiguous: a manifest exists but <AMBIGUOUS_PCT of files are satisfied — the
|
|
6
|
+
suspicious "0/N looks unstarted but was recently touched" cases, usually
|
|
7
|
+
Modify-heavy plans the file-existence signal under-counts.
|
|
8
|
+
"""
|
|
9
|
+
from lib import git_state
|
|
10
|
+
|
|
11
|
+
AMBIGUOUS_PCT = 20.0
|
|
12
|
+
EXCERPT_CHARS = 1500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def select_candidates(rows: list) -> list:
|
|
16
|
+
"""From evaluated rows, return those needing an LLM verdict."""
|
|
17
|
+
out = []
|
|
18
|
+
for r in rows:
|
|
19
|
+
if r["verdict"] == "manifest-less":
|
|
20
|
+
out.append(r)
|
|
21
|
+
elif r["files_declared"] > 0:
|
|
22
|
+
pct = r["files_present"] / r["files_declared"] * 100.0
|
|
23
|
+
if pct < AMBIGUOUS_PCT:
|
|
24
|
+
out.append(r)
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _first_title(text: str) -> str:
|
|
29
|
+
for line in text.splitlines():
|
|
30
|
+
if line.startswith("# "):
|
|
31
|
+
return line[2:].strip()
|
|
32
|
+
return "(no title)"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def gather_evidence(doc, repo_root) -> dict:
|
|
36
|
+
"""Build the evidence dict the model uses to judge one doc."""
|
|
37
|
+
text = doc.path.read_text(encoding="utf-8", errors="replace")
|
|
38
|
+
last = git_state.path_last_commit_date(doc.rel, repo_root)
|
|
39
|
+
return {
|
|
40
|
+
"rel": doc.rel,
|
|
41
|
+
"kind": doc.kind,
|
|
42
|
+
"title": _first_title(text),
|
|
43
|
+
"last_touched": last.date().isoformat() if last else None,
|
|
44
|
+
"excerpt": text[:EXCERPT_CHARS],
|
|
45
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Parse a plan's declared file-manifest + checkboxes, and score it against
|
|
2
|
+
the filesystem and git. The honest completion signal is which declared files
|
|
3
|
+
actually exist / were committed — not the (unreliable) checkbox state.
|
|
4
|
+
"""
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable, Optional
|
|
10
|
+
|
|
11
|
+
# Matches: Create: `path` / Modify: `path:120-145` / Test: `path`
|
|
12
|
+
PATH_RE = re.compile(r"\b(Create|Modify|Test):\s*`([^`]+)`")
|
|
13
|
+
# Trailing line spec: ':120', ':120-145', or comma-joined ':104-115,217-247'
|
|
14
|
+
_RANGE_RE = re.compile(r":\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$")
|
|
15
|
+
_CHK_DONE = re.compile(r"^\s*- \[x\]", re.I | re.M)
|
|
16
|
+
_CHK_TODO = re.compile(r"^\s*- \[ \]", re.M)
|
|
17
|
+
_DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DeclaredPath:
|
|
22
|
+
kind: str # "create" | "modify" | "test"
|
|
23
|
+
path: str # repo-relative, line-range stripped
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_range(p: str) -> str:
|
|
27
|
+
"""'src/foo.ts:120-145' -> 'src/foo.ts'; bare paths unchanged."""
|
|
28
|
+
return _RANGE_RE.sub("", p.strip())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_declared_paths(text: str) -> list:
|
|
32
|
+
"""Extract declared file paths. First kind seen per path wins (dedup)."""
|
|
33
|
+
seen = {} # path -> kind
|
|
34
|
+
for kind, raw in PATH_RE.findall(text):
|
|
35
|
+
p = strip_range(raw)
|
|
36
|
+
if "/" not in p: # skip bare tokens / commands
|
|
37
|
+
continue
|
|
38
|
+
if p.startswith(("http", "git ")): # skip urls / shell
|
|
39
|
+
continue
|
|
40
|
+
seen.setdefault(p, kind.lower())
|
|
41
|
+
return [DeclaredPath(kind=k, path=p) for p, k in seen.items()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def count_checkboxes(text: str) -> tuple:
|
|
45
|
+
"""Return (done, total) markdown task checkboxes."""
|
|
46
|
+
done = len(_CHK_DONE.findall(text))
|
|
47
|
+
todo = len(_CHK_TODO.findall(text))
|
|
48
|
+
return done, done + todo
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def plan_date_from_filename(filename: str) -> Optional[date]:
|
|
52
|
+
"""Pull a YYYY-MM-DD prefix out of a plan filename, if present."""
|
|
53
|
+
m = _DATE_RE.search(filename)
|
|
54
|
+
if not m:
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_in_tree(path: str, repo_root) -> bool:
|
|
63
|
+
"""True if a declared path resolves inside repo_root. A '~'-rooted path, an
|
|
64
|
+
absolute path elsewhere, or a '..'-escaping path is out-of-tree."""
|
|
65
|
+
if path.startswith("~"):
|
|
66
|
+
return False
|
|
67
|
+
root = Path(repo_root).resolve()
|
|
68
|
+
p = Path(path)
|
|
69
|
+
target = p.resolve() if p.is_absolute() else (root / p).resolve()
|
|
70
|
+
try:
|
|
71
|
+
target.relative_to(root)
|
|
72
|
+
return True
|
|
73
|
+
except ValueError:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def out_of_tree_ratio(decls: list, repo_root) -> float:
|
|
78
|
+
"""Fraction of declared paths resolving outside repo_root (0.0 if none declared)."""
|
|
79
|
+
if not decls:
|
|
80
|
+
return 0.0
|
|
81
|
+
out = sum(1 for d in decls if not is_in_tree(d.path, repo_root))
|
|
82
|
+
return out / len(decls)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ManifestScore:
|
|
87
|
+
total: int
|
|
88
|
+
satisfied: int
|
|
89
|
+
by_kind: dict # {"create": (sat, tot), "modify": (sat, tot), "test": (sat, tot)}
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def pct(self) -> Optional[float]:
|
|
93
|
+
return (self.satisfied / self.total * 100.0) if self.total else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _path_satisfied(d, exists, committed_since) -> bool:
|
|
97
|
+
return committed_since(d.path) if d.kind == "modify" else exists(d.path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def score_manifest(
|
|
101
|
+
decls: list,
|
|
102
|
+
repo_root: Path,
|
|
103
|
+
plan_date: Optional[date],
|
|
104
|
+
*,
|
|
105
|
+
exists: Optional[Callable] = None,
|
|
106
|
+
committed_since: Optional[Callable] = None,
|
|
107
|
+
) -> ManifestScore:
|
|
108
|
+
"""Score declared paths. `Create`/`Test` count if the file exists now;
|
|
109
|
+
`Modify` counts only if the file was committed on/after `plan_date`
|
|
110
|
+
(existence alone is meaningless for a pre-existing modify target).
|
|
111
|
+
|
|
112
|
+
`exists(rel)->bool` and `committed_since(rel)->bool` are injectable for
|
|
113
|
+
offline testing; defaults wire to the filesystem and git.
|
|
114
|
+
"""
|
|
115
|
+
if exists is None:
|
|
116
|
+
exists = lambda rel: (Path(repo_root) / rel).exists()
|
|
117
|
+
if committed_since is None:
|
|
118
|
+
from lib import git_state
|
|
119
|
+
# Deliberate degradation: with no plan date we can't ask "committed since
|
|
120
|
+
# when?", so a Modify falls back to mere existence. This can over-count an
|
|
121
|
+
# undated plan's Modify targets — accepted because superpowers plans carry a
|
|
122
|
+
# YYYY-MM-DD filename prefix, so the dateless path is rare in practice.
|
|
123
|
+
committed_since = (
|
|
124
|
+
(lambda rel: git_state.path_committed_since(rel, plan_date, repo_root))
|
|
125
|
+
if plan_date is not None
|
|
126
|
+
else (lambda rel: (Path(repo_root) / rel).exists())
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
by = {"create": [0, 0], "modify": [0, 0], "test": [0, 0]}
|
|
130
|
+
satisfied = 0
|
|
131
|
+
for d in decls:
|
|
132
|
+
by[d.kind][1] += 1
|
|
133
|
+
if _path_satisfied(d, exists, committed_since):
|
|
134
|
+
by[d.kind][0] += 1
|
|
135
|
+
satisfied += 1
|
|
136
|
+
return ManifestScore(
|
|
137
|
+
total=len(decls),
|
|
138
|
+
satisfied=satisfied,
|
|
139
|
+
by_kind={k: tuple(v) for k, v in by.items()},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def unsatisfied_paths(
|
|
144
|
+
decls: list,
|
|
145
|
+
repo_root: Path,
|
|
146
|
+
plan_date: Optional[date],
|
|
147
|
+
*,
|
|
148
|
+
exists: Optional[Callable] = None,
|
|
149
|
+
committed_since: Optional[Callable] = None,
|
|
150
|
+
) -> list:
|
|
151
|
+
"""Return the declared paths that are NOT satisfied (missing / not committed).
|
|
152
|
+
|
|
153
|
+
Same satisfaction rule and injectable predicates as `score_manifest`.
|
|
154
|
+
"""
|
|
155
|
+
if exists is None:
|
|
156
|
+
exists = lambda rel: (Path(repo_root) / rel).exists()
|
|
157
|
+
if committed_since is None:
|
|
158
|
+
from lib import git_state
|
|
159
|
+
committed_since = (
|
|
160
|
+
(lambda rel: git_state.path_committed_since(rel, plan_date, repo_root))
|
|
161
|
+
if plan_date is not None
|
|
162
|
+
else (lambda rel: (Path(repo_root) / rel).exists())
|
|
163
|
+
)
|
|
164
|
+
return [d for d in decls if not _path_satisfied(d, exists, committed_since)]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Detect new GitHub issues that should slot into existing tracks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from lib.github_state import fetch_recent_issues
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_slug_labels(tracks) -> dict[str, list[str]]:
|
|
11
|
+
"""Build a {slug: [labels]} map from tracks with `github.labels` frontmatter.
|
|
12
|
+
|
|
13
|
+
Slugs without an explicit `github.labels` are omitted — callers fall back
|
|
14
|
+
to the default `track/<slug>` pattern in that case.
|
|
15
|
+
"""
|
|
16
|
+
out: dict[str, list[str]] = {}
|
|
17
|
+
for t in tracks:
|
|
18
|
+
if not getattr(t, "has_frontmatter", False):
|
|
19
|
+
continue
|
|
20
|
+
slug = t.meta.get("track", t.name)
|
|
21
|
+
labels = t.meta.get("github", {}).get("labels")
|
|
22
|
+
if labels:
|
|
23
|
+
out[slug] = [str(lab) for lab in labels if str(lab).strip()]
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def match_issue_to_tracks(issue: dict, track_slugs: list[str],
|
|
28
|
+
*, slug_labels: dict[str, list[str]] | None = None) -> list[str]:
|
|
29
|
+
"""Return slugs of tracks this issue might belong to.
|
|
30
|
+
|
|
31
|
+
1. Configured label match → exact match. Each slug uses its own labels from
|
|
32
|
+
`slug_labels` if provided, else falls back to `[track/<slug>]`.
|
|
33
|
+
2. Slug words appear in title → fuzzy match (all >=3-char words must appear).
|
|
34
|
+
"""
|
|
35
|
+
label_names = {l["name"] for l in issue.get("labels", [])}
|
|
36
|
+
title_lower = issue.get("title", "").lower()
|
|
37
|
+
overrides = slug_labels or {}
|
|
38
|
+
|
|
39
|
+
matches = set()
|
|
40
|
+
for slug in track_slugs:
|
|
41
|
+
labels_for_slug = overrides.get(slug) or [f"track/{slug}"]
|
|
42
|
+
if any(lab in label_names for lab in labels_for_slug):
|
|
43
|
+
matches.add(slug)
|
|
44
|
+
|
|
45
|
+
for slug in track_slugs:
|
|
46
|
+
if slug in matches:
|
|
47
|
+
continue
|
|
48
|
+
words = [w for w in re.split(r"[-_]", slug) if len(w) >= 3]
|
|
49
|
+
if not words:
|
|
50
|
+
continue
|
|
51
|
+
if all(w.lower() in title_lower for w in words):
|
|
52
|
+
matches.add(slug)
|
|
53
|
+
|
|
54
|
+
return sorted(matches)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_new_issues_for_tracks(repo: str, track_slugs: list[str],
|
|
58
|
+
*, slug_labels: dict[str, list[str]] | None = None,
|
|
59
|
+
since_days: int = 7) -> dict[str, list[dict]]:
|
|
60
|
+
"""For each track slug, return list of recent issues that match."""
|
|
61
|
+
if not track_slugs:
|
|
62
|
+
return {}
|
|
63
|
+
since_date = (datetime.now() - timedelta(days=since_days)).strftime("%Y-%m-%d")
|
|
64
|
+
recent = fetch_recent_issues(repo, since_iso=since_date)
|
|
65
|
+
out: dict[str, list[dict]] = {s: [] for s in track_slugs}
|
|
66
|
+
for issue in recent:
|
|
67
|
+
for slug in match_issue_to_tracks(issue, track_slugs, slug_labels=slug_labels):
|
|
68
|
+
out[slug].append(issue)
|
|
69
|
+
return out
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Compute a suggested `next_up` issue list for a track.
|
|
2
|
+
|
|
3
|
+
Sort policy: open issues only, exclude blockers, ranked by priority label
|
|
4
|
+
(P0 < P1 < P2 < P3 with missing label defaulting to P3), then by most-
|
|
5
|
+
recently-updated within the same priority bucket. Closed issues are
|
|
6
|
+
filtered out — `next_up` should never propose work that's already done.
|
|
7
|
+
|
|
8
|
+
Used by:
|
|
9
|
+
- `commands/handoff.py` — `--auto-next` flag prompts the user to apply
|
|
10
|
+
the suggestion (with edit/skip options).
|
|
11
|
+
- `commands/brief.py` — when a track sets `next_up_auto: true` in its
|
|
12
|
+
frontmatter, brief computes the suggestion live at display time and
|
|
13
|
+
ignores any stored `next_up` list.
|
|
14
|
+
|
|
15
|
+
The two callers share this helper so the algorithm has one home; if we
|
|
16
|
+
ever want to layer additional signals (assignee, linked PR, milestone),
|
|
17
|
+
they go here.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
from lib.github_state import extract_priority, short_milestone
|
|
25
|
+
|
|
26
|
+
PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
27
|
+
DEFAULT_TOP_N = 3
|
|
28
|
+
|
|
29
|
+
# Milestone alignment ranks: items on the track's declared milestone come
|
|
30
|
+
# first, items on a different milestone next, items with no milestone last.
|
|
31
|
+
MILESTONE_ALIGNED = 0
|
|
32
|
+
MILESTONE_OTHER = 1
|
|
33
|
+
MILESTONE_NONE = 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _updated_unix(issue: dict) -> float:
|
|
37
|
+
"""Parse the gh-formatted updatedAt field to a unix timestamp.
|
|
38
|
+
|
|
39
|
+
Returns 0.0 if the field is missing or unparsable — treats unknown-age
|
|
40
|
+
issues as oldest, which keeps recently-updated items on top of the
|
|
41
|
+
suggestion within the same priority bucket.
|
|
42
|
+
"""
|
|
43
|
+
raw = issue.get("updatedAt") or ""
|
|
44
|
+
if not raw:
|
|
45
|
+
return 0.0
|
|
46
|
+
try:
|
|
47
|
+
# gh emits 'Z'-suffixed UTC; fromisoformat in 3.9 wants '+00:00'.
|
|
48
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
return 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def suggest_next_up(
|
|
54
|
+
issues: list[dict],
|
|
55
|
+
blocker_nums: Iterable[int] | None = None,
|
|
56
|
+
n: int = DEFAULT_TOP_N,
|
|
57
|
+
track_milestone: str | None = None,
|
|
58
|
+
) -> list[int]:
|
|
59
|
+
"""Return up to `n` issue numbers ranked for "what to work on next."
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
issues: issue dicts as returned by `gh issue list --json
|
|
63
|
+
number,state,labels,milestone,updatedAt,...`.
|
|
64
|
+
blocker_nums: iterable of issue numbers to exclude (a track's
|
|
65
|
+
manually-flagged blockers).
|
|
66
|
+
n: maximum items to return. Default is DEFAULT_TOP_N.
|
|
67
|
+
track_milestone: optional `milestone_alignment:` value from the
|
|
68
|
+
track's frontmatter (e.g. `"v0.4.0"`). When provided, issues
|
|
69
|
+
on this milestone rank above items on any other milestone,
|
|
70
|
+
which in turn rank above items with no milestone — keeps
|
|
71
|
+
post-launch deferrals from polluting a launch-window list.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of issue numbers, highest-ranked first. Empty if nothing
|
|
75
|
+
qualifies (e.g., everything closed or blocked).
|
|
76
|
+
"""
|
|
77
|
+
blockers = set(blocker_nums or [])
|
|
78
|
+
candidates = [
|
|
79
|
+
i for i in issues
|
|
80
|
+
if str(i.get("state", "")).upper() == "OPEN"
|
|
81
|
+
and i.get("number") not in blockers
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def milestone_rank(issue: dict) -> int:
|
|
85
|
+
ms = short_milestone(issue.get("milestone"))
|
|
86
|
+
if not ms:
|
|
87
|
+
return MILESTONE_NONE
|
|
88
|
+
if track_milestone and ms == track_milestone:
|
|
89
|
+
return MILESTONE_ALIGNED
|
|
90
|
+
return MILESTONE_OTHER
|
|
91
|
+
|
|
92
|
+
def sort_key(issue: dict) -> tuple[int, int, float]:
|
|
93
|
+
pri = extract_priority(issue.get("labels", []))
|
|
94
|
+
# Negate timestamp so newer comes first within a priority bucket.
|
|
95
|
+
return (milestone_rank(issue), PRIORITY_RANK.get(pri, 3), -_updated_unix(issue))
|
|
96
|
+
|
|
97
|
+
candidates.sort(key=sort_key)
|
|
98
|
+
return [i["number"] for i in candidates[:n]]
|