@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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -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 +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -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 +88 -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 +42 -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/notes_readme.py +38 -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 +248 -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_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -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_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -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 +169 -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_group_apply.py +348 -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 +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -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 +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -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 +239 -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_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -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 +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)]
|