@stylusnexus/work-plan 2026.6.9-2 → 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+530f7e8
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-2",
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()
@@ -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
  })
@@ -37,15 +37,23 @@ def fetch_issue(repo: str, number: int) -> Optional[dict]:
37
37
 
38
38
 
39
39
  def fetch_issues(repo: str, issue_numbers: Iterable[int]) -> list[dict]:
40
- """Fetch state of multiple issues via gh (sequential). Unchanged semantics."""
40
+ """Fetch state of multiple issues via batched GraphQL (full field set).
41
+ Falls back to per-issue `gh issue view` for any numbers the GraphQL query
42
+ didn't return (preserves existing behaviour for transient failures).
43
+ Returns a list in the same order as `issue_numbers` (skips not-found)."""
41
44
  nums = list(issue_numbers)
42
45
  if not nums:
43
46
  return []
47
+ # Fast path: batched GraphQL with full field set
48
+ gql_results = fetch_repo_issues_graphql(repo, nums, fields=_GQL_FIELDS_FULL)
49
+ # Fall back to per-issue fetch for anything GraphQL missed
44
50
  results = []
45
51
  for num in nums:
46
- result = fetch_issue(repo, num)
47
- if result is not None:
48
- results.append(result)
52
+ issue = gql_results.get(num)
53
+ if issue is None:
54
+ issue = fetch_issue(repo, num)
55
+ if issue is not None:
56
+ results.append(issue)
49
57
  return results
50
58
 
51
59
 
@@ -72,11 +80,15 @@ def fetch_issues_concurrent(jobs: Iterable[tuple], max_workers: int = MAX_FETCH_
72
80
 
73
81
 
74
82
  def _normalize_gql_node(node) -> Optional[dict]:
75
- """Reshape a GraphQL issueOrPullRequest node into the REST-ish shape export_model
76
- expects (assignees as [{login}], milestone as {title}|None). None for a null node.
77
- On success returns a dict with keys: number, title, state, assignees, milestone."""
83
+ """Reshape a GraphQL issueOrPullRequest node into the REST-ish shape callers
84
+ expect (labels as [{name}], assignees as [{login}], milestone as {title}|None).
85
+ None for a null node.
86
+ On success returns a dict with keys: number, title, state, labels, milestone,
87
+ closedAt, body, url, updatedAt, assignees."""
78
88
  if not node:
79
89
  return None
90
+ labels = [{"name": l.get("name")} for l in
91
+ ((node.get("labels") or {}).get("nodes") or []) if l.get("name")]
80
92
  assignees = [{"login": a.get("login")} for a in
81
93
  ((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
82
94
  ms = node.get("milestone")
@@ -84,13 +96,38 @@ def _normalize_gql_node(node) -> Optional[dict]:
84
96
  "number": node.get("number"),
85
97
  "title": node.get("title", ""),
86
98
  "state": node.get("state", "OPEN"),
87
- "assignees": assignees,
99
+ "labels": labels,
88
100
  "milestone": {"title": ms["title"]} if ms and ms.get("title") else None,
101
+ "closedAt": node.get("closedAt"),
102
+ "body": node.get("body", ""),
103
+ "url": node.get("url", ""),
104
+ "updatedAt": node.get("updatedAt"),
105
+ "assignees": assignees,
89
106
  }
90
107
 
91
108
 
92
- def _gql_query(owner: str, name: str, numbers: list) -> str:
93
- fields = ("number title state assignees(first: 10) { nodes { login } } milestone { title }")
109
+ # Shared GQL field set used by both export (lean) and fetch_issues (full).
110
+ # Kept as a module-level constant so _gql_query can parameterize at the call site.
111
+ _GQL_FIELDS_FULL = (
112
+ "number title state"
113
+ " labels(first: 20) { nodes { name } }"
114
+ " milestone { title }"
115
+ " closedAt body url updatedAt"
116
+ " assignees(first: 10) { nodes { login } }"
117
+ )
118
+
119
+ _GQL_FIELDS_LEAN = (
120
+ "number title state"
121
+ " assignees(first: 10) { nodes { login } }"
122
+ " milestone { title }"
123
+ )
124
+
125
+
126
+ def _gql_query(owner: str, name: str, numbers: list,
127
+ fields: str = _GQL_FIELDS_LEAN) -> str:
128
+ """Build a batched GraphQL query for issueOrPullRequest nodes.
129
+ `fields` selects the GQL field set; _GQL_FIELDS_LEAN for export, _GQL_FIELDS_FULL
130
+ for fetch_issues (which needs labels, closedAt, body, url, updatedAt)."""
94
131
  aliases = "\n".join(
95
132
  f' i{n}: issueOrPullRequest(number: {int(n)}) {{ '
96
133
  f'... on Issue {{ {fields} }} ... on PullRequest {{ {fields} }} }}'
@@ -100,10 +137,14 @@ def _gql_query(owner: str, name: str, numbers: list) -> str:
100
137
 
101
138
 
102
139
  def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
103
- max_workers: int = MAX_FETCH_WORKERS) -> dict:
140
+ max_workers: int = MAX_FETCH_WORKERS,
141
+ fields: str = _GQL_FIELDS_LEAN) -> dict:
104
142
  """Fetch exactly `numbers` from `repo` via batched GraphQL (issueOrPullRequest, so
105
143
  PRs are included). Returns {number: normalized_issue} for those found. Never raises;
106
- missing/null/errored numbers are simply omitted (caller may fall back per-issue)."""
144
+ missing/null/errored numbers are simply omitted (caller may fall back per-issue).
145
+
146
+ `fields` selects the GQL field set; _GQL_FIELDS_LEAN (default) for export,
147
+ _GQL_FIELDS_FULL for fetch_issues (which needs labels, closedAt, body, url)."""
107
148
  try:
108
149
  nums = list(dict.fromkeys(int(n) for n in numbers))
109
150
  except (ValueError, TypeError):
@@ -116,7 +157,7 @@ def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
116
157
  def _run(batch):
117
158
  try:
118
159
  proc = subprocess.run(
119
- ["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch)],
160
+ ["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch, fields=fields)],
120
161
  capture_output=True, text=True,
121
162
  )
122
163
  except Exception:
@@ -170,6 +170,33 @@ class AutoTriagePrepareTest(unittest.TestCase):
170
170
  self.assertEqual(len(stored["tracks"]), 1)
171
171
  self.assertEqual(stored["tracks"][0]["slug"], "auth-flow")
172
172
 
173
+ def test_limit_truncates_with_more_issues(self):
174
+ """When untracked count exceeds --limit, show first N + truncation hint."""
175
+ cfg = _make_cfg()
176
+ tracks = [_make_track("auth-flow", "org/myrepo", [])]
177
+ issues = _open_issues(*range(1, 110)) # 109 untracked
178
+ rc, out, _ = _drive_prepare(["--limit=10"], cfg=cfg, tracks=tracks,
179
+ open_issues=issues)
180
+ self.assertEqual(rc, 0)
181
+ self.assertIn("Issue 1", out)
182
+ self.assertIn("Issue 10", out)
183
+ self.assertNotIn("Issue 11", out)
184
+ self.assertIn("and 99 more", out)
185
+ self.assertIn("--limit", out)
186
+
187
+ def test_limit_at_or_below_count_shows_all(self):
188
+ """When untracked count is within --limit, show all with no truncation."""
189
+ cfg = _make_cfg()
190
+ tracks = [_make_track("auth-flow", "org/myrepo", [])]
191
+ issues = _open_issues(1, 2, 3)
192
+ rc, out, _ = _drive_prepare([], cfg=cfg, tracks=tracks,
193
+ open_issues=issues)
194
+ self.assertEqual(rc, 0)
195
+ self.assertIn("Issue 1", out)
196
+ self.assertIn("Issue 2", out)
197
+ self.assertIn("Issue 3", out)
198
+ self.assertNotIn("more issues", out)
199
+
173
200
 
174
201
  # ---------------------------------------------------------------------------
175
202
  # Apply step tests
@@ -6,10 +6,11 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_R
6
6
  from lib.export_model import build_export
7
7
  import commands.export as export_cmd
8
8
 
9
- def _track(name, repo, issues, blockers=None, next_up=None, status="active"):
9
+ def _track(name, repo, issues, blockers=None, next_up=None, status="active", depends_on=None):
10
10
  return SimpleNamespace(name=name, repo=repo, tier="private",
11
11
  meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
12
12
  "blockers": blockers or [], "next_up": next_up or [],
13
+ "depends_on": depends_on or [],
13
14
  "github": {"repo": repo, "issues": issues}})
14
15
 
15
16
  class BuildExportTest(unittest.TestCase):
@@ -167,3 +168,127 @@ class BuildExportTierFieldTest(unittest.TestCase):
167
168
  class ExportCommandGateTest(unittest.TestCase):
168
169
  def test_requires_json_flag(self):
169
170
  self.assertEqual(export_cmd.run([]), 2)
171
+
172
+
173
+ class MilestoneSortKeyTest(unittest.TestCase):
174
+ """Tests for milestone_sort_key — the sort-order function."""
175
+
176
+ def test_active_milestone_first(self):
177
+ from lib.export_model import milestone_sort_key
178
+ active = {"number": 10, "milestone": "v1"}
179
+ future = {"number": 20, "milestone": "v2"}
180
+ # active milestone (matches alignment) should sort before future
181
+ self.assertLess(
182
+ milestone_sort_key(active, milestone_alignment="v1"),
183
+ milestone_sort_key(future, milestone_alignment="v1"),
184
+ )
185
+
186
+ def test_future_before_null(self):
187
+ from lib.export_model import milestone_sort_key
188
+ future = {"number": 10, "milestone": "v2"}
189
+ null_ms = {"number": 99, "milestone": None}
190
+ self.assertLess(
191
+ milestone_sort_key(future, milestone_alignment="v1"),
192
+ milestone_sort_key(null_ms, milestone_alignment="v1"),
193
+ )
194
+
195
+ def test_null_last(self):
196
+ from lib.export_model import milestone_sort_key
197
+ null_ms = {"number": 10, "milestone": None}
198
+ active = {"number": 20, "milestone": "v1"}
199
+ self.assertLess(
200
+ milestone_sort_key(active, milestone_alignment="v1"),
201
+ milestone_sort_key(null_ms, milestone_alignment="v1"),
202
+ )
203
+
204
+ def test_number_tiebreak_within_group(self):
205
+ from lib.export_model import milestone_sort_key
206
+ a = {"number": 10, "milestone": "v1"}
207
+ b = {"number": 5, "milestone": "v1"}
208
+ # Both match alignment → tier 0; lower number sorts first
209
+ self.assertLess(
210
+ milestone_sort_key(b, milestone_alignment="v1"),
211
+ milestone_sort_key(a, milestone_alignment="v1"),
212
+ )
213
+
214
+ def test_empty_string_milestone_treated_as_null(self):
215
+ from lib.export_model import milestone_sort_key
216
+ empty = {"number": 1, "milestone": ""}
217
+ null_ms = {"number": 2, "milestone": None}
218
+ # Both should be in tier 2
219
+ k1 = milestone_sort_key(empty, milestone_alignment="v1")
220
+ k2 = milestone_sort_key(null_ms, milestone_alignment="v1")
221
+ self.assertEqual(k1[0], 2) # tier
222
+ self.assertEqual(k2[0], 2)
223
+
224
+
225
+ class GroupIssuesByMilestoneTest(unittest.TestCase):
226
+ """Tests for group_issues_by_milestone."""
227
+
228
+ def test_single_group_returns_one_entry(self):
229
+ from lib.export_model import group_issues_by_milestone
230
+ issues = [
231
+ {"number": 1, "milestone": "v1"},
232
+ {"number": 2, "milestone": "v1"},
233
+ ]
234
+ groups = group_issues_by_milestone(issues, milestone_alignment="v1")
235
+ self.assertEqual(len(groups), 1)
236
+ label, items = groups[0]
237
+ self.assertEqual(label, "v1")
238
+ self.assertEqual([i["number"] for i in items], [1, 2])
239
+
240
+ def test_all_null_returns_single_group(self):
241
+ from lib.export_model import group_issues_by_milestone
242
+ issues = [
243
+ {"number": 2, "milestone": None},
244
+ {"number": 1, "milestone": None},
245
+ ]
246
+ groups = group_issues_by_milestone(issues, milestone_alignment="v1")
247
+ self.assertEqual(len(groups), 1)
248
+ label, items = groups[0]
249
+ self.assertIsNone(label)
250
+ # Sorted by number within the null group
251
+ self.assertEqual([i["number"] for i in items], [1, 2])
252
+
253
+ def test_multi_group_active_first(self):
254
+ from lib.export_model import group_issues_by_milestone
255
+ issues = [
256
+ {"number": 30, "milestone": None},
257
+ {"number": 20, "milestone": "v2"},
258
+ {"number": 10, "milestone": "v1"},
259
+ ]
260
+ groups = group_issues_by_milestone(issues, milestone_alignment="v1")
261
+ self.assertEqual(len(groups), 3)
262
+ # Active milestone (v1) first
263
+ self.assertEqual(groups[0][0], "v1")
264
+ self.assertEqual([i["number"] for i in groups[0][1]], [10])
265
+ # Future (v2) second
266
+ self.assertEqual(groups[1][0], "v2")
267
+ self.assertEqual([i["number"] for i in groups[1][1]], [20])
268
+ # Null last
269
+ self.assertIsNone(groups[2][0])
270
+ self.assertEqual([i["number"] for i in groups[2][1]], [30])
271
+
272
+ def test_empty_issues_returns_empty(self):
273
+ from lib.export_model import group_issues_by_milestone
274
+ self.assertEqual(group_issues_by_milestone([]), [])
275
+
276
+
277
+ class BuildExportDependsOnTest(unittest.TestCase):
278
+ """Tests that depends_on is surfaced in the export JSON (#102)."""
279
+
280
+ def test_depends_on_exported(self):
281
+ tracks = [_track("alpha", "o/r", [1], depends_on=["beta", "gamma"])]
282
+ issues_by_track = {"alpha": [
283
+ {"number": 1, "title": "a", "state": "OPEN", "assignees": []},
284
+ ]}
285
+ out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
286
+ self.assertEqual(out["tracks"][0]["depends_on"], ["beta", "gamma"])
287
+
288
+ def test_depends_on_empty_by_default(self):
289
+ tracks = [_track("alpha", "o/r", [1])]
290
+ issues_by_track = {"alpha": [
291
+ {"number": 1, "title": "a", "state": "OPEN", "assignees": []},
292
+ ]}
293
+ out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
294
+ self.assertEqual(out["tracks"][0]["depends_on"], [])
@@ -72,12 +72,12 @@ class ExportRunJsonTest(unittest.TestCase):
72
72
  self.assertEqual(out["schema"], 1)
73
73
 
74
74
  def test_track_issues_assembled_in_declared_order(self):
75
- # Track declares [2, 1] output order must match declaration, not map-insertion order
75
+ # Issues are milestone-sorted (#101): null-milestone group sorts by number.
76
76
  tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
77
77
  rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
78
78
  self.assertEqual(rc, 0)
79
79
  issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
80
- self.assertEqual(issue_nums, [2, 1])
80
+ self.assertEqual(issue_nums, [1, 2])
81
81
 
82
82
  def test_shared_issue_appears_in_both_tracks(self):
83
83
  tracks = [
@@ -207,28 +207,66 @@ class FetchIssuesConcurrentTest(unittest.TestCase):
207
207
 
208
208
 
209
209
  class FetchIssuesAfterRefactorTest(unittest.TestCase):
210
- """Verify fetch_issues still returns the sequential list after refactor."""
210
+ """Verify fetch_issues uses batched GraphQL + per-issue fallback."""
211
211
 
212
- @patch("lib.github_state.subprocess.run")
213
- def test_returns_list_in_order(self, mock_run):
214
- responses = [
215
- MagicMock(returncode=0, stdout='{"number": 10, "state": "OPEN", "labels": [], "title": "a"}'),
216
- MagicMock(returncode=0, stdout='{"number": 20, "state": "CLOSED", "labels": [], "title": "b"}'),
212
+ @patch("lib.github_state.fetch_repo_issues_graphql")
213
+ @patch("lib.github_state.fetch_issue")
214
+ def test_gql_returns_all_no_fallback(self, mock_fetch_issue, mock_gql):
215
+ """When GraphQL returns every number, no fallback calls needed."""
216
+ mock_gql.return_value = {
217
+ 10: {"number": 10, "state": "OPEN", "labels": [], "title": "a"},
218
+ 20: {"number": 20, "state": "CLOSED", "labels": [], "title": "b"},
219
+ }
220
+ result = fetch_issues("org/repo", [10, 20])
221
+ self.assertEqual(len(result), 2)
222
+ self.assertEqual(result[0]["number"], 10)
223
+ self.assertEqual(result[1]["number"], 20)
224
+ mock_fetch_issue.assert_not_called()
225
+
226
+ @patch("lib.github_state.fetch_repo_issues_graphql")
227
+ @patch("lib.github_state.fetch_issue")
228
+ def test_gql_partial_falls_back(self, mock_fetch_issue, mock_gql):
229
+ """Numbers missing from GraphQL → per-issue fallback for each."""
230
+ mock_gql.return_value = {
231
+ 10: {"number": 10, "state": "OPEN", "labels": []},
232
+ }
233
+ mock_fetch_issue.side_effect = [
234
+ None, # 20 not found via fallback
235
+ {"number": 30, "state": "OPEN", "labels": []},
236
+ ]
237
+ result = fetch_issues("org/repo", [10, 20, 30])
238
+ # 10 from GQL, 20 fallback returns None (no side_effect entry), 30 from fallback
239
+ self.assertEqual(len(result), 2)
240
+ self.assertEqual(result[0]["number"], 10)
241
+ self.assertEqual(result[1]["number"], 30)
242
+ # fetch_issue called for 20 and 30 (only numbers not in GQL)
243
+ self.assertEqual(mock_fetch_issue.call_count, 2)
244
+
245
+ @patch("lib.github_state.fetch_repo_issues_graphql")
246
+ @patch("lib.github_state.fetch_issue")
247
+ def test_gql_empty_falls_back_completely(self, mock_fetch_issue, mock_gql):
248
+ """Empty GraphQL result → all numbers go to per-issue fallback."""
249
+ mock_gql.return_value = {}
250
+ mock_fetch_issue.side_effect = [
251
+ {"number": 10, "state": "OPEN", "labels": []},
252
+ {"number": 20, "state": "CLOSED", "labels": []},
217
253
  ]
218
- mock_run.side_effect = responses
219
254
  result = fetch_issues("org/repo", [10, 20])
220
255
  self.assertEqual(len(result), 2)
221
256
  self.assertEqual(result[0]["number"], 10)
222
257
  self.assertEqual(result[1]["number"], 20)
258
+ self.assertEqual(mock_fetch_issue.call_count, 2)
223
259
 
224
- @patch("lib.github_state.subprocess.run")
225
- def test_skips_failed_fetches(self, mock_run):
226
- responses = [
227
- MagicMock(returncode=0, stdout='{"number": 10, "state": "OPEN", "labels": []}'),
228
- MagicMock(returncode=1, stdout="", stderr="not found"),
229
- MagicMock(returncode=0, stdout='{"number": 30, "state": "OPEN", "labels": []}'),
260
+ @patch("lib.github_state.fetch_repo_issues_graphql")
261
+ @patch("lib.github_state.fetch_issue")
262
+ def test_skips_failed_fallbacks(self, mock_fetch_issue, mock_gql):
263
+ """Per-issue fallback returning None → skipped silently."""
264
+ mock_gql.return_value = {}
265
+ mock_fetch_issue.side_effect = [
266
+ {"number": 10, "state": "OPEN", "labels": []},
267
+ None, # number 20 failed
268
+ {"number": 30, "state": "OPEN", "labels": []},
230
269
  ]
231
- mock_run.side_effect = responses
232
270
  result = fetch_issues("org/repo", [10, 20, 30])
233
271
  self.assertEqual(len(result), 2)
234
272
  self.assertEqual(result[0]["number"], 10)
@@ -343,6 +343,69 @@ class GroupApplyTierRoutingTest(unittest.TestCase):
343
343
  stored = json.loads(batch_file.read_text())
344
344
  self.assertFalse(stored.get("private"))
345
345
 
346
+ def test_limit_truncates_issue_display(self):
347
+ """--limit truncates displayed issues in the AI prompt with remaining count."""
348
+ with tempfile.TemporaryDirectory() as tmpdir:
349
+ notes_root = Path(tmpdir) / "notes"
350
+ notes_root.mkdir()
351
+ cfg = _make_cfg(notes_root=str(notes_root))
352
+ batch_file = Path(tmpdir) / "groups.json"
353
+
354
+ issues = [
355
+ {"number": i, "title": f"Issue {i}", "milestone": None,
356
+ "labels": [], "assignees": [], "state": "OPEN"}
357
+ for i in range(1, 25)
358
+ ]
359
+
360
+ with patch("commands.group.load_config", return_value=cfg), \
361
+ patch("commands.group._batch_path", return_value=batch_file), \
362
+ patch("commands.group._answers_path",
363
+ return_value=Path(tmpdir) / "groups.answers.json"), \
364
+ patch("subprocess.run") as mock_run:
365
+ mock_run.return_value = MagicMock(
366
+ returncode=0, stdout=json.dumps(issues), stderr=""
367
+ )
368
+ buf = io.StringIO()
369
+ with redirect_stdout(buf):
370
+ rc = group.run(["--repo=myrepo", "--limit=10"])
371
+
372
+ self.assertEqual(rc, 0)
373
+ out = buf.getvalue()
374
+ self.assertIn("Issue 1", out)
375
+ self.assertIn("Issue 10", out)
376
+ self.assertNotIn("Issue 11", out)
377
+ self.assertIn("and 14 more", out)
378
+ self.assertIn("--limit", out)
379
+
380
+ def test_limit_at_or_below_count_shows_all_no_truncation(self):
381
+ """When issue count is within --limit, no truncation message appears."""
382
+ with tempfile.TemporaryDirectory() as tmpdir:
383
+ notes_root = Path(tmpdir) / "notes"
384
+ notes_root.mkdir()
385
+ cfg = _make_cfg(notes_root=str(notes_root))
386
+ batch_file = Path(tmpdir) / "groups.json"
387
+
388
+ issues = [
389
+ {"number": 1, "title": "Issue 1", "milestone": None,
390
+ "labels": [], "assignees": [], "state": "OPEN"},
391
+ ]
392
+
393
+ with patch("commands.group.load_config", return_value=cfg), \
394
+ patch("commands.group._batch_path", return_value=batch_file), \
395
+ patch("commands.group._answers_path",
396
+ return_value=Path(tmpdir) / "groups.answers.json"), \
397
+ patch("subprocess.run") as mock_run:
398
+ mock_run.return_value = MagicMock(
399
+ returncode=0, stdout=json.dumps(issues), stderr=""
400
+ )
401
+ buf = io.StringIO()
402
+ with redirect_stdout(buf):
403
+ rc = group.run(["--repo=myrepo"])
404
+
405
+ self.assertEqual(rc, 0)
406
+ out = buf.getvalue()
407
+ self.assertNotIn("more issues", out)
408
+
346
409
 
347
410
  if __name__ == "__main__":
348
411
  unittest.main()
@@ -308,7 +308,7 @@ class NewTrackCommandTest(unittest.TestCase):
308
308
  self.assertEqual(rc, 0)
309
309
  meta = mw.call_args[0][1]
310
310
  for key in ("track", "status", "launch_priority", "milestone_alignment",
311
- "github", "related_tracks", "last_touched", "last_handoff",
311
+ "github", "depends_on", "last_touched", "last_handoff",
312
312
  "next_up", "blockers"):
313
313
  self.assertIn(key, meta, f"meta missing key: {key}")
314
314
 
@@ -320,12 +320,12 @@ class NewTrackCommandTest(unittest.TestCase):
320
320
  self.assertEqual(meta["github"]["issues"], [])
321
321
  self.assertEqual(meta["github"]["branches"], [])
322
322
 
323
- def test_meta_related_tracks_next_up_blockers_empty(self):
324
- """New track starts with empty related_tracks, next_up, blockers."""
323
+ def test_meta_depends_on_next_up_blockers_empty(self):
324
+ """New track starts with empty depends_on, next_up, blockers."""
325
325
  rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
326
326
  self.assertEqual(rc, 0)
327
327
  meta = mw.call_args[0][1]
328
- self.assertEqual(meta["related_tracks"], [])
328
+ self.assertEqual(meta["depends_on"], [])
329
329
  self.assertEqual(meta["next_up"], [])
330
330
  self.assertEqual(meta["blockers"], [])
331
331
 
@@ -378,5 +378,140 @@ class WhereWasINoSessionLogCase(unittest.TestCase):
378
378
  self.assertIn("Last session: (none yet)", out)
379
379
 
380
380
 
381
+ class OrientRepoFlagTest(unittest.TestCase):
382
+ """orient command --repo=<key> and track@repo disambiguation."""
383
+
384
+ def setUp(self):
385
+ self.tmp = tempfile.TemporaryDirectory()
386
+ self.notes_root = Path(self.tmp.name) / "notes_root"
387
+ self.notes_root.mkdir(parents=True)
388
+ # Create two tracks with the same slug in different repos
389
+ for folder in ("repo-a", "repo-b"):
390
+ repo_dir = self.notes_root / folder
391
+ repo_dir.mkdir(parents=True)
392
+ _make_track_file(repo_dir, slug="feat-x")
393
+
394
+ self.cfg = {
395
+ "notes_root": str(self.notes_root),
396
+ "repos": {
397
+ "repo-a": {"github": "org/repo-a"},
398
+ "repo-b": {"github": "org/repo-b"},
399
+ },
400
+ }
401
+
402
+ def tearDown(self):
403
+ self.tmp.cleanup()
404
+
405
+ def _drive(self, args, *, find_result=None):
406
+ """Drive orient.run() with load_config mocked. If find_result is None
407
+ (the normal case), discover_tracks runs for real against tmp files.
408
+ When find_result is an Exception, we mock find_track_by_name."""
409
+ patches = [
410
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
411
+ mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
412
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks",
413
+ return_value={}),
414
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
415
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
416
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
417
+ ]
418
+ if find_result is not None:
419
+ patches.append(
420
+ mock.patch("commands.where_was_i.find_track_by_name",
421
+ side_effect=find_result
422
+ if isinstance(find_result, Exception)
423
+ else None,
424
+ return_value=find_result
425
+ if not isinstance(find_result, Exception)
426
+ else None)
427
+ )
428
+
429
+ for p in patches:
430
+ p.start()
431
+
432
+ try:
433
+ buf = io.StringIO()
434
+ with redirect_stdout(buf):
435
+ rc = where_was_i.run(args)
436
+ return rc, buf.getvalue()
437
+ finally:
438
+ for p in patches:
439
+ p.stop()
440
+
441
+ def test_repo_flag_passed_to_find_track(self):
442
+ """--repo=<key> is passed as repo= kwarg to find_track_by_name."""
443
+ find_mock = mock.MagicMock()
444
+ find_mock.return_value = None # We just care about how it was called
445
+
446
+ patches = [
447
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
448
+ mock.patch("commands.where_was_i.find_track_by_name", find_mock),
449
+ mock.patch("commands.where_was_i.discover_tracks", return_value=[]),
450
+ mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
451
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks",
452
+ return_value={}),
453
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
454
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
455
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
456
+ ]
457
+ for p in patches:
458
+ p.start()
459
+ try:
460
+ buf = io.StringIO()
461
+ with redirect_stdout(buf):
462
+ where_was_i.run(["feat-x", "--repo=repo-a"])
463
+ finally:
464
+ for p in patches:
465
+ p.stop()
466
+
467
+ call_kwargs = find_mock.call_args.kwargs
468
+ self.assertEqual(call_kwargs.get("repo"), "repo-a")
469
+
470
+ def test_at_syntax_passed_to_find_track(self):
471
+ """feat-x@repo-a positional → repo='repo-a' passed to find_track_by_name."""
472
+ find_mock = mock.MagicMock()
473
+ find_mock.return_value = None
474
+
475
+ patches = [
476
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
477
+ mock.patch("commands.where_was_i.find_track_by_name", find_mock),
478
+ mock.patch("commands.where_was_i.discover_tracks", return_value=[]),
479
+ mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
480
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks",
481
+ return_value={}),
482
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
483
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
484
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
485
+ ]
486
+ for p in patches:
487
+ p.start()
488
+ try:
489
+ buf = io.StringIO()
490
+ with redirect_stdout(buf):
491
+ where_was_i.run(["feat-x@repo-a"])
492
+ finally:
493
+ for p in patches:
494
+ p.stop()
495
+
496
+ call_kwargs = find_mock.call_args.kwargs
497
+ self.assertEqual(call_kwargs.get("repo"), "repo-a")
498
+
499
+ def test_ambiguous_error_returns_rc1(self):
500
+ """AmbiguousTrackError → prints message, returns 1."""
501
+ from lib.tracks import Track, AmbiguousTrackError
502
+
503
+ t1 = Track(path=Path("/tmp/fake/repo-a/feat-x.md"), name="feat-x",
504
+ has_frontmatter=True, needs_init=False, needs_filing=False,
505
+ repo="org/a", folder="repo-a", meta={"track": "feat-x", "status": "active"})
506
+ t2 = Track(path=Path("/tmp/fake/repo-b/feat-x.md"), name="feat-x",
507
+ has_frontmatter=True, needs_init=False, needs_filing=False,
508
+ repo="org/b", folder="repo-b", meta={"track": "feat-x", "status": "active"})
509
+ err = AmbiguousTrackError("feat-x", [t1, t2])
510
+
511
+ rc, out = self._drive(["feat-x"], find_result=err)
512
+ self.assertEqual(rc, 1)
513
+ self.assertIn("ambiguous", out.lower())
514
+
515
+
381
516
  if __name__ == "__main__":
382
517
  unittest.main()
@@ -32,6 +32,7 @@ SUBCOMMANDS = {
32
32
  "--orient": "commands.where_was_i", # flag-style alias
33
33
  "slot": "commands.slot",
34
34
  "batch-slot": "commands.batch_slot",
35
+ "move": "commands.move",
35
36
  "close": "commands.close",
36
37
  "refresh-md": "commands.refresh_md",
37
38
  "list": "commands.list_cmd",
@@ -65,8 +66,8 @@ DESCRIPTIONS = [
65
66
  "Wrap up a session: capture touched/next/blockers, update body status table. Use --set-next to set the next_up list explicitly. Use --auto-next to suggest a priority-sorted list from open issues (interactive: apply / edit / skip).",
66
67
  "Ending a work block — before stepping away, going to bed, or switching tracks. Use --auto-next when you don't want to hand-pick issue numbers.",
67
68
  "/work-plan handoff tabletop --auto-next"),
68
- ("where-was-i", "[track] [--pick]",
69
- "Re-orient. With a track name: track paste-block. With no args: cwd snapshot (branch, recent commits, modified files). Add --pick to force the interactive track picker.",
69
+ ("where-was-i", "[track | track@repo] [--pick] [--repo=<key>]",
70
+ "Re-orient. With a track name: track paste-block. With no args: cwd snapshot (branch, recent commits, modified files). Add --pick to force the interactive track picker. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
70
71
  "Switching to a fresh Claude Code session — either on a known track or in a directory that doesn't yet belong to one.",
71
72
  "/work-plan where-was-i ux-redesign (or just `/work-plan orient` for cwd snapshot)"),
72
73
  ("slot", "<issue-num> [track | track@repo] [--repo=<key>]",
@@ -77,6 +78,10 @@ DESCRIPTIONS = [
77
78
  "Slot multiple GitHub issues into a track at once. The last positional argument is the track; everything before it is an issue number. Skips issues already in the track. Use --move to remove issues from any prior owning tracks.",
78
79
  "After bulk-triage with auto-triage or group — when several issues need the same track assignment.",
79
80
  "/work-plan batch-slot 100 101 102 tabletop --move"),
81
+ ("move", "<issue-num> <from-track> <to-track> [--repo=<key>]",
82
+ "Move an issue from one track to another (remove from source frontmatter, add to destination). Source-first — the verb is the intent. Both tracks must be active and in the same repo.",
83
+ "When an issue belongs in a different track than where it currently sits — cleaner than slot --move.",
84
+ "/work-plan move 4234 platform-health org-sharing"),
80
85
  ("close", "<track | track@repo> [--repo=<key>]",
81
86
  "Retire a track: shipped / parked / abandoned. Moves to archive/. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
82
87
  "When a track is done, paused, or won't ship — frees mental space.",
@@ -101,12 +106,12 @@ DESCRIPTIONS = [
101
106
  "AI-assisted batch backfill of priority/PN labels.",
102
107
  "ONE-TIME setup, or whenever a wave of new unlabeled issues piles up.",
103
108
  "/work-plan suggest-priorities --repo=myproject"),
104
- ("group", "[--milestone=X] [--label=Y] [--repo=Z] [--apply]",
105
- "AI-cluster GitHub issues into thematic track files.",
109
+ ("group", "[--milestone=X] [--label=Y] [--repo=Z] [--apply] [--limit=N]",
110
+ "AI-cluster GitHub issues into thematic track files. --limit controls how many issues are shown in the prompt (default 100).",
106
111
  "ONE-TIME bulk organization of an unsorted milestone, or after a re-org.",
107
112
  "/work-plan group --milestone='v1.0.0 — Public Launch'"),
108
- ("auto-triage", "[--repo=<key>] [--apply]",
109
- "AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist.",
113
+ ("auto-triage", "[--repo=<key>] [--apply] [--limit=N]",
114
+ "AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist. --limit controls how many untracked issues are shown (default 100).",
110
115
  "Periodically — when new issues have piled up outside the track model. Run /work-plan coverage first to confirm there's a gap worth triaging.",
111
116
  "/work-plan auto-triage --repo=critforge"),
112
117
  ("reconcile", "<track> | --all | --repo=<key> [--draft]",