@stylusnexus/work-plan 2026.7.10 → 2026.7.15

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.
Files changed (30) hide show
  1. package/README.md +9 -1
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +1 -0
  5. package/skills/work-plan/commands/doctor.py +529 -0
  6. package/skills/work-plan/commands/export.py +14 -11
  7. package/skills/work-plan/commands/init_repo.py +3 -6
  8. package/skills/work-plan/commands/new_track.py +21 -2
  9. package/skills/work-plan/commands/plan_status.py +48 -6
  10. package/skills/work-plan/lib/config.py +29 -0
  11. package/skills/work-plan/lib/doc_discovery.py +22 -2
  12. package/skills/work-plan/lib/export_model.py +16 -3
  13. package/skills/work-plan/lib/github_state.py +25 -0
  14. package/skills/work-plan/lib/notes_vcs.py +21 -9
  15. package/skills/work-plan/lib/plan_worktree.py +47 -8
  16. package/skills/work-plan/lib/tracks.py +17 -3
  17. package/skills/work-plan/tests/test_config.py +59 -0
  18. package/skills/work-plan/tests/test_discover_archived.py +15 -0
  19. package/skills/work-plan/tests/test_doc_discovery.py +11 -0
  20. package/skills/work-plan/tests/test_doctor.py +852 -0
  21. package/skills/work-plan/tests/test_export.py +34 -8
  22. package/skills/work-plan/tests/test_export_command.py +101 -0
  23. package/skills/work-plan/tests/test_github_state.py +41 -1
  24. package/skills/work-plan/tests/test_group_apply.py +4 -0
  25. package/skills/work-plan/tests/test_new_track.py +79 -0
  26. package/skills/work-plan/tests/test_notes_vcs.py +30 -0
  27. package/skills/work-plan/tests/test_plan_status_stamp.py +68 -0
  28. package/skills/work-plan/tests/test_plan_worktree.py +31 -0
  29. package/skills/work-plan/tests/test_tracks.py +55 -1
  30. package/skills/work-plan/work_plan.py +20 -0
@@ -3,18 +3,44 @@ import sys, json, unittest
3
3
  from pathlib import Path
4
4
  from types import SimpleNamespace
5
5
  SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
6
- from lib.export_model import build_export
6
+ from lib.export_model import build_export, track_key
7
7
  import commands.export as export_cmd
8
8
 
9
9
  def _track(name, repo, issues, blockers=None, next_up=None, status="active", depends_on=None):
10
10
  return SimpleNamespace(name=name, repo=repo, tier="private",
11
- path=Path(f"/tmp/notes/{name}.md"), folder="myrepo",
11
+ path=Path(f"/tmp/notes/{name}.md"), folder=repo,
12
12
  meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
13
13
  "blockers": blockers or [], "next_up": next_up or [],
14
14
  "depends_on": depends_on or [],
15
15
  "github": {"repo": repo, "issues": issues}})
16
16
 
17
17
  class BuildExportTest(unittest.TestCase):
18
+ def test_track_key_prefers_config_folder_over_repo_slug(self):
19
+ track = SimpleNamespace(name="alpha", repo="org/repo", folder="config-folder")
20
+ self.assertEqual(track_key(track), ("config-folder", "alpha"))
21
+
22
+ def test_track_key_falls_back_to_folder_without_repo(self):
23
+ track = SimpleNamespace(name="alpha", repo=None, folder="config-folder")
24
+ self.assertEqual(track_key(track), ("config-folder", "alpha"))
25
+
26
+ def test_folder_identity_is_shared_by_issue_hot_and_plan_maps(self):
27
+ track = _track("alpha", None, [7])
28
+ track.folder = "config-folder"
29
+ key = ("config-folder", "alpha")
30
+ badge = {"rel": "docs/plans/p.md", "resolved": False}
31
+ issues = [{"number": 7, "title": "folder-only", "state": "OPEN",
32
+ "assignees": [], "labels": []}]
33
+
34
+ out = build_export(
35
+ [track], {key: issues}, {}, now="t",
36
+ plan_by_track={key: badge}, hot_by_track={key: {7}},
37
+ )
38
+
39
+ exported = out["tracks"][0]
40
+ self.assertEqual(exported["plan"], badge)
41
+ self.assertEqual(exported["issues"][0]["number"], 7)
42
+ self.assertTrue(exported["issues"][0]["in_progress"])
43
+
18
44
  def test_schema_and_shape(self):
19
45
  tracks = [_track("ph", "o/r", [1, 2], blockers=[9], next_up=[1])]
20
46
  issues_by_track = {("o/r", "ph"): [
@@ -31,7 +57,7 @@ class BuildExportTest(unittest.TestCase):
31
57
  # the platform — str(Path) yields backslashes on Windows.
32
58
  self.assertEqual(t["path"], str(Path("/tmp/notes/ph.md")))
33
59
  # Config repo key surfaces for the Plans view's --repo arg (#164).
34
- self.assertEqual(t["folder"], "myrepo")
60
+ self.assertEqual(t["folder"], "o/r")
35
61
  self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
36
62
  self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
37
63
  self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None, "in_progress": False, "in_progress_label": False, "blocked_by": [], "blocking": []})
@@ -74,7 +100,7 @@ class BuildExportNextUpAutoTest(unittest.TestCase):
74
100
  if auto:
75
101
  meta["next_up_auto"] = True
76
102
  return SimpleNamespace(name="t", repo="o/r", tier="private",
77
- path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
103
+ path=Path("/tmp/notes/t.md"), folder="o/r", meta=meta)
78
104
 
79
105
  def _issues(self):
80
106
  # P0 #3, P1 #2, P2 #1 — flow ranks by priority within the same milestone.
@@ -113,7 +139,7 @@ class BuildExportNextUpAutoTest(unittest.TestCase):
113
139
  from types import SimpleNamespace
114
140
  from pathlib import Path
115
141
  t = SimpleNamespace(name="t", repo="o/r", tier="private",
116
- path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
142
+ path=Path("/tmp/notes/t.md"), folder="o/r", meta=meta)
117
143
  out = build_export([t],
118
144
  {("o/r", "t"): []}, # zero issues fetched
119
145
  {"o/r": "PRIVATE"}, now="t")
@@ -381,13 +407,13 @@ class BuildExportPlanTest(unittest.TestCase):
381
407
  "checkboxes_done": 0, "checkboxes_total": 24, "lie_gap": False,
382
408
  "stalled": False, "override": "shipped"}
383
409
  out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
384
- plan_by_track={"alpha": badge})
410
+ plan_by_track={("o/r", "alpha"): badge})
385
411
  self.assertEqual(out["tracks"][0]["plan"], badge)
386
412
 
387
413
  def test_unresolved_badge_passed_through(self):
388
414
  tracks = [_track("alpha", "o/r", [1])]
389
415
  out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
390
- plan_by_track={"alpha": {"rel": "docs/plans/p.md", "resolved": False}})
416
+ plan_by_track={("o/r", "alpha"): {"rel": "docs/plans/p.md", "resolved": False}})
391
417
  self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
392
418
 
393
419
 
@@ -583,7 +609,7 @@ class BuildExportNextUpPresetTest(unittest.TestCase):
583
609
  if track_meta_override:
584
610
  meta.update(track_meta_override)
585
611
  t = SimpleNamespace(name="alpha", repo="o/r", tier="private",
586
- path=Path("/tmp/notes/alpha.md"), folder="myrepo",
612
+ path=Path("/tmp/notes/alpha.md"), folder="o/r",
587
613
  meta=meta)
588
614
  out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"},
589
615
  now="2026-06-14T00:00", next_up_default=next_up_default)
@@ -263,6 +263,53 @@ class ExportRunJsonTest(unittest.TestCase):
263
263
  # repoB: issue 1 — once
264
264
  self.assertEqual(repo_to_numbers[repo_b], [1])
265
265
 
266
+ def test_same_name_tracks_in_different_repos_keep_distinct_plan_badges(self):
267
+ """Plan badges must follow track identity, not collide on track name."""
268
+ repo_a = "org/repoA"
269
+ repo_b = "org/repoB"
270
+ track_a = _track("shared-name", repo_a, [])
271
+ track_b = _track("shared-name", repo_b, [])
272
+ track_a.folder = "repo-a"
273
+ track_b.folder = "repo-b"
274
+ track_a.meta["plan"] = "docs/plans/a.md"
275
+ track_b.meta["plan"] = "docs/plans/b.md"
276
+
277
+ def _badge(track, *_args):
278
+ return {"rel": track.meta["plan"], "resolved": False}
279
+
280
+ with patch("commands.export._plan_badge", side_effect=_badge):
281
+ rc, out, _ = self._run_with_mocks(
282
+ [track_a, track_b], {}, vis={repo_a: "PUBLIC", repo_b: "PRIVATE"}
283
+ )
284
+
285
+ self.assertEqual(rc, 0)
286
+ by_repo = {track["repo"]: track for track in out["tracks"]}
287
+ self.assertEqual(by_repo[repo_a]["plan"]["rel"], "docs/plans/a.md")
288
+ self.assertEqual(by_repo[repo_b]["plan"]["rel"], "docs/plans/b.md")
289
+
290
+ def test_same_repo_alias_folders_keep_distinct_plan_badges(self):
291
+ """Folder keys remain distinct even when aliases share a GitHub slug."""
292
+ repo = "org/shared"
293
+ track_a = _track("shared-name", repo, [])
294
+ track_b = _track("shared-name", repo, [])
295
+ track_a.folder = "alias-a"
296
+ track_b.folder = "alias-b"
297
+ track_a.meta["plan"] = "docs/plans/a.md"
298
+ track_b.meta["plan"] = "docs/plans/b.md"
299
+
300
+ def _badge(track, *_args):
301
+ return {"rel": track.meta["plan"], "resolved": False}
302
+
303
+ with patch("commands.export._plan_badge", side_effect=_badge):
304
+ rc, out, _ = self._run_with_mocks(
305
+ [track_a, track_b], {}, vis={repo: "PRIVATE"}
306
+ )
307
+
308
+ self.assertEqual(rc, 0)
309
+ by_folder = {track["folder"]: track for track in out["tracks"]}
310
+ self.assertEqual(by_folder["alias-a"]["plan"]["rel"], "docs/plans/a.md")
311
+ self.assertEqual(by_folder["alias-b"]["plan"]["rel"], "docs/plans/b.md")
312
+
266
313
 
267
314
  class ExportCommandUntrackedTest(unittest.TestCase):
268
315
  """Verify export.run computes untracked = open issues minus tracked ones."""
@@ -435,6 +482,60 @@ class ExportPlanBadgeTest(unittest.TestCase):
435
482
  badge = self._badge(self._track_with_plan(rel="docs/plans/missing.md"), root)
436
483
  self.assertEqual(badge, {"rel": "docs/plans/missing.md", "resolved": False})
437
484
 
485
+ def test_unresolved_when_plan_traverses_outside_repo(self):
486
+ import tempfile
487
+ with tempfile.TemporaryDirectory() as d:
488
+ root = Path(d) / "repo"
489
+ root.mkdir()
490
+ outside = Path(d) / "outside.md"
491
+ outside.write_text(self.BODY)
492
+ rel = "../outside.md"
493
+
494
+ badge = self._badge(self._track_with_plan(rel=rel), root)
495
+
496
+ self.assertEqual(badge, {"rel": rel, "resolved": False})
497
+
498
+ def test_unresolved_when_plan_is_absolute_path(self):
499
+ import tempfile
500
+ with tempfile.TemporaryDirectory() as d:
501
+ root = Path(d) / "repo"
502
+ root.mkdir()
503
+ outside = Path(d) / "outside.md"
504
+ outside.write_text(self.BODY)
505
+ rel = str(outside)
506
+
507
+ badge = self._badge(self._track_with_plan(rel=rel), root)
508
+
509
+ self.assertEqual(badge, {"rel": rel, "resolved": False})
510
+
511
+ def test_unresolved_when_plan_is_absolute_path_inside_repo(self):
512
+ import tempfile
513
+ with tempfile.TemporaryDirectory() as d:
514
+ root = self._repo_with_plan(d, self.BODY)
515
+ rel = str(root / "docs/plans/p.md")
516
+
517
+ badge = self._badge(self._track_with_plan(rel=rel), root)
518
+
519
+ self.assertEqual(badge, {"rel": rel, "resolved": False})
520
+
521
+ def test_unresolved_when_plan_symlink_resolves_outside_repo(self):
522
+ import tempfile
523
+ with tempfile.TemporaryDirectory() as d:
524
+ root = Path(d) / "repo"
525
+ plans = root / "docs/plans"
526
+ plans.mkdir(parents=True)
527
+ outside = Path(d) / "outside.md"
528
+ outside.write_text(self.BODY)
529
+ link = plans / "p.md"
530
+ link.symlink_to(outside)
531
+
532
+ badge = self._badge(self._track_with_plan(), root)
533
+
534
+ self.assertEqual(
535
+ badge,
536
+ {"rel": "docs/plans/p.md", "resolved": False},
537
+ )
538
+
438
539
  def test_no_folder_is_unresolved(self):
439
540
  t = self._track_with_plan(folder=None)
440
541
  from datetime import date
@@ -12,7 +12,8 @@ from lib.github_state import (
12
12
  fetch_issues, fetch_issue, fetch_issues_concurrent,
13
13
  fetch_repo_issues_graphql, fetch_export_issues, _normalize_gql_node,
14
14
  extract_priority, fetch_recent_issues, short_milestone,
15
- repo_visibility, _VIS_CACHE, fetch_open_issues,
15
+ repo_visibility, _VIS_CACHE, fetch_open_issues, repo_full_name,
16
+ gh_auth_status,
16
17
  _GQL_FIELDS_LEAN, _GQL_FIELDS_FULL,
17
18
  _gql_query, _GQL_ISSUE_DEPS,
18
19
  )
@@ -608,5 +609,44 @@ class NormalizeDepsTest(unittest.TestCase):
608
609
  self.assertFalse(out["deps_truncated"])
609
610
 
610
611
 
612
+ class TestRepoFullName(unittest.TestCase):
613
+ def test_returns_full_name_on_success(self):
614
+ fake = MagicMock(returncode=0, stdout="org/repo-renamed\n", stderr="")
615
+ with patch("lib.github_state.subprocess.run", return_value=fake) as m:
616
+ result = repo_full_name("org/repo-old")
617
+ self.assertEqual(result, "org/repo-renamed")
618
+ args = m.call_args[0][0]
619
+ self.assertEqual(args, ["gh", "api", "repos/org/repo-old", "--jq", ".full_name"])
620
+
621
+ def test_none_on_nonzero_exit(self):
622
+ fake = MagicMock(returncode=1, stdout="", stderr="not found")
623
+ with patch("lib.github_state.subprocess.run", return_value=fake):
624
+ self.assertIsNone(repo_full_name("org/gone"))
625
+
626
+ def test_none_on_invalid_slug(self):
627
+ self.assertIsNone(repo_full_name("not-a-slug"))
628
+
629
+ def test_none_on_subprocess_exception(self):
630
+ with patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
631
+ self.assertIsNone(repo_full_name("org/repo"))
632
+
633
+
634
+ def _gh_authenticated():
635
+ return gh_auth_status()["authenticated"]
636
+
637
+
638
+ class TestRepoFullNameLiveRedirect(unittest.TestCase):
639
+ def test_musical_family_trees_redirect_is_still_live(self):
640
+ # The actual repo this whole design's incident was about. If this ever
641
+ # stops returning the renamed slug, the redirect has lapsed and the
642
+ # design's core "gh api repos/<slug> follows GitHub's own rename
643
+ # redirect" assumption needs re-verifying before trusting doctor's
644
+ # output on real data.
645
+ if not _gh_authenticated():
646
+ self.skipTest("gh not authenticated — skipping live network check")
647
+ result = repo_full_name("evemcgivern/musical-family-trees")
648
+ self.assertEqual(result, "evemcgivern/soundstellation")
649
+
650
+
611
651
  if __name__ == "__main__":
612
652
  unittest.main()
@@ -77,6 +77,8 @@ def _drive_apply(args, *, cfg, batch, answers, vis="PRIVATE"):
77
77
  patch("commands.group.load_config", return_value=cfg), \
78
78
  patch("lib.write_guard.repo_visibility", return_value=vis), \
79
79
  patch("commands.group.is_valid_git_repo", return_value=True), \
80
+ patch("commands.group.shared_tier_dir",
81
+ return_value=Path(cfg["repos"]["myrepo"]["local"]) / ".work-plan"), \
80
82
  patch("commands.group.write_file") as mw, \
81
83
  patch("commands.group.parse_file", return_value=({}, "")), \
82
84
  patch("commands.group.seed_readme") as mseed, \
@@ -193,6 +195,8 @@ class GroupApplyTierRoutingTest(unittest.TestCase):
193
195
  patch("commands.group.load_config", return_value=cfg), \
194
196
  patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
195
197
  patch("commands.group.is_valid_git_repo", return_value=True), \
198
+ patch("commands.group.shared_tier_dir",
199
+ return_value=Path(cfg["repos"]["myrepo"]["local"]) / ".work-plan"), \
196
200
  patch("commands.group.write_file"), \
197
201
  patch("commands.group.parse_file", return_value=({}, "")), \
198
202
  patch("commands.group.seed_readme") as mseed, \
@@ -20,6 +20,7 @@ import io
20
20
  import json
21
21
  import subprocess
22
22
  import sys
23
+ import tempfile
23
24
  import unittest
24
25
  from contextlib import redirect_stdout
25
26
  from pathlib import Path
@@ -441,6 +442,82 @@ class NewTrackCommandTest(unittest.TestCase):
441
442
  self.assertEqual(rc, 0)
442
443
  mw.assert_called_once()
443
444
 
445
+ def test_refuses_shared_track_when_work_plan_root_is_symlinked_outside_repo(self):
446
+ """A repo-controlled shared root must not redirect creation elsewhere."""
447
+ with tempfile.TemporaryDirectory() as d:
448
+ base = Path(d)
449
+ clone = base / "clone"
450
+ outside = base / "outside"
451
+ notes = base / "notes"
452
+ (clone / ".git").mkdir(parents=True)
453
+ outside.mkdir()
454
+ notes.mkdir()
455
+ try:
456
+ (clone / ".work-plan").symlink_to(outside, target_is_directory=True)
457
+ except OSError as exc:
458
+ self.skipTest(f"directory symlinks unavailable: {exc}")
459
+ cfg = {
460
+ "notes_root": str(notes),
461
+ "repos": {
462
+ "myrepo": {"github": "org/myrepo", "local": str(clone)},
463
+ },
464
+ }
465
+
466
+ with patch("commands.new_track.load_config", return_value=cfg), \
467
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"):
468
+ buf = io.StringIO()
469
+ with redirect_stdout(buf):
470
+ rc = new_track.run(["myrepo", "escaped"])
471
+
472
+ self.assertEqual(rc, 1)
473
+ self.assertFalse((outside / "escaped.md").exists())
474
+ self.assertIn("unsafe shared track directory", buf.getvalue())
475
+
476
+ def test_rechecks_shared_root_after_directory_creation_before_write(self):
477
+ """A root swapped after mkdir is rejected before write_file opens it."""
478
+ with tempfile.TemporaryDirectory() as d:
479
+ base = Path(d)
480
+ clone = base / "clone"
481
+ outside = base / "outside"
482
+ notes = base / "notes"
483
+ (clone / ".git").mkdir(parents=True)
484
+ outside.mkdir()
485
+ notes.mkdir()
486
+ cfg = {
487
+ "notes_root": str(notes),
488
+ "repos": {
489
+ "myrepo": {"github": "org/myrepo", "local": str(clone)},
490
+ },
491
+ }
492
+ real_resolve = new_track.resolve_shared_tier
493
+ calls = 0
494
+
495
+ def _swap_after_mkdir(entry):
496
+ nonlocal calls
497
+ calls += 1
498
+ if calls == 3:
499
+ shared = clone / ".work-plan"
500
+ shared.rmdir()
501
+ try:
502
+ shared.symlink_to(outside, target_is_directory=True)
503
+ except OSError as exc:
504
+ self.skipTest(f"directory symlinks unavailable: {exc}")
505
+ return real_resolve(entry)
506
+
507
+ with patch("commands.new_track.load_config", return_value=cfg), \
508
+ patch("commands.new_track.resolve_shared_tier",
509
+ side_effect=_swap_after_mkdir), \
510
+ patch("commands.new_track.write_file") as write, \
511
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"):
512
+ buf = io.StringIO()
513
+ with redirect_stdout(buf):
514
+ rc = new_track.run(["myrepo", "escaped"])
515
+
516
+ self.assertEqual(rc, 1)
517
+ write.assert_not_called()
518
+ self.assertFalse((outside / "escaped.md").exists())
519
+ self.assertIn("unsafe shared track directory", buf.getvalue())
520
+
444
521
 
445
522
  # ---------------------------------------------------------------------------
446
523
  # Phase D: --commit flag tests
@@ -504,6 +581,8 @@ class NewTrackCommitFlagTest(unittest.TestCase):
504
581
 
505
582
  with patch("commands.new_track.load_config", return_value=cfg), \
506
583
  patch("commands.new_track.write_file") as mw, \
584
+ patch("commands.new_track.resolve_shared_tier",
585
+ return_value=(Path(CLONE_ROOT) / ".work-plan", None)), \
507
586
  patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
508
587
  patch("pathlib.Path.exists", _path_exists), \
509
588
  patch("pathlib.Path.is_dir", _is_dir), \
@@ -188,6 +188,36 @@ class RemoteAndOwnershipTest(unittest.TestCase):
188
188
  self.assertTrue(notes_vcs.is_owned(Path(d)))
189
189
 
190
190
 
191
+ class DirtyPathsCheckedTest(unittest.TestCase):
192
+ def test_ok_true_on_clean_tree(self):
193
+ with tempfile.TemporaryDirectory() as d:
194
+ with patch.object(notes_vcs, "_git", _FakeGit(porcelain="")):
195
+ ok, paths = notes_vcs.dirty_paths_checked(Path(d))
196
+ self.assertTrue(ok)
197
+ self.assertEqual(paths, set())
198
+
199
+ def test_ok_true_with_dirty_paths(self):
200
+ with tempfile.TemporaryDirectory() as d:
201
+ with patch.object(notes_vcs, "_git",
202
+ _FakeGit(porcelain=" M foo.md\n?? bar.md\n")):
203
+ ok, paths = notes_vcs.dirty_paths_checked(Path(d))
204
+ self.assertTrue(ok)
205
+ self.assertEqual(paths, {"foo.md", "bar.md"})
206
+
207
+ def test_ok_false_on_git_call_failure(self):
208
+ with tempfile.TemporaryDirectory() as d:
209
+ with patch.object(notes_vcs, "_git", _FakeGit(missing=True)):
210
+ ok, paths = notes_vcs.dirty_paths_checked(Path(d))
211
+ self.assertFalse(ok)
212
+ self.assertEqual(paths, set())
213
+
214
+ def test_dirty_paths_thin_wrapper_matches_checked(self):
215
+ with tempfile.TemporaryDirectory() as d:
216
+ with patch.object(notes_vcs, "_git",
217
+ _FakeGit(porcelain=" M foo.md\n")):
218
+ self.assertEqual(notes_vcs.dirty_paths(Path(d)), {"foo.md"})
219
+
220
+
191
221
  class DirtyPathsTest(unittest.TestCase):
192
222
  def test_parses_porcelain_into_path_set(self):
193
223
  body = " M alpha.md\n?? beta.md\n D gone.md\n"
@@ -1,5 +1,6 @@
1
1
  """Stamp / draft behaviour for plan-status (offline)."""
2
2
  import io
3
+ import os
3
4
  import unittest
4
5
  import sys
5
6
  import tempfile
@@ -11,6 +12,7 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
12
  sys.path.insert(0, str(SKILL_ROOT))
12
13
 
13
14
  from commands import plan_status
15
+ from lib.doc_discovery import Doc
14
16
  from lib.status_header import BEGIN
15
17
 
16
18
  PLAN = (
@@ -21,6 +23,14 @@ PLAN = (
21
23
  "- [ ] Step 1\n"
22
24
  )
23
25
 
26
+ ROW = {
27
+ "glyph": "✅",
28
+ "verdict": "shipped",
29
+ "files_present": 1,
30
+ "files_declared": 1,
31
+ "last_touched": "2026-07-12",
32
+ }
33
+
24
34
 
25
35
  class StampBehaviourTest(unittest.TestCase):
26
36
  def _repo(self, d):
@@ -65,6 +75,64 @@ class StampBehaviourTest(unittest.TestCase):
65
75
  self._run(root, ["--stamp"])
66
76
  self.assertEqual(first, self.plan_path.read_text())
67
77
 
78
+ def test_stamp_rechecks_symlink_before_write(self):
79
+ with tempfile.TemporaryDirectory() as d:
80
+ root = Path(d) / "repo"
81
+ plans = root / "docs/plans"
82
+ plans.mkdir(parents=True)
83
+ victim = Path(d) / "victim.md"
84
+ victim.write_text("# outside\n")
85
+ link = plans / "evil.md"
86
+ link.symlink_to(victim)
87
+ doc = Doc(path=link, rel="docs/plans/evil.md", kind="plan")
88
+
89
+ buf = io.StringIO()
90
+ with redirect_stdout(buf):
91
+ plan_status._stamp_docs([doc], [ROW], repo_root=root, draft=False)
92
+
93
+ self.assertEqual(victim.read_text(), "# outside\n")
94
+ self.assertIn("refusing to stamp unsafe path", buf.getvalue())
95
+
96
+ def test_stamp_cli_does_not_modify_outside_symlink_target(self):
97
+ with tempfile.TemporaryDirectory() as d:
98
+ root = Path(d) / "repo"
99
+ plans = root / "docs/superpowers/plans"
100
+ plans.mkdir(parents=True)
101
+ victim = Path(d) / "victim.md"
102
+ victim.write_text(PLAN)
103
+ before = victim.read_bytes()
104
+ (plans / "evil.md").symlink_to(victim)
105
+
106
+ rc, out = self._run(root, ["--stamp"])
107
+
108
+ self.assertEqual(rc, 0)
109
+ self.assertEqual(victim.read_bytes(), before)
110
+ self.assertNotIn(BEGIN, victim.read_text())
111
+ self.assertIn("stamped 0 doc(s)", out)
112
+
113
+ def test_stamp_replaces_hard_link_without_modifying_outside_inode(self):
114
+ with tempfile.TemporaryDirectory() as d:
115
+ root = Path(d) / "repo"
116
+ plans = root / "docs/superpowers/plans"
117
+ plans.mkdir(parents=True)
118
+ victim = Path(d) / "victim.md"
119
+ victim.write_text(PLAN)
120
+ linked_plan = plans / "linked.md"
121
+ try:
122
+ os.link(str(victim), str(linked_plan))
123
+ except OSError as exc:
124
+ self.skipTest(f"hard links unavailable: {exc}")
125
+ original_inode = victim.stat().st_ino
126
+
127
+ rc, out = self._run(root, ["--stamp"])
128
+
129
+ self.assertEqual(rc, 0)
130
+ self.assertEqual(victim.read_text(), PLAN)
131
+ self.assertEqual(victim.stat().st_ino, original_inode)
132
+ self.assertNotEqual(linked_plan.stat().st_ino, original_inode)
133
+ self.assertIn(BEGIN, linked_plan.read_text())
134
+ self.assertIn("stamped 1 doc(s)", out)
135
+
68
136
 
69
137
  if __name__ == "__main__":
70
138
  unittest.main()
@@ -97,6 +97,20 @@ class SharedTierDirTest(unittest.TestCase):
97
97
  got = pw.shared_tier_dir({"local": d})
98
98
  self.assertEqual(got, Path(d).expanduser() / ".work-plan")
99
99
 
100
+ def test_no_plan_branch_refuses_symlinked_work_plan_root(self):
101
+ with tempfile.TemporaryDirectory() as d:
102
+ base = Path(d)
103
+ clone = base / "clone"
104
+ outside = base / "outside"
105
+ clone.mkdir()
106
+ outside.mkdir()
107
+ try:
108
+ (clone / ".work-plan").symlink_to(outside, target_is_directory=True)
109
+ except OSError as exc:
110
+ self.skipTest(f"directory symlinks unavailable: {exc}")
111
+
112
+ self.assertIsNone(pw.shared_tier_dir({"local": str(clone)}))
113
+
100
114
  def test_plan_branch_uses_worktree_when_present(self):
101
115
  with tempfile.TemporaryDirectory() as d:
102
116
  dest = Path(d) / "wt"
@@ -107,6 +121,23 @@ class SharedTierDirTest(unittest.TestCase):
107
121
  got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
108
122
  self.assertEqual(got, dest / ".work-plan")
109
123
 
124
+ def test_plan_branch_refuses_symlinked_work_plan_root(self):
125
+ with tempfile.TemporaryDirectory() as d:
126
+ base = Path(d)
127
+ dest = base / "wt"
128
+ outside = base / "outside"
129
+ dest.mkdir()
130
+ outside.mkdir()
131
+ (dest / ".git").write_text("gitdir: ...\n")
132
+ try:
133
+ (dest / ".work-plan").symlink_to(outside, target_is_directory=True)
134
+ except OSError as exc:
135
+ self.skipTest(f"directory symlinks unavailable: {exc}")
136
+ with patch.object(pw, "_worktree_dir", return_value=dest), \
137
+ patch.object(pw, "_git", _FakeGit()):
138
+ got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
139
+ self.assertIsNone(got)
140
+
110
141
  def test_plan_branch_none_when_branch_missing(self):
111
142
  with tempfile.TemporaryDirectory() as d:
112
143
  dest = Path(d) / "wt" # no .git → must be created, but branch missing
@@ -9,7 +9,7 @@ from unittest.mock import patch
9
9
  SKILL_ROOT = Path(__file__).resolve().parents[1]
10
10
  sys.path.insert(0, str(SKILL_ROOT))
11
11
 
12
- from lib.tracks import discover_tracks, discover_archived_tracks
12
+ from lib.tracks import discover_tracks, discover_archived_tracks, iter_private_track_paths
13
13
 
14
14
  FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
15
15
 
@@ -120,6 +120,23 @@ class SharedTrackDiscoveryTest(unittest.TestCase):
120
120
  shared = next(t for t in tracks if t.name == "feat-x")
121
121
  self.assertEqual(shared.tier, "shared")
122
122
 
123
+ def test_symlinked_shared_root_outside_repo_is_not_discovered(self):
124
+ with tempfile.TemporaryDirectory() as d:
125
+ base = Path(d)
126
+ clone = _make_git_repo(base / "clone")
127
+ outside = base / "outside"
128
+ _write_track_md(outside / "escaped.md", "escaped", "org/myrepo")
129
+ try:
130
+ (clone / ".work-plan").symlink_to(outside, target_is_directory=True)
131
+ except OSError as exc:
132
+ self.skipTest(f"directory symlinks unavailable: {exc}")
133
+ notes = base / "notes"
134
+ notes.mkdir()
135
+
136
+ tracks = discover_tracks(self._make_cfg(clone, notes))
137
+
138
+ self.assertNotIn("escaped", [track.name for track in tracks])
139
+
123
140
  def test_shared_track_repo_from_folder_config(self):
124
141
  """repo and local_path on a shared track come from folder config, not frontmatter."""
125
142
  with tempfile.TemporaryDirectory() as d:
@@ -446,5 +463,42 @@ class DiscoverArchivedSharedTest(unittest.TestCase):
446
463
  self.assertNotIn("WARN:", stderr_buf.getvalue())
447
464
 
448
465
 
466
+ class TestIterPrivateTrackPaths(unittest.TestCase):
467
+ def _make_tree(self, root: Path):
468
+ (root / "proj-a").mkdir(parents=True)
469
+ (root / "proj-a" / "track-one.md").write_text("---\n---\nbody")
470
+ (root / "proj-a" / "_draft.md").write_text("---\n---\nbody")
471
+ (root / "proj-a" / "archive").mkdir()
472
+ (root / "proj-a" / "archive" / "old-track.md").write_text("---\n---\nbody")
473
+ (root / "proj-a" / ".hidden.md").write_text("---\n---\nbody")
474
+
475
+ def test_excludes_archive_by_default(self):
476
+ with tempfile.TemporaryDirectory() as tmp:
477
+ root = Path(tmp)
478
+ self._make_tree(root)
479
+ paths = iter_private_track_paths(root, include_archive=False)
480
+ names = {p.name for p in paths}
481
+ self.assertEqual(names, {"track-one.md"})
482
+
483
+ def test_includes_archive_when_requested(self):
484
+ with tempfile.TemporaryDirectory() as tmp:
485
+ root = Path(tmp)
486
+ self._make_tree(root)
487
+ paths = iter_private_track_paths(root, include_archive=True)
488
+ names = {p.name for p in paths}
489
+ self.assertEqual(names, {"track-one.md", "old-track.md"})
490
+
491
+ def test_skips_dotfile_underscore_dash_prefixed_names_in_both_modes(self):
492
+ with tempfile.TemporaryDirectory() as tmp:
493
+ root = Path(tmp)
494
+ self._make_tree(root)
495
+ (root / "proj-a" / "-dash.md").write_text("---\n---\nbody")
496
+ for include_archive in (False, True):
497
+ names = {p.name for p in iter_private_track_paths(root, include_archive)}
498
+ self.assertNotIn("_draft.md", names)
499
+ self.assertNotIn(".hidden.md", names)
500
+ self.assertNotIn("-dash.md", names)
501
+
502
+
449
503
  if __name__ == "__main__":
450
504
  unittest.main()