@stylusnexus/work-plan 2026.6.9-2 → 2026.6.9-4

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
@@ -62,11 +62,13 @@ A dozen more subcommands cover slotting new issues into tracks, closing tracks (
62
62
 
63
63
  **Coverage + auto-triage** — `coverage --repo=<key>` reports how many open issues fall outside the track model (42% on a real production repo). `auto-triage --repo=<key>` then produces an AI prompt to assign those orphans to existing tracks. Run both periodically to keep the backlog visible.
64
64
 
65
+ **Cross-track dependencies** — set `depends_on: [<track-slug>]` in a track's frontmatter to declare explicit dependencies between tracks. The VS Code viewer renders these as thick amber `==>` edges in the dependency graph, and the detail panel shows clickable dependency chips that navigate directly to the dependent track. Set via `/work-plan set <track> depends_on=slug1,slug2` or the "Edit Track Fields" right-click menu in VS Code. Complementary to the issue-derived "owns" edges already inferred from blockers.
66
+
65
67
  Beyond issue tracking, **`plan-status`** answers a different question — *which of your accumulated plan/spec docs actually shipped, half-shipped, or died*. It correlates each plan's declared file-manifest (`Create:`/`Modify:`/`Test:` paths) against git and the filesystem rather than trusting checkboxes (which are routinely left unchecked even for shipped work). Read-only by default; optionally stamp the verdict into each doc (`--stamp`), get an AI verdict on prose/ambiguous docs (`--llm`), and act on the results behind confirmation gates (`--archive` dead plans, `--issues` for partial ones). See [Plan & doc liveness](#plan--doc-liveness-plan-status).
66
68
 
67
69
  ## How it works
68
70
 
69
- The toolkit treats GitHub as the canonical source of issue state and never tries to mirror it. Track markdown files are lightweight references — they list issue numbers and a few pieces of derived metadata (priority, milestone, `next_up`, last session timestamp). The CLI re-derives everything else live from `gh`, `git`, and the markdown body.
71
+ The toolkit treats GitHub as the canonical source of issue state and never tries to mirror it. Track markdown files are lightweight references — they list issue numbers and a few pieces of derived metadata (priority, milestone, `next_up`, `depends_on`, last session timestamp). The CLI re-derives everything else live from `gh`, `git`, and the markdown body.
70
72
 
71
73
  ```mermaid
72
74
  flowchart TB
@@ -279,7 +281,7 @@ To install for **both** Claude Code AND Codex, run the installer twice with diff
279
281
 
280
282
  ### VS Code extension
281
283
 
282
- The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks), a Mermaid dependency graph, the Untracked bucket, and full read/write (slot/close/edit/new-track/…) with a public-repo confirm modal.
284
+ The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks), a Mermaid dependency graph (with focus toggle and repo-scoped full map), the Untracked bucket, cross-track dependency chips, per-issue move buttons, and full read/write (slot/close/edit/move/new-track/…) with a public-repo confirm modal.
283
285
 
284
286
  ![Work Plan VS Code extension — sidebar and dependency graph](https://raw.githubusercontent.com/stylusnexus/work-plan-toolkit/main/vscode/media/screenshots/dependency-graph.png)
285
287
 
@@ -378,9 +380,9 @@ work-plan-toolkit/
378
380
  │ ├── work-plan/
379
381
  │ │ ├── SKILL.md
380
382
  │ │ ├── work_plan.py # CLI entry
381
- │ │ ├── commands/ # 16 subcommand modules
383
+ │ │ ├── commands/ # 24 subcommand modules
382
384
  │ │ ├── lib/ # config, frontmatter, gh, git, prompts, …
383
- │ │ └── tests/ # 234 unittest cases
385
+ │ │ └── tests/ # 600+ unittest cases
384
386
  │ └── repo-activity-summary/
385
387
  │ └── SKILL.md # bundled companion skill
386
388
  ├── commands/
@@ -497,8 +499,8 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
497
499
  | `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
500
  | `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
501
  | `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. |
502
+ | `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). |
503
+ | `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
504
  | `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
503
505
  | `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
506
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
@@ -543,7 +545,7 @@ cd skills/work-plan
543
545
  python3 -m unittest discover tests
544
546
  ```
545
547
 
546
- 234 tests, no external dependencies (mocks `gh`/`git` calls).
548
+ 600+ tests, no external dependencies (mocks `gh`/`git` calls).
547
549
 
548
550
  ## License
549
551
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.09+530f7e8
1
+ 2026.06.09+f25e6e1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.9-2",
3
+ "version": "2026.6.9-4",
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()
@@ -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
@@ -3,6 +3,55 @@ from lib.github_state import format_assignees, short_milestone
3
3
 
4
4
  SCHEMA = 1
5
5
 
6
+
7
+ def milestone_sort_key(issue: dict, milestone_alignment=None):
8
+ """Sort key for an issue dict (must have 'number' and 'milestone').
9
+
10
+ Returns (tier, milestone_label, number) so that:
11
+ 0. issues matching milestone_alignment come first
12
+ 1. issues with other non-null milestones come next, grouped by label
13
+ 2. issues with null/empty milestone come last.
14
+
15
+ milestone may be a compact string (as from short_milestone) or None.
16
+ """
17
+ ms = issue.get("milestone")
18
+ num = issue.get("number", 0) or 0
19
+ if ms is None or ms == "":
20
+ return (2, "", num)
21
+ if ms == milestone_alignment:
22
+ return (0, ms, num)
23
+ return (1, ms, num)
24
+
25
+
26
+ def group_issues_by_milestone(issues, milestone_alignment=None):
27
+ """Partition sorted issues into [(label, [issue, ...]), ...].
28
+
29
+ label is the compact milestone string; None for the no-milestone group.
30
+ Groups are emitted in milestone_sort_key order. A single-group result
31
+ means all issues share the same milestone (or all lack one) — callers
32
+ can use this to decide whether to render section headings.
33
+ """
34
+ if not issues:
35
+ return []
36
+ sorted_issues = sorted(issues,
37
+ key=lambda i: milestone_sort_key(i, milestone_alignment))
38
+ groups = []
39
+ current_label = None # sentinel — always differs from the first real label
40
+ current_group = []
41
+ for i in sorted_issues:
42
+ label = i.get("milestone") or None
43
+ if label != current_label:
44
+ if current_group:
45
+ groups.append((current_label, current_group))
46
+ current_label = label
47
+ current_group = [i]
48
+ else:
49
+ current_group.append(i)
50
+ if current_group:
51
+ groups.append((current_label, current_group))
52
+ return groups
53
+
54
+
6
55
  def _issue(i: dict) -> dict:
7
56
  state = (i.get("state") or "OPEN").lower()
8
57
  return {
@@ -13,11 +62,14 @@ def _issue(i: dict) -> dict:
13
62
  "milestone": short_milestone(i.get("milestone")) or None,
14
63
  }
15
64
 
65
+
16
66
  def build_export(tracks, issues_by_track, visibility, now: str,
17
67
  untracked_by_repo=None) -> dict:
18
68
  out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
19
69
  for t in tracks:
20
70
  issues = [_issue(i) for i in issues_by_track.get(t.name, [])]
71
+ milestone_alignment = t.meta.get("milestone_alignment")
72
+ issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
21
73
  opened = sum(1 for i in issues if i["state"] == "open")
22
74
  closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
23
75
  next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
@@ -27,10 +79,11 @@ def build_export(tracks, issues_by_track, visibility, now: str,
27
79
  "tier": getattr(t, "tier", "private") or "private",
28
80
  "status": t.meta.get("status"),
29
81
  "launch_priority": t.meta.get("launch_priority"),
30
- "milestone_alignment": t.meta.get("milestone_alignment"),
82
+ "milestone_alignment": milestone_alignment,
31
83
  "visibility": visibility.get(t.repo),
32
84
  "blockers": list(t.meta.get("blockers") or []),
33
85
  "next_up": next_up,
86
+ "depends_on": list(t.meta.get("depends_on") or []),
34
87
  "rollup": {"open": opened, "closed": len(issues) - opened},
35
88
  "issues": issues,
36
89
  })