@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.
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- 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()
|