@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +36 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +119 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/brief.py +247 -0
  11. package/skills/work-plan/commands/canonicalize.py +122 -0
  12. package/skills/work-plan/commands/close.py +83 -0
  13. package/skills/work-plan/commands/duplicates.py +111 -0
  14. package/skills/work-plan/commands/export.py +69 -0
  15. package/skills/work-plan/commands/group.py +234 -0
  16. package/skills/work-plan/commands/handoff.py +855 -0
  17. package/skills/work-plan/commands/hygiene.py +104 -0
  18. package/skills/work-plan/commands/init.py +96 -0
  19. package/skills/work-plan/commands/init_repo.py +90 -0
  20. package/skills/work-plan/commands/list_cmd.py +39 -0
  21. package/skills/work-plan/commands/new_track.py +148 -0
  22. package/skills/work-plan/commands/plan_status.py +296 -0
  23. package/skills/work-plan/commands/reconcile.py +172 -0
  24. package/skills/work-plan/commands/refresh_md.py +132 -0
  25. package/skills/work-plan/commands/set_field.py +54 -0
  26. package/skills/work-plan/commands/set_notes_root.py +53 -0
  27. package/skills/work-plan/commands/slot.py +139 -0
  28. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  29. package/skills/work-plan/commands/where_was_i.py +325 -0
  30. package/skills/work-plan/lib/__init__.py +0 -0
  31. package/skills/work-plan/lib/closure.py +72 -0
  32. package/skills/work-plan/lib/config.py +82 -0
  33. package/skills/work-plan/lib/doc_discovery.py +41 -0
  34. package/skills/work-plan/lib/drift.py +32 -0
  35. package/skills/work-plan/lib/export_model.py +40 -0
  36. package/skills/work-plan/lib/frontmatter.py +48 -0
  37. package/skills/work-plan/lib/git_state.py +180 -0
  38. package/skills/work-plan/lib/github_state.py +296 -0
  39. package/skills/work-plan/lib/llm_evidence.py +45 -0
  40. package/skills/work-plan/lib/manifest.py +164 -0
  41. package/skills/work-plan/lib/new_issues.py +69 -0
  42. package/skills/work-plan/lib/next_up.py +98 -0
  43. package/skills/work-plan/lib/prompts.py +68 -0
  44. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  45. package/skills/work-plan/lib/render.py +83 -0
  46. package/skills/work-plan/lib/scratch.py +14 -0
  47. package/skills/work-plan/lib/session_log.py +39 -0
  48. package/skills/work-plan/lib/status_header.py +60 -0
  49. package/skills/work-plan/lib/status_table.py +227 -0
  50. package/skills/work-plan/lib/tracks.py +109 -0
  51. package/skills/work-plan/lib/verdict.py +51 -0
  52. package/skills/work-plan/lib/write_guard.py +39 -0
  53. package/skills/work-plan/tests/__init__.py +0 -0
  54. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  55. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  56. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  58. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  59. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  60. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  61. package/skills/work-plan/tests/test_close.py +273 -0
  62. package/skills/work-plan/tests/test_closure.py +51 -0
  63. package/skills/work-plan/tests/test_config.py +85 -0
  64. package/skills/work-plan/tests/test_config_seed.py +41 -0
  65. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  66. package/skills/work-plan/tests/test_drift.py +38 -0
  67. package/skills/work-plan/tests/test_export.py +91 -0
  68. package/skills/work-plan/tests/test_export_command.py +295 -0
  69. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  70. package/skills/work-plan/tests/test_git_state.py +51 -0
  71. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  72. package/skills/work-plan/tests/test_github_state.py +508 -0
  73. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  74. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  75. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  76. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  77. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  78. package/skills/work-plan/tests/test_init.py +289 -0
  79. package/skills/work-plan/tests/test_init_repo.py +251 -0
  80. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  81. package/skills/work-plan/tests/test_manifest.py +162 -0
  82. package/skills/work-plan/tests/test_new_issues.py +130 -0
  83. package/skills/work-plan/tests/test_new_track.py +445 -0
  84. package/skills/work-plan/tests/test_next_up.py +149 -0
  85. package/skills/work-plan/tests/test_plan_status.py +68 -0
  86. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  87. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  88. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  89. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  90. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  91. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  92. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  93. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  94. package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
  95. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  96. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  97. package/skills/work-plan/tests/test_render.py +110 -0
  98. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  99. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  100. package/skills/work-plan/tests/test_session_log.py +39 -0
  101. package/skills/work-plan/tests/test_set_field.py +77 -0
  102. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  103. package/skills/work-plan/tests/test_slot.py +243 -0
  104. package/skills/work-plan/tests/test_slot_move.py +128 -0
  105. package/skills/work-plan/tests/test_smoke.py +46 -0
  106. package/skills/work-plan/tests/test_status_header.py +79 -0
  107. package/skills/work-plan/tests/test_status_table.py +162 -0
  108. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  109. package/skills/work-plan/tests/test_tracks.py +56 -0
  110. package/skills/work-plan/tests/test_verdict.py +60 -0
  111. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  112. package/skills/work-plan/tests/test_write_guard.py +53 -0
  113. 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]]