@stylusnexus/work-plan 2026.6.9 → 2026.6.10-2

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 (71) hide show
  1. package/README.md +94 -15
  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 +31 -62
  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 +32 -11
  15. package/skills/work-plan/commands/hygiene.py +29 -3
  16. package/skills/work-plan/commands/init.py +49 -7
  17. package/skills/work-plan/commands/init_repo.py +51 -3
  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 +107 -23
  21. package/skills/work-plan/commands/reconcile.py +175 -33
  22. package/skills/work-plan/commands/refresh_md.py +125 -43
  23. package/skills/work-plan/commands/rename_track.py +243 -0
  24. package/skills/work-plan/commands/set_field.py +17 -7
  25. package/skills/work-plan/commands/set_notes_root.py +8 -4
  26. package/skills/work-plan/commands/slot.py +20 -5
  27. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  28. package/skills/work-plan/commands/where_was_i.py +23 -5
  29. package/skills/work-plan/lib/config.py +6 -0
  30. package/skills/work-plan/lib/export_model.py +57 -2
  31. package/skills/work-plan/lib/frontmatter.py +12 -3
  32. package/skills/work-plan/lib/git_state.py +61 -52
  33. package/skills/work-plan/lib/github_state.py +100 -26
  34. package/skills/work-plan/lib/notes_readme.py +38 -0
  35. package/skills/work-plan/lib/prompts.py +46 -4
  36. package/skills/work-plan/lib/status_table.py +95 -5
  37. package/skills/work-plan/lib/tracks.py +214 -19
  38. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  39. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  40. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  41. package/skills/work-plan/tests/test_auto_triage.py +351 -0
  42. package/skills/work-plan/tests/test_batch_slot.py +291 -0
  43. package/skills/work-plan/tests/test_close_tier.py +166 -0
  44. package/skills/work-plan/tests/test_config.py +12 -12
  45. package/skills/work-plan/tests/test_config_shared.py +57 -0
  46. package/skills/work-plan/tests/test_coverage.py +192 -0
  47. package/skills/work-plan/tests/test_export.py +204 -1
  48. package/skills/work-plan/tests/test_export_command.py +2 -2
  49. package/skills/work-plan/tests/test_github_state.py +55 -17
  50. package/skills/work-plan/tests/test_group_apply.py +411 -0
  51. package/skills/work-plan/tests/test_init_repo.py +140 -7
  52. package/skills/work-plan/tests/test_init_shared.py +185 -0
  53. package/skills/work-plan/tests/test_list_sort.py +162 -0
  54. package/skills/work-plan/tests/test_move.py +240 -0
  55. package/skills/work-plan/tests/test_new_track.py +176 -11
  56. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  57. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  58. package/skills/work-plan/tests/test_prompts.py +121 -0
  59. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  60. package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
  61. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  62. package/skills/work-plan/tests/test_rename_track.py +351 -0
  63. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  64. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  65. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  66. package/skills/work-plan/tests/test_status_table.py +61 -0
  67. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  68. package/skills/work-plan/tests/test_tracks.py +398 -4
  69. package/skills/work-plan/tests/test_where_was_i.py +135 -0
  70. package/skills/work-plan/work_plan.py +51 -26
  71. /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
@@ -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,12 +13,13 @@ 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 (
20
20
  has_uncommitted, current_branch, parse_iso_timestamp,
21
21
  gap_seconds_to_label, uncommitted_file_count, commits_ahead,
22
+ is_safe_ref, GIT_TIMEOUT,
22
23
  )
23
24
  from lib.github_state import fetch_issues, state_to_status_label, extract_priority, short_milestone
24
25
  from lib.status_table import update_row_status, sync_missing_rows, find_canonical_status_tables, ISSUE_NUM_RE
@@ -28,7 +29,7 @@ from lib.prompts import prompt_lines, parse_flags, prompt_input
28
29
 
29
30
 
30
31
  def run(args: list[str]) -> int:
31
- flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next"})
32
+ flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--repo"})
32
33
  interactive = flags.get("--interactive", False) or flags.get("-i", False)
33
34
  auto_next = flags.get("--auto-next", False)
34
35
 
@@ -54,6 +55,14 @@ def run(args: list[str]) -> int:
54
55
  return 2
55
56
 
56
57
  track_arg = positional[0] if positional else None
58
+ repo_qualifier = flags.get("--repo") if flags.get("--repo") is not True else None
59
+
60
+ # Support <track>@<repo> syntax in positional
61
+ if track_arg:
62
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
63
+ track_arg = name_from_arg
64
+ if repo_from_arg:
65
+ repo_qualifier = repo_from_arg
57
66
 
58
67
  try:
59
68
  cfg = load_config()
@@ -62,7 +71,11 @@ def run(args: list[str]) -> int:
62
71
  return 1
63
72
 
64
73
  tracks = discover_tracks(cfg)
65
- track = _resolve_track(tracks, track_arg)
74
+ try:
75
+ track = _resolve_track(tracks, track_arg, repo_qualifier=repo_qualifier)
76
+ except AmbiguousTrackError as e:
77
+ print(str(e))
78
+ return 1
66
79
  if not track:
67
80
  return 1
68
81
 
@@ -254,9 +267,9 @@ def _check_next_up_collisions(track, proposed: list[int], cfg: dict) -> bool:
254
267
  return answer in ("y", "yes")
255
268
 
256
269
 
257
- def _resolve_track(tracks, track_arg):
270
+ def _resolve_track(tracks, track_arg, repo_qualifier=None):
258
271
  if track_arg:
259
- track = find_track_by_name(track_arg, tracks)
272
+ track = find_track_by_name(track_arg, tracks, repo=repo_qualifier)
260
273
  if not track:
261
274
  print(f"No track matching '{track_arg}'.")
262
275
  return track
@@ -502,12 +515,20 @@ def _recent_commits(track, since_dt) -> list[dict]:
502
515
 
503
516
  if branches:
504
517
  for b in branches:
505
- proc = subprocess.run(
506
- ["git", "-C", str(track.local_path), "log", b,
507
- f"--since={since_iso}",
508
- "--pretty=format:%H|%s|%cI"],
509
- capture_output=True, text=True,
510
- )
518
+ # A branch name from frontmatter is passed as a positional rev; a
519
+ # dash-led value (e.g. `--output=/path`) would be read by git as an
520
+ # option → arbitrary-file write. Reject before use (#192).
521
+ if not is_safe_ref(str(b)):
522
+ continue
523
+ try:
524
+ proc = subprocess.run(
525
+ ["git", "-C", str(track.local_path), "log", b,
526
+ f"--since={since_iso}",
527
+ "--pretty=format:%H|%s|%cI"],
528
+ capture_output=True, text=True, timeout=GIT_TIMEOUT,
529
+ )
530
+ except (subprocess.TimeoutExpired, OSError):
531
+ continue
511
532
  if proc.returncode != 0 or not proc.stdout.strip():
512
533
  continue
513
534
  for line in proc.stdout.strip().split("\n"):
@@ -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,37 @@ 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
+ # Containment guard (#195): a non-shared target MUST live under
71
+ # notes_root. Without this, `init /etc/anything` (any user-writable
72
+ # file with no frontmatter) would get frontmatter prepended via
73
+ # write_file, clobbering it. `path` is already resolved; resolve
74
+ # notes_root too so the comparison is symlink/relative-safe.
75
+ notes_root = Path(cfg["notes_root"]).expanduser().resolve()
76
+ try:
77
+ rel = path.relative_to(notes_root)
78
+ except ValueError:
79
+ print(
80
+ f"ERROR: {path} is not inside notes_root ({notes_root}) or a"
81
+ " registered .work-plan/ directory — refusing to write"
82
+ " frontmatter outside the tracked tree."
83
+ )
84
+ return 1
85
+ folder = rel.parts[0] if len(rel.parts) > 1 else None
86
+ repo = resolve_github_for_folder(folder, cfg) if folder else None
47
87
 
48
88
  issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
49
89
 
@@ -79,6 +119,8 @@ def run(args: list[str]) -> int:
79
119
  print(f"Initializing: {path.name}")
80
120
  print(f" track: {slug}")
81
121
  print(f" repo: {repo or '(unknown — will set TBD)'}")
122
+ if tier == "shared":
123
+ print(" tier: shared")
82
124
  print(f" issues found in body: {issue_nums or '(none)'}")
83
125
 
84
126
  now = datetime.now().strftime("%Y-%m-%dT%H:%M")
@@ -87,7 +129,7 @@ def run(args: list[str]) -> int:
87
129
  "launch_priority": priority,
88
130
  "milestone_alignment": milestone,
89
131
  "github": {"repo": repo or "TBD", "issues": issue_nums, "branches": []},
90
- "related_tracks": [],
132
+ "depends_on": [],
91
133
  "last_touched": now, "last_handoff": now,
92
134
  "next_up": [], "blockers": [],
93
135
  }
@@ -3,14 +3,53 @@
3
3
  Non-interactive: --github is required; --local is optional (no prompts).
4
4
  """
5
5
  import json
6
+ import os
6
7
  import re
7
8
  import subprocess
8
9
  from pathlib import Path
9
10
 
10
- from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH
11
+ from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH, is_valid_git_repo
11
12
  from lib.prompts import parse_flags
12
13
 
13
14
 
15
+ def _count_shared_tracks(work_plan_dir: Path) -> int:
16
+ """Count eligible .md files in a .work-plan/ directory.
17
+
18
+ Excludes: README.md, dotfiles, and anything inside archive/.
19
+ """
20
+ count = 0
21
+ for p in work_plan_dir.iterdir():
22
+ if p.is_dir():
23
+ continue
24
+ if p.name.startswith("."):
25
+ continue
26
+ if p.name.lower() == "readme.md":
27
+ continue
28
+ if p.suffix == ".md":
29
+ count += 1
30
+ return count
31
+
32
+
33
+ def _report_shared_tracks(local_path: "Path | None") -> None:
34
+ """Print a status line about shared tracks found in .work-plan/ (if any).
35
+
36
+ If local_path is None, not a valid git repo, or has no .work-plan/ dir,
37
+ prints the registration-only fallback message instead.
38
+ """
39
+ if local_path is None or not is_valid_git_repo(local_path):
40
+ print()
41
+ print("ℹ No valid local clone provided — registered for future use.")
42
+ print(" Run 'work-plan init-repo <key> --local=<path>' to add the clone path later.")
43
+ return
44
+ work_plan_dir = local_path / ".work-plan"
45
+ if work_plan_dir.is_dir():
46
+ n = _count_shared_tracks(work_plan_dir)
47
+ print(
48
+ f"ℹ Found {n} shared track(s) in {work_plan_dir}/"
49
+ " — they'll appear after 'work-plan brief'."
50
+ )
51
+
52
+
14
53
  def run(args: list[str]) -> int:
15
54
  flags, positional = parse_flags(args, {"--github", "--local"})
16
55
  if not positional:
@@ -45,6 +84,7 @@ def run(args: list[str]) -> int:
45
84
 
46
85
  # --local is optional; if absent, skip (no prompt)
47
86
  local = flags.get("--local") or None
87
+ local_path = None
48
88
  if local:
49
89
  local_path = Path(local).expanduser()
50
90
  if not local_path.exists():
@@ -67,15 +107,23 @@ def run(args: list[str]) -> int:
67
107
  print(f" ├── archive/shipped/")
68
108
  print(f" └── archive/abandoned/")
69
109
 
110
+ # Detect existing shared tracks in .work-plan/ inside the local clone
111
+ _report_shared_tracks(local_path)
112
+
70
113
  repo_block = {"github": github}
71
114
  if local:
72
115
  repo_block["local"] = local
73
116
 
74
- yq_expr = f'.repos.{key} = {json.dumps(repo_block)}'
117
+ # `key` is validated against ^[a-z][a-z0-9-]*$ above, so it's safe in the yq
118
+ # path. The repo block is passed as an OPAQUE env value via env() (parsed as
119
+ # YAML/JSON) rather than interpolated into the expression — uniform with the
120
+ # strenv() hardening in set-notes-root (#196).
121
+ env = {**os.environ, "WP_REPO_BLOCK": json.dumps(repo_block)}
122
+ yq_expr = f".repos.{key} = env(WP_REPO_BLOCK)"
75
123
  try:
76
124
  subprocess.run(
77
125
  ["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
78
- check=True, capture_output=True, text=True,
126
+ check=True, capture_output=True, text=True, env=env,
79
127
  )
80
128
  except subprocess.CalledProcessError as e:
81
129
  print(f"ERROR: yq failed to update config: {e.stderr}")
@@ -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