@stylusnexus/work-plan 2026.6.9-1 → 2026.6.9-3

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 CHANGED
@@ -378,9 +378,9 @@ work-plan-toolkit/
378
378
  │ ├── work-plan/
379
379
  │ │ ├── SKILL.md
380
380
  │ │ ├── work_plan.py # CLI entry
381
- │ │ ├── commands/ # 16 subcommand modules
381
+ │ │ ├── commands/ # 24 subcommand modules
382
382
  │ │ ├── lib/ # config, frontmatter, gh, git, prompts, …
383
- │ │ └── tests/ # 234 unittest cases
383
+ │ │ └── tests/ # 600+ unittest cases
384
384
  │ └── repo-activity-summary/
385
385
  │ └── SKILL.md # bundled companion skill
386
386
  ├── commands/
@@ -497,8 +497,8 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
497
497
  | `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
498
498
  | `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
499
499
  | `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
500
- | `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. |
501
- | `auto-triage [--repo=<key>] [--apply]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. |
500
+ | `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
501
+ | `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
502
502
  | `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
503
503
  | `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). `--draft` previews ADDs/FLAGs without prompting or writing. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
504
504
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
@@ -543,7 +543,7 @@ cd skills/work-plan
543
543
  python3 -m unittest discover tests
544
544
  ```
545
545
 
546
- 234 tests, no external dependencies (mocks `gh`/`git` calls).
546
+ 600+ tests, no external dependencies (mocks `gh`/`git` calls).
547
547
 
548
548
  ## License
549
549
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.09+f7e5ff5
1
+ 2026.06.09+f86ff30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.9-1",
3
+ "version": "2026.6.9-3",
4
4
  "description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
5
5
  "bin": {
6
6
  "work-plan": "bin/work-plan"
@@ -61,6 +61,15 @@ def run(args: list[str]) -> int:
61
61
  apply_mode = "--apply" in args
62
62
  repo_arg = next((a for a in args if a.startswith("--repo=")), None)
63
63
 
64
+ limit = 100
65
+ for a in args:
66
+ if a.startswith("--limit="):
67
+ try:
68
+ limit = int(a.split("=", 1)[1])
69
+ except ValueError:
70
+ print("ERROR: --limit must be an integer.")
71
+ return 2
72
+
64
73
  try:
65
74
  cfg = load_config()
66
75
  except ConfigError as e:
@@ -139,13 +148,17 @@ def run(args: list[str]) -> int:
139
148
 
140
149
  print()
141
150
  print("Untracked issues to assign:")
142
- for i in untracked:
151
+ shown = untracked[:limit]
152
+ for i in shown:
143
153
  num = i.get("number", "?")
144
154
  title = i.get("title", "")
145
155
  milestone = i.get("milestone") or {}
146
156
  m_title = milestone.get("title", "—") if isinstance(milestone, dict) else "—"
147
157
  labels = [lb["name"] for lb in (i.get("labels") or [])]
148
158
  print(f" #{num} [{m_title}] [{','.join(labels) or 'no-labels'}] {title}")
159
+ remainder = len(untracked) - len(shown)
160
+ if remainder > 0:
161
+ print(f" … and {remainder} more issues (use --limit=N to show more)")
149
162
 
150
163
  print("=" * 60)
151
164
  print()
@@ -0,0 +1,184 @@
1
+ """batch-slot subcommand — slot multiple issues into a track at once."""
2
+ import json
3
+ import subprocess
4
+
5
+ from lib.config import load_config, ConfigError
6
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
7
+ from lib.frontmatter import write_file
8
+ from lib.write_guard import needs_confirm, make_token, valid_token
9
+ from lib.prompts import parse_flags
10
+
11
+
12
+ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
13
+ """Active tracks in `repo` (excluding `target_name`) whose frontmatter
14
+ already lists `issue_num`. Shared with slot.py."""
15
+ owners = []
16
+ for t in tracks:
17
+ if not t.has_frontmatter or t.name == target_name or t.repo != repo:
18
+ continue
19
+ if t.meta.get("status") not in ("active", "in-progress", "blocked"):
20
+ continue
21
+ if issue_num in (t.meta.get("github", {}).get("issues") or []):
22
+ owners.append(t)
23
+ return owners
24
+
25
+
26
+ def run(args: list[str]) -> int:
27
+ flags, positional = parse_flags(
28
+ args, {"--confirm", "--move", "--no-move", "--repo"}
29
+ )
30
+
31
+ if len(positional) < 2:
32
+ print(
33
+ "usage: work_plan.py batch-slot <issue-num>... <track | track@repo>"
34
+ " [--repo=<key>]"
35
+ )
36
+ return 2
37
+
38
+ # Last positional is the track; everything before is an issue number.
39
+ *issue_strs, target_arg = positional
40
+
41
+ issue_nums: list[int] = []
42
+ for s in issue_strs:
43
+ try:
44
+ issue_nums.append(int(s))
45
+ except ValueError:
46
+ print(f"ERROR: '{s}' is not an issue number.")
47
+ return 2
48
+
49
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
50
+
51
+ target_name = target_arg
52
+ repo_qualifier = repo_flag
53
+ if target_arg:
54
+ name_from_arg, repo_from_arg = parse_track_repo_arg(target_arg)
55
+ target_name = name_from_arg
56
+ if repo_from_arg:
57
+ repo_qualifier = repo_from_arg
58
+
59
+ if "--move" in flags and "--no-move" in flags:
60
+ print("ERROR: --move and --no-move are mutually exclusive.")
61
+ return 2
62
+
63
+ try:
64
+ cfg = load_config()
65
+ except ConfigError as e:
66
+ print(f"ERROR: {e}")
67
+ return 1
68
+
69
+ tracks = discover_tracks(cfg)
70
+
71
+ try:
72
+ target = find_track_by_name(
73
+ target_name, tracks, active_only=True, repo=repo_qualifier
74
+ )
75
+ except AmbiguousTrackError as e:
76
+ print(str(e))
77
+ return 1
78
+ if not target:
79
+ print(f"No active track matching '{target_name}'.")
80
+ return 1
81
+
82
+ # Confirm gate — fire once for the whole batch.
83
+ confirm = flags.get("--confirm")
84
+ if target.repo and needs_confirm(target.repo, cfg) and not (
85
+ isinstance(confirm, str) and valid_token(confirm, target.repo, target.name)
86
+ ):
87
+ print(
88
+ json.dumps(
89
+ {
90
+ "needs_confirm": True,
91
+ "reason": (
92
+ f"{target.repo} is PUBLIC (or visibility unknown); "
93
+ f"batch-slotting {len(issue_nums)} issue(s) will be"
94
+ f" written there."
95
+ ),
96
+ "token": make_token(target.repo, target.name),
97
+ }
98
+ )
99
+ )
100
+ return 0
101
+
102
+ do_move = "--move" in flags
103
+
104
+ # Collect source tracks that need issue removal (consolidated per source).
105
+ source_removals: dict[str, tuple] = {} # source_name -> (source_track, set[issue_num])
106
+
107
+ issues = list(target.meta.get("github", {}).get("issues") or [])
108
+ skipped: list[int] = []
109
+ slotted: list[int] = []
110
+
111
+ for issue_num in issue_nums:
112
+ if issue_num in issues:
113
+ skipped.append(issue_num)
114
+ continue
115
+
116
+ # Milestone mismatch check (non-blocking warning).
117
+ proc = subprocess.run(
118
+ ["gh", "issue", "view", str(issue_num),
119
+ "--repo", target.repo, "--json", "milestone"],
120
+ capture_output=True, text=True,
121
+ )
122
+ if proc.returncode == 0:
123
+ info = json.loads(proc.stdout)
124
+ m = info.get("milestone", {})
125
+ if (
126
+ m and m.get("title")
127
+ and m["title"] != target.meta.get("milestone_alignment")
128
+ ):
129
+ print(
130
+ f"⚠ #{issue_num} is on milestone '{m['title']}', "
131
+ f"track '{target.name}' aligned to"
132
+ f" '{target.meta.get('milestone_alignment')}'."
133
+ )
134
+
135
+ # Prior-owner detection.
136
+ sources = _find_prior_owners(
137
+ issue_num, target.repo, target.name, tracks
138
+ )
139
+
140
+ issues.append(issue_num)
141
+ slotted.append(issue_num)
142
+
143
+ if sources and do_move:
144
+ for src in sources:
145
+ if src.name not in source_removals:
146
+ source_removals[src.name] = (src, set())
147
+ source_removals[src.name][1].add(issue_num)
148
+ elif sources and not do_move:
149
+ names = ", ".join(f"'{t.name}'" for t in sources)
150
+ print(
151
+ f"ℹ #{issue_num} still listed in {names}"
152
+ f" — re-run with --move to relocate."
153
+ )
154
+
155
+ if not slotted:
156
+ if skipped:
157
+ print(
158
+ f"All {len(skipped)} issue(s) already in track"
159
+ f" '{target.name}'."
160
+ )
161
+ return 0
162
+
163
+ # Write source tracks (consolidated removals).
164
+ if do_move:
165
+ for src_name, (src, removals) in source_removals.items():
166
+ src_issues = [
167
+ n for n in (src.meta.get("github", {}).get("issues") or [])
168
+ if n not in removals
169
+ ]
170
+ src.meta.setdefault("github", {})["issues"] = src_issues
171
+ write_file(src.path, src.meta, src.body)
172
+ removed_str = ", ".join(f"#{n}" for n in sorted(removals))
173
+ print(f" ✓ Removed {removed_str} from '{src_name}'.")
174
+
175
+ # Write target track once.
176
+ target.meta.setdefault("github", {})["issues"] = sorted(issues)
177
+ write_file(target.path, target.meta, target.body)
178
+
179
+ slotted_str = ", ".join(f"#{n}" for n in slotted)
180
+ print(f"✓ Slotted {slotted_str} into '{target.name}'.")
181
+ if skipped:
182
+ skipped_str = ", ".join(f"#{n}" for n in skipped)
183
+ print(f"ℹ Skipped (already in track): {skipped_str}.")
184
+ return 0
@@ -77,6 +77,7 @@ def run(args: list[str]) -> int:
77
77
 
78
78
  new_body = _insert_canonical_table(
79
79
  track.body, issue_nums, issues_by_num, replace=force,
80
+ milestone_alignment=track.meta.get("milestone_alignment"),
80
81
  )
81
82
  write_file(track.path, track.meta, new_body)
82
83
  print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
@@ -88,9 +89,10 @@ def run(args: list[str]) -> int:
88
89
 
89
90
 
90
91
  def _insert_canonical_table(body: str, issue_nums: list[int],
91
- issues_by_num: dict, replace: bool = False) -> str:
92
+ issues_by_num: dict, replace: bool = False,
93
+ milestone_alignment=None) -> str:
92
94
  """Insert (or replace) a canonical table at the top of the body."""
93
- table_md = _render_canonical_table(issue_nums, issues_by_num)
95
+ table_md = _render_canonical_table(issue_nums, issues_by_num, milestone_alignment)
94
96
 
95
97
  if replace:
96
98
  # Strip existing canonical block (marker + heading + table + separator)
@@ -102,22 +104,57 @@ def _insert_canonical_table(body: str, issue_nums: list[int],
102
104
  return leading_whitespace + table_md + "\n---\n\n" + body_stripped
103
105
 
104
106
 
105
- def _render_canonical_table(issue_nums: list[int], issues_by_num: dict) -> str:
107
+ def _render_canonical_table(issue_nums: list[int], issues_by_num: dict,
108
+ milestone_alignment=None) -> str:
106
109
  lines = [
107
110
  "## Issues (canonical)",
108
111
  "",
109
112
  f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
110
113
  "",
111
- "| # | Title | Assignee | Status |",
112
- "|---|---|---|---|",
113
114
  ]
115
+
116
+ # Build a normalized issue list with compact milestone strings.
117
+ from lib.github_state import short_milestone
118
+ norm_issues = []
114
119
  for num in sorted(issue_nums):
115
- i = issues_by_num.get(num, {})
116
- lines.append(render_issue_row(
117
- num, i.get("title", "(not fetched)"),
118
- format_assignees(i), state_to_status_label(i.get("state")),
119
- ))
120
- lines.append("")
120
+ gh = issues_by_num.get(num, {})
121
+ ms = short_milestone(gh.get("milestone")) or None
122
+ norm_issues.append({"number": num, "milestone": ms, "_gh": gh})
123
+
124
+ from lib.export_model import group_issues_by_milestone
125
+ groups = group_issues_by_milestone(norm_issues, milestone_alignment)
126
+
127
+ if len(groups) <= 1:
128
+ # Single milestone group (or all null) — render flat, same as before.
129
+ lines.append("| # | Title | Assignee | Status |")
130
+ lines.append("|---|---|---|---|")
131
+ for num in sorted(issue_nums):
132
+ i = issues_by_num.get(num, {})
133
+ lines.append(render_issue_row(
134
+ num, i.get("title", "(not fetched)"),
135
+ format_assignees(i), state_to_status_label(i.get("state")),
136
+ ))
137
+ lines.append("")
138
+ return "\n".join(lines)
139
+
140
+ # Multiple milestone groups — render with section headings.
141
+ for label, issues in groups:
142
+ if label:
143
+ heading = f"{label} ({len(issues)})"
144
+ else:
145
+ heading = f"No milestone ({len(issues)})"
146
+ lines.append(f"### {heading}")
147
+ lines.append("")
148
+ lines.append("| # | Title | Assignee | Status |")
149
+ lines.append("|---|---|---|---|")
150
+ for norm in issues:
151
+ num = norm["number"]
152
+ i = norm["_gh"]
153
+ lines.append(render_issue_row(
154
+ num, i.get("title", "(not fetched)"),
155
+ format_assignees(i), state_to_status_label(i.get("state")),
156
+ ))
157
+ lines.append("")
121
158
  return "\n".join(lines)
122
159
 
123
160
 
@@ -63,6 +63,15 @@ def run(args: list[str]) -> int:
63
63
  label_arg = next((a for a in args if a.startswith("--label=")), None)
64
64
  state_arg = next((a for a in args if a.startswith("--state=")), None)
65
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
+
66
75
  try:
67
76
  cfg = load_config()
68
77
  except ConfigError as e:
@@ -123,11 +132,15 @@ def run(args: list[str]) -> int:
123
132
  print()
124
133
  print("=" * 60)
125
134
  print(PROMPT_TEMPLATE)
126
- for i in issues:
135
+ shown = issues[:limit]
136
+ for i in shown:
127
137
  m = i.get("milestone", {})
128
138
  m_title = m.get("title", "—") if m else "—"
129
139
  labels = [l["name"] for l in i.get("labels", [])]
130
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)")
131
144
  print("=" * 60)
132
145
  print()
133
146
  print(f"After agent returns clusters JSON, save to {_answers_path()}")
@@ -202,7 +215,9 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
202
215
  slug = _slugify(cluster["slug"])
203
216
  name = cluster.get("name", slug)
204
217
  summary = cluster.get("summary", "")
205
- 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
+ )
206
221
  if not cluster_issues:
207
222
  print(f" SKIP {slug}: no issues")
208
223
  continue
@@ -214,7 +229,10 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
214
229
  print(f" SKIP {slug}: file exists but has no frontmatter; use init first")
215
230
  continue
216
231
  existing_issues = list(existing_meta.get("github", {}).get("issues") or [])
217
- 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
+ )
218
236
  existing_meta.setdefault("github", {})["issues"] = merged
219
237
  existing_meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
220
238
  write_file(path, existing_meta, existing_body)
@@ -228,7 +246,7 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
228
246
  "launch_priority": "P3",
229
247
  "milestone_alignment": batch_milestone,
230
248
  "github": {"repo": repo, "issues": cluster_issues, "branches": []},
231
- "related_tracks": [],
249
+ "depends_on": [],
232
250
  "last_touched": now, "last_handoff": now,
233
251
  "next_up": [], "blockers": [],
234
252
  }
@@ -246,6 +264,26 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
246
264
  return 0
247
265
 
248
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
+
249
287
  def _slugify(s: str) -> str:
250
288
  s = s.strip().lower()
251
289
  s = re.sub(r"[^a-z0-9-]+", "-", s)
@@ -119,7 +119,7 @@ def run(args: list[str]) -> int:
119
119
  "launch_priority": priority,
120
120
  "milestone_alignment": milestone,
121
121
  "github": {"repo": repo or "TBD", "issues": issue_nums, "branches": []},
122
- "related_tracks": [],
122
+ "depends_on": [],
123
123
  "last_touched": now, "last_handoff": now,
124
124
  "next_up": [], "blockers": [],
125
125
  }
@@ -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
@@ -193,7 +193,7 @@ def run(args: list[str]) -> int:
193
193
  "launch_priority": priority,
194
194
  "milestone_alignment": milestone,
195
195
  "github": {"repo": github, "issues": [], "branches": []},
196
- "related_tracks": [],
196
+ "depends_on": [],
197
197
  "last_touched": now,
198
198
  "last_handoff": now,
199
199
  "next_up": [],
@@ -6,7 +6,7 @@ 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
 
@@ -29,7 +29,10 @@ def run(args: list[str]) -> int:
29
29
  k, v = a.split("=", 1)
30
30
  if k not in ALLOWED:
31
31
  print(f"ERROR: field {k!r} not settable (allowed: {sorted(ALLOWED)})"); return 2
32
- 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:
33
36
  try:
34
37
  parsed[k] = [int(x) for x in v.split(",") if x.strip()] if v.strip() else []
35
38
  except ValueError:
@@ -25,7 +25,7 @@ from pathlib import Path
25
25
  from typing import Optional
26
26
 
27
27
  from lib.config import load_config, ConfigError
28
- from lib.tracks import discover_tracks, find_track_by_name
28
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
29
29
  from lib.prompts import prompt_input, parse_flags
30
30
  from lib.github_state import fetch_issues, short_milestone
31
31
  from lib.git_state import (
@@ -40,8 +40,18 @@ RULE_WIDTH = 57
40
40
 
41
41
 
42
42
  def run(args: list[str]) -> int:
43
- flags, positional = parse_flags(args, {"--pick"})
44
- track_name = positional[0] if positional else None
43
+ flags, positional = parse_flags(args, {"--pick", "--repo"})
44
+ track_arg = positional[0] if positional else None
45
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
46
+
47
+ # Resolve track name and repo qualifier from <track>@<repo> syntax
48
+ track_name = track_arg
49
+ repo_qualifier = repo_flag
50
+ if track_arg:
51
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
52
+ track_name = name_from_arg
53
+ if repo_from_arg:
54
+ repo_qualifier = repo_from_arg
45
55
 
46
56
  try:
47
57
  cfg = load_config()
@@ -78,12 +88,20 @@ def run(args: list[str]) -> int:
78
88
  return 1
79
89
  track = active[idx]
80
90
  else:
81
- track = find_track_by_name(choice, tracks)
91
+ try:
92
+ track = find_track_by_name(choice, tracks, repo=repo_qualifier)
93
+ except AmbiguousTrackError as e:
94
+ print(str(e))
95
+ return 1
82
96
  if not track:
83
97
  print(f"No track matching '{choice}'.")
84
98
  return 1
85
99
  else:
86
- track = find_track_by_name(track_name, tracks)
100
+ try:
101
+ track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
102
+ except AmbiguousTrackError as e:
103
+ print(str(e))
104
+ return 1
87
105
  if not track:
88
106
  print(f"No track matching '{track_name}'.")
89
107
  return 1