@stylusnexus/work-plan 2026.6.9 → 2026.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +91 -13
  2. package/VERSION +1 -1
  3. package/bin/work-plan +23 -0
  4. package/package.json +2 -2
  5. package/skills/work-plan/SKILL.md +41 -8
  6. package/skills/work-plan/commands/auto_triage.py +243 -0
  7. package/skills/work-plan/commands/batch_slot.py +184 -0
  8. package/skills/work-plan/commands/brief.py +6 -6
  9. package/skills/work-plan/commands/canonicalize.py +71 -17
  10. package/skills/work-plan/commands/close.py +21 -6
  11. package/skills/work-plan/commands/coverage.py +100 -0
  12. package/skills/work-plan/commands/duplicates.py +21 -8
  13. package/skills/work-plan/commands/group.py +86 -10
  14. package/skills/work-plan/commands/handoff.py +17 -5
  15. package/skills/work-plan/commands/hygiene.py +29 -3
  16. package/skills/work-plan/commands/init.py +39 -7
  17. package/skills/work-plan/commands/init_repo.py +43 -1
  18. package/skills/work-plan/commands/list_cmd.py +34 -6
  19. package/skills/work-plan/commands/move.py +131 -0
  20. package/skills/work-plan/commands/new_track.py +100 -23
  21. package/skills/work-plan/commands/reconcile.py +175 -33
  22. package/skills/work-plan/commands/refresh_md.py +19 -6
  23. package/skills/work-plan/commands/set_field.py +17 -7
  24. package/skills/work-plan/commands/slot.py +20 -5
  25. package/skills/work-plan/commands/where_was_i.py +23 -5
  26. package/skills/work-plan/lib/config.py +6 -0
  27. package/skills/work-plan/lib/export_model.py +57 -2
  28. package/skills/work-plan/lib/github_state.py +54 -13
  29. package/skills/work-plan/lib/notes_readme.py +38 -0
  30. package/skills/work-plan/lib/prompts.py +34 -3
  31. package/skills/work-plan/lib/tracks.py +208 -18
  32. package/skills/work-plan/tests/test_auto_triage.py +351 -0
  33. package/skills/work-plan/tests/test_batch_slot.py +291 -0
  34. package/skills/work-plan/tests/test_close_tier.py +166 -0
  35. package/skills/work-plan/tests/test_config_shared.py +57 -0
  36. package/skills/work-plan/tests/test_coverage.py +192 -0
  37. package/skills/work-plan/tests/test_export.py +204 -1
  38. package/skills/work-plan/tests/test_export_command.py +2 -2
  39. package/skills/work-plan/tests/test_github_state.py +52 -14
  40. package/skills/work-plan/tests/test_group_apply.py +411 -0
  41. package/skills/work-plan/tests/test_init_repo.py +128 -0
  42. package/skills/work-plan/tests/test_init_shared.py +185 -0
  43. package/skills/work-plan/tests/test_list_sort.py +162 -0
  44. package/skills/work-plan/tests/test_move.py +240 -0
  45. package/skills/work-plan/tests/test_new_track.py +169 -4
  46. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  47. package/skills/work-plan/tests/test_prompts.py +121 -0
  48. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  49. package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
  50. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  51. package/skills/work-plan/tests/test_tracks.py +395 -1
  52. package/skills/work-plan/tests/test_where_was_i.py +135 -0
  53. package/skills/work-plan/work_plan.py +38 -18
@@ -1,7 +1,10 @@
1
1
  """Tests for track discovery."""
2
- import unittest
2
+ import io
3
3
  import sys
4
+ import tempfile
5
+ import unittest
4
6
  from pathlib import Path
7
+ from unittest.mock import patch
5
8
 
6
9
  SKILL_ROOT = Path(__file__).resolve().parents[1]
7
10
  sys.path.insert(0, str(SKILL_ROOT))
@@ -10,6 +13,29 @@ from lib.tracks import discover_tracks, discover_archived_tracks
10
13
 
11
14
  FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
12
15
 
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def _make_git_repo(base: Path) -> Path:
21
+ """Create a minimal fake git repo at base (has a .git dir)."""
22
+ (base / ".git").mkdir(parents=True, exist_ok=True)
23
+ return base
24
+
25
+
26
+ def _write_track_md(path: Path, track_name: str, repo: str,
27
+ status: str = "active") -> None:
28
+ path.parent.mkdir(parents=True, exist_ok=True)
29
+ path.write_text(
30
+ f"---\ntrack: {track_name}\nstatus: {status}\n"
31
+ f"github:\n repo: {repo}\n issues: []\n---\n\n# {track_name}\n",
32
+ encoding="utf-8",
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Existing private-notes tests (unchanged behaviour)
38
+ # ---------------------------------------------------------------------------
13
39
 
14
40
  class DiscoverTracksTest(unittest.TestCase):
15
41
  def setUp(self):
@@ -38,6 +64,13 @@ class DiscoverTracksTest(unittest.TestCase):
38
64
  names = [t.name for t in discover_tracks(self.cfg)]
39
65
  self.assertNotIn("old", names)
40
66
 
67
+ def test_private_tracks_tagged_private(self):
68
+ """All tracks from notes_root carry tier='private'."""
69
+ tracks = discover_tracks(self.cfg)
70
+ for t in tracks:
71
+ self.assertEqual(t.tier, "private",
72
+ f"Expected tier='private' for {t.path}")
73
+
41
74
 
42
75
  class DiscoverArchivedTracksTest(unittest.TestCase):
43
76
  def setUp(self):
@@ -52,5 +85,366 @@ class DiscoverArchivedTracksTest(unittest.TestCase):
52
85
  self.assertIn("old", slugs)
53
86
 
54
87
 
88
+ # ---------------------------------------------------------------------------
89
+ # Shared-notes tier tests
90
+ # ---------------------------------------------------------------------------
91
+
92
+ class SharedTrackDiscoveryTest(unittest.TestCase):
93
+ """Shared tracks live in <local>/.work-plan/ and are tagged tier='shared'."""
94
+
95
+ def _make_cfg(self, local_clone: Path, notes_root: Path) -> dict:
96
+ return {
97
+ "notes_root": str(notes_root),
98
+ "repos": {
99
+ "myrepo": {
100
+ "github": "org/myrepo",
101
+ "local": str(local_clone),
102
+ },
103
+ },
104
+ }
105
+
106
+ def test_shared_track_discovered_and_tagged(self):
107
+ """A track in <local>/.work-plan/ is discovered with tier='shared'."""
108
+ with tempfile.TemporaryDirectory() as d:
109
+ base = Path(d)
110
+ clone = _make_git_repo(base / "clone")
111
+ wp = clone / ".work-plan" / "myrepo"
112
+ _write_track_md(wp / "feat-x.md", "feat-x", "org/myrepo")
113
+ notes = base / "notes"
114
+ notes.mkdir()
115
+ cfg = self._make_cfg(clone, notes)
116
+
117
+ tracks = discover_tracks(cfg)
118
+ names = [t.name for t in tracks]
119
+ self.assertIn("feat-x", names)
120
+ shared = next(t for t in tracks if t.name == "feat-x")
121
+ self.assertEqual(shared.tier, "shared")
122
+
123
+ def test_shared_track_repo_from_folder_config(self):
124
+ """repo and local_path on a shared track come from folder config, not frontmatter."""
125
+ with tempfile.TemporaryDirectory() as d:
126
+ base = Path(d)
127
+ clone = _make_git_repo(base / "clone")
128
+ wp = clone / ".work-plan"
129
+ _write_track_md(wp / "feat-y.md", "feat-y", "org/myrepo")
130
+ notes = base / "notes"
131
+ notes.mkdir()
132
+ cfg = self._make_cfg(clone, notes)
133
+
134
+ tracks = discover_tracks(cfg)
135
+ t = next(t for t in tracks if t.name == "feat-y")
136
+ self.assertEqual(t.repo, "org/myrepo")
137
+ self.assertEqual(
138
+ str(Path(t.local_path).resolve()),
139
+ str(clone.resolve()),
140
+ )
141
+
142
+ def test_archive_dir_skipped_by_discover_tracks(self):
143
+ """Files inside .work-plan/archive/ are excluded from active discovery."""
144
+ with tempfile.TemporaryDirectory() as d:
145
+ base = Path(d)
146
+ clone = _make_git_repo(base / "clone")
147
+ _write_track_md(
148
+ clone / ".work-plan" / "archive" / "old.md", "old", "org/myrepo"
149
+ )
150
+ notes = base / "notes"
151
+ notes.mkdir()
152
+ cfg = self._make_cfg(clone, notes)
153
+
154
+ tracks = discover_tracks(cfg)
155
+ names = [t.name for t in tracks]
156
+ self.assertNotIn("old", names)
157
+
158
+ def test_dotfiles_skipped_in_work_plan(self):
159
+ """Dotfiles inside .work-plan/ are not discovered."""
160
+ with tempfile.TemporaryDirectory() as d:
161
+ base = Path(d)
162
+ clone = _make_git_repo(base / "clone")
163
+ wp = clone / ".work-plan"
164
+ wp.mkdir(parents=True)
165
+ (wp / ".hidden.md").write_text("---\ntrack: hidden\n---\n", encoding="utf-8")
166
+ notes = base / "notes"
167
+ notes.mkdir()
168
+ cfg = self._make_cfg(clone, notes)
169
+
170
+ tracks = discover_tracks(cfg)
171
+ names = [t.name for t in tracks]
172
+ self.assertNotIn(".hidden", names)
173
+
174
+ def test_readme_skipped_in_work_plan(self):
175
+ """README.md inside .work-plan/ is not discovered as a track."""
176
+ with tempfile.TemporaryDirectory() as d:
177
+ base = Path(d)
178
+ clone = _make_git_repo(base / "clone")
179
+ wp = clone / ".work-plan"
180
+ wp.mkdir(parents=True)
181
+ (wp / "README.md").write_text("# Notes\n", encoding="utf-8")
182
+ notes = base / "notes"
183
+ notes.mkdir()
184
+ cfg = self._make_cfg(clone, notes)
185
+
186
+ tracks = discover_tracks(cfg)
187
+ names = [t.name for t in tracks]
188
+ self.assertNotIn("README", names)
189
+
190
+ def test_invalid_local_path_contributes_no_tracks(self):
191
+ """Repos with no/invalid local path produce zero shared tracks."""
192
+ with tempfile.TemporaryDirectory() as d:
193
+ base = Path(d)
194
+ notes = base / "notes"
195
+ notes.mkdir()
196
+ cfg = {
197
+ "notes_root": str(notes),
198
+ "repos": {
199
+ "noclone": {
200
+ "github": "org/noclone",
201
+ "local": str(base / "nonexistent"),
202
+ },
203
+ "nolocal": {
204
+ "github": "org/nolocal",
205
+ "local": None,
206
+ },
207
+ },
208
+ }
209
+ # Should return empty list without raising
210
+ tracks = discover_tracks(cfg)
211
+ self.assertEqual(tracks, [])
212
+
213
+ def test_non_git_repo_local_contributes_no_tracks(self):
214
+ """A local path that exists but has no .git/ is skipped silently."""
215
+ with tempfile.TemporaryDirectory() as d:
216
+ base = Path(d)
217
+ not_a_repo = base / "notarepo"
218
+ not_a_repo.mkdir()
219
+ # Create .work-plan with a track but NO .git dir
220
+ _write_track_md(
221
+ not_a_repo / ".work-plan" / "feat.md", "feat", "org/repo"
222
+ )
223
+ notes = base / "notes"
224
+ notes.mkdir()
225
+ cfg = {
226
+ "notes_root": str(notes),
227
+ "repos": {
228
+ "repo": {
229
+ "github": "org/repo",
230
+ "local": str(not_a_repo),
231
+ },
232
+ },
233
+ }
234
+ tracks = discover_tracks(cfg)
235
+ self.assertEqual(tracks, [])
236
+
237
+
238
+ class SharedTrackCollisionTest(unittest.TestCase):
239
+ """When (repo, name) appears in both shared and private, shared wins + warns."""
240
+
241
+ def test_shared_wins_on_collision(self):
242
+ with tempfile.TemporaryDirectory() as d:
243
+ base = Path(d)
244
+ clone = _make_git_repo(base / "clone")
245
+ # Shared track
246
+ _write_track_md(
247
+ clone / ".work-plan" / "feat-x.md", "feat-x", "org/repo"
248
+ )
249
+ # Private track with SAME name in notes_root
250
+ notes = base / "notes"
251
+ _write_track_md(
252
+ notes / "repo" / "feat-x.md", "feat-x", "org/repo"
253
+ )
254
+ cfg = {
255
+ "notes_root": str(notes),
256
+ "repos": {
257
+ "repo": {"github": "org/repo", "local": str(clone)},
258
+ },
259
+ }
260
+
261
+ tracks = discover_tracks(cfg)
262
+ feat_x_tracks = [t for t in tracks if t.name == "feat-x"]
263
+ # Exactly one track survives the collision
264
+ self.assertEqual(len(feat_x_tracks), 1)
265
+ self.assertEqual(feat_x_tracks[0].tier, "shared")
266
+
267
+ def test_collision_emits_one_warning(self):
268
+ with tempfile.TemporaryDirectory() as d:
269
+ base = Path(d)
270
+ clone = _make_git_repo(base / "clone")
271
+ _write_track_md(
272
+ clone / ".work-plan" / "feat-x.md", "feat-x", "org/repo"
273
+ )
274
+ notes = base / "notes"
275
+ _write_track_md(
276
+ notes / "repo" / "feat-x.md", "feat-x", "org/repo"
277
+ )
278
+ cfg = {
279
+ "notes_root": str(notes),
280
+ "repos": {
281
+ "repo": {"github": "org/repo", "local": str(clone)},
282
+ },
283
+ }
284
+
285
+ with patch("sys.stderr", new_callable=io.StringIO) as mock_err:
286
+ discover_tracks(cfg)
287
+ output = mock_err.getvalue()
288
+ # One warning about the collision
289
+ self.assertEqual(output.count("WARN:"), 1)
290
+ self.assertIn("feat-x", output)
291
+
292
+
293
+ class SharedTrackSingleOwnerTest(unittest.TestCase):
294
+ """Frontmatter github.repo that disagrees with folder config → warn, use folder."""
295
+
296
+ def test_frontmatter_repo_disagreement_warns_and_uses_folder(self):
297
+ with tempfile.TemporaryDirectory() as d:
298
+ base = Path(d)
299
+ clone = _make_git_repo(base / "clone")
300
+ # Frontmatter says a DIFFERENT repo than the folder config
301
+ _write_track_md(
302
+ clone / ".work-plan" / "feat.md", "feat", "wrong/repo"
303
+ )
304
+ notes = base / "notes"
305
+ notes.mkdir()
306
+ cfg = {
307
+ "notes_root": str(notes),
308
+ "repos": {
309
+ "myrepo": {"github": "correct/repo", "local": str(clone)},
310
+ },
311
+ }
312
+
313
+ stderr_buf = io.StringIO()
314
+ with patch("sys.stderr", stderr_buf):
315
+ tracks = discover_tracks(cfg)
316
+
317
+ t = next(t for t in tracks if t.name == "feat")
318
+ # Folder config wins
319
+ self.assertEqual(t.repo, "correct/repo")
320
+ # Warning was emitted
321
+ self.assertIn("WARN:", stderr_buf.getvalue())
322
+ self.assertIn("correct/repo", stderr_buf.getvalue())
323
+
324
+ def test_frontmatter_repo_match_no_warning(self):
325
+ with tempfile.TemporaryDirectory() as d:
326
+ base = Path(d)
327
+ clone = _make_git_repo(base / "clone")
328
+ _write_track_md(
329
+ clone / ".work-plan" / "feat.md", "feat", "correct/repo"
330
+ )
331
+ notes = base / "notes"
332
+ notes.mkdir()
333
+ cfg = {
334
+ "notes_root": str(notes),
335
+ "repos": {
336
+ "myrepo": {"github": "correct/repo", "local": str(clone)},
337
+ },
338
+ }
339
+
340
+ stderr_buf = io.StringIO()
341
+ with patch("sys.stderr", stderr_buf):
342
+ discover_tracks(cfg)
343
+
344
+ self.assertNotIn("WARN:", stderr_buf.getvalue())
345
+
346
+
347
+ class DiscoverArchivedSharedTest(unittest.TestCase):
348
+ """discover_archived_tracks also scans shared repos' .work-plan/archive/."""
349
+
350
+ def test_shared_archived_track_found(self):
351
+ with tempfile.TemporaryDirectory() as d:
352
+ base = Path(d)
353
+ clone = _make_git_repo(base / "clone")
354
+ _write_track_md(
355
+ clone / ".work-plan" / "archive" / "shipped.md",
356
+ "shipped", "org/repo", status="shipped",
357
+ )
358
+ notes = base / "notes"
359
+ notes.mkdir()
360
+ cfg = {
361
+ "notes_root": str(notes),
362
+ "repos": {
363
+ "repo": {"github": "org/repo", "local": str(clone)},
364
+ },
365
+ }
366
+
367
+ archived = discover_archived_tracks(cfg)
368
+ names = [t.name for t in archived]
369
+ self.assertIn("shipped", names)
370
+ t = next(t for t in archived if t.name == "shipped")
371
+ self.assertEqual(t.tier, "shared")
372
+
373
+ def test_private_archived_track_still_found(self):
374
+ """Existing notes_root archives continue to be discovered."""
375
+ cfg = {
376
+ "notes_root": str(FIXTURES),
377
+ "repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
378
+ }
379
+ archived = discover_archived_tracks(cfg)
380
+ slugs = [a.meta.get("track") for a in archived]
381
+ self.assertIn("old", slugs)
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
+
448
+
55
449
  if __name__ == "__main__":
56
450
  unittest.main()
@@ -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()