@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.
Files changed (53) hide show
  1. package/README.md +91 -13
  2. package/VERSION +1 -1
  3. package/bin/work-plan +23 -0
  4. package/package.json +2 -2
  5. package/skills/work-plan/SKILL.md +41 -8
  6. package/skills/work-plan/commands/auto_triage.py +243 -0
  7. package/skills/work-plan/commands/batch_slot.py +184 -0
  8. package/skills/work-plan/commands/brief.py +6 -6
  9. package/skills/work-plan/commands/canonicalize.py +71 -17
  10. package/skills/work-plan/commands/close.py +21 -6
  11. package/skills/work-plan/commands/coverage.py +100 -0
  12. package/skills/work-plan/commands/duplicates.py +21 -8
  13. package/skills/work-plan/commands/group.py +86 -10
  14. package/skills/work-plan/commands/handoff.py +17 -5
  15. package/skills/work-plan/commands/hygiene.py +29 -3
  16. package/skills/work-plan/commands/init.py +39 -7
  17. package/skills/work-plan/commands/init_repo.py +43 -1
  18. package/skills/work-plan/commands/list_cmd.py +34 -6
  19. package/skills/work-plan/commands/move.py +131 -0
  20. package/skills/work-plan/commands/new_track.py +100 -23
  21. package/skills/work-plan/commands/reconcile.py +175 -33
  22. package/skills/work-plan/commands/refresh_md.py +19 -6
  23. package/skills/work-plan/commands/set_field.py +17 -7
  24. package/skills/work-plan/commands/slot.py +20 -5
  25. package/skills/work-plan/commands/where_was_i.py +23 -5
  26. package/skills/work-plan/lib/config.py +6 -0
  27. package/skills/work-plan/lib/export_model.py +57 -2
  28. package/skills/work-plan/lib/github_state.py +54 -13
  29. package/skills/work-plan/lib/notes_readme.py +38 -0
  30. package/skills/work-plan/lib/prompts.py +34 -3
  31. package/skills/work-plan/lib/tracks.py +208 -18
  32. package/skills/work-plan/tests/test_auto_triage.py +351 -0
  33. package/skills/work-plan/tests/test_batch_slot.py +291 -0
  34. package/skills/work-plan/tests/test_close_tier.py +166 -0
  35. package/skills/work-plan/tests/test_config_shared.py +57 -0
  36. package/skills/work-plan/tests/test_coverage.py +192 -0
  37. package/skills/work-plan/tests/test_export.py +204 -1
  38. package/skills/work-plan/tests/test_export_command.py +2 -2
  39. package/skills/work-plan/tests/test_github_state.py +52 -14
  40. package/skills/work-plan/tests/test_group_apply.py +411 -0
  41. package/skills/work-plan/tests/test_init_repo.py +128 -0
  42. package/skills/work-plan/tests/test_init_shared.py +185 -0
  43. package/skills/work-plan/tests/test_list_sort.py +162 -0
  44. package/skills/work-plan/tests/test_move.py +240 -0
  45. package/skills/work-plan/tests/test_new_track.py +169 -4
  46. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  47. package/skills/work-plan/tests/test_prompts.py +121 -0
  48. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  49. package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
  50. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  51. package/skills/work-plan/tests/test_tracks.py +395 -1
  52. package/skills/work-plan/tests/test_where_was_i.py +135 -0
  53. 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
- for i in issues:
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
- track_dir = notes_root / folder
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
- print(f"ERROR: {track_dir} doesn't exist. Create it first.")
158
- return 1
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 = sorted(set(cluster.get("issues") or []))
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 = sorted(set(existing_issues) | set(cluster_issues))
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
- "related_tracks": [],
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
- track = _resolve_track(tracks, track_arg)
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
- notes_root = Path(cfg["notes_root"])
41
- try:
42
- rel = path.relative_to(notes_root)
43
- folder = rel.parts[0] if len(rel.parts) > 1 else None
44
- except ValueError:
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
- repo = resolve_github_for_folder(folder, cfg) if folder else None
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
- "related_tracks": [],
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 discover_tracks, discover_archived_tracks
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
- show_all = "--all" in args
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
- flags = []
38
+ flags_out = []
25
39
  if t.needs_init:
26
- flags.append("NEEDS INIT")
40
+ flags_out.append("NEEDS INIT")
27
41
  if t.needs_filing:
28
- flags.append("NEEDS FILING")
29
- flag_str = f" [{', '.join(flags)}]" if flags else ""
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