@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,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