@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
@@ -1,19 +1,22 @@
1
1
  """new-track subcommand — one-shot non-interactive track creation.
2
2
 
3
- Creates a brand-new <slug>.md under notes_root/<folder>/ with frontmatter
4
- written from flags. Designed for headless callers (e.g. the VS Code extension)
5
- that cannot run interactive init + do not know notes_root upfront.
3
+ Creates a brand-new <slug>.md under notes_root/<folder>/ (private tier) or
4
+ <local>/.work-plan/ (shared tier) with frontmatter written from flags.
5
+ Designed for headless callers (e.g. the VS Code extension) that cannot run
6
+ interactive init + do not know notes_root upfront.
6
7
 
7
8
  Usage:
8
9
  new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]
9
- [--private] [--confirm=<token>]
10
+ [--private] [--commit] [--confirm=<token>]
10
11
  """
11
12
  import json
12
13
  import re
14
+ import subprocess
13
15
  from datetime import datetime
14
16
  from pathlib import Path
17
+ from typing import Optional
15
18
 
16
- from lib.config import load_config, ConfigError
19
+ from lib.config import load_config, ConfigError, is_valid_git_repo
17
20
  from lib.frontmatter import write_file
18
21
  from lib.prompts import parse_flags
19
22
  from lib.write_guard import needs_confirm, make_token, valid_token
@@ -22,16 +25,63 @@ _VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
22
25
  _SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
23
26
 
24
27
 
28
+ def _git_commit_track(track_file: Path, slug: str) -> None:
29
+ """Stage and commit a single shared track file (path-scoped, no git add .)."""
30
+ # The clone root is .work-plan/'s parent
31
+ clone_root = track_file.parent.parent
32
+ if not is_valid_git_repo(clone_root):
33
+ print(f"⚠ --commit ignored: track is private (not in a git repo)")
34
+ return
35
+
36
+ # Determine current branch name for the success message
37
+ branch = "HEAD"
38
+ try:
39
+ result = subprocess.run(
40
+ ["git", "-C", str(clone_root), "rev-parse", "--abbrev-ref", "HEAD"],
41
+ capture_output=True, text=True, check=False,
42
+ )
43
+ if result.returncode == 0:
44
+ branch = result.stdout.strip()
45
+ except OSError:
46
+ pass
47
+
48
+ # Stage ONLY this file (never git add .)
49
+ try:
50
+ subprocess.run(
51
+ ["git", "-C", str(clone_root), "add", str(track_file)],
52
+ capture_output=True, text=True, check=True,
53
+ )
54
+ except (subprocess.CalledProcessError, OSError) as e:
55
+ msg = getattr(e, "stderr", str(e))
56
+ print(f"⚠ --commit: git add failed ({msg.strip()!r}) — continuing without commit")
57
+ return
58
+
59
+ # Commit with a conventional message
60
+ commit_msg = f"chore: add shared track '{slug}'"
61
+ try:
62
+ subprocess.run(
63
+ ["git", "-C", str(clone_root), "commit", "-m", commit_msg],
64
+ capture_output=True, text=True, check=True,
65
+ )
66
+ except (subprocess.CalledProcessError, OSError) as e:
67
+ msg = getattr(e, "stderr", str(e))
68
+ print(f"⚠ --commit: git commit failed ({msg.strip()!r}) — continuing without commit")
69
+ return
70
+
71
+ print(f"✓ committed '{slug}' to {branch}")
72
+
73
+
25
74
  def run(args: list[str]) -> int:
26
75
  flags, positional = parse_flags(
27
- args, {"--priority", "--milestone", "--private", "--confirm"}
76
+ args, {"--priority", "--milestone", "--private", "--confirm", "--commit"}
28
77
  )
29
78
 
30
79
  # Require exactly 2 positionals: repo and slug
31
80
  if len(positional) < 2:
32
81
  print(
33
82
  "usage: work_plan.py new-track <repo> <slug>"
34
- " [--priority=P0..P3] [--milestone=<m>] [--private] [--confirm=<token>]"
83
+ " [--priority=P0..P3] [--milestone=<m>] [--private] [--commit]"
84
+ " [--confirm=<token>]"
35
85
  )
36
86
  return 2
37
87
 
@@ -85,14 +135,30 @@ def run(args: list[str]) -> int:
85
135
  milestone = milestone_flag if isinstance(milestone_flag, str) else "v1.0.0"
86
136
 
87
137
  # ------------------------------------------------------------------
88
- # Resolve target path
138
+ # Determine target path: shared (.work-plan/) or private (notes_root/)
139
+ # Shared route: repo is registered, has a local path, and it's a valid git repo.
140
+ # --private overrides to force the private (notes_root) route.
89
141
  # ------------------------------------------------------------------
90
- notes_root = Path(cfg["notes_root"]).expanduser()
91
- if not notes_root.exists():
92
- print(f"ERROR: notes_root {notes_root} does not exist.")
93
- return 1
142
+ use_private = "--private" in flags
143
+
144
+ shared_path: Optional[Path] = None
145
+ if not use_private and folder in cfg.get("repos", {}):
146
+ local_raw = cfg["repos"][folder].get("local")
147
+ if local_raw:
148
+ local_path = Path(local_raw).expanduser()
149
+ if is_valid_git_repo(local_path):
150
+ shared_path = local_path / ".work-plan" / f"{slug}.md"
94
151
 
95
- path = notes_root / folder / f"{slug}.md"
152
+ notes_root = Path(cfg["notes_root"]).expanduser()
153
+ if shared_path is not None:
154
+ path = shared_path
155
+ is_shared = True
156
+ else:
157
+ if not notes_root.exists():
158
+ print(f"ERROR: notes_root {notes_root} does not exist.")
159
+ return 1
160
+ path = notes_root / folder / f"{slug}.md"
161
+ is_shared = False
96
162
 
97
163
  if path.exists():
98
164
  print(f"ERROR: track '{slug}' already exists at {path}")
@@ -115,13 +181,6 @@ def run(args: list[str]) -> int:
115
181
  }))
116
182
  return 0
117
183
 
118
- # ------------------------------------------------------------------
119
- # --private flag: accepted for forward-compat but is a no-op today.
120
- # Every track is effectively private now; the two-tier shared/private
121
- # model is unbuilt. We accept the flag so callers don't error out.
122
- # ------------------------------------------------------------------
123
- # (no branch on --private beyond parsing it)
124
-
125
184
  # ------------------------------------------------------------------
126
185
  # Create folder if missing, then write the track file
127
186
  # ------------------------------------------------------------------
@@ -134,15 +193,33 @@ def run(args: list[str]) -> int:
134
193
  "launch_priority": priority,
135
194
  "milestone_alignment": milestone,
136
195
  "github": {"repo": github, "issues": [], "branches": []},
137
- "related_tracks": [],
196
+ "depends_on": [],
138
197
  "last_touched": now,
139
198
  "last_handoff": now,
140
199
  "next_up": [],
141
200
  "blockers": [],
142
201
  }
202
+ if is_shared:
203
+ meta["tier"] = "shared"
204
+
143
205
  body = f"# {slug}\n"
144
206
  write_file(path, meta, body)
145
207
 
146
- rel = path.relative_to(notes_root)
147
- print(f"✓ Created track '{slug}' for {github} at {rel}")
208
+ if is_shared:
209
+ print(f"✓ Created shared track '{slug}' for {github} at {path}")
210
+ else:
211
+ rel = path.relative_to(notes_root)
212
+ print(f"✓ Created track '{slug}' for {github} at {rel}")
213
+
214
+ # ------------------------------------------------------------------
215
+ # --commit: stage + commit the track file to the shared repo (non-fatal)
216
+ # Only meaningful for shared tracks; warn and skip for private.
217
+ # ------------------------------------------------------------------
218
+ want_commit = "--commit" in flags
219
+ if want_commit:
220
+ if is_shared:
221
+ _git_commit_track(path, slug)
222
+ else:
223
+ print("⚠ --commit ignored: track is private (not in a git repo)")
224
+
148
225
  return 0
@@ -12,8 +12,11 @@ For a given track:
12
12
  of "anything closed looks unlabeled."
13
13
  - Compare against frontmatter `github.issues`.
14
14
  - Propose ADDS (labeled in GitHub but missing from frontmatter).
15
- - Propose FLAGS (in frontmatter but no longer labeled possible move out).
16
- - User confirms before writing to the LOCAL frontmatter file.
15
+ - Propose MOVES (in track A's frontmatter, but now labeled for exactly one
16
+ other active track B in the same repo — a relabel; remove from A, add to B).
17
+ - Propose FLAGS (in frontmatter but no longer labeled, with no single move
18
+ target — possible orphan).
19
+ - User confirms before writing to the LOCAL frontmatter file(s).
17
20
 
18
21
  READ-ONLY GITHUB CONTRACT
19
22
  reconcile only READS GitHub via `gh issue list` and `gh pr list`. It NEVER
@@ -26,11 +29,16 @@ Run with --all to reconcile every active track in one pass.
26
29
  """
27
30
  import json
28
31
  import subprocess
32
+ from concurrent.futures import ThreadPoolExecutor
29
33
 
30
34
  from lib.config import load_config, ConfigError
31
- from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo
35
+ from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
32
36
  from lib.frontmatter import write_file
33
37
  from lib.prompts import parse_flags, prompt_input
38
+ from lib.write_guard import needs_confirm
39
+
40
+
41
+ PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
34
42
 
35
43
 
36
44
  def _resolve_labels(track) -> list[str]:
@@ -59,13 +67,19 @@ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
59
67
  seen: dict[int, dict] = {}
60
68
  for lab in labels:
61
69
  for kind in ("issue", "pr"):
62
- proc = subprocess.run(
63
- ["gh", kind, "list", "--repo", repo,
64
- "--label", lab,
65
- "--state", "all", "--limit", "200",
66
- "--json", "number,title,state"],
67
- capture_output=True, text=True,
68
- )
70
+ try:
71
+ proc = subprocess.run(
72
+ ["gh", kind, "list", "--repo", repo,
73
+ "--label", lab,
74
+ "--state", "all", "--limit", "200",
75
+ "--json", "number,title,state"],
76
+ capture_output=True, text=True,
77
+ timeout=PER_TRACK_TIMEOUT,
78
+ )
79
+ except subprocess.TimeoutExpired:
80
+ raise RuntimeError(
81
+ f"gh {kind} query timed out for label '{lab}'"
82
+ )
69
83
  if proc.returncode != 0:
70
84
  raise RuntimeError(
71
85
  f"gh {kind} query failed for label '{lab}': {proc.stderr.strip()}"
@@ -76,19 +90,28 @@ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
76
90
 
77
91
 
78
92
  def run(args: list[str]) -> int:
79
- flags, positional = parse_flags(args, {"--all", "--draft", "--repo"})
93
+ flags, positional = parse_flags(args, {"--all", "--draft", "--repo", "--yes"})
80
94
  do_all = flags.get("--all", False)
81
95
  draft = flags.get("--draft", False)
96
+ yes = flags.get("--yes", False)
82
97
  repo_key = flags.get("--repo")
83
98
  if repo_key is True:
84
- print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
99
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
85
100
  return 2
86
- track_name = positional[0] if positional else None
101
+ track_arg = positional[0] if positional else None
87
102
 
88
- if not do_all and not track_name and not repo_key:
89
- print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
103
+ if not do_all and not track_arg and not repo_key:
104
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
90
105
  return 2
91
106
 
107
+ track_name = track_arg
108
+ repo_qualifier = repo_key
109
+ if track_arg:
110
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
111
+ track_name = name_from_arg
112
+ if repo_from_arg:
113
+ repo_qualifier = repo_from_arg
114
+
92
115
  try:
93
116
  cfg = load_config()
94
117
  except ConfigError as e:
@@ -99,7 +122,7 @@ def run(args: list[str]) -> int:
99
122
  active = [t for t in tracks if t.has_frontmatter
100
123
  and t.meta.get("status") in ("active", "in-progress", "blocked")]
101
124
 
102
- if do_all or repo_key:
125
+ if do_all or (repo_key and not track_arg):
103
126
  targets = active
104
127
  if repo_key:
105
128
  targets = filter_tracks_by_repo(targets, repo_key)
@@ -107,32 +130,115 @@ def run(args: list[str]) -> int:
107
130
  print(f"No active tracks for repo '{repo_key}'.")
108
131
  return 0
109
132
  else:
110
- target = find_track_by_name(track_name, tracks, active_only=True)
133
+ try:
134
+ target = find_track_by_name(track_name, tracks, active_only=True,
135
+ repo=repo_qualifier)
136
+ except AmbiguousTrackError as e:
137
+ print(str(e))
138
+ return 1
111
139
  if not target:
112
140
  print(f"No active track matching '{track_name}'.")
113
141
  return 1
114
142
  targets = [target]
115
143
 
144
+ # Phase 1: parallel fetch of labeled issues for all tracks
145
+ work_items = [(track, _resolve_labels(track)) for track in targets if track.repo]
146
+ results: dict = {} # track.name → list[dict] or None (timeout/error)
147
+
148
+ total = len(work_items)
149
+ if total > 1:
150
+ # Parallel fetch when there are multiple tracks
151
+ with ThreadPoolExecutor(max_workers=4) as pool:
152
+ submitted: list = []
153
+ for i, (track, labels) in enumerate(work_items, 1):
154
+ print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
155
+ submitted.append((i, track, pool.submit(_fetch_labeled_issues, track.repo, labels)))
156
+ # Iterate in submit order for readable output; futures run in parallel
157
+ for i, track, future in submitted:
158
+ try:
159
+ results[track.name] = future.result()
160
+ print(f" [{i}/{total}] ✓ {track.name}")
161
+ except RuntimeError as e:
162
+ print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
163
+ results[track.name] = None
164
+ else:
165
+ # Single track: fetch directly (no thread overhead)
166
+ for i, (track, labels) in enumerate(work_items, 1):
167
+ print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
168
+ try:
169
+ results[track.name] = _fetch_labeled_issues(track.repo, labels)
170
+ print(f" [{i}/{total}] ✓ {track.name}")
171
+ except RuntimeError as e:
172
+ print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
173
+ results[track.name] = None
174
+
175
+ # Phase 2a: index which fetched track(s) label each issue. Used to turn a
176
+ # bare FLAG (in a track's frontmatter, but it has lost that track's label)
177
+ # into a MOVE when the issue is now labeled for exactly one OTHER active
178
+ # track in the same repo.
179
+ labeled_index: dict = {} # issue number -> list[track]
180
+ for track in targets:
181
+ if not track.repo or results.get(track.name) is None:
182
+ continue
183
+ for num in {i["number"] for i in results[track.name]}:
184
+ labeled_index.setdefault(num, []).append(track)
185
+
186
+ # Phase 2b: detect cross-track moves (#163). An issue qualifies when it is
187
+ # in track A's frontmatter, no longer carries A's label, and is now labeled
188
+ # by exactly one OTHER active track B in the same repo. Ambiguous cases
189
+ # (two or more candidate targets) stay as plain FLAGs.
190
+ moved_out: dict = {} # src track name -> set(num)
191
+ moved_in: dict = {} # dst track name -> set(num)
192
+ move_dst: dict = {} # (src track name, num) -> dst track
193
+ for track in targets:
194
+ if not track.repo or results.get(track.name) is None:
195
+ continue
196
+ labeled_nums = {i["number"] for i in results[track.name]}
197
+ listed_nums = set(track.meta.get("github", {}).get("issues") or [])
198
+ for num in sorted(listed_nums - labeled_nums):
199
+ cands = [b for b in labeled_index.get(num, [])
200
+ if b is not track and b.repo == track.repo]
201
+ if len(cands) == 1:
202
+ dst = cands[0]
203
+ moved_out.setdefault(track.name, set()).add(num)
204
+ moved_in.setdefault(dst.name, set()).add(num)
205
+ move_dst[(track.name, num)] = dst
206
+
207
+ # Phase 2c: per-track diff, report, confirm. Membership changes accumulate
208
+ # in `final` (track name -> desired issue set); each affected track is
209
+ # written exactly ONCE at the end, so a move that touches two tracks never
210
+ # double-writes or clobbers a sibling's accepted ADDs. A move is governed by
211
+ # the confirmation on its SOURCE track (where the issue currently lives).
212
+ final: dict = {} # track name -> set(num)
213
+ affected: dict = {} # track name -> track (only those we may write)
214
+
215
+ def _final_for(t):
216
+ if t.name not in final:
217
+ final[t.name] = set(t.meta.get("github", {}).get("issues") or [])
218
+ affected[t.name] = t
219
+ return final[t.name]
220
+
116
221
  any_changes = False
117
222
  for track in targets:
118
223
  slug = track.meta.get("track", track.name)
119
224
  if not track.repo:
120
225
  continue
121
226
 
122
- labels = _resolve_labels(track)
123
- try:
124
- labeled = _fetch_labeled_issues(track.repo, labels)
125
- except RuntimeError as e:
126
- print(f" ⚠ {slug}: {e}")
227
+ labeled = results.get(track.name)
228
+ if labeled is None:
127
229
  continue
128
230
 
231
+ labels = _resolve_labels(track)
129
232
  labeled_nums = {i["number"] for i in labeled}
130
233
  listed_nums = set(track.meta.get("github", {}).get("issues") or [])
234
+ out_moves = sorted(moved_out.get(track.name, set()))
131
235
 
132
- adds = sorted(labeled_nums - listed_nums)
133
- flag_nums = sorted(listed_nums - labeled_nums)
236
+ # MOVE issues are reported (and applied) as moves, not as ADD on the
237
+ # destination or FLAG on the source.
238
+ adds = sorted(labeled_nums - listed_nums - moved_in.get(track.name, set()))
239
+ flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(track.name, set()))
134
240
 
135
- if not adds and not flag_nums:
241
+ if not adds and not flag_nums and not out_moves:
136
242
  continue
137
243
 
138
244
  any_changes = True
@@ -144,6 +250,13 @@ def run(args: list[str]) -> int:
144
250
  for num in adds:
145
251
  i = issue_lookup[num]
146
252
  print(f" #{num} ({i['state'].lower()}) {i['title']}")
253
+ if out_moves:
254
+ print(f" MOVE ({len(out_moves)}) — relabeled to another track in this repo:")
255
+ for num in out_moves:
256
+ dst = move_dst[(track.name, num)]
257
+ dst_slug = dst.meta.get("track", dst.name)
258
+ pub = " [dst PUBLIC]" if needs_confirm(dst.repo, cfg) else ""
259
+ print(f" #{num} {slug} → {dst_slug}{pub}")
147
260
  if flag_nums:
148
261
  print(f" FLAG ({len(flag_nums)}) — in frontmatter but missing every configured label:")
149
262
  for num in flag_nums:
@@ -151,8 +264,8 @@ def run(args: list[str]) -> int:
151
264
 
152
265
  if listed_nums and len(flag_nums) / len(listed_nums) > 0.5:
153
266
  print(f"\n ⓘ {len(flag_nums)}/{len(listed_nums)} frontmatter issues lack the configured label(s).")
154
- print(f" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
155
- print(f" If you just want to update issue state in the body table, try:")
267
+ print(" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
268
+ print(" If you just want to update issue state in the body table, try:")
156
269
  print(f" /work-plan refresh-md {slug}")
157
270
 
158
271
  if draft:
@@ -160,12 +273,41 @@ def run(args: list[str]) -> int:
160
273
  # Useful for sweep audits and scripted reports.
161
274
  continue
162
275
 
163
- choice = prompt_input(f"\n Apply ADDs to {track.path.name}? [y/N/skip-flags]").lower()
164
- if choice == "y":
165
- new_issues = sorted(listed_nums | labeled_nums)
166
- track.meta.setdefault("github", {})["issues"] = new_issues
167
- write_file(track.path, track.meta, track.body)
168
- print(f" ✓ Updated {track.path.name} ({len(adds)} added)")
276
+ if yes:
277
+ # Non-interactive: apply ADDs + MOVEs without prompting. All writes
278
+ # are local frontmatter — the read-only-GitHub contract is unchanged.
279
+ print(f"\n --yes: applying changes from {track.path.name}")
280
+ choice = "y"
281
+ else:
282
+ choice = prompt_input(f"\n Apply ADDs/MOVEs from {track.path.name}? [y/N]").lower()
283
+ if choice != "y":
284
+ continue
285
+
286
+ if adds:
287
+ _final_for(track).update(adds)
288
+ for num in out_moves:
289
+ dst = move_dst[(track.name, num)]
290
+ # Public-repo guard (#163): under --yes we never silently write
291
+ # membership into a PUBLIC/shared destination track — that move is
292
+ # skipped with a pointer to the gated `move` verb. Interactive runs
293
+ # treat the prompt above as the confirmation.
294
+ if yes and needs_confirm(dst.repo, cfg):
295
+ dst_slug = dst.meta.get("track", dst.name)
296
+ print(f" ⏭ skipped MOVE #{num} → {dst_slug} ({dst.repo} is PUBLIC; "
297
+ f"run `/work-plan move {num} {slug} {dst_slug} --confirm` instead)")
298
+ continue
299
+ _final_for(track).discard(num)
300
+ _final_for(dst).add(num)
301
+
302
+ # Write each affected track exactly once, only if its set actually changed.
303
+ for name, issues in final.items():
304
+ track = affected[name]
305
+ original = set(track.meta.get("github", {}).get("issues") or [])
306
+ if issues == original:
307
+ continue
308
+ track.meta.setdefault("github", {})["issues"] = sorted(issues)
309
+ write_file(track.path, track.meta, track.body)
310
+ print(f" ✓ Updated {track.path.name}")
169
311
 
170
312
  if not any_changes:
171
313
  print("All tracks in sync with configured labels.")
@@ -1,6 +1,6 @@
1
1
  """refresh-md subcommand."""
2
2
  from lib.config import load_config, ConfigError
3
- from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo
3
+ from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
4
4
  from lib.github_state import fetch_issues, state_to_status_label
5
5
  from lib.frontmatter import write_file
6
6
  from lib.status_table import find_all_status_tables, find_canonical_status_tables, sync_missing_rows, ISSUE_NUM_RE
@@ -15,12 +15,20 @@ def run(args: list[str]) -> int:
15
15
  if repo_key is True:
16
16
  print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
17
17
  return 2
18
- track_name = positional[0] if positional else None
18
+ track_arg = positional[0] if positional else None
19
19
 
20
- if not do_all and not track_name and not repo_key:
20
+ if not do_all and not track_arg and not repo_key:
21
21
  print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
22
22
  return 2
23
23
 
24
+ track_name = track_arg
25
+ repo_qualifier = repo_key
26
+ if track_arg:
27
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
28
+ track_name = name_from_arg
29
+ if repo_from_arg:
30
+ repo_qualifier = repo_from_arg
31
+
24
32
  try:
25
33
  cfg = load_config()
26
34
  except ConfigError as e:
@@ -28,7 +36,7 @@ def run(args: list[str]) -> int:
28
36
  return 1
29
37
 
30
38
  tracks = discover_tracks(cfg)
31
- if do_all or repo_key:
39
+ if do_all or (repo_key and not track_arg):
32
40
  targets = [t for t in tracks if t.has_frontmatter
33
41
  and t.meta.get("status") in ("active", "in-progress", "blocked")]
34
42
  if repo_key:
@@ -41,7 +49,11 @@ def run(args: list[str]) -> int:
41
49
  return 0
42
50
  return _refresh_many(targets, yes)
43
51
 
44
- track = find_track_by_name(track_name, tracks)
52
+ try:
53
+ track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
54
+ except AmbiguousTrackError as e:
55
+ print(str(e))
56
+ return 1
45
57
  if not track:
46
58
  print(f"No track matching '{track_name}'.")
47
59
  return 1
@@ -52,7 +64,8 @@ def _refresh_many(tracks: list, yes: bool) -> int:
52
64
  """Refresh one or more tracks. Computes proposed updates, then asks one
53
65
  confirmation (or applies all if --yes)."""
54
66
  pending = []
55
- for track in tracks:
67
+ for i, track in enumerate(tracks, 1):
68
+ print(f" [{i}/{len(tracks)}] {track.path.name}...", flush=True)
56
69
  canonical = find_canonical_status_tables(track.body)
57
70
  all_tables = find_all_status_tables(track.body)
58
71
  tables = canonical if canonical else all_tables
@@ -1,12 +1,12 @@
1
1
  """set subcommand — guarded edit of a track's frontmatter scalar/list fields."""
2
2
  import json
3
3
  from lib.config import load_config, ConfigError
4
- from lib.tracks import discover_tracks, find_track_by_name
4
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
5
5
  from lib.frontmatter import write_file
6
6
  from lib.write_guard import needs_confirm, make_token, valid_token
7
7
  from lib.prompts import parse_flags
8
8
 
9
- ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up"}
9
+ ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on"}
10
10
  LIST_FIELDS = {"blockers", "next_up"}
11
11
  STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
12
12
 
@@ -14,10 +14,14 @@ def run(args: list[str]) -> int:
14
14
  # Confirm token is passed as --confirm=<token> (equals form: parse_flags only
15
15
  # understands --key=value or bare --key, so a space-separated token would be
16
16
  # mis-read as a positional). The VS Code extension invokes the equals form.
17
- flags, positional = parse_flags(args, {"--confirm"})
17
+ flags, positional = parse_flags(args, {"--confirm", "--repo"})
18
18
  if len(positional) < 2:
19
- print("usage: work_plan.py set <track> field=value [field=value …] [--confirm=<token>]"); return 2
20
- name, assignments = positional[0], positional[1:]
19
+ print("usage: work_plan.py set <track> field=value [field=value …] [--confirm=<token>] [--repo=<key>]"); return 2
20
+ track_arg, assignments = positional[0], positional[1:]
21
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
22
+ name = name_from_arg
23
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
24
+ repo_qualifier = repo_from_arg or repo_flag
21
25
  parsed = {}
22
26
  for a in assignments:
23
27
  if "=" not in a:
@@ -25,7 +29,10 @@ def run(args: list[str]) -> int:
25
29
  k, v = a.split("=", 1)
26
30
  if k not in ALLOWED:
27
31
  print(f"ERROR: field {k!r} not settable (allowed: {sorted(ALLOWED)})"); return 2
28
- if k in LIST_FIELDS:
32
+ if k == "depends_on":
33
+ # Comma-separated track slugs (strings, not issue numbers).
34
+ parsed[k] = [x.strip() for x in v.split(",") if x.strip()] if v.strip() else []
35
+ elif k in LIST_FIELDS:
29
36
  try:
30
37
  parsed[k] = [int(x) for x in v.split(",") if x.strip()] if v.strip() else []
31
38
  except ValueError:
@@ -38,7 +45,10 @@ def run(args: list[str]) -> int:
38
45
  cfg = load_config()
39
46
  except ConfigError as e:
40
47
  print(f"ERROR: {e}"); return 1
41
- track = find_track_by_name(name, discover_tracks(cfg))
48
+ try:
49
+ track = find_track_by_name(name, discover_tracks(cfg), repo=repo_qualifier)
50
+ except AmbiguousTrackError as e:
51
+ print(str(e)); return 1
42
52
  if not track:
43
53
  print(f"No track matching {name!r}."); return 1
44
54
  # Public-repo confirm gate (the extension surfaces this as a modal).
@@ -3,7 +3,7 @@ import json
3
3
  import subprocess
4
4
 
5
5
  from lib.config import load_config, ConfigError
6
- from lib.tracks import discover_tracks, find_track_by_name
6
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
7
7
  from lib.frontmatter import write_file
8
8
  from lib.write_guard import needs_confirm, make_token, valid_token
9
9
  from lib.prompts import parse_flags, prompt_input
@@ -27,16 +27,26 @@ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
27
27
  def run(args: list[str]) -> int:
28
28
  # --confirm uses equals form: --confirm=<token>
29
29
  # --move / --no-move are bare flags
30
- flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move"})
30
+ # --repo uses equals form: --repo=<key>
31
+ flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo"})
31
32
  if not positional:
32
- print("usage: work_plan.py slot <issue-num> [track-name]")
33
+ print("usage: work_plan.py slot <issue-num> [track | track@repo] [--repo=<key>]")
33
34
  return 2
34
35
  try:
35
36
  issue_num = int(positional[0])
36
37
  except ValueError:
37
38
  print(f"ERROR: '{positional[0]}' is not an issue number.")
38
39
  return 2
39
- target_name = positional[1] if len(positional) > 1 else None
40
+ target_arg = positional[1] if len(positional) > 1 else None
41
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
42
+
43
+ target_name = target_arg
44
+ repo_qualifier = repo_flag
45
+ if target_arg:
46
+ name_from_arg, repo_from_arg = parse_track_repo_arg(target_arg)
47
+ target_name = name_from_arg
48
+ if repo_from_arg:
49
+ repo_qualifier = repo_from_arg
40
50
 
41
51
  if "--move" in flags and "--no-move" in flags:
42
52
  print("ERROR: --move and --no-move are mutually exclusive.")
@@ -53,7 +63,12 @@ def run(args: list[str]) -> int:
53
63
  and t.meta.get("status") in ("active", "in-progress", "blocked")]
54
64
 
55
65
  if target_name:
56
- target = find_track_by_name(target_name, tracks, active_only=True)
66
+ try:
67
+ target = find_track_by_name(target_name, tracks, active_only=True,
68
+ repo=repo_qualifier)
69
+ except AmbiguousTrackError as e:
70
+ print(str(e))
71
+ return 1
57
72
  if not target:
58
73
  print(f"No active track matching '{target_name}'.")
59
74
  return 1