@stylusnexus/work-plan 2026.6.9-3 → 2026.6.9-4
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/README.md +4 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/tests/test_move.py +240 -0
package/README.md
CHANGED
|
@@ -62,11 +62,13 @@ A dozen more subcommands cover slotting new issues into tracks, closing tracks (
|
|
|
62
62
|
|
|
63
63
|
**Coverage + auto-triage** — `coverage --repo=<key>` reports how many open issues fall outside the track model (42% on a real production repo). `auto-triage --repo=<key>` then produces an AI prompt to assign those orphans to existing tracks. Run both periodically to keep the backlog visible.
|
|
64
64
|
|
|
65
|
+
**Cross-track dependencies** — set `depends_on: [<track-slug>]` in a track's frontmatter to declare explicit dependencies between tracks. The VS Code viewer renders these as thick amber `==>` edges in the dependency graph, and the detail panel shows clickable dependency chips that navigate directly to the dependent track. Set via `/work-plan set <track> depends_on=slug1,slug2` or the "Edit Track Fields" right-click menu in VS Code. Complementary to the issue-derived "owns" edges already inferred from blockers.
|
|
66
|
+
|
|
65
67
|
Beyond issue tracking, **`plan-status`** answers a different question — *which of your accumulated plan/spec docs actually shipped, half-shipped, or died*. It correlates each plan's declared file-manifest (`Create:`/`Modify:`/`Test:` paths) against git and the filesystem rather than trusting checkboxes (which are routinely left unchecked even for shipped work). Read-only by default; optionally stamp the verdict into each doc (`--stamp`), get an AI verdict on prose/ambiguous docs (`--llm`), and act on the results behind confirmation gates (`--archive` dead plans, `--issues` for partial ones). See [Plan & doc liveness](#plan--doc-liveness-plan-status).
|
|
66
68
|
|
|
67
69
|
## How it works
|
|
68
70
|
|
|
69
|
-
The toolkit treats GitHub as the canonical source of issue state and never tries to mirror it. Track markdown files are lightweight references — they list issue numbers and a few pieces of derived metadata (priority, milestone, `next_up`, last session timestamp). The CLI re-derives everything else live from `gh`, `git`, and the markdown body.
|
|
71
|
+
The toolkit treats GitHub as the canonical source of issue state and never tries to mirror it. Track markdown files are lightweight references — they list issue numbers and a few pieces of derived metadata (priority, milestone, `next_up`, `depends_on`, last session timestamp). The CLI re-derives everything else live from `gh`, `git`, and the markdown body.
|
|
70
72
|
|
|
71
73
|
```mermaid
|
|
72
74
|
flowchart TB
|
|
@@ -279,7 +281,7 @@ To install for **both** Claude Code AND Codex, run the installer twice with diff
|
|
|
279
281
|
|
|
280
282
|
### VS Code extension
|
|
281
283
|
|
|
282
|
-
The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks), a Mermaid dependency graph, the Untracked bucket, and full read/write (slot/close/edit/new-track/…) with a public-repo confirm modal.
|
|
284
|
+
The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks), a Mermaid dependency graph (with focus toggle and repo-scoped full map), the Untracked bucket, cross-track dependency chips, per-issue move buttons, and full read/write (slot/close/edit/move/new-track/…) with a public-repo confirm modal.
|
|
283
285
|
|
|
284
286
|

|
|
285
287
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.09+
|
|
1
|
+
2026.06.09+f25e6e1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.9-
|
|
3
|
+
"version": "2026.6.9-4",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Tests for the move subcommand (issue #162).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- Moves an issue from source track to destination track (two writes).
|
|
5
|
+
- Issue not in source → error, rc 1.
|
|
6
|
+
- Cross-repo guard → error, rc 1.
|
|
7
|
+
- Same-track no-op → rc 0, message.
|
|
8
|
+
- Already in destination → remove from source only, rc 0.
|
|
9
|
+
- Public repo confirm gate → prints needs_confirm JSON, rc 0.
|
|
10
|
+
- Public repo with valid --confirm=<token> → writes, rc 0.
|
|
11
|
+
- Bad args → rc 2.
|
|
12
|
+
"""
|
|
13
|
+
import io
|
|
14
|
+
import sys
|
|
15
|
+
import unittest
|
|
16
|
+
from contextlib import redirect_stdout
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import SimpleNamespace
|
|
19
|
+
from unittest.mock import MagicMock, patch
|
|
20
|
+
|
|
21
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
23
|
+
|
|
24
|
+
from commands import move
|
|
25
|
+
from lib.write_guard import make_token
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Helpers
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def _track(*, name, repo="ok/repo", issues=None, status="active"):
|
|
33
|
+
return SimpleNamespace(
|
|
34
|
+
name=name,
|
|
35
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
36
|
+
body="# fake",
|
|
37
|
+
meta={
|
|
38
|
+
"track": name,
|
|
39
|
+
"status": status,
|
|
40
|
+
"github": {"repo": repo, "issues": list(issues or [])},
|
|
41
|
+
},
|
|
42
|
+
has_frontmatter=True,
|
|
43
|
+
repo=repo,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _drive(args, tracks=None, vis="PRIVATE"):
|
|
48
|
+
"""Run move.run(args) with all external I/O mocked."""
|
|
49
|
+
if tracks is None:
|
|
50
|
+
tracks = [
|
|
51
|
+
_track(name="alpha", issues=[42]),
|
|
52
|
+
_track(name="beta", issues=[]),
|
|
53
|
+
]
|
|
54
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
55
|
+
|
|
56
|
+
with patch("commands.move.load_config", return_value=cfg), \
|
|
57
|
+
patch("commands.move.discover_tracks", return_value=tracks), \
|
|
58
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
59
|
+
patch("commands.move.write_file") as mw:
|
|
60
|
+
buf = io.StringIO()
|
|
61
|
+
with redirect_stdout(buf):
|
|
62
|
+
rc = move.run(args)
|
|
63
|
+
return rc, mw, buf.getvalue()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Test cases
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
class MoveBasicTest(unittest.TestCase):
|
|
71
|
+
|
|
72
|
+
def test_moves_issue_from_source_to_destination(self):
|
|
73
|
+
"""Move #42 from alpha to beta: both tracks written."""
|
|
74
|
+
tracks = [
|
|
75
|
+
_track(name="alpha", issues=[42, 99]),
|
|
76
|
+
_track(name="beta", issues=[7]),
|
|
77
|
+
]
|
|
78
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
|
|
79
|
+
self.assertEqual(rc, 0)
|
|
80
|
+
self.assertIn("Removed #42 from 'alpha'", out)
|
|
81
|
+
self.assertIn("Added #42 to 'beta'", out)
|
|
82
|
+
# Two writes: source then destination
|
|
83
|
+
self.assertEqual(mw.call_count, 2)
|
|
84
|
+
|
|
85
|
+
# Source: 42 removed, 99 remains
|
|
86
|
+
source_call = mw.call_args_list[0]
|
|
87
|
+
source_issues = source_call[0][1]["github"]["issues"]
|
|
88
|
+
self.assertNotIn(42, source_issues)
|
|
89
|
+
self.assertIn(99, source_issues)
|
|
90
|
+
|
|
91
|
+
# Destination: 42 added, 7 remains, sorted
|
|
92
|
+
dest_call = mw.call_args_list[1]
|
|
93
|
+
dest_issues = dest_call[0][1]["github"]["issues"]
|
|
94
|
+
self.assertIn(42, dest_issues)
|
|
95
|
+
self.assertIn(7, dest_issues)
|
|
96
|
+
self.assertEqual(dest_issues, sorted(dest_issues))
|
|
97
|
+
|
|
98
|
+
def test_issue_not_in_source_errors(self):
|
|
99
|
+
"""#999 is not in alpha → rc 1, error message."""
|
|
100
|
+
tracks = [
|
|
101
|
+
_track(name="alpha", issues=[42]),
|
|
102
|
+
_track(name="beta", issues=[]),
|
|
103
|
+
]
|
|
104
|
+
rc, mw, out = _drive(["999", "alpha", "beta"], tracks=tracks)
|
|
105
|
+
self.assertEqual(rc, 1)
|
|
106
|
+
self.assertIn("not in track", out)
|
|
107
|
+
mw.assert_not_called()
|
|
108
|
+
|
|
109
|
+
def test_cross_repo_move_errors(self):
|
|
110
|
+
"""Moving between different repos is rejected."""
|
|
111
|
+
tracks = [
|
|
112
|
+
_track(name="alpha", repo="ok/repo", issues=[42]),
|
|
113
|
+
_track(name="beta", repo="other/repo", issues=[]),
|
|
114
|
+
]
|
|
115
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
|
|
116
|
+
self.assertEqual(rc, 1)
|
|
117
|
+
self.assertIn("cross-repo", out)
|
|
118
|
+
mw.assert_not_called()
|
|
119
|
+
|
|
120
|
+
def test_same_track_noop(self):
|
|
121
|
+
"""Moving to the same track is a no-op."""
|
|
122
|
+
tracks = [
|
|
123
|
+
_track(name="alpha", issues=[42]),
|
|
124
|
+
_track(name="beta", issues=[]),
|
|
125
|
+
]
|
|
126
|
+
rc, mw, out = _drive(["42", "alpha", "alpha"], tracks=tracks)
|
|
127
|
+
self.assertEqual(rc, 0)
|
|
128
|
+
self.assertIn("already in track", out)
|
|
129
|
+
mw.assert_not_called()
|
|
130
|
+
|
|
131
|
+
def test_already_in_destination_removes_from_source_only(self):
|
|
132
|
+
"""#42 already in beta → remove from alpha, don't re-add to beta."""
|
|
133
|
+
tracks = [
|
|
134
|
+
_track(name="alpha", issues=[42, 99]),
|
|
135
|
+
_track(name="beta", issues=[42, 7]),
|
|
136
|
+
]
|
|
137
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
|
|
138
|
+
self.assertEqual(rc, 0)
|
|
139
|
+
self.assertIn("already in track 'beta'", out)
|
|
140
|
+
self.assertIn("Removed #42 from 'alpha'", out)
|
|
141
|
+
# Only one write (source only, dest unchanged)
|
|
142
|
+
self.assertEqual(mw.call_count, 1)
|
|
143
|
+
|
|
144
|
+
call = mw.call_args_list[0]
|
|
145
|
+
source_issues = call[0][1]["github"]["issues"]
|
|
146
|
+
self.assertNotIn(42, source_issues)
|
|
147
|
+
self.assertIn(99, source_issues)
|
|
148
|
+
|
|
149
|
+
def test_bad_args_usage(self):
|
|
150
|
+
"""Less than 3 positional args → rc 2."""
|
|
151
|
+
rc, mw, out = _drive(["42", "alpha"])
|
|
152
|
+
self.assertEqual(rc, 2)
|
|
153
|
+
self.assertIn("usage:", out)
|
|
154
|
+
mw.assert_not_called()
|
|
155
|
+
|
|
156
|
+
def test_non_numeric_issue_errors(self):
|
|
157
|
+
"""Non-numeric issue number → rc 2."""
|
|
158
|
+
rc, mw, out = _drive(["abc", "alpha", "beta"])
|
|
159
|
+
self.assertEqual(rc, 2)
|
|
160
|
+
self.assertIn("not an issue number", out)
|
|
161
|
+
mw.assert_not_called()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class MovePublicRepoTest(unittest.TestCase):
|
|
165
|
+
|
|
166
|
+
def test_public_repo_prints_needs_confirm(self):
|
|
167
|
+
"""Public repo without --confirm prints needs_confirm JSON."""
|
|
168
|
+
tracks = [
|
|
169
|
+
_track(name="alpha", repo="ok/repo", issues=[42]),
|
|
170
|
+
_track(name="beta", repo="ok/repo", issues=[]),
|
|
171
|
+
]
|
|
172
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks, vis="PUBLIC")
|
|
173
|
+
self.assertEqual(rc, 0)
|
|
174
|
+
self.assertIn('"needs_confirm": true', out)
|
|
175
|
+
self.assertIn('"token":', out)
|
|
176
|
+
mw.assert_not_called()
|
|
177
|
+
|
|
178
|
+
def test_public_repo_with_valid_confirm_writes(self):
|
|
179
|
+
"""Public repo with valid --confirm=<token> writes successfully."""
|
|
180
|
+
tracks = [
|
|
181
|
+
_track(name="alpha", repo="ok/repo", issues=[42]),
|
|
182
|
+
_track(name="beta", repo="ok/repo", issues=[]),
|
|
183
|
+
]
|
|
184
|
+
token = make_token("ok/repo", "beta")
|
|
185
|
+
rc, mw, out = _drive(
|
|
186
|
+
["42", "alpha", "beta", f"--confirm={token}"],
|
|
187
|
+
tracks=tracks,
|
|
188
|
+
vis="PUBLIC",
|
|
189
|
+
)
|
|
190
|
+
self.assertEqual(rc, 0)
|
|
191
|
+
self.assertIn("Removed #42 from 'alpha'", out)
|
|
192
|
+
self.assertIn("Added #42 to 'beta'", out)
|
|
193
|
+
self.assertEqual(mw.call_count, 2)
|
|
194
|
+
|
|
195
|
+
def test_private_repo_no_token_writes_directly(self):
|
|
196
|
+
"""Private repo writes without any confirm gate."""
|
|
197
|
+
tracks = [
|
|
198
|
+
_track(name="alpha", issues=[42]),
|
|
199
|
+
_track(name="beta", issues=[]),
|
|
200
|
+
]
|
|
201
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
|
|
202
|
+
self.assertEqual(rc, 0)
|
|
203
|
+
self.assertIn("Removed", out)
|
|
204
|
+
self.assertIn("Added", out)
|
|
205
|
+
self.assertEqual(mw.call_count, 2)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class MoveTrackResolutionTest(unittest.TestCase):
|
|
209
|
+
|
|
210
|
+
def test_no_active_track_matching(self):
|
|
211
|
+
"""Inactive or nonexistent source track → rc 1."""
|
|
212
|
+
tracks = [
|
|
213
|
+
_track(name="alpha", issues=[42], status="shipped"),
|
|
214
|
+
_track(name="beta", issues=[]),
|
|
215
|
+
]
|
|
216
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
|
|
217
|
+
self.assertEqual(rc, 1)
|
|
218
|
+
self.assertIn("No active track matching", out)
|
|
219
|
+
|
|
220
|
+
def test_no_active_destination(self):
|
|
221
|
+
"""Inactive destination track → rc 1."""
|
|
222
|
+
tracks = [
|
|
223
|
+
_track(name="alpha", issues=[42]),
|
|
224
|
+
_track(name="beta", issues=[], status="shipped"),
|
|
225
|
+
]
|
|
226
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
|
|
227
|
+
self.assertEqual(rc, 1)
|
|
228
|
+
self.assertIn("No active track matching", out)
|
|
229
|
+
|
|
230
|
+
def test_issue_already_in_archived_track(self):
|
|
231
|
+
"""Moving from an active track even if issue is also in a shipped track."""
|
|
232
|
+
tracks = [
|
|
233
|
+
_track(name="alpha", issues=[42]),
|
|
234
|
+
_track(name="beta", issues=[]),
|
|
235
|
+
]
|
|
236
|
+
rc, mw, out = _drive(["42", "alpha", "beta"], tracks=tracks)
|
|
237
|
+
self.assertEqual(rc, 0)
|
|
238
|
+
self.assertIn("Removed", out)
|
|
239
|
+
self.assertIn("Added", out)
|
|
240
|
+
self.assertEqual(mw.call_count, 2)
|