@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,112 @@
1
+ """Tests for the `Suggested first action` line in handoff's fresh-session prompt.
2
+
3
+ Issue #57: the resume hook surfaced `Pick up #4790 from the 'next_up' list.`
4
+ even though #4790 was rendered as `(state: closed)` directly above. The fix
5
+ filters next_up by state and picks the first non-closed entry; if every entry
6
+ is closed, it emits a 'run handoff to rotate' hint instead.
7
+ """
8
+ import sys
9
+ import unittest
10
+ from pathlib import Path
11
+ from types import SimpleNamespace
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from commands import handoff
17
+
18
+
19
+ class FirstActionableNextUpTest(unittest.TestCase):
20
+ def test_returns_first_open_when_leading_entry_closed(self):
21
+ issues_by_num = {
22
+ 4790: {"number": 4790, "state": "CLOSED"},
23
+ 4789: {"number": 4789, "state": "OPEN"},
24
+ 4788: {"number": 4788, "state": "OPEN"},
25
+ }
26
+ result = handoff._first_actionable_next_up([4790, 4789, 4788], issues_by_num)
27
+ self.assertEqual(result, 4789)
28
+
29
+ def test_returns_first_when_already_open(self):
30
+ issues_by_num = {
31
+ 4789: {"number": 4789, "state": "OPEN"},
32
+ 4790: {"number": 4790, "state": "CLOSED"},
33
+ }
34
+ result = handoff._first_actionable_next_up([4789, 4790], issues_by_num)
35
+ self.assertEqual(result, 4789)
36
+
37
+ def test_returns_none_when_every_entry_closed(self):
38
+ issues_by_num = {
39
+ 4790: {"number": 4790, "state": "CLOSED"},
40
+ 4791: {"number": 4791, "state": "MERGED"},
41
+ }
42
+ result = handoff._first_actionable_next_up([4790, 4791], issues_by_num)
43
+ self.assertIsNone(result)
44
+
45
+ def test_unknown_issue_is_treated_as_actionable(self):
46
+ result = handoff._first_actionable_next_up([9999], {})
47
+ self.assertEqual(result, 9999)
48
+
49
+ def test_unknown_returned_before_closed(self):
50
+ issues_by_num = {4790: {"number": 4790, "state": "CLOSED"}}
51
+ result = handoff._first_actionable_next_up([4790, 9999], issues_by_num)
52
+ self.assertEqual(result, 9999)
53
+
54
+
55
+ def _track_meta(next_up):
56
+ return SimpleNamespace(
57
+ meta={"track": "demo", "launch_priority": "P3", "milestone_alignment": "v1.0.0"},
58
+ repo="org/repo",
59
+ local_path=None,
60
+ path=Path("/tmp/demo.md"),
61
+ name="demo",
62
+ )
63
+
64
+
65
+ class BuildFreshSessionPromptTest(unittest.TestCase):
66
+ def test_suggests_first_open_when_leading_next_up_closed(self):
67
+ track = _track_meta(None)
68
+ next_up = [4790, 4789]
69
+ issues_by_num = {
70
+ 4790: {"number": 4790, "title": "shipped already", "state": "CLOSED"},
71
+ 4789: {"number": 4789, "title": "still open", "state": "OPEN"},
72
+ }
73
+ prompt = handoff._build_fresh_session_prompt(
74
+ track, commits=[], uncommitted=[], last_session="",
75
+ open_items=[], open_source=None,
76
+ next_up=next_up, issues_by_num=issues_by_num,
77
+ repo_wide_commits=0,
78
+ )
79
+ self.assertIn("Pick up #4789 from the `next_up` list.", prompt)
80
+ self.assertNotIn("Pick up #4790 from the `next_up` list.", prompt)
81
+
82
+ def test_emits_rotate_hint_when_all_next_up_closed(self):
83
+ track = _track_meta(None)
84
+ next_up = [4790, 4791]
85
+ issues_by_num = {
86
+ 4790: {"number": 4790, "title": "x", "state": "CLOSED"},
87
+ 4791: {"number": 4791, "title": "y", "state": "MERGED"},
88
+ }
89
+ prompt = handoff._build_fresh_session_prompt(
90
+ track, commits=[], uncommitted=[], last_session="",
91
+ open_items=[], open_source=None,
92
+ next_up=next_up, issues_by_num=issues_by_num,
93
+ repo_wide_commits=0,
94
+ )
95
+ self.assertIn("All `next_up` items are closed", prompt)
96
+ self.assertIn("/work-plan handoff demo", prompt)
97
+ self.assertNotIn("Pick up #", prompt)
98
+
99
+ def test_uncommitted_takes_precedence_over_next_up(self):
100
+ track = _track_meta(None)
101
+ prompt = handoff._build_fresh_session_prompt(
102
+ track, commits=[], uncommitted=["src/foo.ts"], last_session="",
103
+ open_items=[], open_source=None,
104
+ next_up=[4790], issues_by_num={4790: {"state": "CLOSED"}},
105
+ repo_wide_commits=0,
106
+ )
107
+ self.assertIn("Resume the uncommitted work above.", prompt)
108
+ self.assertNotIn("Pick up #", prompt)
109
+
110
+
111
+ if __name__ == "__main__":
112
+ unittest.main()
@@ -0,0 +1,295 @@
1
+ """Tests for repo-qualified track resolution (Phase B).
2
+
3
+ Covers:
4
+ - find_track_by_name: single match, no match, ambiguous (raises AmbiguousTrackError)
5
+ - find_track_by_name with repo=: disambiguates cross-repo same slug
6
+ - parse_track_repo_arg: all split cases
7
+ - close command accepts --repo=<key>
8
+ - handoff command accepts --repo=<key>
9
+ """
10
+ import io
11
+ import sys
12
+ import unittest
13
+ from contextlib import redirect_stdout
14
+ from pathlib import Path
15
+ from types import SimpleNamespace
16
+ from unittest.mock import patch
17
+
18
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
19
+ sys.path.insert(0, str(SKILL_ROOT))
20
+
21
+ from lib.tracks import (
22
+ Track,
23
+ AmbiguousTrackError,
24
+ find_track_by_name,
25
+ parse_track_repo_arg,
26
+ )
27
+ from commands import close, handoff
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Track factory helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def _track(name, repo=None, folder=None, status="active"):
35
+ """Build a minimal Track for testing."""
36
+ return Track(
37
+ path=Path(f"/tmp/notes/{name}.md"),
38
+ name=name,
39
+ has_frontmatter=True,
40
+ needs_init=False,
41
+ needs_filing=False,
42
+ repo=repo,
43
+ folder=folder,
44
+ meta={"track": name, "status": status},
45
+ body="",
46
+ )
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # find_track_by_name tests
51
+ # ---------------------------------------------------------------------------
52
+
53
+ class FindTrackByNameTest(unittest.TestCase):
54
+
55
+ def test_single_match_returns_track(self):
56
+ """One matching track → returned directly."""
57
+ t = _track("feat-x", repo="org/repo")
58
+ result = find_track_by_name("feat-x", [t])
59
+ self.assertIs(result, t)
60
+
61
+ def test_no_match_returns_none(self):
62
+ """No matching track → returns None."""
63
+ t = _track("feat-x", repo="org/repo")
64
+ result = find_track_by_name("feat-y", [t])
65
+ self.assertIsNone(result)
66
+
67
+ def test_two_matches_raises_ambiguous_error(self):
68
+ """Same slug across two repos → raises AmbiguousTrackError."""
69
+ t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
70
+ t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
71
+ with self.assertRaises(AmbiguousTrackError) as cm:
72
+ find_track_by_name("feat-x", [t1, t2])
73
+ err = cm.exception
74
+ self.assertEqual(err.name, "feat-x")
75
+ self.assertIn(t1, err.candidates)
76
+ self.assertIn(t2, err.candidates)
77
+ self.assertEqual(len(err.candidates), 2)
78
+
79
+ def test_ambiguous_error_message_contains_repos(self):
80
+ """Error message names both repos and disambiguation hint."""
81
+ t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
82
+ t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
83
+ with self.assertRaises(AmbiguousTrackError) as cm:
84
+ find_track_by_name("feat-x", [t1, t2])
85
+ msg = str(cm.exception)
86
+ self.assertIn("repo-a", msg)
87
+ self.assertIn("repo-b", msg)
88
+ self.assertIn("--repo=", msg)
89
+ self.assertIn("@", msg)
90
+
91
+ def test_repo_qualifier_by_github_slug_disambiguates(self):
92
+ """repo= matching github slug returns the correct track."""
93
+ t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
94
+ t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
95
+ result = find_track_by_name("feat-x", [t1, t2], repo="org/repo-a")
96
+ self.assertIs(result, t1)
97
+
98
+ def test_repo_qualifier_by_folder_key_disambiguates(self):
99
+ """repo= matching folder key (case-insensitive) returns the correct track."""
100
+ t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
101
+ t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
102
+ result = find_track_by_name("feat-x", [t1, t2], repo="REPO-B")
103
+ self.assertIs(result, t2)
104
+
105
+ def test_repo_qualifier_no_match_returns_none(self):
106
+ """repo= that doesn't match any track → None (not ambiguous)."""
107
+ t1 = _track("feat-x", repo="org/repo-a", folder="repo-a")
108
+ t2 = _track("feat-x", repo="org/repo-b", folder="repo-b")
109
+ result = find_track_by_name("feat-x", [t1, t2], repo="nonexistent")
110
+ self.assertIsNone(result)
111
+
112
+ def test_active_only_filters_before_match(self):
113
+ """active_only=True excludes non-active tracks even when name matches."""
114
+ t_parked = _track("feat-x", repo="org/repo", status="parked")
115
+ t_active = _track("feat-y", repo="org/repo", status="active")
116
+ result = find_track_by_name("feat-x", [t_parked, t_active], active_only=True)
117
+ self.assertIsNone(result)
118
+
119
+ def test_active_only_with_repo_disambiguates(self):
120
+ """active_only=True + repo= both apply; non-active filtered then repo narrows."""
121
+ t1 = _track("feat-x", repo="org/repo-a", folder="repo-a", status="active")
122
+ t2 = _track("feat-x", repo="org/repo-b", folder="repo-b", status="parked")
123
+ result = find_track_by_name("feat-x", [t1, t2], active_only=True, repo="repo-a")
124
+ self.assertIs(result, t1)
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # parse_track_repo_arg tests
129
+ # ---------------------------------------------------------------------------
130
+
131
+ class ParseTrackRepoArgTest(unittest.TestCase):
132
+
133
+ def test_name_at_repo_splits_correctly(self):
134
+ name, repo = parse_track_repo_arg("foo@critforge")
135
+ self.assertEqual(name, "foo")
136
+ self.assertEqual(repo, "critforge")
137
+
138
+ def test_no_at_returns_original_none(self):
139
+ name, repo = parse_track_repo_arg("foo")
140
+ self.assertEqual(name, "foo")
141
+ self.assertIsNone(repo)
142
+
143
+ def test_leading_at_invalid_returns_original_none(self):
144
+ """@foo has no valid name before @, so returns original arg and None."""
145
+ name, repo = parse_track_repo_arg("@foo")
146
+ self.assertEqual(name, "@foo")
147
+ self.assertIsNone(repo)
148
+
149
+ def test_rpartition_uses_last_at(self):
150
+ """track@name@repo uses last @ as separator so earlier @ stays in name."""
151
+ name, repo = parse_track_repo_arg("track@name@repo")
152
+ self.assertEqual(name, "track@name")
153
+ self.assertEqual(repo, "repo")
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # close command — --repo=<key> accepted and passed through
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _make_close_track(name="alpha", repo="org/repo-a", folder="repo-a"):
161
+ return SimpleNamespace(
162
+ name=name,
163
+ path=Path(f"/tmp/fake/{name}.md"),
164
+ body="# body",
165
+ meta={"track": name, "status": "active", "github": {"repo": repo}},
166
+ has_frontmatter=True,
167
+ repo=repo,
168
+ )
169
+
170
+
171
+ class CloseRepoFlagTest(unittest.TestCase):
172
+
173
+ def _drive(self, args, find_result, vis="PRIVATE"):
174
+ cfg = {"notes_root": "/tmp/fake", "repos": {}}
175
+ with patch("commands.close.load_config", return_value=cfg), \
176
+ patch("commands.close.discover_tracks", return_value=[]), \
177
+ patch("commands.close.find_track_by_name", return_value=find_result) as mock_find, \
178
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
179
+ patch("commands.close.write_file"), \
180
+ patch("commands.close.shutil"), \
181
+ patch("pathlib.Path.mkdir"):
182
+ buf = io.StringIO()
183
+ with redirect_stdout(buf):
184
+ rc = close.run(args)
185
+ return rc, mock_find, buf.getvalue()
186
+
187
+ def test_repo_flag_passed_to_find_track(self):
188
+ """--repo=<key> is extracted and passed as repo= to find_track_by_name."""
189
+ track = _make_close_track()
190
+ rc, mock_find, out = self._drive(
191
+ ["alpha", "--state=parked", "--repo=repo-a"],
192
+ find_result=track,
193
+ )
194
+ self.assertEqual(rc, 0)
195
+ call_kwargs = mock_find.call_args
196
+ # find_track_by_name called with repo="repo-a"
197
+ self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
198
+
199
+ def test_at_syntax_extracted_as_repo(self):
200
+ """alpha@repo-a positional arg → track_name='alpha', repo_qualifier='repo-a'."""
201
+ track = _make_close_track()
202
+ rc, mock_find, out = self._drive(
203
+ ["alpha@repo-a", "--state=parked"],
204
+ find_result=track,
205
+ )
206
+ self.assertEqual(rc, 0)
207
+ call_kwargs = mock_find.call_args
208
+ self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
209
+
210
+ def test_ambiguous_error_returns_rc1(self):
211
+ """AmbiguousTrackError from find_track_by_name → prints message, returns 1."""
212
+ t1 = _track("alpha", repo="org/a", folder="a")
213
+ t2 = _track("alpha", repo="org/b", folder="b")
214
+ err = AmbiguousTrackError("alpha", [t1, t2])
215
+ cfg = {"notes_root": "/tmp/fake", "repos": {}}
216
+ with patch("commands.close.load_config", return_value=cfg), \
217
+ patch("commands.close.discover_tracks", return_value=[]), \
218
+ patch("commands.close.find_track_by_name", side_effect=err):
219
+ buf = io.StringIO()
220
+ with redirect_stdout(buf):
221
+ rc = close.run(["alpha", "--state=parked"])
222
+ self.assertEqual(rc, 1)
223
+ self.assertIn("ambiguous", buf.getvalue().lower())
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # handoff command — --repo=<key> accepted and passed through
228
+ # ---------------------------------------------------------------------------
229
+
230
+ class HandoffRepoFlagTest(unittest.TestCase):
231
+
232
+ def _drive(self, args, find_result=None):
233
+ """Drive handoff.run() minimally — mock everything except arg parsing."""
234
+ cfg = {"notes_root": "/tmp/fake", "repos": {}}
235
+
236
+ # Build a minimal track namespace if not supplied
237
+ if find_result is None:
238
+ find_result = SimpleNamespace(
239
+ name="alpha",
240
+ path=Path("/tmp/fake/alpha.md"),
241
+ body="",
242
+ meta={"track": "alpha", "status": "active", "github": {"issues": []}},
243
+ has_frontmatter=True,
244
+ repo="org/repo",
245
+ local_path=None,
246
+ )
247
+
248
+ with patch("commands.handoff.load_config", return_value=cfg), \
249
+ patch("commands.handoff.discover_tracks", return_value=[]), \
250
+ patch("commands.handoff.find_track_by_name", return_value=find_result) as mock_find, \
251
+ patch("commands.handoff.fetch_issues", return_value=[]), \
252
+ patch("commands.handoff.write_file"), \
253
+ patch("commands.handoff.append_session_log", return_value=""), \
254
+ patch("commands.handoff.update_row_status", return_value=""), \
255
+ patch("commands.handoff.sync_missing_rows", return_value=("", 0)), \
256
+ patch("commands.handoff.find_new_issues_for_tracks", return_value={}), \
257
+ patch("commands.handoff.has_uncommitted", return_value=False), \
258
+ patch("commands.handoff.current_branch", return_value=None), \
259
+ patch("commands.handoff.uncommitted_file_count", return_value=0), \
260
+ patch("commands.handoff.commits_ahead", return_value=0):
261
+ buf = io.StringIO()
262
+ with redirect_stdout(buf):
263
+ rc = handoff.run(args)
264
+ return rc, mock_find, buf.getvalue()
265
+
266
+ def test_repo_flag_passed_to_find_track(self):
267
+ """--repo=<key> reaches find_track_by_name as repo= kwarg."""
268
+ rc, mock_find, _ = self._drive(["alpha", "--repo=repo-a"])
269
+ call_kwargs = mock_find.call_args
270
+ self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
271
+
272
+ def test_at_syntax_passed_to_find_track(self):
273
+ """alpha@repo-a positional → track_name='alpha', repo='repo-a'."""
274
+ rc, mock_find, _ = self._drive(["alpha@repo-a"])
275
+ call_kwargs = mock_find.call_args
276
+ self.assertEqual(call_kwargs.kwargs.get("repo"), "repo-a")
277
+
278
+ def test_ambiguous_error_returns_rc1(self):
279
+ """AmbiguousTrackError → prints message, returns 1."""
280
+ t1 = _track("alpha", repo="org/a", folder="a")
281
+ t2 = _track("alpha", repo="org/b", folder="b")
282
+ err = AmbiguousTrackError("alpha", [t1, t2])
283
+ cfg = {"notes_root": "/tmp/fake", "repos": {}}
284
+ with patch("commands.handoff.load_config", return_value=cfg), \
285
+ patch("commands.handoff.discover_tracks", return_value=[]), \
286
+ patch("commands.handoff.find_track_by_name", side_effect=err):
287
+ buf = io.StringIO()
288
+ with redirect_stdout(buf):
289
+ rc = handoff.run(["alpha"])
290
+ self.assertEqual(rc, 1)
291
+ self.assertIn("ambiguous", buf.getvalue().lower())
292
+
293
+
294
+ if __name__ == "__main__":
295
+ unittest.main()