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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.09+f7e5ff5
1
+ 2026.06.09+530f7e8
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-2",
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"
@@ -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
@@ -117,21 +117,45 @@ def find_track_by_name(
117
117
 
118
118
  def discover_archived_tracks(cfg: dict) -> list[Track]:
119
119
  """Walk notes_root for archived .md files, and also scan each repo's
120
- .work-plan/archive/ for shared archived tracks."""
120
+ .work-plan/archive/ for shared archived tracks.
121
+
122
+ Deduplicates by (repo, name): shared wins over private, same as
123
+ discover_tracks for active tracks.
124
+ """
121
125
  notes_root = Path(cfg["notes_root"]).expanduser()
122
- out = []
126
+ private_archived: list[Track] = []
123
127
  if notes_root.exists():
124
128
  for md_path in sorted(notes_root.rglob("*.md")):
125
129
  if "archive" not in md_path.parts:
126
130
  continue
127
131
  if md_path.name.startswith((".", "_")):
128
132
  continue
129
- out.append(_build_track(md_path, notes_root, cfg))
133
+ private_archived.append(_build_track(md_path, notes_root, cfg))
130
134
 
131
- # Also scan shared repos' .work-plan/archive/
132
- out.extend(_discover_shared_tracks(cfg, include_archive=True,
133
- archive_only=True))
134
- return out
135
+ shared_archived = _discover_shared_tracks(cfg, include_archive=True,
136
+ archive_only=True)
137
+
138
+ # Build lookup for shared tracks keyed by (repo, name)
139
+ shared_keys: dict = {}
140
+ for t in shared_archived:
141
+ key = (t.repo, t.name)
142
+ shared_keys[key] = t
143
+
144
+ # Merge: shared wins on collision
145
+ merged = list(shared_archived)
146
+ for t in private_archived:
147
+ key = (t.repo, t.name)
148
+ if key in shared_keys:
149
+ print(
150
+ f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in"
151
+ f" both shared ({shared_keys[key].path}) and private"
152
+ f" ({t.path}); using shared.",
153
+ file=sys.stderr,
154
+ )
155
+ else:
156
+ merged.append(t)
157
+
158
+ return merged
135
159
 
136
160
 
137
161
  # ---------------------------------------------------------------------------
@@ -0,0 +1,291 @@
1
+ """Tests for the non-interactive batch-slot command (issue #140).
2
+
3
+ Covers:
4
+ - Slots multiple new issues into a private-repo track → write_file called once, rc 0.
5
+ - Some issues already present → skipped with note, others slotted.
6
+ - All issues already present → no write, prints skip message.
7
+ - Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0.
8
+ - Public repo with valid --confirm=<token> → write_file called, rc 0.
9
+ - --move with prior owners → removes issues from sources (consolidated writes).
10
+ - Default / --no-move with prior owners → prior owners NOT modified, note printed.
11
+ - Bad issue number / < 2 positionals → rc 2.
12
+ - Mutually exclusive --move + --no-move → rc 2.
13
+ """
14
+ import io
15
+ import sys
16
+ import unittest
17
+ from contextlib import redirect_stdout
18
+ from pathlib import Path
19
+ from types import SimpleNamespace
20
+ from unittest.mock import MagicMock, patch
21
+
22
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
23
+ sys.path.insert(0, str(SKILL_ROOT))
24
+
25
+ from commands import batch_slot
26
+ from lib.write_guard import make_token
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Helpers
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def _track(*, name, repo="ok/repo", issues=None, status="active"):
34
+ return SimpleNamespace(
35
+ name=name,
36
+ path=Path(f"/tmp/fake/{name}.md"),
37
+ body="# fake",
38
+ meta={
39
+ "track": name,
40
+ "status": status,
41
+ "github": {"repo": repo, "issues": list(issues or [])},
42
+ },
43
+ has_frontmatter=True,
44
+ repo=repo,
45
+ )
46
+
47
+
48
+ def _drive(args, tracks=None, vis="PRIVATE"):
49
+ """Run batch_slot.run(args) with all external I/O mocked."""
50
+ if tracks is None:
51
+ tracks = [_track(name="alpha", repo="ok/repo", issues=[])]
52
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
53
+ gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
54
+
55
+ with patch("commands.batch_slot.load_config", return_value=cfg), \
56
+ patch("commands.batch_slot.discover_tracks", return_value=tracks), \
57
+ patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
58
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
59
+ patch("commands.batch_slot.write_file") as mw:
60
+ buf = io.StringIO()
61
+ with redirect_stdout(buf):
62
+ rc = batch_slot.run(args)
63
+ return rc, mw, buf.getvalue()
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Test cases
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class BatchSlotTest(unittest.TestCase):
71
+
72
+ # ------------------------------------------------------------------
73
+ # Basic batch slot (private repo)
74
+ # ------------------------------------------------------------------
75
+
76
+ def test_slots_multiple_new_issues(self):
77
+ """Slots multiple new issues into a private-repo track → write_file called, rc 0."""
78
+ track = _track(name="alpha", repo="ok/repo", issues=[10])
79
+ rc, mw, out = _drive(["30", "40", "alpha"], tracks=[track], vis="PRIVATE")
80
+ self.assertEqual(rc, 0)
81
+ mw.assert_called_once() # target written once
82
+ written_meta = mw.call_args[0][1]
83
+ self.assertIn(10, written_meta["github"]["issues"])
84
+ self.assertIn(30, written_meta["github"]["issues"])
85
+ self.assertIn(40, written_meta["github"]["issues"])
86
+ self.assertEqual(sorted(written_meta["github"]["issues"]),
87
+ written_meta["github"]["issues"])
88
+
89
+ def test_skips_already_present_issues(self):
90
+ """Some issues already present → skipped with note, others slotted."""
91
+ track = _track(name="alpha", repo="ok/repo", issues=[42])
92
+ rc, mw, out = _drive(["42", "99", "alpha"], tracks=[track], vis="PRIVATE")
93
+ self.assertEqual(rc, 0)
94
+ mw.assert_called_once()
95
+ written_meta = mw.call_args[0][1]
96
+ self.assertIn(42, written_meta["github"]["issues"])
97
+ self.assertIn(99, written_meta["github"]["issues"])
98
+ self.assertIn("Skipped", out)
99
+ self.assertIn("42", out)
100
+ self.assertIn("Slotted", out)
101
+ self.assertIn("99", out)
102
+
103
+ def test_all_already_present_no_write(self):
104
+ """All issues already present → no write, prints skip message."""
105
+ track = _track(name="alpha", repo="ok/repo", issues=[42, 99])
106
+ rc, mw, out = _drive(["42", "99", "alpha"], tracks=[track], vis="PRIVATE")
107
+ self.assertEqual(rc, 0)
108
+ mw.assert_not_called()
109
+ self.assertIn("already in track", out)
110
+
111
+ # ------------------------------------------------------------------
112
+ # Confirm-token gate (public repo)
113
+ # ------------------------------------------------------------------
114
+
115
+ def test_public_repo_no_token_returns_needs_confirm_json(self):
116
+ """Public repo, no token → prints needs_confirm JSON, write_file NOT called."""
117
+ import json
118
+ track = _track(name="alpha", repo="ok/repo", issues=[])
119
+ rc, mw, out = _drive(["99", "100", "alpha"], tracks=[track], vis="PUBLIC")
120
+ self.assertEqual(rc, 0)
121
+ mw.assert_not_called()
122
+ data = json.loads(out.strip())
123
+ self.assertTrue(data["needs_confirm"])
124
+ self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
125
+
126
+ def test_public_repo_with_valid_confirm_performs_write(self):
127
+ """Public repo with valid --confirm=<token> → write_file called."""
128
+ track = _track(name="alpha", repo="ok/repo", issues=[])
129
+ tok = make_token("ok/repo", "alpha")
130
+ rc, mw, out = _drive(
131
+ ["99", "100", "alpha", f"--confirm={tok}"],
132
+ tracks=[track], vis="PUBLIC",
133
+ )
134
+ self.assertEqual(rc, 0)
135
+ mw.assert_called_once()
136
+
137
+ def test_public_repo_with_wrong_token_blocks_write(self):
138
+ """Public repo with wrong confirm token → blocked, no write."""
139
+ import json
140
+ track = _track(name="alpha", repo="ok/repo", issues=[])
141
+ rc, mw, out = _drive(
142
+ ["99", "alpha", "--confirm=badtoken"],
143
+ tracks=[track], vis="PUBLIC",
144
+ )
145
+ self.assertEqual(rc, 0)
146
+ mw.assert_not_called()
147
+ data = json.loads(out.strip())
148
+ self.assertTrue(data["needs_confirm"])
149
+
150
+ # ------------------------------------------------------------------
151
+ # --move / --no-move flags
152
+ # ------------------------------------------------------------------
153
+
154
+ def test_move_removes_issues_from_prior_owners(self):
155
+ """--move with prior owners → removes issues from sources, writes all."""
156
+ source = _track(name="alpha", repo="ok/repo", issues=[42, 77])
157
+ target = _track(name="beta", repo="ok/repo", issues=[])
158
+ rc, mw, out = _drive(
159
+ ["42", "77", "beta", "--move"],
160
+ tracks=[source, target], vis="PRIVATE",
161
+ )
162
+ self.assertEqual(rc, 0)
163
+ # source + target both written
164
+ self.assertEqual(2, mw.call_count)
165
+ self.assertEqual([], source.meta["github"]["issues"])
166
+ self.assertIn(42, target.meta["github"]["issues"])
167
+ self.assertIn(77, target.meta["github"]["issues"])
168
+
169
+ def test_move_consolidates_multi_source_removals(self):
170
+ """Multiple issues from same prior owner → source written once."""
171
+ source = _track(name="alpha", repo="ok/repo", issues=[42, 77])
172
+ target = _track(name="beta", repo="ok/repo", issues=[])
173
+ rc, mw, out = _drive(
174
+ ["42", "77", "beta", "--move"],
175
+ tracks=[source, target], vis="PRIVATE",
176
+ )
177
+ self.assertEqual(rc, 0)
178
+ self.assertEqual(2, mw.call_count) # source + target, NOT 3
179
+ # Issues sorted are maintained
180
+ self.assertEqual(sorted(source.meta["github"]["issues"]),
181
+ source.meta["github"]["issues"])
182
+ self.assertEqual(sorted(target.meta["github"]["issues"]),
183
+ target.meta["github"]["issues"])
184
+
185
+ def test_default_no_move_preserves_prior_owners(self):
186
+ """Default (no --move) → prior owners NOT modified; note printed."""
187
+ source = _track(name="alpha", repo="ok/repo", issues=[42])
188
+ target = _track(name="beta", repo="ok/repo", issues=[])
189
+ rc, mw, out = _drive(
190
+ ["42", "beta"], tracks=[source, target], vis="PRIVATE",
191
+ )
192
+ self.assertEqual(rc, 0)
193
+ mw.assert_called_once() # only target written
194
+ self.assertIn(42, source.meta["github"]["issues"])
195
+ self.assertIn(42, target.meta["github"]["issues"])
196
+ self.assertIn("--move", out)
197
+
198
+ def test_explicit_no_move_preserves_prior_owners(self):
199
+ """Explicit --no-move behaves same as default."""
200
+ source = _track(name="alpha", repo="ok/repo", issues=[42])
201
+ target = _track(name="beta", repo="ok/repo", issues=[])
202
+ rc, mw, out = _drive(
203
+ ["42", "beta", "--no-move"],
204
+ tracks=[source, target], vis="PRIVATE",
205
+ )
206
+ self.assertEqual(rc, 0)
207
+ mw.assert_called_once()
208
+ self.assertIn(42, source.meta["github"]["issues"])
209
+ self.assertIn("--move", out)
210
+
211
+ # ------------------------------------------------------------------
212
+ # Error cases
213
+ # ------------------------------------------------------------------
214
+
215
+ def test_less_than_two_positionals_returns_rc2(self):
216
+ """Fewer than 2 positional args (need at least 1 issue + 1 track) → rc 2."""
217
+ rc, mw, out = _drive(["42"])
218
+ self.assertEqual(rc, 2)
219
+ mw.assert_not_called()
220
+
221
+ def test_no_args_returns_rc2(self):
222
+ """No positional arguments → rc 2."""
223
+ rc, mw, out = _drive([])
224
+ self.assertEqual(rc, 2)
225
+ mw.assert_not_called()
226
+
227
+ def test_bad_issue_number_returns_rc2(self):
228
+ """Non-integer in issue position → rc 2."""
229
+ rc, mw, out = _drive(["notanumber", "alpha"])
230
+ self.assertEqual(rc, 2)
231
+ mw.assert_not_called()
232
+
233
+ def test_move_and_no_move_together_returns_rc2(self):
234
+ """Both --move and --no-move → rc 2."""
235
+ track = _track(name="alpha", repo="ok/repo", issues=[])
236
+ rc, mw, out = _drive(
237
+ ["42", "alpha", "--move", "--no-move"],
238
+ tracks=[track], vis="PRIVATE",
239
+ )
240
+ self.assertEqual(rc, 2)
241
+ mw.assert_not_called()
242
+ self.assertIn("mutually exclusive", out)
243
+
244
+ def test_unknown_track_returns_rc1(self):
245
+ """Track not found → rc 1."""
246
+ rc, mw, out = _drive(["42", "nonexistent"])
247
+ self.assertEqual(rc, 1)
248
+ mw.assert_not_called()
249
+
250
+ # ------------------------------------------------------------------
251
+ # Single issue (degenerate case)
252
+ # ------------------------------------------------------------------
253
+
254
+ def test_single_issue_works_like_slot(self):
255
+ """A single issue in batch-slot behaves like regular slot."""
256
+ track = _track(name="alpha", repo="ok/repo", issues=[10])
257
+ rc, mw, out = _drive(["42", "alpha"], tracks=[track], vis="PRIVATE")
258
+ self.assertEqual(rc, 0)
259
+ mw.assert_called_once()
260
+ written_meta = mw.call_args[0][1]
261
+ self.assertIn(42, written_meta["github"]["issues"])
262
+
263
+ # ------------------------------------------------------------------
264
+ # No input() on non-interactive paths
265
+ # ------------------------------------------------------------------
266
+
267
+ def test_no_input_called_on_flagged_paths(self):
268
+ """Flagged paths (issue + track given) never call input()."""
269
+ source = _track(name="alpha", repo="ok/repo", issues=[42])
270
+ target = _track(name="beta", repo="ok/repo", issues=[])
271
+
272
+ def _raise(*a, **kw):
273
+ raise AssertionError("input() must not be called on non-interactive path")
274
+
275
+ with patch("builtins.input", side_effect=_raise), \
276
+ patch("lib.prompts.prompt_input", side_effect=_raise):
277
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
278
+ gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
279
+ with patch("commands.batch_slot.load_config", return_value=cfg), \
280
+ patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \
281
+ patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
282
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
283
+ patch("commands.batch_slot.write_file"):
284
+ buf = io.StringIO()
285
+ with redirect_stdout(buf):
286
+ rc = batch_slot.run(["42", "beta", "--move"])
287
+ self.assertEqual(rc, 0)
288
+
289
+
290
+ if __name__ == "__main__":
291
+ unittest.main()
@@ -380,6 +380,71 @@ class DiscoverArchivedSharedTest(unittest.TestCase):
380
380
  slugs = [a.meta.get("track") for a in archived]
381
381
  self.assertIn("old", slugs)
382
382
 
383
+ def test_archived_collision_shared_wins(self):
384
+ """When (repo, name) collides in archived tracks, shared wins + warns."""
385
+ with tempfile.TemporaryDirectory() as d:
386
+ base = Path(d)
387
+ clone = _make_git_repo(base / "clone")
388
+ # Shared archived track
389
+ _write_track_md(
390
+ clone / ".work-plan" / "archive" / "shipped.md",
391
+ "shipped", "org/repo", status="shipped",
392
+ )
393
+ # Private archived track with SAME name in notes_root
394
+ notes = base / "notes"
395
+ _write_track_md(
396
+ notes / "repo" / "archive" / "shipped.md",
397
+ "shipped", "org/repo", status="shipped",
398
+ )
399
+ cfg = {
400
+ "notes_root": str(notes),
401
+ "repos": {
402
+ "repo": {"github": "org/repo", "local": str(clone)},
403
+ },
404
+ }
405
+
406
+ stderr_buf = io.StringIO()
407
+ with patch("sys.stderr", stderr_buf):
408
+ archived = discover_archived_tracks(cfg)
409
+
410
+ # Exactly one entry for "shipped"
411
+ shipped = [t for t in archived if t.name == "shipped"]
412
+ self.assertEqual(len(shipped), 1)
413
+ self.assertEqual(shipped[0].tier, "shared")
414
+ # Warning was emitted
415
+ self.assertIn("WARN:", stderr_buf.getvalue())
416
+ self.assertIn("shipped", stderr_buf.getvalue())
417
+
418
+ def test_archived_no_collision_no_warning(self):
419
+ """Different names in shared/private archives → no warning, both returned."""
420
+ with tempfile.TemporaryDirectory() as d:
421
+ base = Path(d)
422
+ clone = _make_git_repo(base / "clone")
423
+ _write_track_md(
424
+ clone / ".work-plan" / "archive" / "shared-archived.md",
425
+ "shared-archived", "org/repo", status="shipped",
426
+ )
427
+ notes = base / "notes"
428
+ _write_track_md(
429
+ notes / "repo" / "archive" / "private-archived.md",
430
+ "private-archived", "org/repo", status="shipped",
431
+ )
432
+ cfg = {
433
+ "notes_root": str(notes),
434
+ "repos": {
435
+ "repo": {"github": "org/repo", "local": str(clone)},
436
+ },
437
+ }
438
+
439
+ stderr_buf = io.StringIO()
440
+ with patch("sys.stderr", stderr_buf):
441
+ archived = discover_archived_tracks(cfg)
442
+
443
+ names = [t.name for t in archived]
444
+ self.assertIn("shared-archived", names)
445
+ self.assertIn("private-archived", names)
446
+ self.assertNotIn("WARN:", stderr_buf.getvalue())
447
+
383
448
 
384
449
  if __name__ == "__main__":
385
450
  unittest.main()
@@ -31,6 +31,7 @@ SUBCOMMANDS = {
31
31
  "orient": "commands.where_was_i",
32
32
  "--orient": "commands.where_was_i", # flag-style alias
33
33
  "slot": "commands.slot",
34
+ "batch-slot": "commands.batch_slot",
34
35
  "close": "commands.close",
35
36
  "refresh-md": "commands.refresh_md",
36
37
  "list": "commands.list_cmd",
@@ -72,6 +73,10 @@ DESCRIPTIONS = [
72
73
  "Add a GitHub issue to a track's frontmatter. If the issue is already in another active track in the same repo, prompts to move it (remove from source) rather than duplicate. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
73
74
  "When a new GitHub issue is filed and you want it associated with a track — or when an existing issue was relabeled and needs to move tracks.",
74
75
  "/work-plan slot 4234 tabletop"),
76
+ ("batch-slot", "<issue-num>... <track | track@repo> [--repo=<key>] [--move|--no-move]",
77
+ "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
+ "After bulk-triage with auto-triage or group — when several issues need the same track assignment.",
79
+ "/work-plan batch-slot 100 101 102 tabletop --move"),
75
80
  ("close", "<track | track@repo> [--repo=<key>]",
76
81
  "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.",
77
82
  "When a track is done, paused, or won't ship — frees mental space.",