@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,445 @@
1
+ """Tests for the non-interactive new-track command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - Creates a track for a config-key repo on a PRIVATE repo → write_file called
5
+ with the right meta (github.repo = key's github value; status active;
6
+ priority/milestone defaults), rc 0.
7
+ - Creates a track for a bare org/repo slug → folder = name part after '/',
8
+ github = full slug, rc 0.
9
+ - Public repo, no token → needs_confirm JSON, write_file NOT called, rc 0;
10
+ token == make_token(github, slug).
11
+ - Public repo, valid --confirm=<token> → creates (write_file called), rc 0.
12
+ - Existing path → rc 2, no write.
13
+ - Unknown repo (not a config key, no slash) → rc 1, no write.
14
+ - Invalid slug → rc 2.
15
+ - --priority=P1 --milestone=v2 reflected in meta; defaults applied when absent.
16
+ - --private accepted without error, creates normally, rc 0.
17
+ - "new-track" in SUBCOMMANDS and appears in DESCRIPTIONS.
18
+ """
19
+ import io
20
+ import json
21
+ import sys
22
+ import unittest
23
+ from contextlib import redirect_stdout
24
+ from pathlib import Path
25
+ from unittest.mock import patch, MagicMock, call
26
+
27
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
28
+ sys.path.insert(0, str(SKILL_ROOT))
29
+
30
+ from commands import new_track
31
+ from lib.write_guard import make_token
32
+ import work_plan
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+ NOTES_ROOT = "/tmp/fake-notes"
40
+
41
+ def _make_cfg(*, repos=None):
42
+ if repos is None:
43
+ repos = {
44
+ "myrepo": {"github": "org/myrepo", "local": None},
45
+ "critforge": {"github": "stylusnexus/critforge", "local": None},
46
+ }
47
+ return {"notes_root": NOTES_ROOT, "repos": repos}
48
+
49
+
50
+ def _drive(args, *, vis="PRIVATE", notes_root_exists=True, target_path_exists=False):
51
+ """Run new_track.run(args) with all external I/O mocked.
52
+
53
+ vis: what repo_visibility returns for needs_confirm.
54
+ notes_root_exists: whether notes_root directory exists.
55
+ target_path_exists: whether the target .md path already exists.
56
+ """
57
+ cfg = _make_cfg()
58
+
59
+ def _path_exists(self):
60
+ # notes_root itself → notes_root_exists; target path → target_path_exists.
61
+ # Compare with Path equality (not str ==): on Windows str(Path("/tmp/x"))
62
+ # uses backslashes, so an exact "/tmp/fake-notes" string match never fires
63
+ # and the notes_root-missing case can't be simulated.
64
+ if self == Path(NOTES_ROOT):
65
+ return notes_root_exists
66
+ if self.suffix == ".md":
67
+ return target_path_exists
68
+ # archive dirs and parent dirs default to True
69
+ return True
70
+
71
+ with patch("commands.new_track.load_config", return_value=cfg), \
72
+ patch("commands.new_track.write_file") as mw, \
73
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
74
+ patch("pathlib.Path.exists", _path_exists), \
75
+ patch("pathlib.Path.mkdir"):
76
+ buf = io.StringIO()
77
+ with redirect_stdout(buf):
78
+ rc = new_track.run(args)
79
+ return rc, mw, buf.getvalue()
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Test cases
84
+ # ---------------------------------------------------------------------------
85
+
86
+ class NewTrackCommandTest(unittest.TestCase):
87
+
88
+ # ------------------------------------------------------------------
89
+ # Registry checks
90
+ # ------------------------------------------------------------------
91
+
92
+ def test_subcommand_registered_in_subcommands(self):
93
+ """'new-track' must be in work_plan.SUBCOMMANDS."""
94
+ self.assertIn("new-track", work_plan.SUBCOMMANDS)
95
+
96
+ def test_subcommand_appears_in_descriptions(self):
97
+ """'new-track' must appear in work_plan.DESCRIPTIONS."""
98
+ names = [entry[0] for entry in work_plan.DESCRIPTIONS]
99
+ self.assertIn("new-track", names)
100
+
101
+ # ------------------------------------------------------------------
102
+ # Config-key repo (PRIVATE)
103
+ # ------------------------------------------------------------------
104
+
105
+ def test_config_key_private_creates_track(self):
106
+ """Config-key repo 'myrepo' (PRIVATE) → write_file called with
107
+ github.repo = 'org/myrepo', status = 'active', rc 0."""
108
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
109
+ self.assertEqual(rc, 0)
110
+ mw.assert_called_once()
111
+ meta = mw.call_args[0][1]
112
+ self.assertEqual(meta["github"]["repo"], "org/myrepo")
113
+ self.assertEqual(meta["status"], "active")
114
+
115
+ def test_config_key_folder_resolves_correctly(self):
116
+ """Config-key 'critforge' → folder = 'critforge',
117
+ github.repo = 'stylusnexus/critforge'."""
118
+ rc, mw, out = _drive(["critforge", "encounter-builder"], vis="PRIVATE")
119
+ self.assertEqual(rc, 0)
120
+ mw.assert_called_once()
121
+ meta = mw.call_args[0][1]
122
+ self.assertEqual(meta["github"]["repo"], "stylusnexus/critforge")
123
+ # Track name from slug
124
+ self.assertEqual(meta["track"], "encounter-builder")
125
+ # Path passed to write_file should be under critforge folder
126
+ path_arg = mw.call_args[0][0]
127
+ self.assertIn("critforge", str(path_arg))
128
+
129
+ # ------------------------------------------------------------------
130
+ # Bare org/repo slug
131
+ # ------------------------------------------------------------------
132
+
133
+ def test_bare_org_repo_slug_uses_name_as_folder(self):
134
+ """'stylusnexus/work-plan-toolkit' → folder = 'work-plan-toolkit',
135
+ github.repo = 'stylusnexus/work-plan-toolkit', rc 0."""
136
+ rc, mw, out = _drive(
137
+ ["stylusnexus/work-plan-toolkit", "my-feature"], vis="PRIVATE"
138
+ )
139
+ self.assertEqual(rc, 0)
140
+ mw.assert_called_once()
141
+ meta = mw.call_args[0][1]
142
+ self.assertEqual(meta["github"]["repo"], "stylusnexus/work-plan-toolkit")
143
+ path_arg = mw.call_args[0][0]
144
+ self.assertIn("work-plan-toolkit", str(path_arg))
145
+
146
+ # ------------------------------------------------------------------
147
+ # Public repo — confirm gate
148
+ # ------------------------------------------------------------------
149
+
150
+ def test_public_repo_no_token_returns_needs_confirm_json(self):
151
+ """Public repo, no token → prints needs_confirm JSON, write_file
152
+ NOT called, rc 0; token == make_token(github, slug)."""
153
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PUBLIC")
154
+ self.assertEqual(rc, 0)
155
+ mw.assert_not_called()
156
+ data = json.loads(out.strip())
157
+ self.assertTrue(data["needs_confirm"])
158
+ self.assertEqual(data["token"], make_token("org/myrepo", "my-feature"))
159
+ self.assertIn("PUBLIC", data["reason"])
160
+
161
+ def test_public_repo_unknown_vis_returns_needs_confirm_json(self):
162
+ """Unknown visibility (None) also requires confirm."""
163
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis=None)
164
+ self.assertEqual(rc, 0)
165
+ mw.assert_not_called()
166
+ data = json.loads(out.strip())
167
+ self.assertTrue(data["needs_confirm"])
168
+
169
+ def test_public_repo_wrong_token_blocked(self):
170
+ """Wrong --confirm token → blocked, no write, rc 0."""
171
+ rc, mw, out = _drive(
172
+ ["myrepo", "my-feature", "--confirm=wrongtoken"], vis="PUBLIC"
173
+ )
174
+ self.assertEqual(rc, 0)
175
+ mw.assert_not_called()
176
+ data = json.loads(out.strip())
177
+ self.assertTrue(data["needs_confirm"])
178
+
179
+ def test_public_repo_valid_token_writes(self):
180
+ """Valid --confirm=<token> on a public repo → creates track, rc 0."""
181
+ tok = make_token("org/myrepo", "my-feature")
182
+ rc, mw, out = _drive(
183
+ ["myrepo", "my-feature", f"--confirm={tok}"], vis="PUBLIC"
184
+ )
185
+ self.assertEqual(rc, 0)
186
+ mw.assert_called_once()
187
+
188
+ def test_bare_slug_public_token_uses_full_github(self):
189
+ """Public bare slug: token is make_token(full-github, slug)."""
190
+ tok = make_token("org/other-repo", "new-slug")
191
+ rc, mw, out = _drive(
192
+ ["org/other-repo", "new-slug", f"--confirm={tok}"], vis="PUBLIC"
193
+ )
194
+ self.assertEqual(rc, 0)
195
+ mw.assert_called_once()
196
+
197
+ # ------------------------------------------------------------------
198
+ # Existing path → rc 2
199
+ # ------------------------------------------------------------------
200
+
201
+ def test_existing_path_returns_rc2_no_write(self):
202
+ """Target .md already exists → rc 2, no write."""
203
+ rc, mw, out = _drive(
204
+ ["myrepo", "existing-track"], vis="PRIVATE", target_path_exists=True
205
+ )
206
+ self.assertEqual(rc, 2)
207
+ mw.assert_not_called()
208
+
209
+ # ------------------------------------------------------------------
210
+ # Unknown repo → rc 1
211
+ # ------------------------------------------------------------------
212
+
213
+ def test_unknown_repo_not_key_no_slash_returns_rc1(self):
214
+ """Repo not a config key and no slash → rc 1, no write."""
215
+ rc, mw, out = _drive(["unknown-repo", "my-feature"])
216
+ self.assertEqual(rc, 1)
217
+ mw.assert_not_called()
218
+ self.assertIn("unknown repo", out.lower())
219
+
220
+ # ------------------------------------------------------------------
221
+ # Invalid slug → rc 2
222
+ # ------------------------------------------------------------------
223
+
224
+ def test_invalid_slug_uppercase_returns_rc2(self):
225
+ """Slug with uppercase letters → rc 2."""
226
+ rc, mw, out = _drive(["myrepo", "MyFeature"])
227
+ self.assertEqual(rc, 2)
228
+ mw.assert_not_called()
229
+
230
+ def test_invalid_slug_spaces_returns_rc2(self):
231
+ """Slug with spaces → rc 2."""
232
+ rc, mw, out = _drive(["myrepo", "my feature"])
233
+ self.assertEqual(rc, 2)
234
+ mw.assert_not_called()
235
+
236
+ def test_invalid_slug_special_chars_returns_rc2(self):
237
+ """Slug with special chars (underscore) → rc 2."""
238
+ rc, mw, out = _drive(["myrepo", "my_feature"])
239
+ self.assertEqual(rc, 2)
240
+ mw.assert_not_called()
241
+
242
+ # ------------------------------------------------------------------
243
+ # Missing positionals → rc 2
244
+ # ------------------------------------------------------------------
245
+
246
+ def test_no_args_returns_rc2(self):
247
+ """No positional args at all → rc 2 (usage error)."""
248
+ rc, mw, out = _drive([])
249
+ self.assertEqual(rc, 2)
250
+ mw.assert_not_called()
251
+
252
+ def test_only_one_positional_returns_rc2(self):
253
+ """Only repo, no slug → rc 2 (usage error)."""
254
+ rc, mw, out = _drive(["myrepo"])
255
+ self.assertEqual(rc, 2)
256
+ mw.assert_not_called()
257
+
258
+ # ------------------------------------------------------------------
259
+ # Priority and milestone flags
260
+ # ------------------------------------------------------------------
261
+
262
+ def test_explicit_priority_and_milestone_in_meta(self):
263
+ """--priority=P1 --milestone=v2 → reflected in meta."""
264
+ rc, mw, out = _drive(
265
+ ["myrepo", "my-feature", "--priority=P1", "--milestone=v2"],
266
+ vis="PRIVATE"
267
+ )
268
+ self.assertEqual(rc, 0)
269
+ mw.assert_called_once()
270
+ meta = mw.call_args[0][1]
271
+ self.assertEqual(meta["launch_priority"], "P1")
272
+ self.assertEqual(meta["milestone_alignment"], "v2")
273
+
274
+ def test_defaults_p2_and_v100_when_absent(self):
275
+ """No flags → launch_priority=P2, milestone_alignment=v1.0.0."""
276
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
277
+ self.assertEqual(rc, 0)
278
+ meta = mw.call_args[0][1]
279
+ self.assertEqual(meta["launch_priority"], "P2")
280
+ self.assertEqual(meta["milestone_alignment"], "v1.0.0")
281
+
282
+ def test_invalid_priority_falls_back_to_p2(self):
283
+ """Invalid --priority=P9 → silently falls back to P2."""
284
+ rc, mw, out = _drive(
285
+ ["myrepo", "my-feature", "--priority=P9"], vis="PRIVATE"
286
+ )
287
+ self.assertEqual(rc, 0)
288
+ meta = mw.call_args[0][1]
289
+ self.assertEqual(meta["launch_priority"], "P2")
290
+
291
+ def test_priority_uppercased(self):
292
+ """--priority=p1 (lowercase) → P1 after uppercasing."""
293
+ rc, mw, out = _drive(
294
+ ["myrepo", "my-feature", "--priority=p1"], vis="PRIVATE"
295
+ )
296
+ self.assertEqual(rc, 0)
297
+ meta = mw.call_args[0][1]
298
+ self.assertEqual(meta["launch_priority"], "P1")
299
+
300
+ # ------------------------------------------------------------------
301
+ # Frontmatter structure
302
+ # ------------------------------------------------------------------
303
+
304
+ def test_meta_has_expected_keys(self):
305
+ """Written meta contains all required frontmatter keys."""
306
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
307
+ self.assertEqual(rc, 0)
308
+ meta = mw.call_args[0][1]
309
+ for key in ("track", "status", "launch_priority", "milestone_alignment",
310
+ "github", "related_tracks", "last_touched", "last_handoff",
311
+ "next_up", "blockers"):
312
+ self.assertIn(key, meta, f"meta missing key: {key}")
313
+
314
+ def test_meta_github_issues_and_branches_empty(self):
315
+ """New track starts with github.issues=[] and github.branches=[]."""
316
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
317
+ self.assertEqual(rc, 0)
318
+ meta = mw.call_args[0][1]
319
+ self.assertEqual(meta["github"]["issues"], [])
320
+ self.assertEqual(meta["github"]["branches"], [])
321
+
322
+ def test_meta_related_tracks_next_up_blockers_empty(self):
323
+ """New track starts with empty related_tracks, next_up, blockers."""
324
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
325
+ self.assertEqual(rc, 0)
326
+ meta = mw.call_args[0][1]
327
+ self.assertEqual(meta["related_tracks"], [])
328
+ self.assertEqual(meta["next_up"], [])
329
+ self.assertEqual(meta["blockers"], [])
330
+
331
+ def test_body_contains_slug_heading(self):
332
+ """Body passed to write_file contains a heading with the slug."""
333
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
334
+ self.assertEqual(rc, 0)
335
+ body = mw.call_args[0][2]
336
+ self.assertIn("my-feature", body)
337
+
338
+ # ------------------------------------------------------------------
339
+ # --private flag (no-op, accepted without error)
340
+ # ------------------------------------------------------------------
341
+
342
+ def test_private_flag_accepted_no_error(self):
343
+ """--private accepted without error, creates normally, rc 0."""
344
+ rc, mw, out = _drive(
345
+ ["myrepo", "my-feature", "--private"], vis="PRIVATE"
346
+ )
347
+ self.assertEqual(rc, 0)
348
+ mw.assert_called_once()
349
+
350
+ def test_private_flag_on_public_repo_still_gated(self):
351
+ """--private on a public repo: confirm gate still fires (gate is by
352
+ visibility, not --private flag)."""
353
+ rc, mw, out = _drive(
354
+ ["myrepo", "my-feature", "--private"], vis="PUBLIC"
355
+ )
356
+ self.assertEqual(rc, 0)
357
+ mw.assert_not_called()
358
+ data = json.loads(out.strip())
359
+ self.assertTrue(data["needs_confirm"])
360
+
361
+ # ------------------------------------------------------------------
362
+ # notes_root missing → rc 1
363
+ # ------------------------------------------------------------------
364
+
365
+ def test_missing_notes_root_returns_rc1(self):
366
+ """notes_root directory does not exist → rc 1, no write."""
367
+ rc, mw, out = _drive(
368
+ ["myrepo", "my-feature"], vis="PRIVATE", notes_root_exists=False
369
+ )
370
+ self.assertEqual(rc, 1)
371
+ mw.assert_not_called()
372
+
373
+ # ------------------------------------------------------------------
374
+ # Success output
375
+ # ------------------------------------------------------------------
376
+
377
+ def test_success_prints_created_line(self):
378
+ """On success, output contains 'Created track' and the slug."""
379
+ rc, mw, out = _drive(["myrepo", "my-feature"], vis="PRIVATE")
380
+ self.assertEqual(rc, 0)
381
+ self.assertIn("my-feature", out)
382
+
383
+ # ------------------------------------------------------------------
384
+ # Gate fires BEFORE any FS write
385
+ # ------------------------------------------------------------------
386
+
387
+ def test_gate_before_mkdir_on_public(self):
388
+ """On a public repo without a token, mkdir is NOT called
389
+ (gate fires before any filesystem operation)."""
390
+ cfg = _make_cfg()
391
+
392
+ def _path_exists(self):
393
+ if self == Path(NOTES_ROOT):
394
+ return True
395
+ if self.suffix == ".md":
396
+ return False
397
+ return True
398
+
399
+ with patch("commands.new_track.load_config", return_value=cfg), \
400
+ patch("commands.new_track.write_file") as mw, \
401
+ patch("lib.write_guard.repo_visibility", return_value="PUBLIC"), \
402
+ patch("pathlib.Path.exists", _path_exists), \
403
+ patch("pathlib.Path.mkdir") as mmkdir:
404
+ buf = io.StringIO()
405
+ with redirect_stdout(buf):
406
+ rc = new_track.run(["myrepo", "my-feature"])
407
+ self.assertEqual(rc, 0)
408
+ mw.assert_not_called()
409
+ mmkdir.assert_not_called()
410
+
411
+ # ------------------------------------------------------------------
412
+ # No input() on non-interactive paths
413
+ # ------------------------------------------------------------------
414
+
415
+ def test_no_input_called_on_private_repo(self):
416
+ """Private repo with valid flags never calls input() or prompt_input —
417
+ proving no prompt is hit on the non-interactive code path."""
418
+ cfg = _make_cfg()
419
+
420
+ def _raise(*a, **kw):
421
+ raise AssertionError("input() must not be called — command must be non-interactive")
422
+
423
+ def _path_exists(self):
424
+ if self == Path(NOTES_ROOT):
425
+ return True
426
+ if self.suffix == ".md":
427
+ return False
428
+ return True
429
+
430
+ with patch("builtins.input", side_effect=_raise), \
431
+ patch("lib.prompts.prompt_input", side_effect=_raise):
432
+ with patch("commands.new_track.load_config", return_value=cfg), \
433
+ patch("commands.new_track.write_file") as mw, \
434
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
435
+ patch("pathlib.Path.exists", _path_exists), \
436
+ patch("pathlib.Path.mkdir"):
437
+ buf = io.StringIO()
438
+ with redirect_stdout(buf):
439
+ rc = new_track.run(["myrepo", "my-feature"])
440
+ self.assertEqual(rc, 0)
441
+ mw.assert_called_once()
442
+
443
+
444
+ if __name__ == "__main__":
445
+ unittest.main()
@@ -0,0 +1,149 @@
1
+ """Tests for the next_up suggestion algorithm.
2
+
3
+ Covers the priority + recency sort, blocker exclusion, closed-issue filter,
4
+ top-N capping, and the `updatedAt`-missing fallback. The algorithm has one
5
+ home (lib/next_up.py) shared by handoff's --auto-next flag and brief's
6
+ next_up_auto: true frontmatter knob — so a regression here would surface
7
+ in both commands.
8
+ """
9
+ import sys
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from lib.next_up import suggest_next_up
17
+
18
+
19
+ def _issue(num, *, state="OPEN", priority=None, updated="2026-01-01T00:00:00Z",
20
+ title="", milestone=None):
21
+ """Build a minimal issue dict matching gh's --json output."""
22
+ labels = [{"name": f"priority/{priority}"}] if priority else []
23
+ ms_obj = {"title": milestone} if milestone else None
24
+ return {
25
+ "number": num, "state": state, "labels": labels,
26
+ "updatedAt": updated, "title": title or f"issue #{num}",
27
+ "milestone": ms_obj,
28
+ }
29
+
30
+
31
+ class SuggestNextUpTest(unittest.TestCase):
32
+ def test_empty_input_returns_empty(self):
33
+ self.assertEqual(suggest_next_up([], []), [])
34
+
35
+ def test_only_closed_returns_empty(self):
36
+ issues = [_issue(1, state="CLOSED", priority="P0"),
37
+ _issue(2, state="CLOSED", priority="P1")]
38
+ self.assertEqual(suggest_next_up(issues, []), [])
39
+
40
+ def test_priority_order(self):
41
+ # Same updatedAt across all — pure priority ranking. P0 < P1 < P2 < P3.
42
+ issues = [
43
+ _issue(3, priority="P3"),
44
+ _issue(0, priority="P0"),
45
+ _issue(2, priority="P2"),
46
+ _issue(1, priority="P1"),
47
+ ]
48
+ self.assertEqual(suggest_next_up(issues, []), [0, 1, 2]) # default n=3
49
+
50
+ def test_recency_within_priority_bucket(self):
51
+ # All P1 — most recently updated wins.
52
+ issues = [
53
+ _issue(10, priority="P1", updated="2026-01-01T00:00:00Z"),
54
+ _issue(20, priority="P1", updated="2026-04-30T00:00:00Z"), # newest
55
+ _issue(30, priority="P1", updated="2026-02-15T00:00:00Z"),
56
+ ]
57
+ self.assertEqual(suggest_next_up(issues, []), [20, 30, 10])
58
+
59
+ def test_priority_dominates_recency(self):
60
+ # P0 is older than P3 but still comes first.
61
+ issues = [
62
+ _issue(99, priority="P3", updated="2026-04-30T00:00:00Z"),
63
+ _issue(1, priority="P0", updated="2024-01-01T00:00:00Z"),
64
+ ]
65
+ self.assertEqual(suggest_next_up(issues, []), [1, 99])
66
+
67
+ def test_no_priority_label_defaults_to_p3(self):
68
+ # Unlabeled issues sort with P3, behind P0/P1/P2.
69
+ issues = [
70
+ _issue(50, priority=None, updated="2026-04-30T00:00:00Z"),
71
+ _issue(51, priority="P3", updated="2026-04-30T00:00:00Z"),
72
+ _issue(2, priority="P2"),
73
+ ]
74
+ result = suggest_next_up(issues, [])
75
+ # P2 first; the two P3 (one labeled, one defaulting) follow.
76
+ self.assertEqual(result[0], 2)
77
+ self.assertIn(50, result[1:])
78
+ self.assertIn(51, result[1:])
79
+
80
+ def test_blockers_excluded(self):
81
+ issues = [
82
+ _issue(1, priority="P0"),
83
+ _issue(2, priority="P1"),
84
+ _issue(3, priority="P2"),
85
+ ]
86
+ # #1 is blocked — should NOT appear, even though it's P0.
87
+ self.assertEqual(suggest_next_up(issues, [1]), [2, 3])
88
+
89
+ def test_top_n_caps_result(self):
90
+ issues = [_issue(i, priority="P0") for i in range(10)]
91
+ self.assertEqual(len(suggest_next_up(issues, [], n=2)), 2)
92
+ self.assertEqual(len(suggest_next_up(issues, [], n=5)), 5)
93
+
94
+ def test_default_n_is_3(self):
95
+ issues = [_issue(i, priority="P0") for i in range(10)]
96
+ self.assertEqual(len(suggest_next_up(issues, [])), 3)
97
+
98
+ def test_missing_updatedAt_treated_as_oldest(self):
99
+ # Within same priority, an issue without updatedAt should sort LAST.
100
+ issues = [
101
+ _issue(1, priority="P1", updated="2026-01-01T00:00:00Z"),
102
+ _issue(2, priority="P1", updated=""), # missing
103
+ _issue(3, priority="P1", updated="2026-04-30T00:00:00Z"),
104
+ ]
105
+ result = suggest_next_up(issues, [])
106
+ self.assertEqual(result[0], 3) # newest first
107
+ self.assertEqual(result[-1], 2) # missing-updated last
108
+
109
+ def test_track_milestone_aligned_outranks_other_milestone(self):
110
+ # Track is gated by v0.4.0. A P0 on v2.0.0 must sort BEHIND a P3 on v0.4.0
111
+ # because milestone alignment dominates priority — keeps post-launch
112
+ # work from polluting a launch-window auto-next.
113
+ issues = [
114
+ _issue(1, priority="P0", milestone="v2.0.0 — Post-Launch",
115
+ updated="2026-04-30T00:00:00Z"),
116
+ _issue(2, priority="P3", milestone="v0.4.0 — MVP",
117
+ updated="2026-01-01T00:00:00Z"),
118
+ ]
119
+ self.assertEqual(suggest_next_up(issues, [], track_milestone="v0.4.0"), [2, 1])
120
+
121
+ def test_track_milestone_unmilestoned_sorts_last(self):
122
+ # Items with no milestone fall behind any milestoned item.
123
+ issues = [
124
+ _issue(1, priority="P0", milestone=None),
125
+ _issue(2, priority="P3", milestone="v2.0.0"),
126
+ ]
127
+ self.assertEqual(suggest_next_up(issues, [], track_milestone="v0.4.0"), [2, 1])
128
+
129
+ def test_no_track_milestone_preserves_priority_order(self):
130
+ # Without a track milestone, behavior matches the legacy priority+recency sort:
131
+ # all milestone buckets collapse to "OTHER" so they tie, leaving priority to decide.
132
+ issues = [
133
+ _issue(1, priority="P0", milestone="v2.0.0"),
134
+ _issue(2, priority="P3", milestone="v0.4.0"),
135
+ ]
136
+ self.assertEqual(suggest_next_up(issues, []), [1, 2])
137
+
138
+ def test_unparsable_updatedAt_falls_back_gracefully(self):
139
+ # A garbage timestamp string should be treated like missing — not crash.
140
+ issues = [
141
+ _issue(1, priority="P0", updated="not-a-date"),
142
+ _issue(2, priority="P0", updated="2026-04-30T00:00:00Z"),
143
+ ]
144
+ result = suggest_next_up(issues, [])
145
+ self.assertEqual(result, [2, 1]) # parsable+newer wins; garbage trails
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()
@@ -0,0 +1,68 @@
1
+ """Smoke + behavior test for the plan-status command (offline)."""
2
+ import io
3
+ import json
4
+ import unittest
5
+ import sys
6
+ import tempfile
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
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 commands import plan_status
15
+
16
+ PLAN_BODY = (
17
+ "# Idea Mode Implementation Plan\n\n"
18
+ "**Files:**\n"
19
+ "- Create: `src/new.ts`\n"
20
+ "- Create: `src/missing.ts`\n"
21
+ "- [ ] Step 1: do the thing\n"
22
+ "- [ ] Step 2: do the other thing\n"
23
+ )
24
+
25
+
26
+ class PlanStatusRunTest(unittest.TestCase):
27
+ def _repo(self, d):
28
+ root = Path(d)
29
+ (root / "docs/superpowers/plans").mkdir(parents=True)
30
+ (root / "docs/superpowers/plans/2026-03-16-idea-mode-ui.md").write_text(PLAN_BODY)
31
+ (root / "src").mkdir()
32
+ (root / "src/new.ts").write_text("export const x = 1") # 1 of 2 created
33
+ return root
34
+
35
+ def test_json_report_classifies_partial(self):
36
+ with tempfile.TemporaryDirectory() as d:
37
+ root = self._repo(d)
38
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
39
+ return_value=None), \
40
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
41
+ buf = io.StringIO()
42
+ with redirect_stdout(buf):
43
+ rc = plan_status.run(["--json"])
44
+ self.assertEqual(rc, 0)
45
+ data = json.loads(buf.getvalue())
46
+ self.assertEqual(len(data["docs"]), 1)
47
+ row = data["docs"][0]
48
+ self.assertEqual(row["files_present"], 1)
49
+ self.assertEqual(row["files_declared"], 2)
50
+ self.assertEqual(row["verdict"], "partial") # 50% -> partial
51
+
52
+ def test_human_report_runs(self):
53
+ with tempfile.TemporaryDirectory() as d:
54
+ root = self._repo(d)
55
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
56
+ return_value=None), \
57
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
58
+ buf = io.StringIO()
59
+ with redirect_stdout(buf):
60
+ rc = plan_status.run([])
61
+ self.assertEqual(rc, 0)
62
+ out = buf.getvalue()
63
+ self.assertIn("plan-status", out)
64
+ self.assertIn("partial", out)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()