@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,292 @@
1
+ """Tests for the non-interactive set-notes-root command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - set-notes-root /some/new/path → yq called with correct expression, mkdir
5
+ called, rc 0.
6
+ - Missing positional path → rc 2, no yq call.
7
+ - Orphan warning: current notes_root differs and discover_tracks returns ≥1
8
+ track → WARN line printed but rc 0 and yq still called.
9
+ - No warning when new path equals current notes_root.
10
+ - No warning when discover_tracks returns no tracks.
11
+ - yq failure (CalledProcessError) → rc 1.
12
+ - Subcommand registered in SUBCOMMANDS and DESCRIPTIONS.
13
+ - Non-interactive guard: input()/prompt_input patched to raise, must not fire.
14
+ """
15
+ import io
16
+ import subprocess
17
+ import sys
18
+ import unittest
19
+ from contextlib import redirect_stdout
20
+ from pathlib import Path
21
+ from unittest.mock import patch, MagicMock, call
22
+
23
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
24
+ sys.path.insert(0, str(SKILL_ROOT))
25
+
26
+ import work_plan
27
+ from commands import set_notes_root
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def _make_cfg(*, notes_root="/tmp/old-notes"):
35
+ return {"notes_root": notes_root, "repos": {}}
36
+
37
+
38
+ def _fake_track():
39
+ """Return a minimal Track-like object (just needs to be truthy in a list)."""
40
+ t = MagicMock()
41
+ t.has_frontmatter = True
42
+ return t
43
+
44
+
45
+ def _drive(args, *, cfg_notes_root="/tmp/old-notes", tracks=None, yq_raises=False):
46
+ """Run set_notes_root.run(args) with all external I/O mocked.
47
+
48
+ cfg_notes_root: the current notes_root recorded in config.
49
+ tracks: list returned by discover_tracks (default []).
50
+ yq_raises: if True, subprocess.run raises CalledProcessError.
51
+ """
52
+ cfg = _make_cfg(notes_root=cfg_notes_root)
53
+ if tracks is None:
54
+ tracks = []
55
+
56
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
57
+
58
+ if yq_raises:
59
+ err = subprocess.CalledProcessError(1, ["yq"], stderr="yq error")
60
+ sub_side = err
61
+ else:
62
+ sub_side = None
63
+
64
+ with patch("commands.set_notes_root.load_config", return_value=cfg), \
65
+ patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
66
+ patch("commands.set_notes_root.subprocess.run",
67
+ return_value=mock_proc,
68
+ side_effect=sub_side) as msub, \
69
+ patch("pathlib.Path.mkdir") as mmkdir:
70
+ buf = io.StringIO()
71
+ with redirect_stdout(buf):
72
+ rc = set_notes_root.run(args)
73
+ return rc, msub, mmkdir, buf.getvalue()
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Test cases
78
+ # ---------------------------------------------------------------------------
79
+
80
+ class SetNotesRootTest(unittest.TestCase):
81
+
82
+ # ------------------------------------------------------------------
83
+ # Happy path: updates config and creates dir
84
+ # ------------------------------------------------------------------
85
+
86
+ def test_happy_path_calls_yq_and_mkdir(self):
87
+ """set-notes-root /some/new/path → yq -i called with the absolute path
88
+ expression, mkdir called, rc 0."""
89
+ rc, msub, mmkdir, out = _drive(
90
+ ["/some/new/path"],
91
+ cfg_notes_root="/tmp/old-notes",
92
+ )
93
+ self.assertEqual(rc, 0)
94
+
95
+ # yq must have been called
96
+ msub.assert_called_once()
97
+ yq_args = msub.call_args[0][0]
98
+ self.assertEqual(yq_args[0], "yq")
99
+ self.assertEqual(yq_args[1], "-i")
100
+ # Expression must set .notes_root to the absolute path. The command
101
+ # resolves the input to an absolute path, which on Windows uses a drive
102
+ # letter + backslashes — so compare against the same resolution, not the
103
+ # raw POSIX input string.
104
+ expected = str(Path("/some/new/path").expanduser().resolve())
105
+ expr = yq_args[2]
106
+ self.assertIn(".notes_root", expr)
107
+ self.assertIn(expected, expr)
108
+
109
+ # mkdir must have been called (creates the dir)
110
+ mmkdir.assert_called_once()
111
+
112
+ # Success confirmation in output
113
+ self.assertIn("✓", out)
114
+ self.assertIn(expected, out)
115
+
116
+ def test_yq_receives_config_path_as_last_arg(self):
117
+ """yq -i call passes DEFAULT_CONFIG_PATH as the file argument."""
118
+ from lib.config import DEFAULT_CONFIG_PATH
119
+ rc, msub, mmkdir, out = _drive(["/new/path"])
120
+ self.assertEqual(rc, 0)
121
+ yq_args = msub.call_args[0][0]
122
+ self.assertEqual(yq_args[-1], str(DEFAULT_CONFIG_PATH))
123
+
124
+ # ------------------------------------------------------------------
125
+ # Missing positional → rc 2
126
+ # ------------------------------------------------------------------
127
+
128
+ def test_missing_path_returns_rc2(self):
129
+ """No positional argument → rc 2, yq NOT called."""
130
+ rc, msub, mmkdir, out = _drive([])
131
+ self.assertEqual(rc, 2)
132
+ msub.assert_not_called()
133
+ self.assertIn("usage", out.lower())
134
+
135
+ # ------------------------------------------------------------------
136
+ # Orphan warning: tracks exist at old root
137
+ # ------------------------------------------------------------------
138
+
139
+ def test_orphan_warning_when_tracks_exist_and_root_differs(self):
140
+ """New path differs from current, discover_tracks returns 1 track →
141
+ WARN line printed, rc 0, yq still called."""
142
+ tracks = [_fake_track()]
143
+ rc, msub, mmkdir, out = _drive(
144
+ ["/some/new/path"],
145
+ cfg_notes_root="/tmp/old-notes",
146
+ tracks=tracks,
147
+ )
148
+ self.assertEqual(rc, 0)
149
+ msub.assert_called_once()
150
+ self.assertIn("WARN", out)
151
+
152
+ def test_orphan_warning_names_count(self):
153
+ """Orphan warning mentions the track count."""
154
+ tracks = [_fake_track(), _fake_track(), _fake_track()]
155
+ rc, msub, mmkdir, out = _drive(
156
+ ["/brand/new/path"],
157
+ cfg_notes_root="/tmp/old-notes",
158
+ tracks=tracks,
159
+ )
160
+ self.assertEqual(rc, 0)
161
+ self.assertIn("3", out)
162
+
163
+ def test_orphan_warning_mentions_not_moved(self):
164
+ """Orphan warning states tracks will NOT be moved."""
165
+ tracks = [_fake_track()]
166
+ rc, msub, mmkdir, out = _drive(
167
+ ["/brand/new/path"],
168
+ cfg_notes_root="/tmp/old-notes",
169
+ tracks=tracks,
170
+ )
171
+ self.assertIn("WARN", out)
172
+ # The warning should communicate non-movement
173
+ out_lower = out.lower()
174
+ self.assertTrue(
175
+ "not" in out_lower or "won't" in out_lower or "manual" in out_lower,
176
+ f"Expected move-warning language in: {out!r}",
177
+ )
178
+
179
+ # ------------------------------------------------------------------
180
+ # No warning when new path equals current notes_root
181
+ # ------------------------------------------------------------------
182
+
183
+ def test_no_warning_when_path_unchanged(self):
184
+ """New path resolves to same location as current → no WARN, rc 0."""
185
+ tracks = [_fake_track()]
186
+ rc, msub, mmkdir, out = _drive(
187
+ ["/tmp/old-notes"],
188
+ cfg_notes_root="/tmp/old-notes",
189
+ tracks=tracks,
190
+ )
191
+ self.assertEqual(rc, 0)
192
+ self.assertNotIn("WARN", out)
193
+
194
+ # ------------------------------------------------------------------
195
+ # No warning when there are no tracks
196
+ # ------------------------------------------------------------------
197
+
198
+ def test_no_warning_when_no_tracks(self):
199
+ """Paths differ but no tracks → no WARN, rc 0."""
200
+ rc, msub, mmkdir, out = _drive(
201
+ ["/some/new/path"],
202
+ cfg_notes_root="/tmp/old-notes",
203
+ tracks=[],
204
+ )
205
+ self.assertEqual(rc, 0)
206
+ self.assertNotIn("WARN", out)
207
+
208
+ # ------------------------------------------------------------------
209
+ # yq failure → rc 1
210
+ # ------------------------------------------------------------------
211
+
212
+ def test_yq_failure_returns_rc1(self):
213
+ """CalledProcessError from yq → error message printed, rc 1."""
214
+ rc, msub, mmkdir, out = _drive(
215
+ ["/some/new/path"],
216
+ yq_raises=True,
217
+ )
218
+ self.assertEqual(rc, 1)
219
+ self.assertIn("ERROR", out)
220
+
221
+ # ------------------------------------------------------------------
222
+ # Subcommand registration
223
+ # ------------------------------------------------------------------
224
+
225
+ def test_subcommand_registered_in_subcommands(self):
226
+ """'set-notes-root' appears in work_plan.SUBCOMMANDS."""
227
+ self.assertIn("set-notes-root", work_plan.SUBCOMMANDS)
228
+
229
+ def test_subcommand_registered_in_descriptions(self):
230
+ """'set-notes-root' appears in work_plan.DESCRIPTIONS."""
231
+ names = [entry[0] for entry in work_plan.DESCRIPTIONS]
232
+ self.assertIn("set-notes-root", names)
233
+
234
+ def test_subcommand_module_path(self):
235
+ """SUBCOMMANDS['set-notes-root'] points to commands.set_notes_root."""
236
+ self.assertEqual(
237
+ work_plan.SUBCOMMANDS["set-notes-root"],
238
+ "commands.set_notes_root",
239
+ )
240
+
241
+ # ------------------------------------------------------------------
242
+ # Non-interactive guard
243
+ # ------------------------------------------------------------------
244
+
245
+ def test_no_input_called_happy_path(self):
246
+ """Happy path must not call input() or prompt_input."""
247
+ cfg = _make_cfg(notes_root="/tmp/old-notes")
248
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
249
+
250
+ def _raise(*a, **kw):
251
+ raise AssertionError(
252
+ "input() must not be called — command must be non-interactive"
253
+ )
254
+
255
+ with patch("builtins.input", side_effect=_raise), \
256
+ patch("lib.prompts.prompt_input", side_effect=_raise), \
257
+ patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
258
+ patch("commands.set_notes_root.load_config", return_value=cfg), \
259
+ patch("commands.set_notes_root.discover_tracks", return_value=[]), \
260
+ patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
261
+ patch("pathlib.Path.mkdir"):
262
+ buf = io.StringIO()
263
+ with redirect_stdout(buf):
264
+ rc = set_notes_root.run(["/some/path"])
265
+ self.assertEqual(rc, 0)
266
+
267
+ def test_no_input_called_with_tracks(self):
268
+ """Orphan warning path must also not call input()."""
269
+ cfg = _make_cfg(notes_root="/tmp/old-notes")
270
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
271
+ tracks = [_fake_track()]
272
+
273
+ def _raise(*a, **kw):
274
+ raise AssertionError(
275
+ "input() must not be called — command must be non-interactive"
276
+ )
277
+
278
+ with patch("builtins.input", side_effect=_raise), \
279
+ patch("lib.prompts.prompt_input", side_effect=_raise), \
280
+ patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
281
+ patch("commands.set_notes_root.load_config", return_value=cfg), \
282
+ patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
283
+ patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
284
+ patch("pathlib.Path.mkdir"):
285
+ buf = io.StringIO()
286
+ with redirect_stdout(buf):
287
+ rc = set_notes_root.run(["/some/new/path"])
288
+ self.assertEqual(rc, 0)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ unittest.main()
@@ -0,0 +1,243 @@
1
+ """Tests for the non-interactive slot command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - Slots a new issue into a private-repo track → write_file called, rc 0.
5
+ - Issue already present → no write, rc 0, prints "already in".
6
+ - Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0.
7
+ - Public repo with valid --confirm=<token> → write_file called, rc 0.
8
+ - --move with a prior owner → removes issue from prior owner (two writes).
9
+ - Default / --no-move with a prior owner → prior owner NOT modified (one write)
10
+ and "still listed … --move" note is printed.
11
+ - Bad issue number / no positional → rc 2.
12
+ - No input() is reached on the non-interactive flagged paths.
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 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 slot.run(args) with all external I/O mocked.
50
+
51
+ vis controls what repo_visibility returns (used by needs_confirm).
52
+ tracks defaults to a single empty private-repo track named 'alpha'.
53
+ """
54
+ if tracks is None:
55
+ tracks = [_track(name="alpha", repo="ok/repo", issues=[])]
56
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
57
+ gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
58
+
59
+ with patch("commands.slot.load_config", return_value=cfg), \
60
+ patch("commands.slot.discover_tracks", return_value=tracks), \
61
+ patch("commands.slot.subprocess.run", return_value=gh_proc), \
62
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
63
+ patch("commands.slot.write_file") as mw:
64
+ buf = io.StringIO()
65
+ with redirect_stdout(buf):
66
+ rc = slot.run(args)
67
+ return rc, mw, buf.getvalue()
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Test cases
72
+ # ---------------------------------------------------------------------------
73
+
74
+ class SlotNonInteractiveTest(unittest.TestCase):
75
+
76
+ # ------------------------------------------------------------------
77
+ # Basic slot (private repo)
78
+ # ------------------------------------------------------------------
79
+
80
+ def test_slots_new_issue_into_private_track(self):
81
+ """Slots a new issue into a private-repo track → write_file called, rc 0."""
82
+ track = _track(name="alpha", repo="ok/repo", issues=[10, 20])
83
+ rc, mw, out = _drive(["30", "alpha"], tracks=[track], vis="PRIVATE")
84
+ self.assertEqual(rc, 0)
85
+ mw.assert_called_once()
86
+ written_meta = mw.call_args[0][1]
87
+ self.assertIn(30, written_meta["github"]["issues"])
88
+ # Issues are sorted
89
+ self.assertEqual(sorted(written_meta["github"]["issues"]),
90
+ written_meta["github"]["issues"])
91
+
92
+ def test_already_present_no_write(self):
93
+ """Issue already present → no write, rc 0, prints 'already in'."""
94
+ track = _track(name="alpha", repo="ok/repo", issues=[42])
95
+ rc, mw, out = _drive(["42", "alpha"], tracks=[track], vis="PRIVATE")
96
+ self.assertEqual(rc, 0)
97
+ mw.assert_not_called()
98
+ self.assertIn("already in", out)
99
+
100
+ # ------------------------------------------------------------------
101
+ # Confirm-token gate (public repo)
102
+ # ------------------------------------------------------------------
103
+
104
+ def test_public_repo_no_token_returns_needs_confirm_json(self):
105
+ """Public repo, no token → prints needs_confirm JSON, write_file NOT called, rc 0."""
106
+ import json
107
+ track = _track(name="alpha", repo="ok/repo", issues=[])
108
+ rc, mw, out = _drive(["99", "alpha"], tracks=[track], vis="PUBLIC")
109
+ self.assertEqual(rc, 0)
110
+ mw.assert_not_called()
111
+ # Output should be parseable JSON with needs_confirm key
112
+ data = json.loads(out.strip())
113
+ self.assertTrue(data["needs_confirm"])
114
+ self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
115
+
116
+ def test_public_repo_unknown_visibility_returns_needs_confirm_json(self):
117
+ """Unknown visibility (None) → also requires confirm."""
118
+ import json
119
+ track = _track(name="alpha", repo="ok/repo", issues=[])
120
+ rc, mw, out = _drive(["99", "alpha"], tracks=[track], vis=None)
121
+ self.assertEqual(rc, 0)
122
+ mw.assert_not_called()
123
+ data = json.loads(out.strip())
124
+ self.assertTrue(data["needs_confirm"])
125
+
126
+ def test_public_repo_with_valid_confirm_performs_write(self):
127
+ """Public repo with valid --confirm=<token> → write_file called, rc 0."""
128
+ track = _track(name="alpha", repo="ok/repo", issues=[])
129
+ tok = make_token("ok/repo", "alpha")
130
+ rc, mw, out = _drive(["99", "alpha", f"--confirm={tok}"],
131
+ tracks=[track], vis="PUBLIC")
132
+ self.assertEqual(rc, 0)
133
+ mw.assert_called_once()
134
+
135
+ def test_public_repo_with_wrong_token_blocks_write(self):
136
+ """Public repo with wrong confirm token → blocked, no write."""
137
+ import json
138
+ track = _track(name="alpha", repo="ok/repo", issues=[])
139
+ rc, mw, out = _drive(["99", "alpha", "--confirm=badtoken"],
140
+ tracks=[track], vis="PUBLIC")
141
+ self.assertEqual(rc, 0)
142
+ mw.assert_not_called()
143
+ data = json.loads(out.strip())
144
+ self.assertTrue(data["needs_confirm"])
145
+
146
+ # ------------------------------------------------------------------
147
+ # --move / --no-move flags
148
+ # ------------------------------------------------------------------
149
+
150
+ def test_move_flag_removes_issue_from_prior_owner(self):
151
+ """--move with a prior owner → removes issue from source, writes both."""
152
+ source = _track(name="alpha", repo="ok/repo", issues=[42])
153
+ target = _track(name="beta", repo="ok/repo", issues=[])
154
+ rc, mw, out = _drive(["42", "beta", "--move"],
155
+ tracks=[source, target], vis="PRIVATE")
156
+ self.assertEqual(rc, 0)
157
+ self.assertEqual(2, mw.call_count,
158
+ "source + target should both be written with --move")
159
+ self.assertNotIn(42, source.meta["github"]["issues"])
160
+ self.assertIn(42, target.meta["github"]["issues"])
161
+
162
+ def test_default_no_move_preserves_prior_owner(self):
163
+ """Default (no flags) with a prior owner → prior owner NOT modified; note printed."""
164
+ source = _track(name="alpha", repo="ok/repo", issues=[42])
165
+ target = _track(name="beta", repo="ok/repo", issues=[])
166
+ rc, mw, out = _drive(["42", "beta"], tracks=[source, target], vis="PRIVATE")
167
+ self.assertEqual(rc, 0)
168
+ mw.assert_called_once() # only target written
169
+ # Source is untouched
170
+ self.assertIn(42, source.meta["github"]["issues"])
171
+ self.assertIn(42, target.meta["github"]["issues"])
172
+ # Note about --move must be printed
173
+ self.assertIn("--move", out)
174
+ self.assertIn("alpha", out)
175
+
176
+ def test_explicit_no_move_preserves_prior_owner(self):
177
+ """Explicit --no-move behaves same as default: prior owner NOT modified."""
178
+ source = _track(name="alpha", repo="ok/repo", issues=[42])
179
+ target = _track(name="beta", repo="ok/repo", issues=[])
180
+ rc, mw, out = _drive(["42", "beta", "--no-move"],
181
+ tracks=[source, target], vis="PRIVATE")
182
+ self.assertEqual(rc, 0)
183
+ mw.assert_called_once()
184
+ self.assertIn(42, source.meta["github"]["issues"])
185
+ self.assertIn(42, target.meta["github"]["issues"])
186
+ self.assertIn("--move", out)
187
+
188
+ # ------------------------------------------------------------------
189
+ # Error cases
190
+ # ------------------------------------------------------------------
191
+
192
+ def test_no_positional_args_returns_rc2(self):
193
+ """No positional arguments → rc 2 (usage error)."""
194
+ rc, mw, out = _drive([])
195
+ self.assertEqual(rc, 2)
196
+ mw.assert_not_called()
197
+
198
+ def test_bad_issue_number_returns_rc2(self):
199
+ """Non-integer issue number → rc 2."""
200
+ rc, mw, out = _drive(["notanumber", "alpha"])
201
+ self.assertEqual(rc, 2)
202
+ mw.assert_not_called()
203
+
204
+ def test_move_and_no_move_together_returns_rc2(self):
205
+ """Passing both --move and --no-move → rc 2, write_file NOT called."""
206
+ track = _track(name="alpha", repo="ok/repo", issues=[42])
207
+ rc, mw, out = _drive(["42", "alpha", "--move", "--no-move"], tracks=[track], vis="PRIVATE")
208
+ self.assertEqual(rc, 2)
209
+ mw.assert_not_called()
210
+ self.assertIn("ERROR", out)
211
+ self.assertIn("mutually exclusive", out)
212
+
213
+ # ------------------------------------------------------------------
214
+ # No input() on non-interactive paths
215
+ # ------------------------------------------------------------------
216
+
217
+ def test_no_input_called_on_flagged_paths(self):
218
+ """Flagged paths (issue + track given) never call input() even if
219
+ prior owners exist or a public repo is detected."""
220
+ source = _track(name="alpha", repo="ok/repo", issues=[42])
221
+ target = _track(name="beta", repo="ok/repo", issues=[])
222
+
223
+ def _raise(*a, **kw):
224
+ raise AssertionError("input() must not be called on non-interactive path")
225
+
226
+ with patch("builtins.input", side_effect=_raise), \
227
+ patch("lib.prompts.prompt_input", side_effect=_raise):
228
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
229
+ gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
230
+ with patch("commands.slot.load_config", return_value=cfg), \
231
+ patch("commands.slot.discover_tracks", return_value=[source, target]), \
232
+ patch("commands.slot.subprocess.run", return_value=gh_proc), \
233
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
234
+ patch("commands.slot.write_file"):
235
+ buf = io.StringIO()
236
+ with redirect_stdout(buf):
237
+ # --move (prior owner path) + private repo (no confirm gate)
238
+ rc = slot.run(["42", "beta", "--move"])
239
+ self.assertEqual(rc, 0)
240
+
241
+
242
+ if __name__ == "__main__":
243
+ unittest.main()
@@ -0,0 +1,128 @@
1
+ """Tests for slot's prior-ownership detection (issue #62).
2
+
3
+ Before #62, `slot` was add-only — running `slot 4562 chat-nlu` while #4562 was
4
+ already listed in `ai-generators.md` frontmatter would leave the issue in BOTH
5
+ tracks. The only fix was hand-editing YAML, which SKILL.md explicitly warns
6
+ against. These tests pin the non-interactive behavior introduced in #87:
7
+ detect prior ownership, and move only on explicit --move so non-interactive
8
+ runs preserve add-only semantics by default.
9
+ """
10
+ import sys
11
+ import unittest
12
+ from pathlib import Path
13
+ from types import SimpleNamespace
14
+ from unittest.mock import MagicMock, patch
15
+
16
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
17
+ sys.path.insert(0, str(SKILL_ROOT))
18
+
19
+ from commands import slot
20
+
21
+
22
+ def _track(*, name, repo, issues, status="active"):
23
+ return SimpleNamespace(
24
+ name=name,
25
+ path=Path(f"/tmp/fake/{name}.md"),
26
+ body="# fake",
27
+ meta={
28
+ "track": name,
29
+ "status": status,
30
+ "github": {"repo": repo, "issues": list(issues)},
31
+ },
32
+ has_frontmatter=True,
33
+ repo=repo,
34
+ )
35
+
36
+
37
+ class SlotMoveTest(unittest.TestCase):
38
+ def _drive(self, *, tracks, args):
39
+ cfg = {"notes_root": "/tmp/fake-notes",
40
+ "repos": {"ok": {"github": "ok/ok"}}}
41
+ gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
42
+ with patch("commands.slot.subprocess.run", return_value=gh_proc), \
43
+ patch("commands.slot.load_config", return_value=cfg), \
44
+ patch("commands.slot.discover_tracks", return_value=tracks), \
45
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
46
+ patch("commands.slot.write_file") as mw:
47
+ rc = slot.run(args)
48
+ return rc, mw
49
+
50
+ def test_no_prior_ownership_writes_only_target(self):
51
+ target = _track(name="alpha", repo="ok/ok", issues=[])
52
+ rc, mw = self._drive(tracks=[target], args=["100", "alpha"])
53
+ self.assertEqual(rc, 0)
54
+ mw.assert_called_once()
55
+ self.assertEqual(100, target.meta["github"]["issues"][0])
56
+
57
+ def test_prior_ownership_with_move_flag_removes_from_source(self):
58
+ source = _track(name="alpha", repo="ok/ok", issues=[42])
59
+ target = _track(name="beta", repo="ok/ok", issues=[])
60
+ rc, mw = self._drive(
61
+ tracks=[source, target], args=["42", "beta", "--move"],
62
+ )
63
+ self.assertEqual(rc, 0)
64
+ self.assertEqual(2, mw.call_count, "source + target should both be written")
65
+ self.assertEqual([], source.meta["github"]["issues"])
66
+ self.assertEqual([42], target.meta["github"]["issues"])
67
+
68
+ def test_prior_ownership_without_move_flag_preserves_add_only(self):
69
+ # Default (no --move flag) → pre-#62 behavior: target gets the issue,
70
+ # source is untouched (duplicated state that reconcile can later FLAG).
71
+ source = _track(name="alpha", repo="ok/ok", issues=[42])
72
+ target = _track(name="beta", repo="ok/ok", issues=[])
73
+ rc, mw = self._drive(
74
+ tracks=[source, target], args=["42", "beta"],
75
+ )
76
+ self.assertEqual(rc, 0)
77
+ mw.assert_called_once()
78
+ self.assertEqual([42], source.meta["github"]["issues"])
79
+ self.assertEqual([42], target.meta["github"]["issues"])
80
+
81
+ def test_explicit_no_move_flag_preserves_add_only(self):
82
+ # --no-move behaves identically to the default.
83
+ source = _track(name="alpha", repo="ok/ok", issues=[42])
84
+ target = _track(name="beta", repo="ok/ok", issues=[])
85
+ rc, mw = self._drive(
86
+ tracks=[source, target], args=["42", "beta", "--no-move"],
87
+ )
88
+ self.assertEqual(rc, 0)
89
+ mw.assert_called_once()
90
+ self.assertEqual([42], source.meta["github"]["issues"])
91
+
92
+ def test_already_in_target_short_circuits(self):
93
+ # Issue already listed in target → no write.
94
+ source = _track(name="alpha", repo="ok/ok", issues=[42])
95
+ target = _track(name="beta", repo="ok/ok", issues=[42])
96
+ rc, mw = self._drive(
97
+ tracks=[source, target], args=["42", "beta"],
98
+ )
99
+ self.assertEqual(rc, 0)
100
+ mw.assert_not_called()
101
+
102
+ def test_cross_repo_issue_not_detected_as_prior_owner(self):
103
+ # Same number in a different repo is a different issue. The prior-
104
+ # owner sweep MUST filter by track.repo or it will spuriously offer
105
+ # to move unrelated issues across repos.
106
+ other_repo = _track(name="alpha", repo="other/repo", issues=[42])
107
+ target = _track(name="beta", repo="ok/ok", issues=[])
108
+ rc, mw = self._drive(
109
+ tracks=[other_repo, target], args=["42", "beta"],
110
+ )
111
+ self.assertEqual(rc, 0)
112
+ mw.assert_called_once()
113
+ self.assertEqual([42], other_repo.meta["github"]["issues"])
114
+
115
+ def test_inactive_source_not_detected_as_prior_owner(self):
116
+ # Archived/parked tracks shouldn't be candidates — moving FROM a
117
+ # closed track is the wrong mental model; that's a reopen, not a slot.
118
+ parked = _track(name="alpha", repo="ok/ok", issues=[42], status="parked")
119
+ target = _track(name="beta", repo="ok/ok", issues=[])
120
+ rc, mw = self._drive(
121
+ tracks=[parked, target], args=["42", "beta"],
122
+ )
123
+ self.assertEqual(rc, 0)
124
+ self.assertEqual([42], parked.meta["github"]["issues"])
125
+
126
+
127
+ if __name__ == "__main__":
128
+ unittest.main()