@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,325 @@
1
+ """where-was-i / orient subcommand.
2
+
3
+ Two modes:
4
+
5
+ 1. With a track name (`/work-plan orient ux-redesign`):
6
+ Prints a tight ~15-line paste-ready block summarizing where the track stands.
7
+ Header rule, priority + milestone + repo, track + local paths, last session
8
+ timestamp + one-line summary, the next pick by issue number + title, up to 3
9
+ issues behind it, current local-git state, and (if any) new related issues
10
+ filed since last handoff.
11
+
12
+ 2. With no track name (`/work-plan orient`):
13
+ Snapshot of the current working directory — branch, ahead-of-upstream count,
14
+ uncommitted file count, last 3 commits, modified files. Use this when you're
15
+ working on something that doesn't yet belong to a track.
16
+
17
+ Add `--pick` to force the interactive track picker instead of cwd-snapshot mode.
18
+
19
+ No closed/merged dump — that's what the GitHub issue list is for.
20
+ """
21
+ import re
22
+ import subprocess
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ from lib.config import load_config, ConfigError
28
+ from lib.tracks import discover_tracks, find_track_by_name
29
+ from lib.prompts import prompt_input, parse_flags
30
+ from lib.github_state import fetch_issues, short_milestone
31
+ from lib.git_state import (
32
+ parse_iso_timestamp,
33
+ current_branch, uncommitted_file_count, commits_ahead,
34
+ )
35
+ from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
36
+
37
+
38
+ RULE_CHAR = "─"
39
+ RULE_WIDTH = 57
40
+
41
+
42
+ def run(args: list[str]) -> int:
43
+ flags, positional = parse_flags(args, {"--pick"})
44
+ track_name = positional[0] if positional else None
45
+
46
+ try:
47
+ cfg = load_config()
48
+ except ConfigError as e:
49
+ print(f"ERROR: {e}")
50
+ return 1
51
+
52
+ # Mode 1 (track named): orient on that track.
53
+ # Mode 2 (--pick): interactive track picker (preserves old default behavior).
54
+ # Mode 3 (no args, no flag): cwd snapshot.
55
+ if not track_name and "--pick" not in flags:
56
+ return _orient_cwd()
57
+
58
+ tracks = discover_tracks(cfg)
59
+
60
+ if not track_name:
61
+ # --pick: interactive
62
+ active = [t for t in tracks if t.has_frontmatter
63
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
64
+ if not active:
65
+ print("No active tracks.")
66
+ return 1
67
+ print("Active tracks:")
68
+ for i, t in enumerate(active, 1):
69
+ print(f" [{i}] {t.name} ({t.meta.get('launch_priority','P3')})")
70
+ choice = prompt_input("\nWhich track? (number or name):")
71
+ if not choice:
72
+ print("No selection. Cancelled.")
73
+ return 1
74
+ if choice.isdigit():
75
+ idx = int(choice) - 1
76
+ if not (0 <= idx < len(active)):
77
+ print("Out of range.")
78
+ return 1
79
+ track = active[idx]
80
+ else:
81
+ track = find_track_by_name(choice, tracks)
82
+ if not track:
83
+ print(f"No track matching '{choice}'.")
84
+ return 1
85
+ else:
86
+ track = find_track_by_name(track_name, tracks)
87
+ if not track:
88
+ print(f"No track matching '{track_name}'.")
89
+ return 1
90
+
91
+ return _orient_track(track)
92
+
93
+
94
+ def _orient_track(track) -> int:
95
+ """Render the track paste-block (mode 1)."""
96
+ slug = track.meta.get("track", track.name)
97
+ priority = track.meta.get("launch_priority", "P3")
98
+ milestone = track.meta.get("milestone_alignment", "—")
99
+ repo = track.repo or "—"
100
+ next_up = track.meta.get("next_up") or []
101
+ last_handoff_iso = track.meta.get("last_handoff")
102
+
103
+ issue_nums = track.meta.get("github", {}).get("issues") or []
104
+ titles_by_num: dict[int, str] = {}
105
+ states_by_num: dict[int, str] = {}
106
+ milestones_by_num: dict[int, str] = {}
107
+ if track.repo and next_up:
108
+ wanted = next_up[:4]
109
+ fetched = fetch_issues(track.repo, wanted)
110
+ for i in fetched:
111
+ titles_by_num[i["number"]] = i.get("title", "")
112
+ states_by_num[i["number"]] = (i.get("state") or "").upper()
113
+ milestones_by_num[i["number"]] = short_milestone(i.get("milestone"))
114
+
115
+ print(_top_rule(slug))
116
+ print(f"Priority: {priority} · Milestone: {milestone} · Repo: {repo}")
117
+ print(f"Track: {track.path}")
118
+ if track.local_path:
119
+ print(f"Local: {track.local_path}")
120
+ print()
121
+
122
+ last_ts, last_summary = _last_session_summary(track.body)
123
+ if last_ts:
124
+ print(f"Last session ({last_ts}):")
125
+ print(f" {last_summary}")
126
+ else:
127
+ print("Last session: (none yet)")
128
+ print()
129
+
130
+ if next_up:
131
+ pick_num = next_up[0]
132
+ pick_title = titles_by_num.get(pick_num, "")
133
+ pick_suffix = _state_suffix(states_by_num.get(pick_num))
134
+ pick_ms = _milestone_prefix(milestones_by_num.get(pick_num))
135
+ print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}".rstrip())
136
+ if _is_closed(states_by_num.get(pick_num)):
137
+ print(f" ⚠ next_up:[0] has shipped — run `/work-plan handoff {slug}` to rotate")
138
+ rest = next_up[1:4]
139
+ if rest:
140
+ print()
141
+ print("Behind it:")
142
+ for num in rest:
143
+ title = titles_by_num.get(num, "")
144
+ suffix = _state_suffix(states_by_num.get(num))
145
+ ms = _milestone_prefix(milestones_by_num.get(num))
146
+ print(f" #{num} {ms}{title}{suffix}".rstrip())
147
+ else:
148
+ print("Next pick: (none set — run `/work-plan handoff` to set one)")
149
+
150
+ if track.local_path:
151
+ cur = current_branch(track.local_path)
152
+ if cur:
153
+ ahead = commits_ahead(cur, "dev", track.local_path)
154
+ uc = uncommitted_file_count(track.local_path)
155
+ print()
156
+ print(f"Local: on {cur} ({ahead} ahead of dev, {uc} uncommitted)")
157
+
158
+ new_unlisted = _new_issues_since_handoff(track, last_handoff_iso, slug, issue_nums)
159
+ if new_unlisted:
160
+ print()
161
+ print(f"New issues since last handoff ({len(new_unlisted)}):")
162
+ for i in new_unlisted[:6]:
163
+ print(f" #{i['number']} {i['title']}")
164
+
165
+ print(_bottom_rule())
166
+ return 0
167
+
168
+
169
+ def _orient_cwd() -> int:
170
+ """Render the cwd snapshot (mode 3) — for non-track-bound work."""
171
+ cwd = Path.cwd()
172
+ if not _is_git_repo(cwd):
173
+ print("ERROR: not inside a git repository.")
174
+ print(" cwd-snapshot mode of orient needs git state to display.")
175
+ print(" Use `/work-plan orient <track>` for a track paste-block instead,")
176
+ print(" or `/work-plan orient --pick` for the interactive track picker.")
177
+ return 1
178
+
179
+ branch = _git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
180
+ upstream, ahead = _ahead_of_upstream(cwd)
181
+ modified = _modified_files(cwd)
182
+ commits = _recent_commits(cwd, n=3)
183
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
184
+
185
+ print(_top_rule("current directory"))
186
+ print(f"Path: {cwd}")
187
+ if upstream:
188
+ print(f"Branch: {branch} ({ahead} ahead of {upstream}, {len(modified)} uncommitted)")
189
+ else:
190
+ print(f"Branch: {branch} (no upstream tracked, {len(modified)} uncommitted)")
191
+
192
+ if commits:
193
+ print()
194
+ print("Last 3 commits:")
195
+ for sha, msg in commits:
196
+ print(f" {sha} {msg}")
197
+
198
+ if modified:
199
+ print()
200
+ print("Modified:")
201
+ for entry in modified[:20]:
202
+ print(f" {entry}")
203
+ if len(modified) > 20:
204
+ print(f" … and {len(modified) - 20} more")
205
+
206
+ print()
207
+ print(f"Snapshot: {now}")
208
+ print(_bottom_rule())
209
+ return 0
210
+
211
+
212
+ def _is_closed(state: Optional[str]) -> bool:
213
+ return (state or "").upper() in ("CLOSED", "MERGED")
214
+
215
+
216
+ def _state_suffix(state: Optional[str]) -> str:
217
+ return " (closed)" if _is_closed(state) else ""
218
+
219
+
220
+ def _milestone_prefix(ms: Optional[str]) -> str:
221
+ return f"[{ms}] " if ms else ""
222
+
223
+
224
+ def _top_rule(slug: str) -> str:
225
+ label = f" {slug} "
226
+ left = RULE_CHAR * 3
227
+ used = len(left) + len(label)
228
+ right = RULE_CHAR * max(3, RULE_WIDTH - used)
229
+ return f"{left}{label}{right}"
230
+
231
+
232
+ def _bottom_rule() -> str:
233
+ return RULE_CHAR * RULE_WIDTH
234
+
235
+
236
+ def _last_session_summary(body: str) -> tuple[Optional[str], str]:
237
+ """Return (timestamp, one-line summary) of the most recent session block."""
238
+ if "### Session — " not in body:
239
+ return (None, "")
240
+ idx = body.rfind("### Session — ")
241
+ rest = body[idx:]
242
+ end = len(rest)
243
+ for marker in ("\n### ", "\n## "):
244
+ m = rest.find(marker, 1)
245
+ if m != -1 and m < end:
246
+ end = m
247
+ block = rest[:end]
248
+
249
+ lines = block.split("\n")
250
+ header = lines[0]
251
+ ts_match = re.match(r"### Session — (.+?)\s*$", header)
252
+ ts = ts_match.group(1).strip() if ts_match else None
253
+
254
+ summary = ""
255
+ for line in lines[1:]:
256
+ s = line.strip()
257
+ if not s:
258
+ continue
259
+ if s.startswith("- "):
260
+ s = s[2:].strip()
261
+ summary = s
262
+ break
263
+ return (ts, summary)
264
+
265
+
266
+ def _new_issues_since_handoff(track, last_handoff_iso: Optional[str],
267
+ slug: str, listed_nums: list[int]) -> list[dict]:
268
+ if not (track.repo and last_handoff_iso):
269
+ return []
270
+ try:
271
+ last_dt = parse_iso_timestamp(last_handoff_iso)
272
+ except ValueError:
273
+ return []
274
+ days = max(1, int((datetime.now() - last_dt).total_seconds() / 86400))
275
+ slug_labels = build_slug_labels([track])
276
+ new_map = find_new_issues_for_tracks(track.repo, [slug], slug_labels=slug_labels, since_days=days)
277
+ listed = set(listed_nums)
278
+ return [i for i in new_map.get(slug, []) if i["number"] not in listed]
279
+
280
+
281
+ # === Helpers for cwd-snapshot mode ===
282
+
283
+ def _is_git_repo(cwd: Path) -> bool:
284
+ proc = subprocess.run(
285
+ ["git", "rev-parse", "--is-inside-work-tree"],
286
+ cwd=str(cwd), capture_output=True, text=True,
287
+ )
288
+ return proc.returncode == 0 and proc.stdout.strip() == "true"
289
+
290
+
291
+ def _git(args: list[str], cwd: Path) -> str:
292
+ proc = subprocess.run(
293
+ ["git", *args], cwd=str(cwd), capture_output=True, text=True,
294
+ )
295
+ return proc.stdout.strip() if proc.returncode == 0 else ""
296
+
297
+
298
+ def _ahead_of_upstream(cwd: Path) -> tuple[str, int]:
299
+ upstream = _git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd)
300
+ if not upstream:
301
+ return ("", 0)
302
+ ahead_str = _git(["rev-list", "--count", f"{upstream}..HEAD"], cwd)
303
+ try:
304
+ return (upstream, int(ahead_str or "0"))
305
+ except ValueError:
306
+ return (upstream, 0)
307
+
308
+
309
+ def _modified_files(cwd: Path) -> list[str]:
310
+ out = _git(["status", "--porcelain"], cwd)
311
+ if not out:
312
+ return []
313
+ return [line for line in out.split("\n") if line.strip()]
314
+
315
+
316
+ def _recent_commits(cwd: Path, n: int = 3) -> list[tuple[str, str]]:
317
+ out = _git(["log", f"-{n}", "--pretty=format:%h %s"], cwd)
318
+ if not out:
319
+ return []
320
+ pairs = []
321
+ for line in out.split("\n"):
322
+ if " " in line:
323
+ sha, msg = line.split(" ", 1)
324
+ pairs.append((sha, msg))
325
+ return pairs
File without changes
@@ -0,0 +1,72 @@
1
+ """Closure-ready signal detection."""
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from lib.git_state import last_commit_date, branch_exists
8
+
9
+
10
+ @dataclass
11
+ class ClosureSignals:
12
+ """The 5 closure signals from the spec."""
13
+ all_issues_closed: bool
14
+ all_branches_done: bool
15
+ next_up_empty: bool
16
+ cold_14d: bool
17
+ no_recent_related_issues: bool
18
+
19
+
20
+ def is_closure_ready(signals: ClosureSignals) -> tuple[bool, list[str]]:
21
+ """All signals must be true. Returns (ready, blocking-reasons)."""
22
+ reasons = []
23
+ if not signals.all_issues_closed:
24
+ reasons.append("open issues remain")
25
+ if not signals.all_branches_done:
26
+ reasons.append("branches still active")
27
+ if not signals.next_up_empty:
28
+ reasons.append("next_up is not empty")
29
+ if not signals.cold_14d:
30
+ reasons.append("recent commits within 14 days")
31
+ if not signals.no_recent_related_issues:
32
+ reasons.append("new related issues in last 30 days")
33
+ return (not reasons, reasons)
34
+
35
+
36
+ def compute_signals(track_meta: dict, github_issues: list[dict],
37
+ repo_path: Optional[Path],
38
+ recent_related_count: int) -> ClosureSignals:
39
+ """Build ClosureSignals from observed state."""
40
+ listed_issue_nums = track_meta.get("github", {}).get("issues") or []
41
+ state_by_num = {i["number"]: i.get("state", "OPEN") for i in github_issues}
42
+
43
+ all_closed = bool(listed_issue_nums) and all(
44
+ state_by_num.get(n, "OPEN") == "CLOSED" for n in listed_issue_nums
45
+ )
46
+
47
+ branches = track_meta.get("github", {}).get("branches") or []
48
+ if repo_path:
49
+ all_branches_done = all(not branch_exists(b, repo_path) for b in branches)
50
+ else:
51
+ all_branches_done = len(branches) == 0
52
+
53
+ next_up_empty = not (track_meta.get("next_up") or [])
54
+
55
+ cutoff = datetime.now() - timedelta(days=14)
56
+ cold = True
57
+ if repo_path:
58
+ for b in branches:
59
+ last = last_commit_date(b, repo_path)
60
+ if last and last > cutoff:
61
+ cold = False
62
+ break
63
+
64
+ no_recent = recent_related_count == 0
65
+
66
+ return ClosureSignals(
67
+ all_issues_closed=all_closed,
68
+ all_branches_done=all_branches_done,
69
+ next_up_empty=next_up_empty,
70
+ cold_14d=cold,
71
+ no_recent_related_issues=no_recent,
72
+ )
@@ -0,0 +1,88 @@
1
+ """Load + validate ~/.claude/work-plan/config.yml."""
2
+ import json
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ DEFAULT_CONFIG_PATH = Path.home() / ".claude" / "work-plan" / "config.yml"
8
+ DEFAULT_NOTES_ROOT = Path.home() / ".claude" / "work-plan" / "notes"
9
+
10
+ _SEED_TEMPLATE = (
11
+ "# work-plan config — auto-seeded on first run. Edit to customize.\n"
12
+ "# Run /work-plan init-repo <key> --github=<org/repo> to populate repos:.\n"
13
+ "notes_root: {notes_root}\n"
14
+ "repos: {{}}\n"
15
+ )
16
+
17
+
18
+ class ConfigError(Exception):
19
+ pass
20
+
21
+
22
+ def ensure_config(path: Path = DEFAULT_CONFIG_PATH,
23
+ notes_root: Path = DEFAULT_NOTES_ROOT) -> bool:
24
+ """Create a default config.yml (and notes_root dir) if absent.
25
+
26
+ Single source of the seed content — install.sh/install.ps1 delegate here, so
27
+ plugin installs (which run no install hook) and script installs behave
28
+ identically. `notes_root` is written as an ABSOLUTE path (never a literal
29
+ `~`, which downstream `Path(...)` would not expand). Returns True if it
30
+ created the file, False if it already existed.
31
+ """
32
+ path = Path(path)
33
+ if path.exists():
34
+ return False
35
+ notes_root = Path(notes_root).expanduser()
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ notes_root.mkdir(parents=True, exist_ok=True)
38
+ path.write_text(_SEED_TEMPLATE.format(notes_root=notes_root), encoding="utf-8")
39
+ return True
40
+
41
+
42
+ def load_config(path: Path = DEFAULT_CONFIG_PATH,
43
+ notes_root: Path = DEFAULT_NOTES_ROOT) -> dict:
44
+ """Load and validate. Self-seeds a default config if absent (no install hook
45
+ exists for plugin installs). Normalizes string-shape repo entries to dicts."""
46
+ path = Path(path)
47
+ if not path.exists():
48
+ ensure_config(path, notes_root)
49
+ text = path.read_text(encoding="utf-8")
50
+ proc = subprocess.run(
51
+ ["yq", "-o=json", "."], input=text,
52
+ capture_output=True, text=True, check=True,
53
+ )
54
+ cfg = json.loads(proc.stdout)
55
+ if not isinstance(cfg, dict):
56
+ raise ConfigError(f"config.yml must be a YAML mapping; got {type(cfg).__name__}")
57
+ if "notes_root" not in cfg:
58
+ raise ConfigError("config.yml missing required key 'notes_root'.")
59
+ cfg.setdefault("repos", {})
60
+ # Normalize string-shape entries to dict shape
61
+ for folder, val in list(cfg["repos"].items()):
62
+ if isinstance(val, str):
63
+ cfg["repos"][folder] = {"github": val, "local": None}
64
+ elif isinstance(val, dict):
65
+ val.setdefault("local", None)
66
+ if "github" not in val:
67
+ raise ConfigError(f"repo '{folder}' missing 'github' key")
68
+ else:
69
+ raise ConfigError(f"repo '{folder}' must be string or dict, got {type(val).__name__}")
70
+ return cfg
71
+
72
+
73
+ def is_valid_git_repo(path: Path) -> bool:
74
+ """Return True if path is a directory that contains a .git entry."""
75
+ p = Path(path)
76
+ return p.is_dir() and (p / ".git").exists()
77
+
78
+
79
+ def resolve_github_for_folder(folder_name: str, cfg: dict) -> Optional[str]:
80
+ entry = cfg.get("repos", {}).get(folder_name)
81
+ return entry.get("github") if entry else None
82
+
83
+
84
+ def resolve_local_path_for_folder(folder_name: str, cfg: dict) -> Optional[Path]:
85
+ entry = cfg.get("repos", {}).get(folder_name)
86
+ if not entry or not entry.get("local"):
87
+ return None
88
+ return Path(entry["local"]).expanduser()
@@ -0,0 +1,41 @@
1
+ """Discover plan/spec docs in a repo via configurable globs, and classify each."""
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ DEFAULT_GLOBS = [
7
+ "docs/superpowers/plans/*.md",
8
+ "docs/superpowers/specs/*.md",
9
+ "docs/plans/*.md",
10
+ ]
11
+
12
+
13
+ @dataclass
14
+ class Doc:
15
+ path: Path # absolute
16
+ rel: str # repo-relative POSIX-style
17
+ kind: str # "plan" | "spec" | "adhoc"
18
+
19
+
20
+ def classify_kind(rel: str) -> str:
21
+ """Heuristic doc-kind from its repo-relative path."""
22
+ if rel.endswith("-design.md") or "/specs/" in rel:
23
+ return "spec"
24
+ if "/plans/" in rel:
25
+ return "plan"
26
+ return "adhoc"
27
+
28
+
29
+ def discover_docs(repo_root: Path, globs: Optional[list] = None) -> list:
30
+ globs = globs or DEFAULT_GLOBS
31
+ repo_root = Path(repo_root)
32
+ out = []
33
+ seen = set()
34
+ for g in globs:
35
+ for p in sorted(repo_root.glob(g)):
36
+ if not p.is_file() or p in seen:
37
+ continue
38
+ seen.add(p)
39
+ rel = p.relative_to(repo_root).as_posix()
40
+ out.append(Doc(path=p, rel=rel, kind=classify_kind(rel)))
41
+ return out
@@ -0,0 +1,32 @@
1
+ """Detect drift between body status table and GitHub state."""
2
+ from lib.status_table import find_status_table, ISSUE_NUM_RE
3
+
4
+
5
+ def detect_drift(body: str, github_issues: list[dict]) -> list[dict]:
6
+ """Return list of {issue, body_status, github_state} for drifted rows."""
7
+ table = find_status_table(body)
8
+ if not table:
9
+ return []
10
+
11
+ state_by_num = {i["number"]: i.get("state", "OPEN") for i in github_issues}
12
+ drift = []
13
+ sidx = table["status_col_index"]
14
+ for row in table["rows"]:
15
+ nums = []
16
+ for cell in row["cells"]:
17
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
18
+ if not nums:
19
+ continue
20
+ body_status = row["cells"][sidx].strip().lower() if sidx < len(row["cells"]) else ""
21
+ for num in nums:
22
+ if num not in state_by_num:
23
+ continue
24
+ gh_state = state_by_num[num]
25
+ looks_closed = any(k in body_status for k in ("✅", "shipped", "merged", "closed"))
26
+ looks_open = "🔲" in body_status or "open" in body_status
27
+
28
+ if gh_state == "CLOSED" and not looks_closed:
29
+ drift.append({"issue": num, "body_status": body_status, "github_state": "CLOSED"})
30
+ elif gh_state == "OPEN" and looks_closed:
31
+ drift.append({"issue": num, "body_status": body_status, "github_state": "OPEN"})
32
+ return drift
@@ -0,0 +1,42 @@
1
+ """Build the versioned viewer export structure from tracks + fetched issues."""
2
+ from lib.github_state import format_assignees, short_milestone
3
+
4
+ SCHEMA = 1
5
+
6
+ def _issue(i: dict) -> dict:
7
+ state = (i.get("state") or "OPEN").lower()
8
+ return {
9
+ "number": i.get("number"),
10
+ "title": i.get("title", ""),
11
+ "state": "closed" if state in ("closed", "merged") else "open",
12
+ "assignee": (format_assignees(i) if i.get("assignees") else "—"),
13
+ "milestone": short_milestone(i.get("milestone")) or None,
14
+ }
15
+
16
+ def build_export(tracks, issues_by_track, visibility, now: str,
17
+ untracked_by_repo=None) -> dict:
18
+ out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
19
+ for t in tracks:
20
+ issues = [_issue(i) for i in issues_by_track.get(t.name, [])]
21
+ opened = sum(1 for i in issues if i["state"] == "open")
22
+ closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
23
+ next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
24
+ out["tracks"].append({
25
+ "name": t.name,
26
+ "repo": t.repo,
27
+ "tier": getattr(t, "tier", "private") or "private",
28
+ "status": t.meta.get("status"),
29
+ "launch_priority": t.meta.get("launch_priority"),
30
+ "milestone_alignment": t.meta.get("milestone_alignment"),
31
+ "visibility": visibility.get(t.repo),
32
+ "blockers": list(t.meta.get("blockers") or []),
33
+ "next_up": next_up,
34
+ "rollup": {"open": opened, "closed": len(issues) - opened},
35
+ "issues": issues,
36
+ })
37
+ out["untracked"] = [
38
+ {"repo": repo, "issues": [_issue(r) for r in rows]}
39
+ for repo, rows in (untracked_by_repo or {}).items()
40
+ if rows
41
+ ]
42
+ return out
@@ -0,0 +1,48 @@
1
+ """Parse + write YAML frontmatter on markdown files. Body-preserving."""
2
+ import json
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Tuple
7
+
8
+ # Use [ \t]* (not \s*) so horizontal-only whitespace is consumed after ---,
9
+ # preserving any leading newline that is part of the body.
10
+ FRONTMATTER_RE = re.compile(r"^---[ \t]*\n(.*?)\n---[ \t]*\n(.*)$", re.DOTALL)
11
+
12
+
13
+ def parse_file(path: Path) -> Tuple[dict, str]:
14
+ """Parse markdown with optional YAML frontmatter. Returns (meta, body)."""
15
+ text = Path(path).read_text(encoding="utf-8")
16
+ match = FRONTMATTER_RE.match(text)
17
+ if not match:
18
+ return ({}, text)
19
+ meta = _yaml_to_dict(match.group(1))
20
+ return (meta, match.group(2))
21
+
22
+
23
+ def write_file(path: Path, meta: dict, body: str) -> None:
24
+ """Write markdown with frontmatter. Empty meta = body only."""
25
+ if not meta:
26
+ Path(path).write_text(body, encoding="utf-8")
27
+ return
28
+ yaml_text = _dict_to_yaml(meta)
29
+ Path(path).write_text(f"---\n{yaml_text}---\n{body}", encoding="utf-8")
30
+
31
+
32
+ def _yaml_to_dict(yaml_text: str) -> dict:
33
+ proc = subprocess.run(
34
+ ["yq", "-o=json", "."], input=yaml_text,
35
+ capture_output=True, text=True, check=True,
36
+ )
37
+ return json.loads(proc.stdout)
38
+
39
+
40
+ def _dict_to_yaml(d: dict) -> str:
41
+ proc = subprocess.run(
42
+ ["yq", "-P", "."], input=json.dumps(d),
43
+ capture_output=True, text=True, check=True,
44
+ )
45
+ out = proc.stdout
46
+ if not out.endswith("\n"):
47
+ out += "\n"
48
+ return out