@stylusnexus/work-plan 2026.6.22 → 2026.7.3

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.
@@ -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()
@@ -43,7 +43,9 @@ class PlanArchiveTest(unittest.TestCase):
43
43
  def test_archives_shipped_with_yes_json(self):
44
44
  with tempfile.TemporaryDirectory() as d:
45
45
  root = _repo(d, SHIPPED_BODY)
46
- with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv",
46
+ with mock.patch("commands.plan_archive.archive_lib.git_state.is_tracked",
47
+ return_value=True), \
48
+ mock.patch("commands.plan_archive.archive_lib.git_state.git_mv",
47
49
  return_value=True) as mv:
48
50
  rc, out = _run(root, ["--repo=x", "--yes", "--json",
49
51
  "--", "docs/plans/p.md"])
@@ -29,6 +29,9 @@ class ArchiveTest(unittest.TestCase):
29
29
  # run() now reads the batched paths_last_commit_dates map (#391); mock its
30
30
  # .get to return the stale date for every doc (path_last_commit_date is the
31
31
  # fallback path, mocked too).
32
+ #
33
+ # _archive_dead now routes through archive_lib.move_to_archive, so patch
34
+ # is_tracked + git_mv on lib.archive.git_state (not plan_status.git_state).
32
35
  stale = datetime(2026, 1, 1)
33
36
  batched = mock.MagicMock()
34
37
  batched.get.return_value = stale
@@ -37,7 +40,8 @@ class ArchiveTest(unittest.TestCase):
37
40
  mock.patch("commands.plan_status.git_state.paths_last_commit_dates",
38
41
  return_value=batched), \
39
42
  mock.patch("commands.plan_status.Path.cwd", return_value=root), \
40
- mock.patch("commands.plan_status.git_state.git_mv",
43
+ mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
44
+ mock.patch("lib.archive.git_state.git_mv",
41
45
  return_value=mv_ok) as mv, \
42
46
  mock.patch("commands.plan_status.prompt_yes_no", return_value=True):
43
47
  buf = io.StringIO()
@@ -107,7 +107,9 @@ class ArchiveShippedBatchTest(unittest.TestCase):
107
107
  # to "no" on non-TTY stdin) and archive, mirroring per-doc plan-archive.
108
108
  with tempfile.TemporaryDirectory() as d:
109
109
  root = _repo_batch(d)
110
- with mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
110
+ with mock.patch("commands.plan_status.archive_lib.git_state.is_tracked",
111
+ return_value=True), \
112
+ mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
111
113
  return_value=True) as mv:
112
114
  rc, out = _run(root, ["--archive-shipped", "--yes"])
113
115
  self.assertEqual(rc, 0)
@@ -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.",