@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.
@@ -1,21 +1,67 @@
1
- """The archive move primitive: collision-checked, history-preserving `git mv`
2
- of a doc into archive/<kind>/. Eligibility (is-this-shipped) is the caller's
3
- job this only moves files."""
4
- from pathlib import Path
1
+ """The archive move primitive: collision-checked move of a doc into
2
+ archive/<kind>/. For tracked files the move is a history-preserving `git mv`
3
+ (staged rename); for untracked/gitignored files it falls back to a plain
4
+ filesystem move. Eligibility (is-this-shipped) is the caller's job — this
5
+ only moves files."""
6
+ import shutil
7
+ from pathlib import Path, PurePosixPath
5
8
 
6
9
  from lib import git_state
7
10
  from lib import reconcile_actions
8
11
 
9
12
 
10
13
  def move_to_archive(rel: str, repo_root, kind: str):
11
- """git-mv `rel` into archive/<kind>/. Returns:
12
- "archived" on a clean move,
13
- "skipped_collision" if the destination already exists (never overwrite),
14
- None if git_mv failed for any other reason (hard error).
14
+ """Move `rel` into archive/<kind>/. Returns:
15
+ "archived" tracked file: history-preserving `git mv` (staged rename),
16
+ "archived_local" untracked/gitignored file: plain filesystem move,
17
+ "skipped_collision" destination already exists (never overwrite),
18
+ None hard failure (git mv failed on a tracked file, or OSError
19
+ on an untracked file).
15
20
  """
16
21
  dest = reconcile_actions.archive_dest(rel, kind)
17
22
  if (Path(repo_root) / dest).exists():
18
23
  return "skipped_collision"
19
- if git_state.git_mv(rel, dest, repo_root):
20
- return "archived"
21
- return None
24
+ if git_state.is_tracked(rel, Path(repo_root)):
25
+ if git_state.git_mv(rel, dest, repo_root):
26
+ return "archived"
27
+ return None
28
+ # Untracked / gitignored — plain filesystem move.
29
+ src_path = Path(repo_root) / rel
30
+ dst_path = Path(repo_root) / dest
31
+ try:
32
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
33
+ shutil.move(str(src_path), str(dst_path))
34
+ return "archived_local"
35
+ except OSError:
36
+ return None
37
+
38
+
39
+ def restore_from_archive(rel: str, repo_root):
40
+ """Inverse of move_to_archive: move an archived doc back OUT of
41
+ `.../archive/<kind>/<name>` to its original directory (`.../name`). Returns:
42
+ "restored" tracked file: history-preserving `git mv` (staged),
43
+ "restored_local" untracked/gitignored file: plain filesystem move,
44
+ "skipped_collision" a live doc already exists at the destination,
45
+ None `rel` isn't under archive/<kind>/, or a hard move failure.
46
+ Shared by unarchive-track (#328) and plan-unarchive (#388).
47
+ """
48
+ p = PurePosixPath(rel)
49
+ # Expect .../archive/<kind>/<name>; the grandparent dir must be "archive".
50
+ if p.parent.parent.name != "archive":
51
+ return None
52
+ dest = str(p.parent.parent.parent / p.name)
53
+ if (Path(repo_root) / dest).exists():
54
+ return "skipped_collision"
55
+ if git_state.is_tracked(rel, Path(repo_root)):
56
+ if git_state.git_mv(rel, dest, repo_root):
57
+ return "restored"
58
+ return None
59
+ # Untracked / gitignored — plain filesystem move.
60
+ src_path = Path(repo_root) / rel
61
+ dst_path = Path(repo_root) / dest
62
+ try:
63
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
64
+ shutil.move(str(src_path), str(dst_path))
65
+ return "restored_local"
66
+ except OSError:
67
+ return None
@@ -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),
@@ -358,6 +358,18 @@ def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
358
358
  return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
359
359
 
360
360
 
361
+ def is_tracked(rel_path: str, repo_path: Path) -> bool:
362
+ """True iff `rel_path` is tracked by git (git ls-files --error-unmatch).
363
+
364
+ False for untracked/gitignored paths AND when repo_path isn't a git repo
365
+ or doesn't exist. Never raises.
366
+ """
367
+ if not repo_path or not Path(repo_path).exists():
368
+ return False
369
+ proc = _git(repo_path, "ls-files", "--error-unmatch", rel_path)
370
+ return proc is not None and proc.returncode == 0
371
+
372
+
361
373
  def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
362
374
  """git-mv `src_rel` -> `dst_rel` (both repo-relative), creating the dest
363
375
  directory first. Returns True on success. History-preserving."""
@@ -366,3 +378,13 @@ def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
366
378
  (Path(repo_path) / dst_rel).parent.mkdir(parents=True, exist_ok=True)
367
379
  proc = _git(repo_path, "mv", src_rel, dst_rel)
368
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)
@@ -1,12 +1,15 @@
1
1
  """Pure archive helpers: destination path + shipped-row selection (offline)."""
2
2
  import sys
3
+ import tempfile
3
4
  import unittest
4
5
  from pathlib import Path
6
+ from unittest import mock
5
7
 
6
8
  SKILL_ROOT = Path(__file__).resolve().parents[1]
7
9
  sys.path.insert(0, str(SKILL_ROOT))
8
10
 
9
11
  from lib import reconcile_actions as ra
12
+ from lib import archive as archive_lib
10
13
 
11
14
 
12
15
  class ArchiveDestTest(unittest.TestCase):
@@ -46,11 +49,6 @@ class ShippedRowsTest(unittest.TestCase):
46
49
  self.assertEqual(rels, ["a.md", "b.md"])
47
50
 
48
51
 
49
- import tempfile
50
- from unittest import mock
51
- from lib import archive as archive_lib
52
-
53
-
54
52
  class MoveToArchiveTest(unittest.TestCase):
55
53
  def _repo(self, d):
56
54
  root = Path(d)
@@ -58,32 +56,128 @@ class MoveToArchiveTest(unittest.TestCase):
58
56
  (root / "docs/plans/x.md").write_text("# x")
59
57
  return root
60
58
 
61
- def test_archived_calls_git_mv_to_shipped_dir(self):
59
+ # ------------------------------------------------------------------
60
+ # tracked path: branches through git_mv
61
+ # ------------------------------------------------------------------
62
+
63
+ def test_tracked_archived_calls_git_mv(self):
64
+ """Tracked file: is_tracked=True -> git_mv called -> 'archived'."""
62
65
  with tempfile.TemporaryDirectory() as d:
63
66
  root = self._repo(d)
64
- with mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
67
+ with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
68
+ mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
65
69
  outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
66
70
  self.assertEqual(outcome, "archived")
67
71
  mv.assert_called_once_with(
68
72
  "docs/plans/x.md", "docs/plans/archive/shipped/x.md", root)
69
73
 
70
- def test_collision_skips_without_calling_git_mv(self):
74
+ def test_tracked_git_mv_failure_returns_none(self):
75
+ """Tracked file where git_mv fails -> None (hard error)."""
76
+ with tempfile.TemporaryDirectory() as d:
77
+ root = self._repo(d)
78
+ with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
79
+ mock.patch("lib.archive.git_state.git_mv", return_value=False):
80
+ outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
81
+ self.assertIsNone(outcome)
82
+
83
+ # ------------------------------------------------------------------
84
+ # untracked/gitignored path: plain filesystem move
85
+ # ------------------------------------------------------------------
86
+
87
+ def test_untracked_archived_local_via_filesystem(self):
88
+ """Untracked file: is_tracked=False -> shutil.move called -> 'archived_local'.
89
+
90
+ The file must actually move on disk (src gone, dest exists)."""
91
+ with tempfile.TemporaryDirectory() as d:
92
+ root = self._repo(d)
93
+ with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
94
+ mock.patch("lib.archive.git_state.git_mv") as mv:
95
+ outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
96
+ self.assertEqual(outcome, "archived_local")
97
+ mv.assert_not_called()
98
+ # Source is gone; dest exists.
99
+ self.assertFalse((root / "docs/plans/x.md").exists())
100
+ self.assertTrue((root / "docs/plans/archive/shipped/x.md").exists())
101
+
102
+ def test_untracked_oserror_returns_none(self):
103
+ """Untracked file where shutil.move raises OSError -> None."""
104
+ with tempfile.TemporaryDirectory() as d:
105
+ root = self._repo(d)
106
+ with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
107
+ mock.patch("lib.archive.shutil.move",
108
+ side_effect=OSError("permission denied")):
109
+ outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
110
+ self.assertIsNone(outcome)
111
+
112
+ # ------------------------------------------------------------------
113
+ # collision: checked before tracking branch, git_mv never called
114
+ # ------------------------------------------------------------------
115
+
116
+ def test_collision_skips_without_any_move(self):
117
+ """Destination already exists -> 'skipped_collision', no move attempted."""
71
118
  with tempfile.TemporaryDirectory() as d:
72
119
  root = self._repo(d)
73
120
  dest = root / "docs/plans/archive/shipped"
74
121
  dest.mkdir(parents=True)
75
122
  (dest / "x.md").write_text("# already here")
76
- with mock.patch("lib.archive.git_state.git_mv") as mv:
123
+ with mock.patch("lib.archive.git_state.is_tracked") as is_tracked, \
124
+ mock.patch("lib.archive.git_state.git_mv") as mv:
77
125
  outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
78
126
  self.assertEqual(outcome, "skipped_collision")
127
+ is_tracked.assert_not_called()
79
128
  mv.assert_not_called()
80
129
 
81
- def test_git_mv_failure_returns_none(self):
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'."""
82
140
  with tempfile.TemporaryDirectory() as d:
83
- root = self._repo(d)
84
- with mock.patch("lib.archive.git_state.git_mv", return_value=False):
85
- outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
86
- self.assertIsNone(outcome)
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))
87
181
 
88
182
 
89
183
  if __name__ == "__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,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