@stylusnexus/work-plan 2026.6.24 → 2026.7.10
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 +6 -0
- package/VERSION +1 -1
- package/bin/work-plan +8 -0
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +5 -0
- package/skills/work-plan/commands/archive_track.py +67 -0
- package/skills/work-plan/commands/brief.py +16 -1
- package/skills/work-plan/commands/delete_track.py +92 -0
- package/skills/work-plan/commands/export.py +12 -3
- package/skills/work-plan/commands/hygiene.py +27 -0
- package/skills/work-plan/commands/mark_cleanup.py +78 -0
- package/skills/work-plan/commands/plan_unarchive.py +87 -0
- package/skills/work-plan/commands/unarchive_track.py +64 -0
- package/skills/work-plan/lib/archive.py +32 -1
- package/skills/work-plan/lib/export_model.py +8 -0
- package/skills/work-plan/lib/git_state.py +10 -0
- package/skills/work-plan/lib/render.py +16 -0
- package/skills/work-plan/tests/test_archive_lib.py +52 -0
- package/skills/work-plan/tests/test_archive_track.py +100 -0
- package/skills/work-plan/tests/test_brief_numeric_refs.py +36 -0
- package/skills/work-plan/tests/test_delete_track.py +108 -0
- package/skills/work-plan/tests/test_export.py +41 -0
- package/skills/work-plan/tests/test_mark_cleanup.py +88 -0
- package/skills/work-plan/tests/test_plan_unarchive.py +77 -0
- package/skills/work-plan/work_plan.py +25 -0
|
@@ -139,6 +139,14 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
139
139
|
"folder": getattr(t, "folder", None),
|
|
140
140
|
"tier": getattr(t, "tier", "private") or "private",
|
|
141
141
|
"status": t.meta.get("status"),
|
|
142
|
+
# True for a track in the archive tier (#328), surfaced only when
|
|
143
|
+
# export ran with --include-archived; the viewer greys these.
|
|
144
|
+
"archived": bool(t.meta.get("archived")),
|
|
145
|
+
# Cleanup earmark (#328): a lightweight "candidate for retirement"
|
|
146
|
+
# flag set via `mark-cleanup`, with an optional free-text reason
|
|
147
|
+
# (null when unset). Surfaced by the viewer + hygiene callout.
|
|
148
|
+
"cleanup_candidate": bool(t.meta.get("cleanup_candidate")),
|
|
149
|
+
"cleanup_reason": t.meta.get("cleanup_reason"),
|
|
142
150
|
"launch_priority": t.meta.get("launch_priority"),
|
|
143
151
|
"milestone_alignment": milestone_alignment,
|
|
144
152
|
"visibility": visibility.get(t.repo),
|
|
@@ -378,3 +378,13 @@ def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
|
|
|
378
378
|
(Path(repo_path) / dst_rel).parent.mkdir(parents=True, exist_ok=True)
|
|
379
379
|
proc = _git(repo_path, "mv", src_rel, dst_rel)
|
|
380
380
|
return proc is not None and proc.returncode == 0
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def git_rm(rel_path: str, repo_path: Path) -> bool:
|
|
384
|
+
"""git-rm `rel_path` (repo-relative) — stage its deletion. Returns True on
|
|
385
|
+
success. The removal stays in the index (undoable from git history) until
|
|
386
|
+
the caller commits; #330 delete-track uses it for tracked tracks."""
|
|
387
|
+
if not repo_path or not Path(repo_path).exists():
|
|
388
|
+
return False
|
|
389
|
+
proc = _git(repo_path, "rm", rel_path)
|
|
390
|
+
return proc is not None and proc.returncode == 0
|
|
@@ -90,3 +90,19 @@ def render_track_row(t: dict) -> str:
|
|
|
90
90
|
def render_archived_reopen(repo: str, slug: str, issue: dict) -> str:
|
|
91
91
|
return (f"⚠ archive/{slug}.md (shipped) — new issue #{issue['number']} "
|
|
92
92
|
f"matches this slug. Re-open or slot into a different track?")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_cleanup_callout(candidates: list) -> str:
|
|
96
|
+
"""Render the hygiene "marked for cleanup" section, or "" if none.
|
|
97
|
+
|
|
98
|
+
`candidates` is a list of (name, reason) tuples; reason may be None/empty.
|
|
99
|
+
Returns a multi-line block (header + one line per track) or an empty string
|
|
100
|
+
when nothing is marked, so the caller can skip printing entirely.
|
|
101
|
+
"""
|
|
102
|
+
if not candidates:
|
|
103
|
+
return ""
|
|
104
|
+
lines = ["🧹 Marked for cleanup:"]
|
|
105
|
+
for name, reason in candidates:
|
|
106
|
+
suffix = f" — {reason}" if reason else ""
|
|
107
|
+
lines.append(f" {name}{suffix}")
|
|
108
|
+
return "\n".join(lines)
|
|
@@ -128,5 +128,57 @@ class MoveToArchiveTest(unittest.TestCase):
|
|
|
128
128
|
mv.assert_not_called()
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
class RestoreFromArchiveTest(unittest.TestCase):
|
|
132
|
+
def _archived_repo(self, d):
|
|
133
|
+
root = Path(d)
|
|
134
|
+
(root / "docs/plans/archive/shipped").mkdir(parents=True)
|
|
135
|
+
(root / "docs/plans/archive/shipped/x.md").write_text("# x")
|
|
136
|
+
return root
|
|
137
|
+
|
|
138
|
+
def test_tracked_restore_calls_git_mv_to_original_dir(self):
|
|
139
|
+
"""Tracked archived file → staged git mv back out → 'restored'."""
|
|
140
|
+
with tempfile.TemporaryDirectory() as d:
|
|
141
|
+
root = self._archived_repo(d)
|
|
142
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
|
|
143
|
+
mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
|
|
144
|
+
outcome = archive_lib.restore_from_archive(
|
|
145
|
+
"docs/plans/archive/shipped/x.md", root)
|
|
146
|
+
self.assertEqual(outcome, "restored")
|
|
147
|
+
mv.assert_called_once_with(
|
|
148
|
+
"docs/plans/archive/shipped/x.md", "docs/plans/x.md", root)
|
|
149
|
+
|
|
150
|
+
def test_untracked_restore_local_via_filesystem(self):
|
|
151
|
+
"""Untracked archived file → shutil.move back → 'restored_local'; moves on disk."""
|
|
152
|
+
with tempfile.TemporaryDirectory() as d:
|
|
153
|
+
root = self._archived_repo(d)
|
|
154
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
|
|
155
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
156
|
+
outcome = archive_lib.restore_from_archive(
|
|
157
|
+
"docs/plans/archive/shipped/x.md", root)
|
|
158
|
+
self.assertEqual(outcome, "restored_local")
|
|
159
|
+
mv.assert_not_called()
|
|
160
|
+
self.assertFalse((root / "docs/plans/archive/shipped/x.md").exists())
|
|
161
|
+
self.assertTrue((root / "docs/plans/x.md").exists())
|
|
162
|
+
|
|
163
|
+
def test_collision_refuses_when_live_doc_exists(self):
|
|
164
|
+
"""A live doc already at the destination → 'skipped_collision', no move."""
|
|
165
|
+
with tempfile.TemporaryDirectory() as d:
|
|
166
|
+
root = self._archived_repo(d)
|
|
167
|
+
(root / "docs/plans/x.md").write_text("# live already")
|
|
168
|
+
with mock.patch("lib.archive.git_state.is_tracked") as is_tracked, \
|
|
169
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
170
|
+
outcome = archive_lib.restore_from_archive(
|
|
171
|
+
"docs/plans/archive/shipped/x.md", root)
|
|
172
|
+
self.assertEqual(outcome, "skipped_collision")
|
|
173
|
+
is_tracked.assert_not_called()
|
|
174
|
+
mv.assert_not_called()
|
|
175
|
+
|
|
176
|
+
def test_not_under_archive_returns_none(self):
|
|
177
|
+
"""A path not under archive/<kind>/ → None (nothing to restore)."""
|
|
178
|
+
with tempfile.TemporaryDirectory() as d:
|
|
179
|
+
root = Path(d)
|
|
180
|
+
self.assertIsNone(archive_lib.restore_from_archive("docs/plans/x.md", root))
|
|
181
|
+
|
|
182
|
+
|
|
131
183
|
if __name__ == "__main__":
|
|
132
184
|
unittest.main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""archive-track / unarchive-track commands (offline; move primitives mocked)."""
|
|
2
|
+
import io
|
|
3
|
+
import sys
|
|
4
|
+
import unittest
|
|
5
|
+
from contextlib import redirect_stdout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
12
|
+
|
|
13
|
+
from commands import archive_track, unarchive_track
|
|
14
|
+
from lib.write_guard import make_token
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _t(name="ph", repo="o/r", path="/tmp/notes/proj/ph.md", tier="private"):
|
|
18
|
+
return SimpleNamespace(name=name, repo=repo, path=Path(path), tier=tier,
|
|
19
|
+
folder="proj", has_frontmatter=True, meta={"status": "active"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ArchiveTrackTest(unittest.TestCase):
|
|
23
|
+
def _drive(self, args, track=None, outcome="archived", vis="PRIVATE"):
|
|
24
|
+
track = track or _t()
|
|
25
|
+
with patch("commands.archive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
26
|
+
patch("commands.archive_track.discover_tracks", return_value=[track]), \
|
|
27
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
28
|
+
patch("commands.archive_track.move_to_archive", return_value=outcome) as mv:
|
|
29
|
+
buf = io.StringIO()
|
|
30
|
+
with redirect_stdout(buf):
|
|
31
|
+
rc = archive_track.run(args)
|
|
32
|
+
return rc, mv, buf.getvalue()
|
|
33
|
+
|
|
34
|
+
def test_archives_private_track(self):
|
|
35
|
+
rc, mv, out = self._drive(["ph"])
|
|
36
|
+
self.assertEqual(rc, 0)
|
|
37
|
+
mv.assert_called_once_with("ph.md", Path("/tmp/notes/proj"), "parked")
|
|
38
|
+
self.assertIn("archived", out)
|
|
39
|
+
|
|
40
|
+
def test_shared_track_prints_push_hint(self):
|
|
41
|
+
rc, mv, out = self._drive(["ph"], track=_t(tier="shared"))
|
|
42
|
+
self.assertEqual(rc, 0)
|
|
43
|
+
self.assertIn("commit & push", out)
|
|
44
|
+
|
|
45
|
+
def test_collision_is_reported_not_errored(self):
|
|
46
|
+
rc, mv, out = self._drive(["ph"], outcome="skipped_collision")
|
|
47
|
+
self.assertEqual(rc, 0)
|
|
48
|
+
self.assertIn("already exists", out)
|
|
49
|
+
|
|
50
|
+
def test_hard_failure_returns_1(self):
|
|
51
|
+
rc, mv, out = self._drive(["ph"], outcome=None)
|
|
52
|
+
self.assertEqual(rc, 1)
|
|
53
|
+
|
|
54
|
+
def test_public_blocks_without_confirm(self):
|
|
55
|
+
rc, mv, out = self._drive(["ph"], vis="PUBLIC")
|
|
56
|
+
self.assertEqual(rc, 0)
|
|
57
|
+
mv.assert_not_called()
|
|
58
|
+
self.assertIn("needs_confirm", out)
|
|
59
|
+
|
|
60
|
+
def test_public_with_confirm_archives(self):
|
|
61
|
+
tok = make_token("o/r", "ph")
|
|
62
|
+
rc, mv, out = self._drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
|
|
63
|
+
self.assertEqual(rc, 0)
|
|
64
|
+
mv.assert_called_once()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnarchiveTrackTest(unittest.TestCase):
|
|
68
|
+
def _drive(self, args, track=None, outcome="restored", vis="PRIVATE"):
|
|
69
|
+
track = track or _t(path="/tmp/notes/proj/archive/parked/ph.md")
|
|
70
|
+
with patch("commands.unarchive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
71
|
+
patch("commands.unarchive_track.discover_archived_tracks", return_value=[track]), \
|
|
72
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
73
|
+
patch("commands.unarchive_track.restore_from_archive", return_value=outcome) as rs:
|
|
74
|
+
buf = io.StringIO()
|
|
75
|
+
with redirect_stdout(buf):
|
|
76
|
+
rc = unarchive_track.run(args)
|
|
77
|
+
return rc, rs, buf.getvalue()
|
|
78
|
+
|
|
79
|
+
def test_restores_from_archive_with_correct_rel_and_base(self):
|
|
80
|
+
rc, rs, out = self._drive(["ph"])
|
|
81
|
+
self.assertEqual(rc, 0)
|
|
82
|
+
rs.assert_called_once_with("archive/parked/ph.md", Path("/tmp/notes/proj"))
|
|
83
|
+
self.assertIn("restored", out)
|
|
84
|
+
|
|
85
|
+
def test_collision_reported(self):
|
|
86
|
+
rc, rs, out = self._drive(["ph"], outcome="skipped_collision")
|
|
87
|
+
self.assertEqual(rc, 0)
|
|
88
|
+
self.assertIn("already exists", out)
|
|
89
|
+
|
|
90
|
+
def test_not_found_returns_1(self):
|
|
91
|
+
with patch("commands.unarchive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
92
|
+
patch("commands.unarchive_track.discover_archived_tracks", return_value=[]):
|
|
93
|
+
buf = io.StringIO()
|
|
94
|
+
with redirect_stdout(buf):
|
|
95
|
+
rc = unarchive_track.run(["nope"])
|
|
96
|
+
self.assertEqual(rc, 1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
unittest.main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""#417 — brief must not crash when next_up mixes issue numbers and string tokens.
|
|
2
|
+
|
|
3
|
+
`sorted(set(issue_nums) | set(stored_next_up))` raised
|
|
4
|
+
`TypeError: '<' not supported between instances of 'str' and 'int'` for any track
|
|
5
|
+
whose `next_up` held a non-issue token (e.g. an epic name like `golden-path-v2`)
|
|
6
|
+
next to issue numbers. `_numeric_refs` unions the ref lists, drops non-int tokens
|
|
7
|
+
(they aren't fetchable issues), and sorts what remains.
|
|
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 commands.brief import _numeric_refs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NumericRefsTest(unittest.TestCase):
|
|
20
|
+
def test_mixed_str_and_int_does_not_crash_and_drops_tokens(self):
|
|
21
|
+
# The exact shape that crashed live: a string epic token beside numbers.
|
|
22
|
+
result = _numeric_refs([6012, 6015], ["golden-path-v2", 5996, 5979])
|
|
23
|
+
self.assertEqual(result, [5979, 5996, 6012, 6015])
|
|
24
|
+
|
|
25
|
+
def test_dedupes_across_lists(self):
|
|
26
|
+
self.assertEqual(_numeric_refs([100, 200], [200, 300]), [100, 200, 300])
|
|
27
|
+
|
|
28
|
+
def test_handles_none_and_empty(self):
|
|
29
|
+
self.assertEqual(_numeric_refs(None, []), [])
|
|
30
|
+
|
|
31
|
+
def test_all_string_tokens_yield_empty(self):
|
|
32
|
+
self.assertEqual(_numeric_refs(["golden-path-v2", "epic-x"]), [])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
unittest.main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""delete-track (offline; git mocked). Verifies the destructive-but-bounded
|
|
2
|
+
behavior AND the never-touch-GitHub guarantee."""
|
|
3
|
+
import io
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stdout
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from commands import delete_track
|
|
15
|
+
from lib.write_guard import make_token
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _t(name="ph", repo="o/r", path="/tmp/notes/proj/ph.md", tier="private"):
|
|
19
|
+
return SimpleNamespace(name=name, repo=repo, path=Path(path), tier=tier,
|
|
20
|
+
folder="proj", has_frontmatter=True, meta={"status": "active"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _drive(args, track=None, vis="PRIVATE", tracked=True, git_rm_ok=True,
|
|
24
|
+
archived=None):
|
|
25
|
+
track = track or _t()
|
|
26
|
+
with patch("commands.delete_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
|
|
27
|
+
patch("commands.delete_track.discover_tracks", return_value=[track]), \
|
|
28
|
+
patch("commands.delete_track.discover_archived_tracks", return_value=archived or []), \
|
|
29
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
30
|
+
patch("commands.delete_track.git_state.is_tracked", return_value=tracked), \
|
|
31
|
+
patch("commands.delete_track.git_state.git_rm", return_value=git_rm_ok) as grm, \
|
|
32
|
+
patch("pathlib.Path.unlink") as unlink:
|
|
33
|
+
buf = io.StringIO()
|
|
34
|
+
with redirect_stdout(buf):
|
|
35
|
+
rc = delete_track.run(args)
|
|
36
|
+
return rc, grm, unlink, buf.getvalue()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DeleteTrackTest(unittest.TestCase):
|
|
40
|
+
def test_private_tracked_git_rm_and_undo_message(self):
|
|
41
|
+
rc, grm, unlink, out = _drive(["ph"])
|
|
42
|
+
self.assertEqual(rc, 0)
|
|
43
|
+
grm.assert_called_once_with("ph.md", Path("/tmp/notes/proj"))
|
|
44
|
+
unlink.assert_not_called()
|
|
45
|
+
self.assertIn("deleted track", out)
|
|
46
|
+
self.assertIn("GitHub issues are untouched", out)
|
|
47
|
+
self.assertIn("Recoverable via notes-vcs", out)
|
|
48
|
+
|
|
49
|
+
def test_shared_tracked_prints_commit_push(self):
|
|
50
|
+
rc, grm, unlink, out = _drive(["ph"], track=_t(tier="shared"))
|
|
51
|
+
self.assertEqual(rc, 0)
|
|
52
|
+
grm.assert_called_once()
|
|
53
|
+
self.assertIn("commit & push", out)
|
|
54
|
+
|
|
55
|
+
def test_untracked_uses_filesystem_unlink_and_warns_permanent(self):
|
|
56
|
+
# notes-vcs off (untracked) → unlink → the message must say PERMANENT,
|
|
57
|
+
# NOT promise notes-vcs undo (the review's borderline-critical finding).
|
|
58
|
+
rc, grm, unlink, out = _drive(["ph"], tracked=False)
|
|
59
|
+
self.assertEqual(rc, 0)
|
|
60
|
+
grm.assert_not_called()
|
|
61
|
+
unlink.assert_called_once()
|
|
62
|
+
self.assertIn("PERMANENT", out)
|
|
63
|
+
self.assertNotIn("Recoverable via notes-vcs", out)
|
|
64
|
+
|
|
65
|
+
def test_private_tracked_message_is_recoverable_not_permanent(self):
|
|
66
|
+
# notes-vcs on (tracked) → git rm → recoverable, and NOT flagged permanent.
|
|
67
|
+
rc, grm, unlink, out = _drive(["ph"], tracked=True)
|
|
68
|
+
self.assertEqual(rc, 0)
|
|
69
|
+
self.assertIn("Recoverable via notes-vcs", out)
|
|
70
|
+
self.assertNotIn("PERMANENT", out)
|
|
71
|
+
|
|
72
|
+
def test_git_rm_failure_returns_1(self):
|
|
73
|
+
rc, grm, unlink, out = _drive(["ph"], git_rm_ok=False)
|
|
74
|
+
self.assertEqual(rc, 1)
|
|
75
|
+
|
|
76
|
+
def test_public_blocks_without_confirm(self):
|
|
77
|
+
rc, grm, unlink, out = _drive(["ph"], vis="PUBLIC")
|
|
78
|
+
self.assertEqual(rc, 0)
|
|
79
|
+
grm.assert_not_called()
|
|
80
|
+
unlink.assert_not_called()
|
|
81
|
+
self.assertIn("needs_confirm", out)
|
|
82
|
+
|
|
83
|
+
def test_public_with_confirm_deletes(self):
|
|
84
|
+
tok = make_token("o/r", "ph")
|
|
85
|
+
rc, grm, unlink, out = _drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
|
|
86
|
+
self.assertEqual(rc, 0)
|
|
87
|
+
grm.assert_called_once()
|
|
88
|
+
|
|
89
|
+
def test_not_found_returns_1(self):
|
|
90
|
+
with patch("commands.delete_track.load_config", return_value={"notes_root": "/tmp"}), \
|
|
91
|
+
patch("commands.delete_track.discover_tracks", return_value=[]), \
|
|
92
|
+
patch("commands.delete_track.discover_archived_tracks", return_value=[]):
|
|
93
|
+
buf = io.StringIO()
|
|
94
|
+
with redirect_stdout(buf):
|
|
95
|
+
rc = delete_track.run(["nope"])
|
|
96
|
+
self.assertEqual(rc, 1)
|
|
97
|
+
|
|
98
|
+
def test_module_never_touches_github(self):
|
|
99
|
+
"""Guardrail: delete-track must not import a GitHub-mutating helper.
|
|
100
|
+
The issues outlive the track (#330)."""
|
|
101
|
+
src = (SKILL_ROOT / "commands" / "delete_track.py").read_text()
|
|
102
|
+
self.assertNotIn("github_state", src)
|
|
103
|
+
self.assertNotIn("gh issue", src)
|
|
104
|
+
self.assertNotIn("subprocess", src)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|
|
@@ -38,8 +38,18 @@ class BuildExportTest(unittest.TestCase):
|
|
|
38
38
|
# Phase 2: next_up_preset must be present in every track
|
|
39
39
|
self.assertIn("next_up_preset", t)
|
|
40
40
|
self.assertEqual(t["next_up_preset"], "flow") # default when no next_up_order in meta
|
|
41
|
+
# archived defaults false (active tracks); #328.
|
|
42
|
+
self.assertFalse(t["archived"])
|
|
41
43
|
json.dumps(out) # must be serializable
|
|
42
44
|
|
|
45
|
+
def test_archived_flag_emitted_when_meta_set(self):
|
|
46
|
+
"""A track tagged archived (the --include-archived union sets meta) exports
|
|
47
|
+
archived:true so the viewer can grey it (#328)."""
|
|
48
|
+
tr = _track("old", "o/r", [1])
|
|
49
|
+
tr.meta["archived"] = True
|
|
50
|
+
out = build_export([tr], {("o/r", "old"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
51
|
+
self.assertTrue(out["tracks"][0]["archived"])
|
|
52
|
+
|
|
43
53
|
def test_path_is_null_when_track_has_no_path(self):
|
|
44
54
|
"""A track object without a `path` attribute exports path=None, so the
|
|
45
55
|
viewer disables its open-file affordance instead of erroring (#211)."""
|
|
@@ -381,6 +391,37 @@ class BuildExportPlanTest(unittest.TestCase):
|
|
|
381
391
|
self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
|
|
382
392
|
|
|
383
393
|
|
|
394
|
+
class BuildExportCleanupCandidateTest(unittest.TestCase):
|
|
395
|
+
"""cleanup_candidate / cleanup_reason surface in the export JSON (#328)."""
|
|
396
|
+
|
|
397
|
+
def test_cleanup_absent_defaults(self):
|
|
398
|
+
"""A track with no cleanup marker → candidate False, reason None."""
|
|
399
|
+
tracks = [_track("alpha", "o/r", [1])]
|
|
400
|
+
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
401
|
+
tr = out["tracks"][0]
|
|
402
|
+
self.assertFalse(tr["cleanup_candidate"])
|
|
403
|
+
self.assertIsNone(tr["cleanup_reason"])
|
|
404
|
+
|
|
405
|
+
def test_cleanup_candidate_and_reason_exported(self):
|
|
406
|
+
"""cleanup_candidate: true + cleanup_reason → both surface in export."""
|
|
407
|
+
t = _track("alpha", "o/r", [1])
|
|
408
|
+
t.meta["cleanup_candidate"] = True
|
|
409
|
+
t.meta["cleanup_reason"] = "superseded by v2"
|
|
410
|
+
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
411
|
+
tr = out["tracks"][0]
|
|
412
|
+
self.assertTrue(tr["cleanup_candidate"])
|
|
413
|
+
self.assertEqual(tr["cleanup_reason"], "superseded by v2")
|
|
414
|
+
|
|
415
|
+
def test_cleanup_candidate_without_reason(self):
|
|
416
|
+
"""Marked but no reason → candidate True, reason None."""
|
|
417
|
+
t = _track("alpha", "o/r", [1])
|
|
418
|
+
t.meta["cleanup_candidate"] = True
|
|
419
|
+
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
|
|
420
|
+
tr = out["tracks"][0]
|
|
421
|
+
self.assertTrue(tr["cleanup_candidate"])
|
|
422
|
+
self.assertIsNone(tr["cleanup_reason"])
|
|
423
|
+
|
|
424
|
+
|
|
384
425
|
class BuildExportDependsOnTest(unittest.TestCase):
|
|
385
426
|
"""Tests that depends_on is surfaced in the export JSON (#102)."""
|
|
386
427
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# tests/test_mark_cleanup.py
|
|
2
|
+
import io, sys, unittest
|
|
3
|
+
from contextlib import redirect_stdout
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
from commands import mark_cleanup
|
|
9
|
+
from lib.write_guard import make_token
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _t(name="ph", repo="o/r", meta=None):
|
|
13
|
+
return SimpleNamespace(name=name, repo=repo, path=Path(f"/tmp/{name}.md"),
|
|
14
|
+
has_frontmatter=True,
|
|
15
|
+
meta=meta if meta is not None else {"status": "active", "github": {"repo": repo}},
|
|
16
|
+
body="# b")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _drive(args, track=None, vis="PRIVATE"):
|
|
20
|
+
track = track or _t()
|
|
21
|
+
with patch("commands.mark_cleanup.load_config", return_value={"notes_root": "/tmp"}), \
|
|
22
|
+
patch("commands.mark_cleanup.discover_tracks", return_value=[track]), \
|
|
23
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
24
|
+
patch("commands.mark_cleanup.write_file") as mw:
|
|
25
|
+
buf = io.StringIO()
|
|
26
|
+
with redirect_stdout(buf):
|
|
27
|
+
rc = mark_cleanup.run(args)
|
|
28
|
+
return rc, mw, buf.getvalue()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MarkCleanupTest(unittest.TestCase):
|
|
32
|
+
def test_marks_private_track(self):
|
|
33
|
+
rc, mw, out = _drive(["ph"])
|
|
34
|
+
self.assertEqual(rc, 0)
|
|
35
|
+
mw.assert_called_once()
|
|
36
|
+
self.assertIs(mw.call_args[0][1]["cleanup_candidate"], True)
|
|
37
|
+
self.assertIn("marked for cleanup", out)
|
|
38
|
+
|
|
39
|
+
def test_marks_with_reason(self):
|
|
40
|
+
rc, mw, out = _drive(["ph", "--reason=superseded by v2"])
|
|
41
|
+
self.assertEqual(rc, 0)
|
|
42
|
+
mw.assert_called_once()
|
|
43
|
+
written = mw.call_args[0][1]
|
|
44
|
+
self.assertIs(written["cleanup_candidate"], True)
|
|
45
|
+
self.assertEqual(written["cleanup_reason"], "superseded by v2")
|
|
46
|
+
self.assertIn("superseded by v2", out)
|
|
47
|
+
|
|
48
|
+
def test_clear_removes_both_keys(self):
|
|
49
|
+
t = _t(meta={"status": "active", "github": {"repo": "o/r"},
|
|
50
|
+
"cleanup_candidate": True, "cleanup_reason": "old"})
|
|
51
|
+
rc, mw, out = _drive(["ph", "--clear"], track=t)
|
|
52
|
+
self.assertEqual(rc, 0)
|
|
53
|
+
mw.assert_called_once()
|
|
54
|
+
written = mw.call_args[0][1]
|
|
55
|
+
self.assertNotIn("cleanup_candidate", written)
|
|
56
|
+
self.assertNotIn("cleanup_reason", written)
|
|
57
|
+
self.assertIn("cleared", out)
|
|
58
|
+
|
|
59
|
+
def test_clear_when_only_candidate_present(self):
|
|
60
|
+
# --clear must not error when only one of the two keys exists.
|
|
61
|
+
t = _t(meta={"status": "active", "github": {"repo": "o/r"},
|
|
62
|
+
"cleanup_candidate": True})
|
|
63
|
+
rc, mw, out = _drive(["ph", "--clear"], track=t)
|
|
64
|
+
self.assertEqual(rc, 0)
|
|
65
|
+
mw.assert_called_once()
|
|
66
|
+
self.assertNotIn("cleanup_candidate", mw.call_args[0][1])
|
|
67
|
+
|
|
68
|
+
def test_public_blocks_without_confirm(self):
|
|
69
|
+
rc, mw, out = _drive(["ph"], vis="PUBLIC")
|
|
70
|
+
self.assertEqual(rc, 0)
|
|
71
|
+
mw.assert_not_called()
|
|
72
|
+
self.assertIn("needs_confirm", out)
|
|
73
|
+
|
|
74
|
+
def test_public_with_valid_confirm_writes(self):
|
|
75
|
+
tok = make_token("o/r", "ph")
|
|
76
|
+
rc, mw, out = _drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
|
|
77
|
+
self.assertEqual(rc, 0)
|
|
78
|
+
mw.assert_called_once()
|
|
79
|
+
self.assertIs(mw.call_args[0][1]["cleanup_candidate"], True)
|
|
80
|
+
|
|
81
|
+
def test_no_track_arg_is_usage_error(self):
|
|
82
|
+
rc, mw, out = _drive([])
|
|
83
|
+
self.assertEqual(rc, 2)
|
|
84
|
+
mw.assert_not_called()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
unittest.main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""plan-unarchive: restore an archived plan doc (offline; restore primitive
|
|
2
|
+
+ repo-root resolution mocked)."""
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
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_unarchive
|
|
15
|
+
|
|
16
|
+
ARCHIVED = "docs/plans/archive/shipped/p.md"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _drive(args, outcome="restored", is_file=True):
|
|
20
|
+
with mock.patch("commands.plan_unarchive.plan_status._resolve_repo_root",
|
|
21
|
+
return_value=Path("/repo")), \
|
|
22
|
+
mock.patch("pathlib.Path.is_file", return_value=is_file), \
|
|
23
|
+
mock.patch("commands.plan_unarchive.archive_lib.restore_from_archive",
|
|
24
|
+
return_value=outcome) as rs:
|
|
25
|
+
buf = io.StringIO()
|
|
26
|
+
with redirect_stdout(buf):
|
|
27
|
+
rc = plan_unarchive.run(args)
|
|
28
|
+
return rc, rs, buf.getvalue()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PlanUnarchiveTest(unittest.TestCase):
|
|
32
|
+
def test_restores_and_emits_json(self):
|
|
33
|
+
rc, rs, out = _drive(["--repo=r", "--yes", "--json", "--", ARCHIVED])
|
|
34
|
+
self.assertEqual(rc, 0)
|
|
35
|
+
rs.assert_called_once_with(ARCHIVED, Path("/repo"))
|
|
36
|
+
obj = json.loads(out)
|
|
37
|
+
self.assertEqual(obj["action"], "unarchive")
|
|
38
|
+
self.assertEqual(obj["outcome"], "restored")
|
|
39
|
+
# dest strips archive/shipped/ back to the live location.
|
|
40
|
+
self.assertEqual(obj["dest"], "docs/plans/p.md")
|
|
41
|
+
|
|
42
|
+
def test_collision_refused(self):
|
|
43
|
+
rc, rs, out = _drive(["--repo=r", "--yes", "--json", "--", ARCHIVED],
|
|
44
|
+
outcome="skipped_collision")
|
|
45
|
+
self.assertEqual(rc, 0)
|
|
46
|
+
self.assertEqual(json.loads(out)["outcome"], "skipped_collision")
|
|
47
|
+
|
|
48
|
+
def test_restored_local(self):
|
|
49
|
+
rc, rs, out = _drive(["--repo=r", "--yes", "--json", "--", ARCHIVED],
|
|
50
|
+
outcome="restored_local")
|
|
51
|
+
self.assertEqual(json.loads(out)["outcome"], "restored_local")
|
|
52
|
+
|
|
53
|
+
def test_draft_previews_without_moving(self):
|
|
54
|
+
rc, rs, out = _drive(["--repo=r", "--draft", "--", ARCHIVED])
|
|
55
|
+
self.assertEqual(rc, 0)
|
|
56
|
+
rs.assert_not_called()
|
|
57
|
+
self.assertIn("Would restore", out)
|
|
58
|
+
|
|
59
|
+
def test_not_under_archive_errors(self):
|
|
60
|
+
rc, rs, out = _drive(["--repo=r", "--yes", "--", "docs/plans/p.md"])
|
|
61
|
+
self.assertEqual(rc, 1)
|
|
62
|
+
rs.assert_not_called()
|
|
63
|
+
|
|
64
|
+
def test_missing_file_errors(self):
|
|
65
|
+
rc, rs, out = _drive(["--repo=r", "--yes", "--", ARCHIVED], is_file=False)
|
|
66
|
+
self.assertEqual(rc, 1)
|
|
67
|
+
rs.assert_not_called()
|
|
68
|
+
|
|
69
|
+
def test_repo_required(self):
|
|
70
|
+
buf = io.StringIO()
|
|
71
|
+
with redirect_stdout(buf):
|
|
72
|
+
rc = plan_unarchive.run(["--yes", "--", ARCHIVED])
|
|
73
|
+
self.assertEqual(rc, 2)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
unittest.main()
|
|
@@ -54,6 +54,7 @@ SUBCOMMANDS = {
|
|
|
54
54
|
"--plan-status": "commands.plan_status", # flag-style alias
|
|
55
55
|
"plan-confirm": "commands.plan_confirm",
|
|
56
56
|
"plan-archive": "commands.plan_archive",
|
|
57
|
+
"plan-unarchive": "commands.plan_unarchive",
|
|
57
58
|
"plan-ack": "commands.plan_ack",
|
|
58
59
|
"plan-baseline": "commands.plan_baseline",
|
|
59
60
|
"close-issue": "commands.close_issue",
|
|
@@ -66,6 +67,10 @@ SUBCOMMANDS = {
|
|
|
66
67
|
"set-next-up": "commands.set_next_up",
|
|
67
68
|
"new-track": "commands.new_track",
|
|
68
69
|
"rename-track": "commands.rename_track",
|
|
70
|
+
"mark-cleanup": "commands.mark_cleanup",
|
|
71
|
+
"archive-track": "commands.archive_track",
|
|
72
|
+
"unarchive-track": "commands.unarchive_track",
|
|
73
|
+
"delete-track": "commands.delete_track",
|
|
69
74
|
"set-notes-root": "commands.set_notes_root",
|
|
70
75
|
"notes-vcs": "commands.notes_vcs",
|
|
71
76
|
"plan-branch": "commands.plan_branch",
|
|
@@ -186,6 +191,22 @@ DESCRIPTIONS = [
|
|
|
186
191
|
"Rename an active track's slug: moves the .md file, updates the frontmatter `track` field + last_touched. Resolve <old-slug> with track@repo or --repo when ambiguous. Validates <new-slug> like new-track and rejects a name already taken in the same repo/tier. For shared tracks, --commit stages + commits the move (else prints a 'commit to share' hint). --fix-refs rewrites sibling tracks' depends_on that reference the old slug (otherwise they're just warned about). Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token>.",
|
|
187
192
|
"When a project pivots, a track name turns out misleading, or a slug needs norming — instead of hand-editing the file + frontmatter. Archived tracks are immutable (not renamable).",
|
|
188
193
|
"/work-plan rename-track old-project-name new-project-name"),
|
|
194
|
+
("mark-cleanup", "<track | track@repo> [--repo=<key>] [--clear] [--reason=<text>] [--confirm=<token>]",
|
|
195
|
+
"Flag a track as a cleanup candidate by writing `cleanup_candidate: true` (and an optional `cleanup_reason`) into its frontmatter — a lightweight earmark that `hygiene` surfaces so stale/consolidatable tracks don't get lost. --clear removes both keys. Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token>.",
|
|
196
|
+
"When you notice a track is stale, superseded, or ready to retire but aren't closing it yet — mark it so the next hygiene pass reminds you.",
|
|
197
|
+
"/work-plan mark-cleanup old-experiment --reason='superseded by v2'"),
|
|
198
|
+
("archive-track", "<track | track@repo> [--repo=<key>] [--confirm=<token>]",
|
|
199
|
+
"Set a track aside REVERSIBLY: move its .md into archive/parked/ so it drops out of the active views but is kept for reference (distinct from `close`, which is terminal). Git-aware — a shared track archives as a staged `git mv` (commit & push to share), a private one as a filesystem move. Never touches GitHub. Gates on public repos. Restore with unarchive-track.",
|
|
200
|
+
"When a track is dormant but you don't want to close it — park it out of the active rotation.",
|
|
201
|
+
"/work-plan archive-track spike-idea"),
|
|
202
|
+
("unarchive-track", "<track | track@repo> [--repo=<key>] [--confirm=<token>]",
|
|
203
|
+
"Restore an archived track (parked/shipped/abandoned) back into the active set — the inverse of archive-track and of `close`'s move. Git-aware staged/local move; never touches GitHub. Gates on public repos.",
|
|
204
|
+
"When an archived track becomes relevant again — pull it back into the active views.",
|
|
205
|
+
"/work-plan unarchive-track spike-idea"),
|
|
206
|
+
("delete-track", "<track | track@repo> [--repo=<key>] [--confirm=<token>]",
|
|
207
|
+
"DESTRUCTIVE: remove a track's local .md file. NEVER touches GitHub — the issues it referenced outlive the track. Private tier: the deletion is an undoable notes-vcs commit. Shared tier: a staged `git rm` you commit & push (recoverable from git history until then). Gates on public repos (the VS Code viewer adds a hard modal + type-to-confirm for shared tracks). Prefer archive-track for a reversible set-aside.",
|
|
208
|
+
"When a track is genuinely dead and you want it gone (not just archived) — its GitHub issues are unaffected.",
|
|
209
|
+
"/work-plan delete-track abandoned-spike"),
|
|
189
210
|
("plan-status", "[--repo=<key>] [--json] [--stamp [--draft]] [--llm [--apply]] [--archive | --issues] [--draft] [--since-days=N] [--type=plan|spec]",
|
|
190
211
|
"Reach a verdict on every plan/spec doc in a repo by correlating each plan's declared file-manifest (Create/Modify/Test paths) against the filesystem + git — not the unreliable checkboxes. Read-only: reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. --json for machine output. Add --stamp to write each verdict into its doc as an idempotent status header (--draft previews without writing). Add --llm for a two-step AI pass that judges prose/ambiguous docs (writes a prompt; you save JSON to the cache; re-run with --llm --apply). --archive moves dead plans to archive/abandoned/ (gated); --issues opens a GitHub issue per partial plan listing its unsatisfied files (gated). Both honor --draft.",
|
|
191
212
|
"When you point at a repo and need to know what's actually done vs. half-done vs. dead among accumulated plans. Run from inside the repo, or use --repo=<key> for a configured one.",
|
|
@@ -198,6 +219,10 @@ DESCRIPTIONS = [
|
|
|
198
219
|
"Archive ONE plan/spec doc whose effective verdict is shipped: history-preserving `git mv` into archive/shipped/. Refuses non-shipped docs; skips (never overwrites) a name collision. --draft previews; --yes skips the prompt for non-interactive callers (the VS Code viewer); --json emits a single {action,rel,outcome,dest} object. `<rel>` is the repo-relative doc path from `plan-status --json`.",
|
|
199
220
|
"When a shipped plan clutters the active plans view and you want to move it to archive/shipped/ in one step — with full git history preserved.",
|
|
200
221
|
"/work-plan plan-archive --repo=myproject -- docs/superpowers/plans/2026-03-16-idea-mode-ui.md"),
|
|
222
|
+
("plan-unarchive", "--repo=<key> [--draft] [--yes] [--json] -- <rel>",
|
|
223
|
+
"Restore ONE archived plan/spec doc back OUT of archive/<kind>/ to its live location — the inverse of plan-archive (#388). Refuses (never overwrites) a collision with a live doc of the same name. Git-aware staged/local move. --draft previews; --yes / --json for non-interactive callers. `<rel>` is the archived doc's repo-relative path (from `plan-status --json --include-archived`).",
|
|
224
|
+
"When a plan was archived by mistake, or a shipped plan needs more work — pull it back into the live plans set without a hand-run git mv.",
|
|
225
|
+
"/work-plan plan-unarchive --repo=myproject -- docs/superpowers/plans/archive/shipped/2026-03-16-idea-mode-ui.md"),
|
|
201
226
|
("plan-ack", "--repo=<key> [--clear] [--confirm=<token>] -- <rel>",
|
|
202
227
|
"Persist an acknowledgment into ONE plan/spec doc's YAML **frontmatter only** (`acknowledged: true`) — never the body/manifest/checkboxes/banner (#286). Unlike the VS Code viewer's default ack (per-machine, ephemeral `workspaceState`), this is durable + shared: it's committed with the repo, and `plan-status` reads it back to demote the doc. `<rel>` is the repo-relative doc path. Public-repo gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`). `--clear` removes it.",
|
|
203
228
|
"When you want a 'stop flagging this plan' that sticks across machines and teammates, not just on your laptop.",
|