@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,247 @@
|
|
|
1
|
+
"""brief subcommand — fully featured."""
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lib.config import load_config, ConfigError
|
|
6
|
+
from lib.tracks import discover_tracks, discover_archived_tracks, filter_tracks_by_repo
|
|
7
|
+
from lib.github_state import fetch_issues, extract_priority, short_milestone
|
|
8
|
+
from lib.prompts import parse_flags
|
|
9
|
+
from lib.git_state import (
|
|
10
|
+
parse_iso_timestamp, gap_seconds_to_label,
|
|
11
|
+
branch_in_progress, commits_ahead, uncommitted_file_count, current_branch,
|
|
12
|
+
)
|
|
13
|
+
from lib.closure import compute_signals, is_closure_ready
|
|
14
|
+
from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
|
|
15
|
+
from lib.next_up import suggest_next_up
|
|
16
|
+
from lib.drift import detect_drift
|
|
17
|
+
from lib.render import time_aware_framing, render_track_row, render_archived_reopen
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(args: list[str]) -> int:
|
|
21
|
+
flags, _ = parse_flags(args, {"--repo"})
|
|
22
|
+
repo_key = flags.get("--repo")
|
|
23
|
+
if repo_key is True:
|
|
24
|
+
print("usage: work_plan.py brief [--repo=<key>]")
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
except ConfigError as e:
|
|
30
|
+
print(f"ERROR: {e}", flush=True)
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
tracks = discover_tracks(cfg)
|
|
34
|
+
if repo_key:
|
|
35
|
+
scoped = filter_tracks_by_repo(tracks, repo_key)
|
|
36
|
+
if not scoped:
|
|
37
|
+
print(f"No tracks found for repo '{repo_key}'.")
|
|
38
|
+
available = sorted((cfg.get("repos") or {}).keys())
|
|
39
|
+
if available:
|
|
40
|
+
print(f"Configured repo keys: {', '.join(available)}")
|
|
41
|
+
return 0
|
|
42
|
+
tracks = scoped
|
|
43
|
+
active = [t for t in tracks if t.has_frontmatter
|
|
44
|
+
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
45
|
+
|
|
46
|
+
if not active and not tracks:
|
|
47
|
+
print("No tracks found.")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
now = datetime.now()
|
|
51
|
+
most_recent = max(
|
|
52
|
+
(parse_iso_timestamp(t.meta["last_touched"]) for t in active if t.meta.get("last_touched")),
|
|
53
|
+
default=None,
|
|
54
|
+
)
|
|
55
|
+
gap = int((now - most_recent).total_seconds()) if most_recent else 999999
|
|
56
|
+
handoff_today = any(
|
|
57
|
+
t.meta.get("last_handoff", "").startswith(now.strftime("%Y-%m-%d")) for t in active
|
|
58
|
+
)
|
|
59
|
+
framing = time_aware_framing(gap, now.hour, handoff_today)
|
|
60
|
+
|
|
61
|
+
print(f"DAILY BRIEF — {now.strftime('%Y-%m-%d %H:%M')} (gap: {gap_seconds_to_label(gap)})")
|
|
62
|
+
print()
|
|
63
|
+
print(framing)
|
|
64
|
+
print()
|
|
65
|
+
|
|
66
|
+
blocks = []
|
|
67
|
+
for t in active:
|
|
68
|
+
b = _build_track_block(t, cfg, now)
|
|
69
|
+
blocks.append((b["sort_key"], b))
|
|
70
|
+
|
|
71
|
+
blocks.sort(key=lambda x: x[0])
|
|
72
|
+
for _, block in blocks:
|
|
73
|
+
print(render_track_row(block))
|
|
74
|
+
print()
|
|
75
|
+
|
|
76
|
+
needs_init = [t for t in tracks if t.needs_init]
|
|
77
|
+
needs_filing = [t for t in tracks if t.needs_filing]
|
|
78
|
+
if needs_init or needs_filing:
|
|
79
|
+
print("--- Setup needed ---")
|
|
80
|
+
for t in needs_init:
|
|
81
|
+
print(f" needs init: {t.path} → /work-plan init '{t.path}'")
|
|
82
|
+
for t in needs_filing:
|
|
83
|
+
print(f" needs filing: {t.path} → move into a repo subfolder")
|
|
84
|
+
print()
|
|
85
|
+
|
|
86
|
+
_surface_archived_reopens(cfg, repo_key=repo_key)
|
|
87
|
+
|
|
88
|
+
n_active = len(active)
|
|
89
|
+
n_in_progress = sum(1 for _, b in blocks if b["operational_status"] == "in-progress")
|
|
90
|
+
n_closure = sum(1 for _, b in blocks if b["closure_ready"])
|
|
91
|
+
n_drift = sum(1 for _, b in blocks if b["drift_items"])
|
|
92
|
+
n_new = sum(len(b["new_issues"]) for _, b in blocks)
|
|
93
|
+
print(f"{n_active} active tracks. "
|
|
94
|
+
f"{n_in_progress} in-progress. {n_closure} closure-ready. "
|
|
95
|
+
f"{n_drift} with drift. {n_new} new issues to slot.")
|
|
96
|
+
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_track_block(track, cfg, now: datetime) -> dict:
|
|
101
|
+
meta = track.meta
|
|
102
|
+
repo = track.repo
|
|
103
|
+
local = track.local_path
|
|
104
|
+
|
|
105
|
+
issue_nums = meta.get("github", {}).get("issues") or []
|
|
106
|
+
stored_next_up = meta.get("next_up") or []
|
|
107
|
+
# Fetch state for stored next_up issues even if they're not in github.issues,
|
|
108
|
+
# so stale closed entries surface as a clear signal rather than vanishing.
|
|
109
|
+
fetch_nums = sorted(set(issue_nums) | set(stored_next_up))
|
|
110
|
+
issues = fetch_issues(repo, fetch_nums) if (repo and fetch_nums) else []
|
|
111
|
+
issues_by_num = {i["number"]: i for i in issues}
|
|
112
|
+
|
|
113
|
+
# When `next_up_auto: true` is set in track frontmatter, derive the list
|
|
114
|
+
# live from open issues (priority-sorted, blockers excluded) instead of
|
|
115
|
+
# reading the stored `next_up`. The track's persisted list is ignored
|
|
116
|
+
# for display purposes — useful for tracks where you don't want to
|
|
117
|
+
# hand-curate but still want a sensible "what's next" surfaced.
|
|
118
|
+
track_milestone = meta.get("milestone_alignment") or None
|
|
119
|
+
if meta.get("next_up_auto") and issues:
|
|
120
|
+
blocker_nums = meta.get("blockers") or []
|
|
121
|
+
next_up_nums = suggest_next_up(issues, blocker_nums, track_milestone=track_milestone)
|
|
122
|
+
else:
|
|
123
|
+
next_up_nums = stored_next_up
|
|
124
|
+
|
|
125
|
+
next_up_items = []
|
|
126
|
+
next_up_closed_count = 0
|
|
127
|
+
for num in next_up_nums:
|
|
128
|
+
i = issues_by_num.get(num)
|
|
129
|
+
if not i:
|
|
130
|
+
continue
|
|
131
|
+
state = (i.get("state") or "").upper()
|
|
132
|
+
if state in ("CLOSED", "MERGED"):
|
|
133
|
+
next_up_closed_count += 1
|
|
134
|
+
continue
|
|
135
|
+
next_up_items.append({
|
|
136
|
+
"number": num, "title": i.get("title", ""),
|
|
137
|
+
"priority": extract_priority(i.get("labels", [])),
|
|
138
|
+
"state": state.lower() or "open",
|
|
139
|
+
"milestone": short_milestone(i.get("milestone")),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
branch_names = meta.get("github", {}).get("branches") or []
|
|
143
|
+
active_branches = []
|
|
144
|
+
branch_in_prog = False
|
|
145
|
+
for bn in branch_names:
|
|
146
|
+
in_prog = branch_in_progress(bn, local)
|
|
147
|
+
if in_prog:
|
|
148
|
+
branch_in_prog = True
|
|
149
|
+
active_branches.append({
|
|
150
|
+
"name": bn,
|
|
151
|
+
"ahead": commits_ahead(bn, "dev", local) if local else 0,
|
|
152
|
+
"uncommitted_files": (
|
|
153
|
+
uncommitted_file_count(local)
|
|
154
|
+
if local and current_branch(local) == bn else 0
|
|
155
|
+
),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
stored_status = meta.get("status", "active")
|
|
159
|
+
if stored_status == "active" and branch_in_prog:
|
|
160
|
+
operational_status = "in-progress"
|
|
161
|
+
else:
|
|
162
|
+
operational_status = stored_status
|
|
163
|
+
|
|
164
|
+
track_slug = meta.get("track", track.name)
|
|
165
|
+
slug_labels = build_slug_labels([track])
|
|
166
|
+
new_issues_map = find_new_issues_for_tracks(repo, [track_slug], slug_labels=slug_labels, since_days=7) if repo else {}
|
|
167
|
+
listed_set = set(issue_nums)
|
|
168
|
+
new_issues = []
|
|
169
|
+
for issue in new_issues_map.get(track_slug, []):
|
|
170
|
+
if issue["number"] in listed_set:
|
|
171
|
+
continue
|
|
172
|
+
new_issues.append({"number": issue["number"], "title": issue["title"]})
|
|
173
|
+
|
|
174
|
+
drift_items = detect_drift(track.body, issues) if issues else []
|
|
175
|
+
|
|
176
|
+
related_recent_count = len(new_issues_map.get(track_slug, []))
|
|
177
|
+
signals = compute_signals(meta, issues, local, related_recent_count)
|
|
178
|
+
closure_ready, _ = is_closure_ready(signals)
|
|
179
|
+
if closure_ready:
|
|
180
|
+
closure_signals_summary = None
|
|
181
|
+
else:
|
|
182
|
+
green = sum([signals.all_issues_closed, signals.all_branches_done,
|
|
183
|
+
signals.next_up_empty, signals.cold_14d, signals.no_recent_related_issues])
|
|
184
|
+
closure_signals_summary = f"{green}/5 signals green"
|
|
185
|
+
|
|
186
|
+
blockers = [{"number": bn, "reason": "manually flagged"}
|
|
187
|
+
for bn in (meta.get("blockers") or [])]
|
|
188
|
+
|
|
189
|
+
def lbl(key):
|
|
190
|
+
if not meta.get(key):
|
|
191
|
+
return "?"
|
|
192
|
+
gs = (now - parse_iso_timestamp(meta[key])).total_seconds()
|
|
193
|
+
return gap_seconds_to_label(int(gs))
|
|
194
|
+
|
|
195
|
+
in_prog_rank = 0 if operational_status == "in-progress" else 1
|
|
196
|
+
pri_rank = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(meta.get("launch_priority", "P3"), 3)
|
|
197
|
+
recency_key = (
|
|
198
|
+
-parse_iso_timestamp(meta["last_touched"]).timestamp()
|
|
199
|
+
if meta.get("last_touched") else 0
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"name": meta.get("track", track.name),
|
|
204
|
+
"operational_status": operational_status,
|
|
205
|
+
"launch_priority": meta.get("launch_priority", "P3"),
|
|
206
|
+
"milestone_alignment": meta.get("milestone_alignment", "—"),
|
|
207
|
+
"last_touched_label": lbl("last_touched"),
|
|
208
|
+
"last_handoff_label": lbl("last_handoff"),
|
|
209
|
+
"next_up": next_up_items,
|
|
210
|
+
"next_up_stale_closed_count": (
|
|
211
|
+
next_up_closed_count if not next_up_items and next_up_nums else 0
|
|
212
|
+
),
|
|
213
|
+
"track_slug": meta.get("track", track.name),
|
|
214
|
+
"active_branches": active_branches,
|
|
215
|
+
"new_issues": new_issues,
|
|
216
|
+
"blockers": blockers,
|
|
217
|
+
"drift_items": drift_items,
|
|
218
|
+
"closure_ready": closure_ready,
|
|
219
|
+
"closure_signals_summary": closure_signals_summary,
|
|
220
|
+
"archived_reopen": [],
|
|
221
|
+
"sort_key": (in_prog_rank, pri_rank, recency_key),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _surface_archived_reopens(cfg: dict, repo_key: str = None) -> None:
|
|
226
|
+
archived = discover_archived_tracks(cfg)
|
|
227
|
+
if repo_key:
|
|
228
|
+
archived = filter_tracks_by_repo(archived, repo_key)
|
|
229
|
+
if not archived:
|
|
230
|
+
return
|
|
231
|
+
by_repo: dict[str, list] = {}
|
|
232
|
+
for a in archived:
|
|
233
|
+
if a.repo:
|
|
234
|
+
by_repo.setdefault(a.repo, []).append(a)
|
|
235
|
+
callouts = []
|
|
236
|
+
for repo, tracks_in_repo in by_repo.items():
|
|
237
|
+
slugs = [a.meta.get("track", a.name) for a in tracks_in_repo]
|
|
238
|
+
slug_labels = build_slug_labels(tracks_in_repo)
|
|
239
|
+
new_map = find_new_issues_for_tracks(repo, slugs, slug_labels=slug_labels, since_days=14)
|
|
240
|
+
for slug, issues in new_map.items():
|
|
241
|
+
for issue in issues:
|
|
242
|
+
callouts.append((slug, issue))
|
|
243
|
+
if callouts:
|
|
244
|
+
print("--- Archived tracks with new activity ---")
|
|
245
|
+
for slug, issue in callouts:
|
|
246
|
+
print(" " + render_archived_reopen(repo, slug, issue))
|
|
247
|
+
print()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""canonicalize subcommand: add a canonical master issue table to a track.
|
|
2
|
+
|
|
3
|
+
Generates one-row-per-issue table from frontmatter github.issues, with assignee
|
|
4
|
+
and status columns. Inserts at top of body with a marker so refresh-md targets
|
|
5
|
+
ONLY this table (skipping narrative tables in the existing body).
|
|
6
|
+
|
|
7
|
+
Use --all to canonicalize every active track that doesn't yet have one.
|
|
8
|
+
"""
|
|
9
|
+
from lib.config import load_config, ConfigError
|
|
10
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
11
|
+
from lib.github_state import fetch_issues, state_to_status_label, format_assignees
|
|
12
|
+
from lib.frontmatter import write_file
|
|
13
|
+
from lib.status_table import CANONICAL_MARKER, find_canonical_status_tables, render_issue_row
|
|
14
|
+
from lib.prompts import parse_flags
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(args: list[str]) -> int:
|
|
18
|
+
flags, positional = parse_flags(args, {"--all", "--force", "--repo"})
|
|
19
|
+
do_all = flags.get("--all", False)
|
|
20
|
+
force = flags.get("--force", False)
|
|
21
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
22
|
+
track_arg = positional[0] if positional else None
|
|
23
|
+
|
|
24
|
+
if not do_all and not track_arg:
|
|
25
|
+
print("usage: work_plan.py canonicalize <track-name> | --all [--force] [--repo=<key>]")
|
|
26
|
+
return 2
|
|
27
|
+
|
|
28
|
+
track_name = track_arg
|
|
29
|
+
repo_qualifier = repo_flag
|
|
30
|
+
if track_arg:
|
|
31
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
32
|
+
track_name = name_from_arg
|
|
33
|
+
if repo_from_arg:
|
|
34
|
+
repo_qualifier = repo_from_arg
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
cfg = load_config()
|
|
38
|
+
except ConfigError as e:
|
|
39
|
+
print(f"ERROR: {e}")
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
tracks = discover_tracks(cfg)
|
|
43
|
+
|
|
44
|
+
if do_all:
|
|
45
|
+
targets = [t for t in tracks if t.has_frontmatter
|
|
46
|
+
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
47
|
+
if repo_qualifier:
|
|
48
|
+
from lib.tracks import filter_tracks_by_repo
|
|
49
|
+
targets = filter_tracks_by_repo(targets, repo_qualifier)
|
|
50
|
+
else:
|
|
51
|
+
try:
|
|
52
|
+
target = find_track_by_name(track_name, tracks, active_only=True,
|
|
53
|
+
repo=repo_qualifier)
|
|
54
|
+
except AmbiguousTrackError as e:
|
|
55
|
+
print(str(e))
|
|
56
|
+
return 1
|
|
57
|
+
if not target:
|
|
58
|
+
print(f"No active track matching '{track_name}'.")
|
|
59
|
+
return 1
|
|
60
|
+
targets = [target]
|
|
61
|
+
|
|
62
|
+
any_changes = False
|
|
63
|
+
for track in targets:
|
|
64
|
+
existing = find_canonical_status_tables(track.body)
|
|
65
|
+
if existing and not force:
|
|
66
|
+
print(f" skip {track.name}: already has canonical table (use --force to replace)")
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
issue_nums = track.meta.get("github", {}).get("issues") or []
|
|
70
|
+
if not issue_nums or not track.repo:
|
|
71
|
+
print(f" skip {track.name}: no issues or repo")
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
print(f" fetching {len(issue_nums)} issue(s) for {track.name}...")
|
|
75
|
+
issues = fetch_issues(track.repo, issue_nums)
|
|
76
|
+
issues_by_num = {i["number"]: i for i in issues}
|
|
77
|
+
|
|
78
|
+
new_body = _insert_canonical_table(
|
|
79
|
+
track.body, issue_nums, issues_by_num, replace=force,
|
|
80
|
+
)
|
|
81
|
+
write_file(track.path, track.meta, new_body)
|
|
82
|
+
print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
|
|
83
|
+
any_changes = True
|
|
84
|
+
|
|
85
|
+
if not any_changes:
|
|
86
|
+
print("Nothing to do.")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _insert_canonical_table(body: str, issue_nums: list[int],
|
|
91
|
+
issues_by_num: dict, replace: bool = False) -> str:
|
|
92
|
+
"""Insert (or replace) a canonical table at the top of the body."""
|
|
93
|
+
table_md = _render_canonical_table(issue_nums, issues_by_num)
|
|
94
|
+
|
|
95
|
+
if replace:
|
|
96
|
+
# Strip existing canonical block (marker + heading + table + separator)
|
|
97
|
+
body = _strip_existing_canonical(body)
|
|
98
|
+
|
|
99
|
+
# Prepend table after any leading whitespace
|
|
100
|
+
body_stripped = body.lstrip("\n")
|
|
101
|
+
leading_whitespace = body[: len(body) - len(body_stripped)]
|
|
102
|
+
return leading_whitespace + table_md + "\n---\n\n" + body_stripped
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _render_canonical_table(issue_nums: list[int], issues_by_num: dict) -> str:
|
|
106
|
+
lines = [
|
|
107
|
+
"## Issues (canonical)",
|
|
108
|
+
"",
|
|
109
|
+
f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
|
|
110
|
+
"",
|
|
111
|
+
"| # | Title | Assignee | Status |",
|
|
112
|
+
"|---|---|---|---|",
|
|
113
|
+
]
|
|
114
|
+
for num in sorted(issue_nums):
|
|
115
|
+
i = issues_by_num.get(num, {})
|
|
116
|
+
lines.append(render_issue_row(
|
|
117
|
+
num, i.get("title", "(not fetched)"),
|
|
118
|
+
format_assignees(i), state_to_status_label(i.get("state")),
|
|
119
|
+
))
|
|
120
|
+
lines.append("")
|
|
121
|
+
return "\n".join(lines)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _strip_existing_canonical(body: str) -> str:
|
|
125
|
+
"""Remove an existing canonical-table block from the top of the body."""
|
|
126
|
+
if CANONICAL_MARKER not in body:
|
|
127
|
+
return body
|
|
128
|
+
# Find the start of the heading "## Issues (canonical)" if present, else the marker
|
|
129
|
+
heading_idx = body.find("## Issues (canonical)")
|
|
130
|
+
marker_idx = body.find(CANONICAL_MARKER)
|
|
131
|
+
start = heading_idx if 0 <= heading_idx < marker_idx else marker_idx
|
|
132
|
+
# Find end: the next "---\n" separator after the marker
|
|
133
|
+
sep_idx = body.find("\n---\n", marker_idx)
|
|
134
|
+
if sep_idx == -1:
|
|
135
|
+
# No separator — strip just the marker line
|
|
136
|
+
end = body.find("\n", marker_idx) + 1
|
|
137
|
+
else:
|
|
138
|
+
end = sep_idx + len("\n---\n")
|
|
139
|
+
return body[:start] + body[end:].lstrip("\n")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""close subcommand — non-interactive, flag-driven."""
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lib.config import load_config, ConfigError
|
|
7
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
8
|
+
from lib.frontmatter import write_file
|
|
9
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
10
|
+
from lib.prompts import parse_flags
|
|
11
|
+
|
|
12
|
+
VALID_STATES = {"shipped", "parked", "abandoned"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(args: list[str]) -> int:
|
|
16
|
+
# --confirm uses equals form: --confirm=<token>
|
|
17
|
+
# --state and --note also use equals form: --state=shipped, --note=...
|
|
18
|
+
# --repo uses equals form: --repo=<key>
|
|
19
|
+
flags, positional = parse_flags(args, {"--state", "--note", "--confirm", "--repo"})
|
|
20
|
+
|
|
21
|
+
if not positional:
|
|
22
|
+
print("usage: work_plan.py close <track-name> --state=shipped|parked|abandoned [--note=<text>] [--confirm=<token>] [--repo=<key>]")
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
track_arg = positional[0]
|
|
26
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
27
|
+
track_name = name_from_arg
|
|
28
|
+
repo_qualifier = repo_from_arg or (flags.get("--repo") if flags.get("--repo") is not True else None)
|
|
29
|
+
|
|
30
|
+
# Validate --state (required)
|
|
31
|
+
end_state = flags.get("--state")
|
|
32
|
+
if not end_state or end_state not in VALID_STATES:
|
|
33
|
+
if not end_state:
|
|
34
|
+
print("ERROR: --state is required (shipped|parked|abandoned).")
|
|
35
|
+
else:
|
|
36
|
+
print(f"ERROR: --state={end_state!r} is not valid (allowed: abandoned, parked, shipped).")
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
cfg = load_config()
|
|
41
|
+
except ConfigError as e:
|
|
42
|
+
print(f"ERROR: {e}")
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
tracks = discover_tracks(cfg)
|
|
46
|
+
try:
|
|
47
|
+
track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
|
|
48
|
+
except AmbiguousTrackError as e:
|
|
49
|
+
print(str(e))
|
|
50
|
+
return 1
|
|
51
|
+
if not track:
|
|
52
|
+
print(f"No track matching '{track_name}'.")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
# Public-repo confirm gate (the extension surfaces this as a modal).
|
|
56
|
+
# Placed after track resolution but before any write/move.
|
|
57
|
+
confirm = flags.get("--confirm")
|
|
58
|
+
if track.repo and needs_confirm(track.repo, cfg) and not (
|
|
59
|
+
isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)
|
|
60
|
+
):
|
|
61
|
+
print(json.dumps({
|
|
62
|
+
"needs_confirm": True,
|
|
63
|
+
"reason": (
|
|
64
|
+
f"{track.repo} is PUBLIC (or visibility unknown); "
|
|
65
|
+
f"closing '{track.name}' will be written there."
|
|
66
|
+
),
|
|
67
|
+
"token": make_token(track.repo, track.name),
|
|
68
|
+
}))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
# Apply state and optional wrap-up note.
|
|
72
|
+
track.meta["status"] = end_state
|
|
73
|
+
new_body = track.body
|
|
74
|
+
note = flags.get("--note")
|
|
75
|
+
if note and isinstance(note, str) and note.strip():
|
|
76
|
+
new_body += f"\n\n## Wrap-up\n\n{note}\n"
|
|
77
|
+
|
|
78
|
+
write_file(track.path, track.meta, new_body)
|
|
79
|
+
|
|
80
|
+
if end_state == "parked":
|
|
81
|
+
print(f"✓ '{track.name}' marked parked. Stays in place.")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
notes_root = Path(cfg["notes_root"])
|
|
85
|
+
folder = track.path.parent
|
|
86
|
+
archive_dir = folder / "archive" / end_state
|
|
87
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
dest = archive_dir / track.path.name
|
|
89
|
+
shutil.move(str(track.path), str(dest))
|
|
90
|
+
# Use relative path from tier root; fall back to absolute if outside notes_root
|
|
91
|
+
try:
|
|
92
|
+
display = dest.relative_to(notes_root)
|
|
93
|
+
except ValueError:
|
|
94
|
+
display = dest
|
|
95
|
+
print(f"✓ '{track.name}' marked {end_state}, moved to {display}")
|
|
96
|
+
if getattr(track, "tier", None) == "shared":
|
|
97
|
+
print(" ↑ shared track — commit + push to share this archive with teammates.")
|
|
98
|
+
return 0
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""coverage subcommand: report open issues not referenced by any track.
|
|
2
|
+
|
|
3
|
+
Read-only. Fetches live from gh — no cache. Use --repo=<key> to scope to
|
|
4
|
+
one repo; omit for all configured repos. Use --list to print untracked
|
|
5
|
+
issue titles. Use --limit=N to control how many are shown (default 20).
|
|
6
|
+
"""
|
|
7
|
+
from lib.config import load_config, ConfigError
|
|
8
|
+
from lib.tracks import discover_tracks
|
|
9
|
+
from lib.github_state import fetch_open_issues
|
|
10
|
+
from lib.prompts import parse_flags
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args: list[str]) -> int:
|
|
14
|
+
flags, _ = parse_flags(args, {"--list", "--repo"})
|
|
15
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
16
|
+
show_list = bool(flags.get("--list"))
|
|
17
|
+
|
|
18
|
+
limit = 20
|
|
19
|
+
for a in args:
|
|
20
|
+
if a.startswith("--limit="):
|
|
21
|
+
try:
|
|
22
|
+
limit = int(a.split("=", 1)[1])
|
|
23
|
+
except ValueError:
|
|
24
|
+
print("ERROR: --limit must be an integer.")
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
except ConfigError as e:
|
|
30
|
+
print(f"ERROR: {e}")
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
repos_cfg = cfg.get("repos", {})
|
|
34
|
+
|
|
35
|
+
if repo_flag:
|
|
36
|
+
if repo_flag not in repos_cfg:
|
|
37
|
+
print(f"ERROR: repo folder '{repo_flag}' not in config.yml.")
|
|
38
|
+
return 1
|
|
39
|
+
folders = [repo_flag]
|
|
40
|
+
else:
|
|
41
|
+
folders = list(repos_cfg.keys())
|
|
42
|
+
|
|
43
|
+
if not folders:
|
|
44
|
+
print("ERROR: no repos configured in config.yml.")
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
tracks = discover_tracks(cfg)
|
|
48
|
+
|
|
49
|
+
# Build per-repo set of tracked issue numbers across all tracks.
|
|
50
|
+
tracked_by_repo: dict[str, set] = {}
|
|
51
|
+
for t in tracks:
|
|
52
|
+
if not t.repo or not t.has_frontmatter:
|
|
53
|
+
continue
|
|
54
|
+
nums = t.meta.get("github", {}).get("issues") or []
|
|
55
|
+
tracked_by_repo.setdefault(t.repo, set()).update(nums)
|
|
56
|
+
|
|
57
|
+
any_output = False
|
|
58
|
+
for folder in folders:
|
|
59
|
+
repo = repos_cfg[folder].get("github")
|
|
60
|
+
if not repo:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
print(f"Fetching open issues for {repo}...")
|
|
64
|
+
open_issues = fetch_open_issues(repo)
|
|
65
|
+
tracked = tracked_by_repo.get(repo, set())
|
|
66
|
+
|
|
67
|
+
untracked = [i for i in open_issues if i.get("number") not in tracked]
|
|
68
|
+
total = len(open_issues)
|
|
69
|
+
n_untracked = len(untracked)
|
|
70
|
+
n_tracked = total - n_untracked
|
|
71
|
+
pct_tracked = round(100 * n_tracked / total) if total else 0
|
|
72
|
+
pct_untracked = 100 - pct_tracked if total else 0
|
|
73
|
+
|
|
74
|
+
print()
|
|
75
|
+
print(f"{folder} ({repo}):")
|
|
76
|
+
print(f" Open issues: {total}")
|
|
77
|
+
if total == 0:
|
|
78
|
+
print(" No open issues.")
|
|
79
|
+
else:
|
|
80
|
+
print(f" In a track: {n_tracked} ({pct_tracked}%)")
|
|
81
|
+
if n_untracked == 0:
|
|
82
|
+
print(" Untracked: 0 — full coverage!")
|
|
83
|
+
else:
|
|
84
|
+
print(f" Untracked: {n_untracked} ({pct_untracked}%)")
|
|
85
|
+
if show_list:
|
|
86
|
+
shown = untracked[:limit]
|
|
87
|
+
for i in shown:
|
|
88
|
+
num = i.get("number", "?")
|
|
89
|
+
title = i.get("title", "")
|
|
90
|
+
print(f" #{num} {title}")
|
|
91
|
+
remainder = n_untracked - len(shown)
|
|
92
|
+
if remainder > 0:
|
|
93
|
+
print(f" … and {remainder} more")
|
|
94
|
+
else:
|
|
95
|
+
print(f" Run with --list to see titles, or /work-plan group to cluster.")
|
|
96
|
+
any_output = True
|
|
97
|
+
|
|
98
|
+
if not any_output:
|
|
99
|
+
print("No repos with a 'github' entry found in config.")
|
|
100
|
+
return 0
|