@stylusnexus/work-plan 2026.6.9

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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +36 -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 +119 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/brief.py +247 -0
  11. package/skills/work-plan/commands/canonicalize.py +122 -0
  12. package/skills/work-plan/commands/close.py +83 -0
  13. package/skills/work-plan/commands/duplicates.py +111 -0
  14. package/skills/work-plan/commands/export.py +69 -0
  15. package/skills/work-plan/commands/group.py +234 -0
  16. package/skills/work-plan/commands/handoff.py +855 -0
  17. package/skills/work-plan/commands/hygiene.py +104 -0
  18. package/skills/work-plan/commands/init.py +96 -0
  19. package/skills/work-plan/commands/init_repo.py +90 -0
  20. package/skills/work-plan/commands/list_cmd.py +39 -0
  21. package/skills/work-plan/commands/new_track.py +148 -0
  22. package/skills/work-plan/commands/plan_status.py +296 -0
  23. package/skills/work-plan/commands/reconcile.py +172 -0
  24. package/skills/work-plan/commands/refresh_md.py +132 -0
  25. package/skills/work-plan/commands/set_field.py +54 -0
  26. package/skills/work-plan/commands/set_notes_root.py +53 -0
  27. package/skills/work-plan/commands/slot.py +139 -0
  28. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  29. package/skills/work-plan/commands/where_was_i.py +325 -0
  30. package/skills/work-plan/lib/__init__.py +0 -0
  31. package/skills/work-plan/lib/closure.py +72 -0
  32. package/skills/work-plan/lib/config.py +82 -0
  33. package/skills/work-plan/lib/doc_discovery.py +41 -0
  34. package/skills/work-plan/lib/drift.py +32 -0
  35. package/skills/work-plan/lib/export_model.py +40 -0
  36. package/skills/work-plan/lib/frontmatter.py +48 -0
  37. package/skills/work-plan/lib/git_state.py +180 -0
  38. package/skills/work-plan/lib/github_state.py +296 -0
  39. package/skills/work-plan/lib/llm_evidence.py +45 -0
  40. package/skills/work-plan/lib/manifest.py +164 -0
  41. package/skills/work-plan/lib/new_issues.py +69 -0
  42. package/skills/work-plan/lib/next_up.py +98 -0
  43. package/skills/work-plan/lib/prompts.py +68 -0
  44. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  45. package/skills/work-plan/lib/render.py +83 -0
  46. package/skills/work-plan/lib/scratch.py +14 -0
  47. package/skills/work-plan/lib/session_log.py +39 -0
  48. package/skills/work-plan/lib/status_header.py +60 -0
  49. package/skills/work-plan/lib/status_table.py +227 -0
  50. package/skills/work-plan/lib/tracks.py +109 -0
  51. package/skills/work-plan/lib/verdict.py +51 -0
  52. package/skills/work-plan/lib/write_guard.py +39 -0
  53. package/skills/work-plan/tests/__init__.py +0 -0
  54. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  55. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  56. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  58. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  59. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  60. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  61. package/skills/work-plan/tests/test_close.py +273 -0
  62. package/skills/work-plan/tests/test_closure.py +51 -0
  63. package/skills/work-plan/tests/test_config.py +85 -0
  64. package/skills/work-plan/tests/test_config_seed.py +41 -0
  65. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  66. package/skills/work-plan/tests/test_drift.py +38 -0
  67. package/skills/work-plan/tests/test_export.py +91 -0
  68. package/skills/work-plan/tests/test_export_command.py +295 -0
  69. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  70. package/skills/work-plan/tests/test_git_state.py +51 -0
  71. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  72. package/skills/work-plan/tests/test_github_state.py +508 -0
  73. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  74. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  75. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  76. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  77. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  78. package/skills/work-plan/tests/test_init.py +289 -0
  79. package/skills/work-plan/tests/test_init_repo.py +251 -0
  80. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  81. package/skills/work-plan/tests/test_manifest.py +162 -0
  82. package/skills/work-plan/tests/test_new_issues.py +130 -0
  83. package/skills/work-plan/tests/test_new_track.py +445 -0
  84. package/skills/work-plan/tests/test_next_up.py +149 -0
  85. package/skills/work-plan/tests/test_plan_status.py +68 -0
  86. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  87. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  88. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  89. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  90. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  91. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  92. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  93. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  94. package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
  95. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  96. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  97. package/skills/work-plan/tests/test_render.py +110 -0
  98. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  99. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  100. package/skills/work-plan/tests/test_session_log.py +39 -0
  101. package/skills/work-plan/tests/test_set_field.py +77 -0
  102. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  103. package/skills/work-plan/tests/test_slot.py +243 -0
  104. package/skills/work-plan/tests/test_slot_move.py +128 -0
  105. package/skills/work-plan/tests/test_smoke.py +46 -0
  106. package/skills/work-plan/tests/test_status_header.py +79 -0
  107. package/skills/work-plan/tests/test_status_table.py +162 -0
  108. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  109. package/skills/work-plan/tests/test_tracks.py +56 -0
  110. package/skills/work-plan/tests/test_verdict.py +60 -0
  111. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  112. package/skills/work-plan/tests/test_write_guard.py +53 -0
  113. package/skills/work-plan/work_plan.py +210 -0
@@ -0,0 +1,152 @@
1
+ """Tests for handoff commit-attribution helpers: path-glob attribution
2
+ (`github.paths`) and the repo-wide commit counter that drives the soft
3
+ 'silence is expected' signal.
4
+ """
5
+ import sys
6
+ import unittest
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from unittest import mock
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from datetime import datetime
15
+
16
+ from commands import handoff
17
+
18
+
19
+ def _track(meta_github, local_path="/tmp/repo"):
20
+ return SimpleNamespace(
21
+ meta={"github": meta_github},
22
+ local_path=local_path,
23
+ )
24
+
25
+
26
+ def _proc(stdout="", returncode=0):
27
+ return SimpleNamespace(stdout=stdout, returncode=returncode, stderr="")
28
+
29
+
30
+ SINCE = datetime(2026, 4, 29, 0, 0, 0)
31
+
32
+
33
+ class RecentCommitsPathGlobsTest(unittest.TestCase):
34
+ def test_path_glob_attributes_commit_with_no_issue_ref(self):
35
+ """A commit whose subject doesn't mention any tracked issue but
36
+ whose changed paths match `github.paths` should be attributed."""
37
+ log_output = (
38
+ "---COMMIT---\nabc1234|fix(useToast): debounce stacking|2026-04-30T10:00:00+00:00\n"
39
+ "---BODY---\n\n---ENDBODY---\n"
40
+ "apps/web/src/hooks/useToast.tsx\napps/web/src/hooks/useToast.test.tsx\n\n"
41
+ "---COMMIT---\ndef5678|chore: bump deps|2026-04-30T09:00:00+00:00\n"
42
+ "---BODY---\n\n---ENDBODY---\n"
43
+ "package.json\n"
44
+ )
45
+ track = _track({
46
+ "issues": [4148, 4149],
47
+ "paths": ["apps/web/src/hooks/useToast*"],
48
+ })
49
+ with mock.patch("commands.handoff.subprocess.run",
50
+ return_value=_proc(stdout=log_output)):
51
+ commits = handoff._recent_commits(track, SINCE)
52
+ self.assertEqual(len(commits), 1)
53
+ self.assertEqual(commits[0]["sha"], "abc1234")
54
+
55
+ def test_issue_ref_still_attributes_when_paths_set(self):
56
+ """Issue-ref attribution and path attribution are an OR, not AND."""
57
+ log_output = (
58
+ "---COMMIT---\nabc1234|fix #4148: tighten guardrails|2026-04-30T10:00:00+00:00\n"
59
+ "---BODY---\n\n---ENDBODY---\n"
60
+ "infra/iam/policy.tf\n"
61
+ )
62
+ track = _track({
63
+ "issues": [4148],
64
+ "paths": ["apps/web/src/hooks/useToast*"],
65
+ })
66
+ with mock.patch("commands.handoff.subprocess.run",
67
+ return_value=_proc(stdout=log_output)):
68
+ commits = handoff._recent_commits(track, SINCE)
69
+ self.assertEqual(len(commits), 1)
70
+ self.assertEqual(commits[0]["sha"], "abc1234")
71
+
72
+ def test_body_issue_ref_attributes_squash_merge_commit(self):
73
+ """Squash-merged PRs use Conventional Commit subjects with the issue
74
+ ref in the body (e.g. 'Closes #4148'). Subject scanning alone misses
75
+ these; body scanning must catch them."""
76
+ log_output = (
77
+ "---COMMIT---\nabc1234|feat(adventure): cache regen prompts|2026-04-30T10:00:00+00:00\n"
78
+ "---BODY---\n"
79
+ "Reduce duplicate LLM calls when artifacts regenerate.\n"
80
+ "\n"
81
+ "Closes #4148\n"
82
+ "---ENDBODY---\n"
83
+ )
84
+ track = _track({"issues": [4148]})
85
+ with mock.patch("commands.handoff.subprocess.run",
86
+ return_value=_proc(stdout=log_output)):
87
+ commits = handoff._recent_commits(track, SINCE)
88
+ self.assertEqual(len(commits), 1)
89
+ self.assertEqual(commits[0]["sha"], "abc1234")
90
+
91
+ def test_body_ref_to_untracked_issue_does_not_attribute(self):
92
+ """A body that references an issue NOT in github.issues must not
93
+ attribute — otherwise any commit citing any issue would get picked up."""
94
+ log_output = (
95
+ "---COMMIT---\nabc1234|chore: unrelated|2026-04-30T10:00:00+00:00\n"
96
+ "---BODY---\nCloses #9999\n---ENDBODY---\n"
97
+ )
98
+ track = _track({"issues": [4148]})
99
+ with mock.patch("commands.handoff.subprocess.run",
100
+ return_value=_proc(stdout=log_output)):
101
+ commits = handoff._recent_commits(track, SINCE)
102
+ self.assertEqual(commits, [])
103
+
104
+ def test_no_paths_no_issues_returns_empty(self):
105
+ """A track with neither tracked issues nor path globs gets nothing."""
106
+ track = _track({"issues": [], "paths": []})
107
+ with mock.patch("commands.handoff.subprocess.run") as run:
108
+ commits = handoff._recent_commits(track, SINCE)
109
+ run.assert_not_called()
110
+ self.assertEqual(commits, [])
111
+
112
+ def test_explicit_branches_skip_path_globs(self):
113
+ """When `github.branches` is set, paths do not apply (explicit
114
+ branches are the contract)."""
115
+ log_output = "abc1234|merge: feature work|2026-04-30T10:00:00+00:00"
116
+ track = _track({
117
+ "issues": [4148],
118
+ "branches": ["feature/x"],
119
+ "paths": ["should-not-apply/**"],
120
+ })
121
+ with mock.patch("commands.handoff.subprocess.run",
122
+ return_value=_proc(stdout=log_output)) as run:
123
+ commits = handoff._recent_commits(track, SINCE)
124
+ args = run.call_args.args[0]
125
+ self.assertIn("feature/x", args)
126
+ self.assertNotIn("--name-only", args)
127
+ self.assertEqual(len(commits), 1)
128
+
129
+
130
+ class RepoCommitsSinceTest(unittest.TestCase):
131
+ def test_counts_lines_in_log_output(self):
132
+ out = "sha1\nsha2\nsha3\n"
133
+ with mock.patch("commands.handoff.subprocess.run",
134
+ return_value=_proc(stdout=out)):
135
+ n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
136
+ self.assertEqual(n, 3)
137
+
138
+ def test_returns_zero_on_empty(self):
139
+ with mock.patch("commands.handoff.subprocess.run",
140
+ return_value=_proc(stdout="")):
141
+ n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
142
+ self.assertEqual(n, 0)
143
+
144
+ def test_returns_zero_on_failure(self):
145
+ with mock.patch("commands.handoff.subprocess.run",
146
+ return_value=_proc(stdout="", returncode=128)):
147
+ n = handoff._repo_commits_since(Path("/tmp/repo"), SINCE)
148
+ self.assertEqual(n, 0)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ unittest.main()
@@ -0,0 +1,183 @@
1
+ """Tests for --auto-next sibling-claim filtering (#50 corrected scope).
2
+
3
+ `handoff --auto-next` is non-interactive on collisions: when the suggester
4
+ returns issues already next_up on a sibling active track in the same repo,
5
+ those issues are dropped silently (with a transparent "↷ skipped" line),
6
+ NOT prompted. The edit branch falls back to --set-next-style warn/confirm
7
+ because the user is explicit there.
8
+ """
9
+ import io
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from contextlib import redirect_stdout
14
+ from pathlib import Path
15
+ from unittest import mock
16
+
17
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(SKILL_ROOT))
19
+
20
+ from commands import handoff
21
+ from lib.frontmatter import parse_file, write_file
22
+
23
+
24
+ def _make_track(dir_path: Path, slug: str, *, repo: str, status: str = "active",
25
+ next_up=None, issues=None) -> Path:
26
+ meta = {
27
+ "track": slug,
28
+ "status": status,
29
+ "launch_priority": "P1",
30
+ "github": {
31
+ "repo": repo,
32
+ "issues": list(issues or [100, 200, 300, 400]),
33
+ "branches": [],
34
+ },
35
+ "next_up": list(next_up or []),
36
+ }
37
+ body = f"\n# {slug}\n\nBody.\n"
38
+ path = dir_path / f"{slug}.md"
39
+ write_file(path, meta, body)
40
+ return path
41
+
42
+
43
+ def _open_issue(num: int, *, priority: str = "P1") -> dict:
44
+ return {
45
+ "number": num,
46
+ "title": f"issue-{num}",
47
+ "state": "OPEN",
48
+ "labels": [{"name": f"priority/{priority}"}],
49
+ "updatedAt": "2026-04-30T00:00:00Z",
50
+ }
51
+
52
+
53
+ class AutoNextSkipTest(unittest.TestCase):
54
+ def setUp(self):
55
+ self.tmp = tempfile.TemporaryDirectory()
56
+ self.notes_root = Path(self.tmp.name) / "notes_root"
57
+ self.repo_dir = self.notes_root / "demo"
58
+ self.repo_dir.mkdir(parents=True)
59
+
60
+ self.cfg = {
61
+ "notes_root": str(self.notes_root),
62
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
63
+ }
64
+
65
+ self._patches = [
66
+ mock.patch("commands.handoff.load_config", return_value=self.cfg),
67
+ mock.patch("commands.handoff.has_uncommitted", return_value=False),
68
+ ]
69
+ for p in self._patches:
70
+ p.start()
71
+
72
+ def tearDown(self):
73
+ for p in self._patches:
74
+ p.stop()
75
+ self.tmp.cleanup()
76
+
77
+ def _run_auto_next(self, track_name: str, *, issues_response, prompt_answer="y"):
78
+ """Run handoff --auto-next with mocked fetch_issues + prompt_input."""
79
+ buf = io.StringIO()
80
+ with mock.patch("commands.handoff.fetch_issues", return_value=issues_response), \
81
+ mock.patch("commands.handoff.prompt_input", return_value=prompt_answer), \
82
+ redirect_stdout(buf):
83
+ rc = handoff.run([track_name, "--auto-next"])
84
+ return rc, buf.getvalue()
85
+
86
+ def test_no_collisions_passes_through_full_suggestion(self):
87
+ """Sibling has nothing in next_up → suggestion is unchanged."""
88
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
89
+ issues=[100, 200])
90
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[])
91
+
92
+ rc, out = self._run_auto_next("track-a",
93
+ issues_response=[_open_issue(100), _open_issue(200)])
94
+
95
+ self.assertEqual(rc, 0)
96
+ self.assertNotIn("↷ skipped", out)
97
+ meta, _ = parse_file(target)
98
+ self.assertEqual(meta["next_up"], [100, 200])
99
+
100
+ def test_sibling_claimed_issue_is_skipped_with_transparent_line(self):
101
+ """Sibling has #100 → #100 is dropped from suggestion, message printed."""
102
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
103
+ issues=[100, 200])
104
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
105
+
106
+ rc, out = self._run_auto_next("track-a",
107
+ issues_response=[_open_issue(100), _open_issue(200)])
108
+
109
+ self.assertEqual(rc, 0)
110
+ self.assertIn("↷ skipped #100 (already next_up on 'track-b')", out)
111
+ meta, _ = parse_file(target)
112
+ self.assertEqual(meta["next_up"], [200]) # #100 dropped
113
+
114
+ def test_all_suggestions_claimed_returns_zero_unchanged(self):
115
+ """Every suggested issue is sibling-claimed → next_up unchanged, rc 0."""
116
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
117
+ issues=[100, 200], next_up=[42])
118
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo",
119
+ next_up=[100, 200])
120
+
121
+ rc, out = self._run_auto_next("track-a",
122
+ issues_response=[_open_issue(100), _open_issue(200)])
123
+
124
+ self.assertEqual(rc, 0)
125
+ self.assertIn("All suggested issues are already next_up on sibling tracks", out)
126
+ meta, _ = parse_file(target)
127
+ self.assertEqual(meta["next_up"], [42]) # original list intact
128
+
129
+ def test_parked_sibling_does_not_filter(self):
130
+ """Parked sibling holding #100 should NOT trigger a skip — parked
131
+ tracks don't compete for attention."""
132
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
133
+ issues=[100, 200])
134
+ _make_track(self.repo_dir, "track-parked", repo="stylusnexus/Demo",
135
+ status="parked", next_up=[100])
136
+
137
+ rc, out = self._run_auto_next("track-a",
138
+ issues_response=[_open_issue(100), _open_issue(200)])
139
+
140
+ self.assertEqual(rc, 0)
141
+ self.assertNotIn("↷ skipped", out)
142
+ meta, _ = parse_file(target)
143
+ self.assertEqual(meta["next_up"], [100, 200])
144
+
145
+ def test_cross_repo_sibling_does_not_filter(self):
146
+ """Sibling in a different repo holding the same issue number should
147
+ NOT trigger a skip — issue numbers are repo-scoped."""
148
+ other_dir = self.notes_root / "other"
149
+ other_dir.mkdir(parents=True)
150
+ self.cfg["repos"]["other"] = {"github": "stylusnexus/Other"}
151
+
152
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
153
+ issues=[100, 200])
154
+ _make_track(other_dir, "track-other", repo="stylusnexus/Other",
155
+ next_up=[100])
156
+
157
+ rc, out = self._run_auto_next("track-a",
158
+ issues_response=[_open_issue(100), _open_issue(200)])
159
+
160
+ self.assertEqual(rc, 0)
161
+ self.assertNotIn("↷ skipped", out)
162
+ meta, _ = parse_file(target)
163
+ self.assertEqual(meta["next_up"], [100, 200])
164
+
165
+ def test_user_decline_at_apply_prompt_keeps_skipped_record(self):
166
+ """User picks 'n' at the apply prompt → next_up unchanged, but skip
167
+ line was still printed (the filter ran before the prompt)."""
168
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
169
+ issues=[100, 200], next_up=[7])
170
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
171
+
172
+ rc, out = self._run_auto_next("track-a",
173
+ issues_response=[_open_issue(100), _open_issue(200)],
174
+ prompt_answer="n")
175
+
176
+ self.assertEqual(rc, 0)
177
+ self.assertIn("↷ skipped #100", out)
178
+ meta, _ = parse_file(target)
179
+ self.assertEqual(meta["next_up"], [7]) # decline preserved
180
+
181
+
182
+ if __name__ == "__main__":
183
+ unittest.main()
@@ -0,0 +1,149 @@
1
+ """Tests for cross-track next_up collision warning (#50).
2
+
3
+ When the user applies a next_up list to one track, the CLI should warn if any
4
+ of those issues are already next_up on a sibling active track in the same repo.
5
+ The prompt is read-only on local frontmatter — no GitHub calls — and respects
6
+ y/N: 'y' applies anyway, 'N' (default) skips the write.
7
+ """
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+ from unittest import mock
13
+
14
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
15
+ sys.path.insert(0, str(SKILL_ROOT))
16
+
17
+ from commands import handoff
18
+ from lib.frontmatter import parse_file, write_file
19
+
20
+
21
+ def _make_track(dir_path: Path, slug: str, *, repo: str, status: str = "active",
22
+ next_up=None) -> Path:
23
+ meta = {
24
+ "track": slug,
25
+ "status": status,
26
+ "launch_priority": "P1",
27
+ "github": {"repo": repo, "issues": [100, 200, 300, 400], "branches": []},
28
+ "next_up": list(next_up or []),
29
+ }
30
+ body = f"\n# {slug}\n\nBody.\n"
31
+ path = dir_path / f"{slug}.md"
32
+ write_file(path, meta, body)
33
+ return path
34
+
35
+
36
+ class CollisionWarningTest(unittest.TestCase):
37
+ def setUp(self):
38
+ self.tmp = tempfile.TemporaryDirectory()
39
+ self.notes_root = Path(self.tmp.name) / "notes_root"
40
+ self.repo_dir = self.notes_root / "demo"
41
+ self.repo_dir.mkdir(parents=True)
42
+
43
+ self.cfg = {
44
+ "notes_root": str(self.notes_root),
45
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
46
+ }
47
+
48
+ self._patches = [
49
+ mock.patch("commands.handoff.load_config", return_value=self.cfg),
50
+ mock.patch("commands.handoff.fetch_issues", return_value=[]),
51
+ mock.patch("commands.handoff.has_uncommitted", return_value=False),
52
+ ]
53
+ for p in self._patches:
54
+ p.start()
55
+
56
+ def tearDown(self):
57
+ for p in self._patches:
58
+ p.stop()
59
+ self.tmp.cleanup()
60
+
61
+ def test_no_collision_no_prompt(self):
62
+ """No sibling holds the proposed issue → write proceeds without prompt."""
63
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
64
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[999])
65
+
66
+ with mock.patch("commands.handoff.prompt_input") as mock_prompt:
67
+ rc = handoff.run(["track-a", "--set-next", "100,200"])
68
+
69
+ self.assertEqual(rc, 0)
70
+ mock_prompt.assert_not_called()
71
+ meta, _ = parse_file(target)
72
+ self.assertEqual(meta["next_up"], [100, 200])
73
+
74
+ def test_collision_user_accepts_writes(self):
75
+ """Sibling holds #100 in next_up → prompt fires; 'y' writes anyway."""
76
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
77
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
78
+
79
+ with mock.patch("commands.handoff.prompt_input", return_value="y"):
80
+ rc = handoff.run(["track-a", "--set-next", "100,200"])
81
+
82
+ self.assertEqual(rc, 0)
83
+ meta, _ = parse_file(target)
84
+ self.assertEqual(meta["next_up"], [100, 200])
85
+
86
+ def test_collision_user_declines_skips_write(self):
87
+ """Sibling holds #100; user declines → next_up unchanged."""
88
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
89
+ next_up=[42])
90
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
91
+
92
+ with mock.patch("commands.handoff.prompt_input", return_value="n"):
93
+ rc = handoff.run(["track-a", "--set-next", "100,200"])
94
+
95
+ self.assertEqual(rc, 0)
96
+ meta, _ = parse_file(target)
97
+ self.assertEqual(meta["next_up"], [42]) # original list intact
98
+
99
+ def test_parked_sibling_does_not_trigger_warning(self):
100
+ """A parked / abandoned track holding the issue should not flag —
101
+ parked tracks don't compete for attention."""
102
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
103
+ _make_track(self.repo_dir, "track-parked", repo="stylusnexus/Demo",
104
+ status="parked", next_up=[100])
105
+
106
+ with mock.patch("commands.handoff.prompt_input") as mock_prompt:
107
+ rc = handoff.run(["track-a", "--set-next", "100"])
108
+
109
+ self.assertEqual(rc, 0)
110
+ mock_prompt.assert_not_called()
111
+ meta, _ = parse_file(target)
112
+ self.assertEqual(meta["next_up"], [100])
113
+
114
+ def test_cross_repo_does_not_trigger_warning(self):
115
+ """A sibling in a different repo holding the same issue number should
116
+ not flag — issue numbers are repo-scoped."""
117
+ other_repo_dir = self.notes_root / "other"
118
+ other_repo_dir.mkdir(parents=True)
119
+ # Make config aware of the second repo so discover_tracks resolves it.
120
+ self.cfg["repos"]["other"] = {"github": "stylusnexus/Other"}
121
+
122
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo")
123
+ _make_track(other_repo_dir, "track-other", repo="stylusnexus/Other",
124
+ next_up=[100])
125
+
126
+ with mock.patch("commands.handoff.prompt_input") as mock_prompt:
127
+ rc = handoff.run(["track-a", "--set-next", "100"])
128
+
129
+ self.assertEqual(rc, 0)
130
+ mock_prompt.assert_not_called()
131
+ meta, _ = parse_file(target)
132
+ self.assertEqual(meta["next_up"], [100])
133
+
134
+ def test_self_track_excluded_from_check(self):
135
+ """Re-applying the same list to the SAME track must not self-collide."""
136
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
137
+ next_up=[100, 200])
138
+
139
+ with mock.patch("commands.handoff.prompt_input") as mock_prompt:
140
+ rc = handoff.run(["track-a", "--set-next", "100,200"])
141
+
142
+ self.assertEqual(rc, 0)
143
+ mock_prompt.assert_not_called()
144
+ meta, _ = parse_file(target)
145
+ self.assertEqual(meta["next_up"], [100, 200])
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()
@@ -0,0 +1,106 @@
1
+ """Tests for `handoff --set-next` flag (Claude-driven next_up persistence)."""
2
+ import os
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest import mock
8
+
9
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(SKILL_ROOT))
11
+
12
+ from commands import handoff
13
+ from lib.frontmatter import parse_file, write_file
14
+
15
+
16
+ def _make_track_file(dir_path: Path, slug: str = "demo-track") -> Path:
17
+ """Build a minimal track .md with empty next_up + frontmatter the
18
+ handoff command can resolve."""
19
+ meta = {
20
+ "track": slug,
21
+ "status": "active",
22
+ "launch_priority": "P1",
23
+ "github": {"repo": "stylusnexus/Demo", "issues": [100, 200, 300], "branches": []},
24
+ "next_up": [],
25
+ }
26
+ body = "\n# Demo\n\nBody content for the track.\n"
27
+ path = dir_path / f"{slug}.md"
28
+ write_file(path, meta, body)
29
+ return path
30
+
31
+
32
+ class HandoffSetNextTest(unittest.TestCase):
33
+ def setUp(self):
34
+ self.tmp = tempfile.TemporaryDirectory()
35
+ self.notes_root = Path(self.tmp.name) / "notes_root"
36
+ # Tracks live under <notes_root>/<repo-folder>/<slug>.md so config
37
+ # discovery + repo resolution work the same as in production.
38
+ self.repo_dir = self.notes_root / "demo"
39
+ self.repo_dir.mkdir(parents=True)
40
+ self.track_path = _make_track_file(self.repo_dir, "demo-track")
41
+
42
+ # Stub config so discover_tracks walks our temp notes_root.
43
+ self.cfg = {
44
+ "notes_root": str(self.notes_root),
45
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
46
+ }
47
+
48
+ # Patch load_config to return our stub.
49
+ self._patches = [
50
+ mock.patch("commands.handoff.load_config", return_value=self.cfg),
51
+ # Skip GitHub fetch — fetch_issues hits `gh` over the network
52
+ # in production. Return [] so the rest of handoff runs purely
53
+ # off the body + frontmatter.
54
+ mock.patch("commands.handoff.fetch_issues", return_value=[]),
55
+ # Avoid scanning a real git repo — track has no local_path and
56
+ # current_branch isn't called when local_path is None, but be
57
+ # defensive in case derived_handoff drifts.
58
+ mock.patch("commands.handoff.has_uncommitted", return_value=False),
59
+ ]
60
+ for p in self._patches:
61
+ p.start()
62
+
63
+ def tearDown(self):
64
+ for p in self._patches:
65
+ p.stop()
66
+ self.tmp.cleanup()
67
+
68
+ def test_set_next_persists_to_frontmatter(self):
69
+ """--set-next 100,200,300 should write next_up: [100, 200, 300]."""
70
+ rc = handoff.run(["demo-track", "--set-next", "100,200,300"])
71
+ self.assertEqual(rc, 0)
72
+ meta, _ = parse_file(self.track_path)
73
+ self.assertEqual(meta["next_up"], [100, 200, 300])
74
+
75
+ def test_set_next_replaces_existing_list(self):
76
+ """--set-next overwrites any prior next_up entries."""
77
+ meta, body = parse_file(self.track_path)
78
+ meta["next_up"] = [9999]
79
+ write_file(self.track_path, meta, body)
80
+
81
+ rc = handoff.run(["demo-track", "--set-next", "100,200"])
82
+ self.assertEqual(rc, 0)
83
+ meta, _ = parse_file(self.track_path)
84
+ self.assertEqual(meta["next_up"], [100, 200])
85
+
86
+ def test_set_next_equals_form_also_works(self):
87
+ """--set-next=100,200 (key=value) should behave the same as space form."""
88
+ rc = handoff.run(["demo-track", "--set-next=100,200"])
89
+ self.assertEqual(rc, 0)
90
+ meta, _ = parse_file(self.track_path)
91
+ self.assertEqual(meta["next_up"], [100, 200])
92
+
93
+ def test_set_next_rejects_non_numeric(self):
94
+ """Garbage input → exit 2, frontmatter untouched."""
95
+ # Pre-condition: next_up is empty.
96
+ meta, _ = parse_file(self.track_path)
97
+ self.assertEqual(meta["next_up"], [])
98
+
99
+ rc = handoff.run(["demo-track", "--set-next", "not-numbers"])
100
+ self.assertEqual(rc, 2)
101
+ meta, _ = parse_file(self.track_path)
102
+ self.assertEqual(meta["next_up"], []) # unchanged
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main()