@stylusnexus/work-plan 2026.6.9 → 2026.6.10
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/README.md +91 -13
- package/VERSION +1 -1
- package/bin/work-plan +23 -0
- package/package.json +2 -2
- package/skills/work-plan/SKILL.md +41 -8
- package/skills/work-plan/commands/auto_triage.py +243 -0
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/canonicalize.py +71 -17
- package/skills/work-plan/commands/close.py +21 -6
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +21 -8
- package/skills/work-plan/commands/group.py +86 -10
- package/skills/work-plan/commands/handoff.py +17 -5
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +39 -7
- package/skills/work-plan/commands/init_repo.py +43 -1
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +100 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +19 -6
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/config.py +6 -0
- package/skills/work-plan/lib/export_model.py +57 -2
- package/skills/work-plan/lib/github_state.py +54 -13
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +34 -3
- package/skills/work-plan/lib/tracks.py +208 -18
- package/skills/work-plan/tests/test_auto_triage.py +351 -0
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -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_export.py +204 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +52 -14
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +128 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_move.py +240 -0
- package/skills/work-plan/tests/test_new_track.py +169 -4
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +395 -1
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +38 -18
|
@@ -14,9 +14,11 @@ import sys
|
|
|
14
14
|
from datetime import datetime
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
-
from lib.config import load_config, ConfigError
|
|
17
|
+
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
18
18
|
from lib.frontmatter import parse_file, write_file
|
|
19
|
+
from lib.notes_readme import seed_readme
|
|
19
20
|
from lib.scratch import cache_dir
|
|
21
|
+
from lib.write_guard import needs_confirm
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
def _batch_path() -> Path:
|
|
@@ -61,6 +63,15 @@ def run(args: list[str]) -> int:
|
|
|
61
63
|
label_arg = next((a for a in args if a.startswith("--label=")), None)
|
|
62
64
|
state_arg = next((a for a in args if a.startswith("--state=")), None)
|
|
63
65
|
|
|
66
|
+
limit = 100
|
|
67
|
+
for a in args:
|
|
68
|
+
if a.startswith("--limit="):
|
|
69
|
+
try:
|
|
70
|
+
limit = int(a.split("=", 1)[1])
|
|
71
|
+
except ValueError:
|
|
72
|
+
print("ERROR: --limit must be an integer.")
|
|
73
|
+
return 2
|
|
74
|
+
|
|
64
75
|
try:
|
|
65
76
|
cfg = load_config()
|
|
66
77
|
except ConfigError as e:
|
|
@@ -68,7 +79,7 @@ def run(args: list[str]) -> int:
|
|
|
68
79
|
return 1
|
|
69
80
|
|
|
70
81
|
if apply_mode:
|
|
71
|
-
return _apply(cfg)
|
|
82
|
+
return _apply(cfg, args)
|
|
72
83
|
|
|
73
84
|
# Resolve repo
|
|
74
85
|
repos = list(cfg["repos"].keys())
|
|
@@ -113,6 +124,7 @@ def run(args: list[str]) -> int:
|
|
|
113
124
|
"repo": repo,
|
|
114
125
|
"folder": folder,
|
|
115
126
|
"milestone": milestone_arg.split("=", 1)[1] if milestone_arg else None,
|
|
127
|
+
"private": "--private" in args,
|
|
116
128
|
"issues": issues,
|
|
117
129
|
}, indent=2))
|
|
118
130
|
|
|
@@ -120,11 +132,15 @@ def run(args: list[str]) -> int:
|
|
|
120
132
|
print()
|
|
121
133
|
print("=" * 60)
|
|
122
134
|
print(PROMPT_TEMPLATE)
|
|
123
|
-
|
|
135
|
+
shown = issues[:limit]
|
|
136
|
+
for i in shown:
|
|
124
137
|
m = i.get("milestone", {})
|
|
125
138
|
m_title = m.get("title", "—") if m else "—"
|
|
126
139
|
labels = [l["name"] for l in i.get("labels", [])]
|
|
127
140
|
print(f"#{i['number']} [{m_title}] [{','.join(labels) or 'no-labels'}] {i['title']}")
|
|
141
|
+
remainder = len(issues) - len(shown)
|
|
142
|
+
if remainder > 0:
|
|
143
|
+
print(f"… and {remainder} more issues (use --limit=N to show more)")
|
|
128
144
|
print("=" * 60)
|
|
129
145
|
print()
|
|
130
146
|
print(f"After agent returns clusters JSON, save to {_answers_path()}")
|
|
@@ -132,7 +148,9 @@ def run(args: list[str]) -> int:
|
|
|
132
148
|
return 0
|
|
133
149
|
|
|
134
150
|
|
|
135
|
-
def _apply(cfg: dict) -> int:
|
|
151
|
+
def _apply(cfg: dict, args: list[str] = None) -> int:
|
|
152
|
+
if args is None:
|
|
153
|
+
args = []
|
|
136
154
|
answers_path = _answers_path()
|
|
137
155
|
batch_path = _batch_path()
|
|
138
156
|
if not answers_path.exists():
|
|
@@ -149,13 +167,44 @@ def _apply(cfg: dict) -> int:
|
|
|
149
167
|
print(f"ERROR: batch folder '{folder}' not in config.yml repos.")
|
|
150
168
|
return 1
|
|
151
169
|
batch_milestone = batch.get("milestone") or "v1.0.0"
|
|
170
|
+
|
|
171
|
+
# --private: from current args OR stored in batch (so re-invocation is consistent)
|
|
172
|
+
use_private = "--private" in args or batch.get("private", False)
|
|
173
|
+
|
|
152
174
|
answers = json.loads(answers_path.read_text())
|
|
153
175
|
|
|
154
176
|
notes_root = Path(cfg["notes_root"])
|
|
155
|
-
|
|
177
|
+
|
|
178
|
+
# Determine track directory: shared (.work-plan/) or private (notes_root/folder/)
|
|
179
|
+
repo_entry = cfg["repos"].get(folder, {})
|
|
180
|
+
local_raw = repo_entry.get("local")
|
|
181
|
+
shared_dir = None
|
|
182
|
+
if not use_private and local_raw:
|
|
183
|
+
local_path = Path(local_raw).expanduser()
|
|
184
|
+
if is_valid_git_repo(local_path):
|
|
185
|
+
shared_dir = local_path / ".work-plan"
|
|
186
|
+
|
|
187
|
+
if shared_dir is not None:
|
|
188
|
+
track_dir = shared_dir
|
|
189
|
+
is_shared_route = True
|
|
190
|
+
else:
|
|
191
|
+
track_dir = notes_root / folder
|
|
192
|
+
is_shared_route = False
|
|
193
|
+
|
|
194
|
+
# Public-repo heads-up (non-blocking) — print once before processing
|
|
195
|
+
if is_shared_route and needs_confirm(repo, cfg):
|
|
196
|
+
print(
|
|
197
|
+
f"HEADS-UP: {repo} is PUBLIC (or visibility unknown) — shared tracks"
|
|
198
|
+
" will be committed publicly. Use --private to keep them local."
|
|
199
|
+
)
|
|
200
|
+
|
|
156
201
|
if not track_dir.exists():
|
|
157
|
-
|
|
158
|
-
|
|
202
|
+
if is_shared_route:
|
|
203
|
+
track_dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
seed_readme(track_dir)
|
|
205
|
+
else:
|
|
206
|
+
print(f"ERROR: {track_dir} doesn't exist. Create it first.")
|
|
207
|
+
return 1
|
|
159
208
|
|
|
160
209
|
issues_by_num = {i["number"]: i for i in batch["issues"]}
|
|
161
210
|
|
|
@@ -166,7 +215,9 @@ def _apply(cfg: dict) -> int:
|
|
|
166
215
|
slug = _slugify(cluster["slug"])
|
|
167
216
|
name = cluster.get("name", slug)
|
|
168
217
|
summary = cluster.get("summary", "")
|
|
169
|
-
cluster_issues =
|
|
218
|
+
cluster_issues = _sort_by_milestone(
|
|
219
|
+
sorted(set(cluster.get("issues") or [])), issues_by_num, batch_milestone,
|
|
220
|
+
)
|
|
170
221
|
if not cluster_issues:
|
|
171
222
|
print(f" SKIP {slug}: no issues")
|
|
172
223
|
continue
|
|
@@ -178,7 +229,10 @@ def _apply(cfg: dict) -> int:
|
|
|
178
229
|
print(f" SKIP {slug}: file exists but has no frontmatter; use init first")
|
|
179
230
|
continue
|
|
180
231
|
existing_issues = list(existing_meta.get("github", {}).get("issues") or [])
|
|
181
|
-
merged =
|
|
232
|
+
merged = _sort_by_milestone(
|
|
233
|
+
sorted(set(existing_issues) | set(cluster_issues)), issues_by_num,
|
|
234
|
+
existing_meta.get("milestone_alignment") or batch_milestone,
|
|
235
|
+
)
|
|
182
236
|
existing_meta.setdefault("github", {})["issues"] = merged
|
|
183
237
|
existing_meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
184
238
|
write_file(path, existing_meta, existing_body)
|
|
@@ -192,13 +246,15 @@ def _apply(cfg: dict) -> int:
|
|
|
192
246
|
"launch_priority": "P3",
|
|
193
247
|
"milestone_alignment": batch_milestone,
|
|
194
248
|
"github": {"repo": repo, "issues": cluster_issues, "branches": []},
|
|
195
|
-
"
|
|
249
|
+
"depends_on": [],
|
|
196
250
|
"last_touched": now, "last_handoff": now,
|
|
197
251
|
"next_up": [], "blockers": [],
|
|
198
252
|
}
|
|
199
253
|
body = _build_body(name, summary, cluster_issues, issues_by_num)
|
|
200
254
|
write_file(path, meta, body)
|
|
201
255
|
print(f" ✓ {slug}.md created ({len(cluster_issues)} issues)")
|
|
256
|
+
if is_shared_route:
|
|
257
|
+
print(" ↑ shared — commit + push to share with teammates.")
|
|
202
258
|
created += 1
|
|
203
259
|
|
|
204
260
|
print()
|
|
@@ -208,6 +264,26 @@ def _apply(cfg: dict) -> int:
|
|
|
208
264
|
return 0
|
|
209
265
|
|
|
210
266
|
|
|
267
|
+
def _sort_by_milestone(issue_nums, issues_by_num, milestone_alignment=None):
|
|
268
|
+
"""Return issue_nums sorted by milestone then number.
|
|
269
|
+
|
|
270
|
+
milestone_alignment issues come first, then other non-null milestones
|
|
271
|
+
(grouped by label), then null-milestone issues last. Graceful fallback:
|
|
272
|
+
if no milestone data is available, falls back to pure numeric sort.
|
|
273
|
+
"""
|
|
274
|
+
from lib.export_model import milestone_sort_key
|
|
275
|
+
from lib.github_state import short_milestone
|
|
276
|
+
|
|
277
|
+
norm = []
|
|
278
|
+
for num in issue_nums:
|
|
279
|
+
gh = issues_by_num.get(num, {})
|
|
280
|
+
ms = short_milestone(gh.get("milestone")) or None
|
|
281
|
+
norm.append({"number": num, "milestone": ms})
|
|
282
|
+
|
|
283
|
+
norm.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
284
|
+
return [i["number"] for i in norm]
|
|
285
|
+
|
|
286
|
+
|
|
211
287
|
def _slugify(s: str) -> str:
|
|
212
288
|
s = s.strip().lower()
|
|
213
289
|
s = re.sub(r"[^a-z0-9-]+", "-", s)
|
|
@@ -13,7 +13,7 @@ import subprocess
|
|
|
13
13
|
from datetime import datetime, timedelta
|
|
14
14
|
|
|
15
15
|
from lib.config import load_config, ConfigError
|
|
16
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
16
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
17
17
|
from lib.frontmatter import write_file
|
|
18
18
|
from lib.session_log import append_session_log, SESSION_LOG_HEADER
|
|
19
19
|
from lib.git_state import (
|
|
@@ -28,7 +28,7 @@ from lib.prompts import prompt_lines, parse_flags, prompt_input
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def run(args: list[str]) -> int:
|
|
31
|
-
flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next"})
|
|
31
|
+
flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--repo"})
|
|
32
32
|
interactive = flags.get("--interactive", False) or flags.get("-i", False)
|
|
33
33
|
auto_next = flags.get("--auto-next", False)
|
|
34
34
|
|
|
@@ -54,6 +54,14 @@ def run(args: list[str]) -> int:
|
|
|
54
54
|
return 2
|
|
55
55
|
|
|
56
56
|
track_arg = positional[0] if positional else None
|
|
57
|
+
repo_qualifier = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
58
|
+
|
|
59
|
+
# Support <track>@<repo> syntax in positional
|
|
60
|
+
if track_arg:
|
|
61
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
62
|
+
track_arg = name_from_arg
|
|
63
|
+
if repo_from_arg:
|
|
64
|
+
repo_qualifier = repo_from_arg
|
|
57
65
|
|
|
58
66
|
try:
|
|
59
67
|
cfg = load_config()
|
|
@@ -62,7 +70,11 @@ def run(args: list[str]) -> int:
|
|
|
62
70
|
return 1
|
|
63
71
|
|
|
64
72
|
tracks = discover_tracks(cfg)
|
|
65
|
-
|
|
73
|
+
try:
|
|
74
|
+
track = _resolve_track(tracks, track_arg, repo_qualifier=repo_qualifier)
|
|
75
|
+
except AmbiguousTrackError as e:
|
|
76
|
+
print(str(e))
|
|
77
|
+
return 1
|
|
66
78
|
if not track:
|
|
67
79
|
return 1
|
|
68
80
|
|
|
@@ -254,9 +266,9 @@ def _check_next_up_collisions(track, proposed: list[int], cfg: dict) -> bool:
|
|
|
254
266
|
return answer in ("y", "yes")
|
|
255
267
|
|
|
256
268
|
|
|
257
|
-
def _resolve_track(tracks, track_arg):
|
|
269
|
+
def _resolve_track(tracks, track_arg, repo_qualifier=None):
|
|
258
270
|
if track_arg:
|
|
259
|
-
track = find_track_by_name(track_arg, tracks)
|
|
271
|
+
track = find_track_by_name(track_arg, tracks, repo=repo_qualifier)
|
|
260
272
|
if not track:
|
|
261
273
|
print(f"No track matching '{track_arg}'.")
|
|
262
274
|
return track
|
|
@@ -14,10 +14,14 @@ is per-repo, so:
|
|
|
14
14
|
that repo;
|
|
15
15
|
- when --repo is absent and config has multiple repos, it's skipped cleanly
|
|
16
16
|
(rather than letting duplicates exit non-zero on the ambiguous case).
|
|
17
|
+
|
|
18
|
+
Pass --timeout=N to set the gh subprocess timeout for the duplicates step
|
|
19
|
+
(default 30s).
|
|
17
20
|
"""
|
|
18
21
|
from commands import refresh_md, reconcile, duplicates
|
|
19
22
|
from lib.config import load_config, ConfigError
|
|
20
23
|
from lib.prompts import parse_flags
|
|
24
|
+
import time
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
def _resolve_repo_folder(repo_key: str, cfg: dict):
|
|
@@ -35,16 +39,28 @@ def _resolve_repo_folder(repo_key: str, cfg: dict):
|
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def run(args: list[str]) -> int:
|
|
38
|
-
flags, _ = parse_flags(args, {"--yes", "--no-duplicates", "--repo"})
|
|
42
|
+
flags, _ = parse_flags(args, {"--yes", "--no-duplicates", "--repo", "--timeout"})
|
|
39
43
|
skip_dups = flags.get("--no-duplicates", False)
|
|
40
44
|
yes = flags.get("--yes", False)
|
|
41
45
|
repo_key = flags.get("--repo")
|
|
42
46
|
if repo_key is True:
|
|
43
|
-
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>]")
|
|
47
|
+
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]")
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
gh_timeout = None
|
|
51
|
+
raw_timeout = flags.get("--timeout")
|
|
52
|
+
if raw_timeout is not None and raw_timeout is not True:
|
|
53
|
+
try:
|
|
54
|
+
gh_timeout = int(raw_timeout)
|
|
55
|
+
except ValueError:
|
|
56
|
+
print(f"WARNING: invalid --timeout value '{raw_timeout}'; using default")
|
|
57
|
+
elif raw_timeout is True:
|
|
58
|
+
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]")
|
|
44
59
|
return 2
|
|
45
60
|
|
|
46
61
|
scope_label = f" --repo={repo_key}" if repo_key else " --all"
|
|
47
62
|
|
|
63
|
+
t0 = time.time()
|
|
48
64
|
print("=" * 60)
|
|
49
65
|
print(f"WEEKLY HYGIENE — step 1 of 3: refresh-md{scope_label}")
|
|
50
66
|
print("=" * 60)
|
|
@@ -54,21 +70,27 @@ def run(args: list[str]) -> int:
|
|
|
54
70
|
rc = refresh_md.run(refresh_args)
|
|
55
71
|
if rc != 0:
|
|
56
72
|
print(f"\n⚠ refresh-md exited with code {rc}; continuing.")
|
|
73
|
+
print(f" (step 1/3 done in {time.time() - t0:.1f}s)")
|
|
57
74
|
|
|
75
|
+
t1 = time.time()
|
|
58
76
|
print()
|
|
59
77
|
print("=" * 60)
|
|
60
78
|
print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
|
|
61
79
|
print("=" * 60)
|
|
62
80
|
reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
81
|
+
if yes:
|
|
82
|
+
reconcile_args.append("--yes")
|
|
63
83
|
rc = reconcile.run(reconcile_args)
|
|
64
84
|
if rc != 0:
|
|
65
85
|
print(f"\n⚠ reconcile exited with code {rc}; continuing.")
|
|
86
|
+
print(f" (step 2/3 done in {time.time() - t1:.1f}s)")
|
|
66
87
|
|
|
67
88
|
if skip_dups:
|
|
68
89
|
print()
|
|
69
90
|
print("(skipping duplicates per --no-duplicates)")
|
|
70
91
|
return 0
|
|
71
92
|
|
|
93
|
+
t2 = time.time()
|
|
72
94
|
print()
|
|
73
95
|
print("=" * 60)
|
|
74
96
|
print("WEEKLY HYGIENE — step 3 of 3: duplicates")
|
|
@@ -94,11 +116,15 @@ def run(args: list[str]) -> int:
|
|
|
94
116
|
return 0
|
|
95
117
|
# else: 0 or 1 repos → duplicates handles both (errors / single-repo auto-pick)
|
|
96
118
|
|
|
119
|
+
if gh_timeout is not None:
|
|
120
|
+
dupes_args.append(f"--timeout={gh_timeout}")
|
|
121
|
+
|
|
97
122
|
rc = duplicates.run(dupes_args)
|
|
98
123
|
if rc != 0:
|
|
99
124
|
print(f"\n⚠ duplicates exited with code {rc}.")
|
|
125
|
+
print(f" (step 3/3 done in {time.time() - t2:.1f}s)")
|
|
100
126
|
|
|
101
127
|
print()
|
|
102
|
-
print("✓ Weekly hygiene complete. Review the duplicate candidates above and "
|
|
128
|
+
print(f"✓ Weekly hygiene complete ({time.time() - t0:.1f}s total). Review the duplicate candidates above and "
|
|
103
129
|
"consolidate any real dupes via `gh issue close`.")
|
|
104
130
|
return 0
|
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import re
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
6
7
|
|
|
7
8
|
from lib.config import load_config, ConfigError, resolve_github_for_folder
|
|
8
9
|
from lib.frontmatter import parse_file, write_file
|
|
@@ -12,6 +13,21 @@ from lib.write_guard import needs_confirm, make_token, valid_token
|
|
|
12
13
|
_VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def _find_repo_for_shared_path(path: Path, cfg: dict) -> Optional[str]:
|
|
17
|
+
"""If path is inside a .work-plan/ dir, find the configured github repo for that clone."""
|
|
18
|
+
# Walk up the path looking for a .work-plan ancestor
|
|
19
|
+
for parent in path.parents:
|
|
20
|
+
if parent.name == ".work-plan":
|
|
21
|
+
clone_root = parent.parent
|
|
22
|
+
for folder, entry in cfg.get("repos", {}).items():
|
|
23
|
+
if entry.get("local"):
|
|
24
|
+
local = Path(entry["local"]).expanduser().resolve()
|
|
25
|
+
if local == clone_root.resolve():
|
|
26
|
+
return entry.get("github")
|
|
27
|
+
return None # In .work-plan/ but not registered
|
|
28
|
+
return None # Not in a .work-plan/
|
|
29
|
+
|
|
30
|
+
|
|
15
31
|
def run(args: list[str]) -> int:
|
|
16
32
|
flags, positional = parse_flags(args, {"--priority", "--milestone", "--confirm"})
|
|
17
33
|
|
|
@@ -37,13 +53,27 @@ def run(args: list[str]) -> int:
|
|
|
37
53
|
|
|
38
54
|
slug = re.sub(r"[^a-z0-9-]+", "-", path.stem.lower()).strip("-")
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
# Detect if this path is inside a .work-plan/ shared directory
|
|
57
|
+
is_shared = ".work-plan" in path.parts
|
|
58
|
+
tier = "shared" if is_shared else None
|
|
59
|
+
|
|
60
|
+
if is_shared:
|
|
61
|
+
repo = _find_repo_for_shared_path(path, cfg)
|
|
62
|
+
if repo is None:
|
|
63
|
+
print(
|
|
64
|
+
"ERROR: path is inside a .work-plan/ directory but its repo isn't"
|
|
65
|
+
" registered in config — run init-repo first"
|
|
66
|
+
)
|
|
67
|
+
return 1
|
|
45
68
|
folder = None
|
|
46
|
-
|
|
69
|
+
else:
|
|
70
|
+
notes_root = Path(cfg["notes_root"])
|
|
71
|
+
try:
|
|
72
|
+
rel = path.relative_to(notes_root)
|
|
73
|
+
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
74
|
+
except ValueError:
|
|
75
|
+
folder = None
|
|
76
|
+
repo = resolve_github_for_folder(folder, cfg) if folder else None
|
|
47
77
|
|
|
48
78
|
issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
|
|
49
79
|
|
|
@@ -79,6 +109,8 @@ def run(args: list[str]) -> int:
|
|
|
79
109
|
print(f"Initializing: {path.name}")
|
|
80
110
|
print(f" track: {slug}")
|
|
81
111
|
print(f" repo: {repo or '(unknown — will set TBD)'}")
|
|
112
|
+
if tier == "shared":
|
|
113
|
+
print(" tier: shared")
|
|
82
114
|
print(f" issues found in body: {issue_nums or '(none)'}")
|
|
83
115
|
|
|
84
116
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
@@ -87,7 +119,7 @@ def run(args: list[str]) -> int:
|
|
|
87
119
|
"launch_priority": priority,
|
|
88
120
|
"milestone_alignment": milestone,
|
|
89
121
|
"github": {"repo": repo or "TBD", "issues": issue_nums, "branches": []},
|
|
90
|
-
"
|
|
122
|
+
"depends_on": [],
|
|
91
123
|
"last_touched": now, "last_handoff": now,
|
|
92
124
|
"next_up": [], "blockers": [],
|
|
93
125
|
}
|
|
@@ -7,10 +7,48 @@ import re
|
|
|
7
7
|
import subprocess
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH
|
|
10
|
+
from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH, is_valid_git_repo
|
|
11
11
|
from lib.prompts import parse_flags
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def _count_shared_tracks(work_plan_dir: Path) -> int:
|
|
15
|
+
"""Count eligible .md files in a .work-plan/ directory.
|
|
16
|
+
|
|
17
|
+
Excludes: README.md, dotfiles, and anything inside archive/.
|
|
18
|
+
"""
|
|
19
|
+
count = 0
|
|
20
|
+
for p in work_plan_dir.iterdir():
|
|
21
|
+
if p.is_dir():
|
|
22
|
+
continue
|
|
23
|
+
if p.name.startswith("."):
|
|
24
|
+
continue
|
|
25
|
+
if p.name.lower() == "readme.md":
|
|
26
|
+
continue
|
|
27
|
+
if p.suffix == ".md":
|
|
28
|
+
count += 1
|
|
29
|
+
return count
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _report_shared_tracks(local_path: "Path | None") -> None:
|
|
33
|
+
"""Print a status line about shared tracks found in .work-plan/ (if any).
|
|
34
|
+
|
|
35
|
+
If local_path is None, not a valid git repo, or has no .work-plan/ dir,
|
|
36
|
+
prints the registration-only fallback message instead.
|
|
37
|
+
"""
|
|
38
|
+
if local_path is None or not is_valid_git_repo(local_path):
|
|
39
|
+
print()
|
|
40
|
+
print("ℹ No valid local clone provided — registered for future use.")
|
|
41
|
+
print(" Run 'work-plan init-repo <key> --local=<path>' to add the clone path later.")
|
|
42
|
+
return
|
|
43
|
+
work_plan_dir = local_path / ".work-plan"
|
|
44
|
+
if work_plan_dir.is_dir():
|
|
45
|
+
n = _count_shared_tracks(work_plan_dir)
|
|
46
|
+
print(
|
|
47
|
+
f"ℹ Found {n} shared track(s) in {work_plan_dir}/"
|
|
48
|
+
" — they'll appear after 'work-plan brief'."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
14
52
|
def run(args: list[str]) -> int:
|
|
15
53
|
flags, positional = parse_flags(args, {"--github", "--local"})
|
|
16
54
|
if not positional:
|
|
@@ -45,6 +83,7 @@ def run(args: list[str]) -> int:
|
|
|
45
83
|
|
|
46
84
|
# --local is optional; if absent, skip (no prompt)
|
|
47
85
|
local = flags.get("--local") or None
|
|
86
|
+
local_path = None
|
|
48
87
|
if local:
|
|
49
88
|
local_path = Path(local).expanduser()
|
|
50
89
|
if not local_path.exists():
|
|
@@ -67,6 +106,9 @@ def run(args: list[str]) -> int:
|
|
|
67
106
|
print(f" ├── archive/shipped/")
|
|
68
107
|
print(f" └── archive/abandoned/")
|
|
69
108
|
|
|
109
|
+
# Detect existing shared tracks in .work-plan/ inside the local clone
|
|
110
|
+
_report_shared_tracks(local_path)
|
|
111
|
+
|
|
70
112
|
repo_block = {"github": github}
|
|
71
113
|
if local:
|
|
72
114
|
repo_block["local"] = local
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
"""list subcommand."""
|
|
2
2
|
from lib.config import load_config, ConfigError
|
|
3
|
-
from lib.tracks import
|
|
3
|
+
from lib.tracks import (
|
|
4
|
+
discover_tracks, discover_archived_tracks,
|
|
5
|
+
priority_rank, recency_sort_key,
|
|
6
|
+
)
|
|
7
|
+
from lib.prompts import parse_flags
|
|
8
|
+
|
|
9
|
+
_VALID_SORTS = ("recent", "priority")
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
def run(args: list[str]) -> int:
|
|
7
|
-
|
|
13
|
+
flags, _ = parse_flags(args, {"--all", "--sort"})
|
|
14
|
+
show_all = "--all" in flags
|
|
15
|
+
sort_mode = flags.get("--sort")
|
|
16
|
+
if sort_mode is True or (sort_mode and sort_mode not in _VALID_SORTS):
|
|
17
|
+
print(f"usage: work_plan.py list [--all] [--sort={'|'.join(_VALID_SORTS)}]")
|
|
18
|
+
return 2
|
|
19
|
+
|
|
8
20
|
try:
|
|
9
21
|
cfg = load_config()
|
|
10
22
|
except ConfigError as e:
|
|
@@ -16,17 +28,19 @@ def run(args: list[str]) -> int:
|
|
|
16
28
|
print(f"No tracks found under {cfg['notes_root']}")
|
|
17
29
|
return 0
|
|
18
30
|
|
|
31
|
+
tracks = _sort_tracks(tracks, sort_mode)
|
|
32
|
+
|
|
19
33
|
print(f"Tracks under {cfg['notes_root']}:\n")
|
|
20
34
|
for t in tracks:
|
|
21
35
|
status = t.meta.get("status", "(no frontmatter)")
|
|
22
36
|
priority = t.meta.get("launch_priority", "—")
|
|
23
37
|
repo = t.repo or "(no repo)"
|
|
24
|
-
|
|
38
|
+
flags_out = []
|
|
25
39
|
if t.needs_init:
|
|
26
|
-
|
|
40
|
+
flags_out.append("NEEDS INIT")
|
|
27
41
|
if t.needs_filing:
|
|
28
|
-
|
|
29
|
-
flag_str = f" [{', '.join(
|
|
42
|
+
flags_out.append("NEEDS FILING")
|
|
43
|
+
flag_str = f" [{', '.join(flags_out)}]" if flags_out else ""
|
|
30
44
|
print(f" {t.name:30} {status:14} {priority:3} {repo}{flag_str}")
|
|
31
45
|
|
|
32
46
|
if show_all:
|
|
@@ -37,3 +51,17 @@ def run(args: list[str]) -> int:
|
|
|
37
51
|
end_state = a.meta.get("status", "?")
|
|
38
52
|
print(f" {a.name:30} {end_state:14} {a.repo or '(no repo)'}")
|
|
39
53
|
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sort_tracks(tracks: list, sort_mode):
|
|
57
|
+
"""Order active tracks per --sort. None preserves discovery order.
|
|
58
|
+
|
|
59
|
+
- "recent": by last_touched descending (missing last_touched sorts last).
|
|
60
|
+
- "priority": by launch_priority ascending (P0→P3, then missing/other),
|
|
61
|
+
with last_touched recency as tiebreaker.
|
|
62
|
+
"""
|
|
63
|
+
if sort_mode == "recent":
|
|
64
|
+
return sorted(tracks, key=lambda t: recency_sort_key(t.meta))
|
|
65
|
+
if sort_mode == "priority":
|
|
66
|
+
return sorted(tracks, key=lambda t: (priority_rank(t.meta), recency_sort_key(t.meta)))
|
|
67
|
+
return tracks
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""move subcommand — source-first issue relocation between tracks."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from lib.config import load_config, ConfigError
|
|
5
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
6
|
+
from lib.frontmatter import write_file
|
|
7
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
8
|
+
from lib.prompts import parse_flags
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(args: list[str]) -> int:
|
|
12
|
+
"""move <issue-num> <from-track> <to-track> [--confirm=<token>] [--repo=<key>]
|
|
13
|
+
|
|
14
|
+
Removes <issue-num> from <from-track>'s frontmatter and adds it to
|
|
15
|
+
<to-track>'s frontmatter. Both tracks must be active and in the same
|
|
16
|
+
repo. Public-repo writes gate behind --confirm (same flow as slot/set).
|
|
17
|
+
"""
|
|
18
|
+
flags, positional = parse_flags(args, {"--confirm", "--repo"})
|
|
19
|
+
if len(positional) < 3:
|
|
20
|
+
print("usage: work_plan.py move <issue-num> <from-track> <to-track> [--confirm=<token>] [--repo=<key>]")
|
|
21
|
+
return 2
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
issue_num = int(positional[0])
|
|
25
|
+
except ValueError:
|
|
26
|
+
print(f"ERROR: '{positional[0]}' is not an issue number.")
|
|
27
|
+
return 2
|
|
28
|
+
|
|
29
|
+
from_arg, to_arg = positional[1], positional[2]
|
|
30
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
31
|
+
|
|
32
|
+
# Resolve from-track
|
|
33
|
+
from_name = from_arg
|
|
34
|
+
repo_qualifier = repo_flag
|
|
35
|
+
name_from, repo_from = parse_track_repo_arg(from_arg)
|
|
36
|
+
if name_from:
|
|
37
|
+
from_name = name_from
|
|
38
|
+
if repo_from:
|
|
39
|
+
repo_qualifier = repo_from
|
|
40
|
+
|
|
41
|
+
# Resolve to-track (may override repo qualifier)
|
|
42
|
+
to_name = to_arg
|
|
43
|
+
name_to, repo_to = parse_track_repo_arg(to_arg)
|
|
44
|
+
if name_to:
|
|
45
|
+
to_name = name_to
|
|
46
|
+
if repo_to:
|
|
47
|
+
repo_qualifier = repo_to
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
cfg = load_config()
|
|
51
|
+
except ConfigError as e:
|
|
52
|
+
print(f"ERROR: {e}")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
tracks = discover_tracks(cfg)
|
|
56
|
+
|
|
57
|
+
# Find both tracks (active only)
|
|
58
|
+
try:
|
|
59
|
+
src = find_track_by_name(from_name, tracks, active_only=True, repo=repo_qualifier)
|
|
60
|
+
except AmbiguousTrackError as e:
|
|
61
|
+
print(str(e))
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
if not src:
|
|
65
|
+
print(f"No active track matching '{from_name}'.")
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
dst = find_track_by_name(to_name, tracks, active_only=True, repo=repo_qualifier)
|
|
70
|
+
except AmbiguousTrackError as e:
|
|
71
|
+
print(str(e))
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
if not dst:
|
|
75
|
+
print(f"No active track matching '{to_name}'.")
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
# Same-repo guard
|
|
79
|
+
if src.repo != dst.repo:
|
|
80
|
+
print(f"ERROR: cross-repo moves not supported ({src.repo} ≠ {dst.repo}).")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
# Same-track no-op
|
|
84
|
+
if src.name == dst.name:
|
|
85
|
+
print(f"#{issue_num} already in track '{src.name}'.")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
# Validate issue is in source
|
|
89
|
+
src_issues = list(src.meta.get("github", {}).get("issues") or [])
|
|
90
|
+
if issue_num not in src_issues:
|
|
91
|
+
print(f"ERROR: #{issue_num} is not in track '{src.name}'.")
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
# Check if already in destination
|
|
95
|
+
dst_issues = list(dst.meta.get("github", {}).get("issues") or [])
|
|
96
|
+
if issue_num in dst_issues:
|
|
97
|
+
print(f"#{issue_num} already in track '{dst.name}'. Removing from '{src.name}' only.")
|
|
98
|
+
# Still remove from source even if already in dest
|
|
99
|
+
src_issues.remove(issue_num)
|
|
100
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
101
|
+
write_file(src.path, src.meta, src.body)
|
|
102
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
# Public-repo confirm gate (on the destination write)
|
|
106
|
+
confirm = flags.get("--confirm")
|
|
107
|
+
if dst.repo and needs_confirm(dst.repo, cfg) and not (
|
|
108
|
+
isinstance(confirm, str) and valid_token(confirm, dst.repo, dst.name)
|
|
109
|
+
):
|
|
110
|
+
print(json.dumps({
|
|
111
|
+
"needs_confirm": True,
|
|
112
|
+
"reason": (
|
|
113
|
+
f"{dst.repo} is PUBLIC (or visibility unknown); "
|
|
114
|
+
f"moving #{issue_num} will be written there."
|
|
115
|
+
),
|
|
116
|
+
"token": make_token(dst.repo, dst.name),
|
|
117
|
+
}))
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
# Execute: remove from source, add to destination
|
|
121
|
+
src_issues.remove(issue_num)
|
|
122
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
123
|
+
write_file(src.path, src.meta, src.body)
|
|
124
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
125
|
+
|
|
126
|
+
dst_issues.append(issue_num)
|
|
127
|
+
dst.meta.setdefault("github", {})["issues"] = sorted(dst_issues)
|
|
128
|
+
write_file(dst.path, dst.meta, dst.body)
|
|
129
|
+
print(f" ✓ Added #{issue_num} to '{dst.name}'.")
|
|
130
|
+
|
|
131
|
+
return 0
|