@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -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 +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,180 @@
1
+ """Local git queries + time helpers."""
2
+ import subprocess
3
+ from datetime import date, datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ def gap_seconds_to_label(seconds: int) -> str:
9
+ """'Nm ago' / 'Nh ago' / 'Nd ago'."""
10
+ minutes = seconds // 60
11
+ if minutes < 60:
12
+ return f"{minutes}m ago"
13
+ hours = minutes // 60
14
+ if hours < 24:
15
+ return f"{hours}h ago"
16
+ days = hours // 24
17
+ return f"{days}d ago"
18
+
19
+
20
+ def parse_iso_timestamp(s: str) -> datetime:
21
+ if "T" in s:
22
+ return datetime.strptime(s, "%Y-%m-%dT%H:%M")
23
+ return datetime.strptime(s, "%Y-%m-%d")
24
+
25
+
26
+ def current_branch(repo_path: Path) -> Optional[str]:
27
+ if not repo_path or not Path(repo_path).exists():
28
+ return None
29
+ proc = subprocess.run(
30
+ ["git", "-C", str(repo_path), "branch", "--show-current"],
31
+ capture_output=True, text=True,
32
+ )
33
+ if proc.returncode != 0:
34
+ return None
35
+ return proc.stdout.strip() or None
36
+
37
+
38
+ def has_uncommitted(repo_path: Path) -> bool:
39
+ if not repo_path or not Path(repo_path).exists():
40
+ return False
41
+ proc = subprocess.run(
42
+ ["git", "-C", str(repo_path), "status", "--short"],
43
+ capture_output=True, text=True,
44
+ )
45
+ return proc.returncode == 0 and bool(proc.stdout.strip())
46
+
47
+
48
+ def uncommitted_file_count(repo_path: Path) -> int:
49
+ if not repo_path or not Path(repo_path).exists():
50
+ return 0
51
+ proc = subprocess.run(
52
+ ["git", "-C", str(repo_path), "status", "--short"],
53
+ capture_output=True, text=True,
54
+ )
55
+ if proc.returncode != 0:
56
+ return 0
57
+ return len([l for l in proc.stdout.splitlines() if l.strip()])
58
+
59
+
60
+ def commits_ahead(branch_name: str, base: str, repo_path: Path) -> int:
61
+ if not repo_path or not Path(repo_path).exists():
62
+ return 0
63
+ proc = subprocess.run(
64
+ ["git", "-C", str(repo_path), "rev-list", "--count", f"{base}..{branch_name}"],
65
+ capture_output=True, text=True,
66
+ )
67
+ if proc.returncode != 0:
68
+ return 0
69
+ try:
70
+ return int(proc.stdout.strip())
71
+ except ValueError:
72
+ return 0
73
+
74
+
75
+ def branch_exists(branch_name: str, repo_path: Path) -> bool:
76
+ if not repo_path or not Path(repo_path).exists():
77
+ return False
78
+ proc = subprocess.run(
79
+ ["git", "-C", str(repo_path), "rev-parse", "--verify", branch_name],
80
+ capture_output=True, text=True,
81
+ )
82
+ return proc.returncode == 0
83
+
84
+
85
+ def _has_recent_commits(branch_name: str, repo_path: Path, hours: int = 24) -> bool:
86
+ if not repo_path or not Path(repo_path).exists():
87
+ return False
88
+ if not branch_exists(branch_name, repo_path):
89
+ return False
90
+ since = (datetime.now() - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S")
91
+ proc = subprocess.run(
92
+ ["git", "-C", str(repo_path), "log", branch_name,
93
+ f"--since={since}", "--pretty=format:%H"],
94
+ capture_output=True, text=True,
95
+ )
96
+ return proc.returncode == 0 and bool(proc.stdout.strip())
97
+
98
+
99
+ def branch_in_progress(branch_name: str, repo_path: Path) -> bool:
100
+ """Detect 'in-progress':
101
+ - It's the current branch AND has uncommitted changes, OR
102
+ - It has commits in the last 24 hours.
103
+ """
104
+ if not repo_path or not Path(repo_path).exists():
105
+ return False
106
+ if not branch_exists(branch_name, repo_path):
107
+ return False
108
+ cur = current_branch(repo_path)
109
+ if cur == branch_name and has_uncommitted(repo_path):
110
+ return True
111
+ return _has_recent_commits(branch_name, repo_path, hours=24)
112
+
113
+
114
+ def last_commit_date(branch_name: str, repo_path: Path) -> Optional[datetime]:
115
+ """Most recent commit timestamp on branch (naive)."""
116
+ if not repo_path or not Path(repo_path).exists():
117
+ return None
118
+ if not branch_exists(branch_name, repo_path):
119
+ return None
120
+ proc = subprocess.run(
121
+ ["git", "-C", str(repo_path), "log", "-1", branch_name, "--pretty=format:%cI"],
122
+ capture_output=True, text=True,
123
+ )
124
+ if proc.returncode != 0 or not proc.stdout.strip():
125
+ return None
126
+ try:
127
+ s = proc.stdout.strip().split("+")[0].split("Z")[0]
128
+ return datetime.fromisoformat(s)
129
+ except (ValueError, IndexError):
130
+ return None
131
+
132
+
133
+ def path_last_commit_date(rel_path: str, repo_path: Path) -> Optional[datetime]:
134
+ """Timestamp of the most recent commit touching `rel_path` (naive datetime)."""
135
+ if not repo_path or not Path(repo_path).exists():
136
+ return None
137
+ proc = subprocess.run(
138
+ ["git", "-C", str(repo_path), "log", "-1", "--pretty=format:%cI", "--", rel_path],
139
+ capture_output=True, text=True,
140
+ )
141
+ if proc.returncode != 0 or not proc.stdout.strip():
142
+ return None
143
+ try:
144
+ s = proc.stdout.strip().split("+")[0].split("Z")[0]
145
+ return datetime.fromisoformat(s)
146
+ except (ValueError, IndexError):
147
+ return None
148
+
149
+
150
+ def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
151
+ """True if `rel_path` has any commit on/around `since` or later (a datetime.date).
152
+
153
+ `git log --since` resolves to local midnight and can drop commits made on the
154
+ plan date itself (timezone-dependent) — the common case where a plan is written
155
+ and its files land the same day. We widen the window by one day so same-day
156
+ Modify commits are reliably counted; including the prior day is an acceptable
157
+ cost for a liveness heuristic.
158
+ """
159
+ if not repo_path or not Path(repo_path).exists():
160
+ return False
161
+ window_start = since - timedelta(days=1)
162
+ proc = subprocess.run(
163
+ ["git", "-C", str(repo_path), "log",
164
+ f"--since={window_start.isoformat()}", "--pretty=format:%H", "--", rel_path],
165
+ capture_output=True, text=True,
166
+ )
167
+ return proc.returncode == 0 and bool(proc.stdout.strip())
168
+
169
+
170
+ def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
171
+ """git-mv `src_rel` -> `dst_rel` (both repo-relative), creating the dest
172
+ directory first. Returns True on success. History-preserving."""
173
+ if not repo_path or not Path(repo_path).exists():
174
+ return False
175
+ (Path(repo_path) / dst_rel).parent.mkdir(parents=True, exist_ok=True)
176
+ proc = subprocess.run(
177
+ ["git", "-C", str(repo_path), "mv", src_rel, dst_rel],
178
+ capture_output=True, text=True,
179
+ )
180
+ return proc.returncode == 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)]