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