@stylusnexus/work-plan 2026.7.13 → 2026.7.16

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.
@@ -59,7 +59,8 @@ class ExportRunJsonTest(unittest.TestCase):
59
59
  with patch("commands.export.load_config", return_value={}), \
60
60
  patch("commands.export.discover_tracks", return_value=tracks), \
61
61
  patch("commands.export.fetch_export_issues", return_value=export_map) as mock_fei, \
62
- patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
62
+ patch("commands.export.fetch_visibility_concurrent",
63
+ side_effect=lambda repos: {r: vis.get(r) for r in repos}), \
63
64
  patch("commands.export.datetime") as mock_dt:
64
65
  mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
65
66
  buf = io.StringIO()
@@ -320,14 +321,13 @@ class ExportCommandUntrackedTest(unittest.TestCase):
320
321
 
321
322
  vis = vis or {_SHARED_REPO: "PUBLIC"}
322
323
 
323
- def _fake_open_issues(repo, limit=1000):
324
- return open_rows_by_repo.get(repo, [])
325
-
326
324
  with patch("commands.export.load_config", return_value={}), \
327
325
  patch("commands.export.discover_tracks", return_value=tracks), \
328
326
  patch("commands.export.fetch_export_issues", return_value=export_map), \
329
- patch("commands.export.fetch_open_issues", side_effect=_fake_open_issues), \
330
- patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
327
+ patch("commands.export.fetch_open_issues_concurrent",
328
+ side_effect=lambda repos: {r: open_rows_by_repo.get(r, []) for r in repos}), \
329
+ patch("commands.export.fetch_visibility_concurrent",
330
+ side_effect=lambda repos: {r: vis.get(r) for r in repos}), \
331
331
  patch("commands.export.datetime") as mock_dt:
332
332
  mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
333
333
  buf = io.StringIO()
@@ -407,6 +407,25 @@ class ExportCommandUntrackedTest(unittest.TestCase):
407
407
  rc, out = self._run_with_mocks(tracks, export_map, open_rows)
408
408
  json.dumps(out) # must not raise
409
409
 
410
+ def test_untracked_order_is_deterministic_across_many_repos(self):
411
+ """`tracked_repos` must be a first-seen-order list, not a set — a set's
412
+ iteration order varies run-to-run (hash randomization), which would
413
+ make `out["untracked"]`'s ordering nondeterministic since it iterates
414
+ that structure directly to build output (no sort downstream)."""
415
+ repo_names = [f"org/repo{i}" for i in range(8)]
416
+ tracks = [_track(f"t{i}", repo_names[i], [i]) for i in range(8)]
417
+ export_map = {(repo_names[i], i): {"number": i, "title": f"issue{i}",
418
+ "state": "OPEN", "assignees": [], "milestone": None}
419
+ for i in range(8)}
420
+ open_rows = {r: [{"number": 900 + idx, "title": "extra", "state": "OPEN",
421
+ "assignees": [], "milestone": None}]
422
+ for idx, r in enumerate(repo_names)}
423
+ vis = {r: "PUBLIC" for r in repo_names}
424
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows, vis=vis)
425
+ self.assertEqual(rc, 0)
426
+ seen_order = [entry["repo"] for entry in out["untracked"]]
427
+ self.assertEqual(seen_order, repo_names)
428
+
410
429
 
411
430
  class ExportCommandGateTest(unittest.TestCase):
412
431
  def test_requires_json_flag(self):
@@ -543,6 +562,139 @@ class ExportPlanBadgeTest(unittest.TestCase):
543
562
  self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
544
563
 
545
564
 
565
+ class ExportPlanBadgeBatchingTest(unittest.TestCase):
566
+ """#422: linked plans sharing a local clone batch their git history into
567
+ ONE paths_last_commit_dates call instead of one per doc/declared path."""
568
+
569
+ def _write_plan(self, root, rel, body):
570
+ path = root / rel
571
+ path.parent.mkdir(parents=True, exist_ok=True)
572
+ path.write_text(body)
573
+
574
+ def _track(self, name, folder, plan_rel):
575
+ return SimpleNamespace(
576
+ name=name, repo="o/r", tier="private", folder=folder,
577
+ path=Path(f"/tmp/notes/{name}.md"), has_frontmatter=True,
578
+ meta={"status": "active", "plan": plan_rel, "github": {"repo": "o/r", "issues": []}})
579
+
580
+ def _run(self, tracks, local_by_folder, paths_side_effect=None):
581
+ import io
582
+ import tempfile as _tf
583
+ from contextlib import redirect_stdout
584
+ from datetime import date as _date
585
+
586
+ def _resolve_local(folder, cfg):
587
+ return local_by_folder.get(folder)
588
+
589
+ with patch("commands.export.load_config", return_value={}), \
590
+ patch("commands.export.discover_tracks", return_value=tracks), \
591
+ patch("commands.export.fetch_export_issues", return_value={}), \
592
+ patch("commands.export.fetch_visibility_concurrent", return_value={}), \
593
+ patch("commands.export.fetch_open_issues_concurrent", return_value={}), \
594
+ patch("commands.export.resolve_local_path_for_folder", side_effect=_resolve_local), \
595
+ patch("commands.export.paths_last_commit_dates",
596
+ side_effect=paths_side_effect or (lambda paths, root: {})) as mock_pld, \
597
+ patch("commands.export.datetime") as mock_dt:
598
+ mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
599
+ buf = io.StringIO()
600
+ with redirect_stdout(buf):
601
+ rc = export_cmd.run(["--json"])
602
+ return rc, json.loads(buf.getvalue()), mock_pld
603
+
604
+ def test_two_tracks_same_clone_share_one_batched_call(self):
605
+ import tempfile
606
+ with tempfile.TemporaryDirectory() as d:
607
+ root = Path(d)
608
+ self._write_plan(root, "docs/plans/a.md",
609
+ "# A\n**Files:**\n- Create: `src/a.ts`\n")
610
+ self._write_plan(root, "docs/plans/b.md",
611
+ "# B\n**Files:**\n- Create: `src/b.ts`\n")
612
+ (root / "src").mkdir()
613
+ (root / "src/a.ts").write_text("a")
614
+ (root / "src/b.ts").write_text("b")
615
+ track_a = self._track("alpha", "demo", "docs/plans/a.md")
616
+ track_b = self._track("beta", "demo", "docs/plans/b.md")
617
+
618
+ rc, out, mock_pld = self._run([track_a, track_b], {"demo": root})
619
+
620
+ self.assertEqual(rc, 0)
621
+ self.assertEqual(mock_pld.call_count, 1)
622
+ called_paths, called_root = mock_pld.call_args[0]
623
+ self.assertEqual(called_root, root)
624
+ self.assertEqual(
625
+ set(called_paths),
626
+ {"docs/plans/a.md", "docs/plans/b.md", "src/a.ts", "src/b.ts"},
627
+ )
628
+
629
+ def test_two_tracks_different_clones_batch_independently(self):
630
+ import tempfile
631
+ with tempfile.TemporaryDirectory() as d1, tempfile.TemporaryDirectory() as d2:
632
+ root1, root2 = Path(d1), Path(d2)
633
+ self._write_plan(root1, "docs/plans/a.md", "# A\n")
634
+ self._write_plan(root2, "docs/plans/b.md", "# B\n")
635
+ track_a = self._track("alpha", "repo1", "docs/plans/a.md")
636
+ track_b = self._track("beta", "repo2", "docs/plans/b.md")
637
+
638
+ rc, out, mock_pld = self._run(
639
+ [track_a, track_b], {"repo1": root1, "repo2": root2})
640
+
641
+ self.assertEqual(rc, 0)
642
+ self.assertEqual(mock_pld.call_count, 2)
643
+ roots_called = {c.args[1] for c in mock_pld.call_args_list}
644
+ self.assertEqual(roots_called, {root1, root2})
645
+
646
+ def test_unresolved_tracks_never_trigger_a_batch_call(self):
647
+ track = self._track("alpha", "missing", "docs/plans/a.md")
648
+ rc, out, mock_pld = self._run([track], {}) # no local clone resolves
649
+ self.assertEqual(rc, 0)
650
+ mock_pld.assert_not_called()
651
+
652
+ def test_overlapping_declared_path_across_two_plans_deduped_in_one_call(self):
653
+ """Two linked plans in the same clone that both declare the SAME
654
+ path — the shared path appears once in the batched request."""
655
+ import tempfile
656
+ with tempfile.TemporaryDirectory() as d:
657
+ root = Path(d)
658
+ self._write_plan(root, "docs/plans/a.md",
659
+ "# A\n**Files:**\n- Create: `src/shared.ts`\n")
660
+ self._write_plan(root, "docs/plans/b.md",
661
+ "# B\n**Files:**\n- Modify: `src/shared.ts`\n")
662
+ (root / "src").mkdir()
663
+ (root / "src/shared.ts").write_text("shared")
664
+ track_a = self._track("alpha", "demo", "docs/plans/a.md")
665
+ track_b = self._track("beta", "demo", "docs/plans/b.md")
666
+
667
+ rc, out, mock_pld = self._run([track_a, track_b], {"demo": root})
668
+
669
+ self.assertEqual(mock_pld.call_count, 1)
670
+ called_paths = mock_pld.call_args[0][0]
671
+ self.assertEqual(called_paths.count("src/shared.ts"), 1)
672
+
673
+ def test_batched_run_produces_correct_verdict_and_lie_gap(self):
674
+ """Same body/expected verdict as ExportPlanBadgeTest's direct-call
675
+ test_resolved_badge_with_lie_gap — batching changes call count, not
676
+ verdict/lie_gap semantics."""
677
+ import tempfile
678
+ with tempfile.TemporaryDirectory() as d:
679
+ root = Path(d)
680
+ body = ("# P\n\n**Files:**\n- Create: `src/new.ts`\n"
681
+ "- [ ] Step 1\n- [ ] Step 2\n")
682
+ self._write_plan(root, "docs/plans/p.md", body)
683
+ (root / "src").mkdir()
684
+ (root / "src/new.ts").write_text("export const x = 1")
685
+ track = self._track("alpha", "demo", "docs/plans/p.md")
686
+
687
+ rc, out, mock_pld = self._run([track], {"demo": root})
688
+
689
+ self.assertEqual(rc, 0)
690
+ badge = out["tracks"][0]["plan"]
691
+ self.assertTrue(badge["resolved"])
692
+ self.assertEqual(badge["verdict"], "shipped")
693
+ self.assertEqual(badge["files_present"], 1)
694
+ self.assertEqual(badge["files_declared"], 1)
695
+ self.assertTrue(badge["lie_gap"])
696
+
697
+
546
698
  class ExportHotByTrackTest(unittest.TestCase):
547
699
  def test_export_marks_in_progress_from_hot_branch(self):
548
700
  import io
@@ -556,8 +708,9 @@ class ExportHotByTrackTest(unittest.TestCase):
556
708
  patch("commands.export.discover_tracks", return_value=[track]), \
557
709
  patch("commands.export.fetch_export_issues",
558
710
  return_value={("o/r", 1): issue}), \
559
- patch("commands.export.fetch_open_issues", return_value=[]), \
560
- patch("commands.export.repo_visibility", return_value="PRIVATE"), \
711
+ patch("commands.export.fetch_open_issues_concurrent", return_value={}), \
712
+ patch("commands.export.fetch_visibility_concurrent",
713
+ side_effect=lambda repos: {r: "PRIVATE" for r in repos}), \
561
714
  patch("commands.export.resolve_local_path_for_folder",
562
715
  return_value=Path("/repo")), \
563
716
  patch("commands.export.hot_issue_numbers", return_value={1}), \
@@ -12,7 +12,9 @@ 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
+ fetch_visibility_concurrent, fetch_open_issues_concurrent,
17
+ gh_auth_status,
16
18
  _GQL_FIELDS_LEAN, _GQL_FIELDS_FULL,
17
19
  _gql_query, _GQL_ISSUE_DEPS,
18
20
  )
@@ -104,6 +106,56 @@ class RepoVisibilityTest(unittest.TestCase):
104
106
  self.assertEqual(m.call_count, 1)
105
107
 
106
108
 
109
+ class FetchVisibilityConcurrentTest(unittest.TestCase):
110
+ """Unit tests for the per-repo concurrent batch fetch_visibility_concurrent() (#424)."""
111
+
112
+ @patch("lib.github_state.repo_visibility")
113
+ def test_returns_keyed_dict_per_repo(self, mock_rv):
114
+ mock_rv.side_effect = lambda r: {"org/a": "PUBLIC", "org/b": "PRIVATE"}.get(r)
115
+ result = fetch_visibility_concurrent(["org/a", "org/b"])
116
+ self.assertEqual(result, {"org/a": "PUBLIC", "org/b": "PRIVATE"})
117
+
118
+ @patch("lib.github_state.repo_visibility")
119
+ def test_dedupes_repeated_repos(self, mock_rv):
120
+ mock_rv.return_value = "PUBLIC"
121
+ fetch_visibility_concurrent(["org/a", "org/a", "org/a"])
122
+ self.assertEqual(mock_rv.call_count, 1)
123
+
124
+ @patch("lib.github_state.repo_visibility")
125
+ def test_falsy_repos_filtered(self, mock_rv):
126
+ mock_rv.return_value = "PUBLIC"
127
+ result = fetch_visibility_concurrent(["org/a", None, "", "org/a"])
128
+ self.assertEqual(list(result.keys()), ["org/a"])
129
+
130
+ def test_empty_input_returns_empty_dict_no_gh_call(self):
131
+ with patch("lib.github_state.repo_visibility") as mock_rv:
132
+ self.assertEqual(fetch_visibility_concurrent([]), {})
133
+ mock_rv.assert_not_called()
134
+
135
+ @patch("lib.github_state.repo_visibility")
136
+ def test_concurrency_bound_is_respected(self, mock_rv):
137
+ """Peak simultaneous in-flight calls never exceeds max_workers, and
138
+ does exceed 1 — proving work is genuinely bounded-concurrent, not
139
+ serial (#424 acceptance criteria)."""
140
+ import threading, time
141
+ lock = threading.Lock()
142
+ state = {"current": 0, "peak": 0}
143
+
144
+ def _tracked(repo):
145
+ with lock:
146
+ state["current"] += 1
147
+ state["peak"] = max(state["peak"], state["current"])
148
+ time.sleep(0.05)
149
+ with lock:
150
+ state["current"] -= 1
151
+ return "PUBLIC"
152
+ mock_rv.side_effect = _tracked
153
+ repos = [f"org/repo{i}" for i in range(10)]
154
+ fetch_visibility_concurrent(repos, max_workers=3)
155
+ self.assertLessEqual(state["peak"], 3)
156
+ self.assertGreater(state["peak"], 1)
157
+
158
+
107
159
  _ISSUE_JSON = '{"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": null, "url": "u", "closedAt": null, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}'
108
160
  _ISSUE_DICT = {"number": 1, "state": "OPEN", "labels": [], "title": "t", "milestone": None, "url": "u", "closedAt": None, "body": "", "updatedAt": "2026-01-01T00:00:00Z", "assignees": []}
109
161
 
@@ -544,6 +596,57 @@ class FetchOpenIssuesTest(unittest.TestCase):
544
596
  self.assertIsInstance(result, list)
545
597
 
546
598
 
599
+ class FetchOpenIssuesConcurrentTest(unittest.TestCase):
600
+ """Unit tests for the per-repo concurrent batch fetch_open_issues_concurrent() (#424)."""
601
+
602
+ @patch("lib.github_state.fetch_open_issues")
603
+ def test_returns_keyed_dict_per_repo(self, mock_foi):
604
+ mock_foi.side_effect = lambda repo: [{"number": 1, "repo": repo}]
605
+ result = fetch_open_issues_concurrent(["org/a", "org/b"])
606
+ self.assertEqual(result["org/a"], [{"number": 1, "repo": "org/a"}])
607
+ self.assertEqual(result["org/b"], [{"number": 1, "repo": "org/b"}])
608
+
609
+ @patch("lib.github_state.fetch_open_issues")
610
+ def test_dedupes_repeated_repos(self, mock_foi):
611
+ mock_foi.return_value = []
612
+ fetch_open_issues_concurrent(["org/a", "org/a", "org/a"])
613
+ self.assertEqual(mock_foi.call_count, 1)
614
+
615
+ @patch("lib.github_state.fetch_open_issues")
616
+ def test_falsy_repos_filtered(self, mock_foi):
617
+ mock_foi.return_value = []
618
+ result = fetch_open_issues_concurrent(["org/a", None, "", "org/a"])
619
+ self.assertEqual(list(result.keys()), ["org/a"])
620
+
621
+ def test_empty_input_returns_empty_dict_no_gh_call(self):
622
+ with patch("lib.github_state.fetch_open_issues") as mock_foi:
623
+ self.assertEqual(fetch_open_issues_concurrent([]), {})
624
+ mock_foi.assert_not_called()
625
+
626
+ @patch("lib.github_state.fetch_open_issues")
627
+ def test_concurrency_bound_is_respected(self, mock_foi):
628
+ """Peak simultaneous in-flight calls never exceeds max_workers, and
629
+ does exceed 1 — proving work is genuinely bounded-concurrent, not
630
+ serial (#424 acceptance criteria)."""
631
+ import threading, time
632
+ lock = threading.Lock()
633
+ state = {"current": 0, "peak": 0}
634
+
635
+ def _tracked(repo):
636
+ with lock:
637
+ state["current"] += 1
638
+ state["peak"] = max(state["peak"], state["current"])
639
+ time.sleep(0.05)
640
+ with lock:
641
+ state["current"] -= 1
642
+ return []
643
+ mock_foi.side_effect = _tracked
644
+ repos = [f"org/repo{i}" for i in range(10)]
645
+ fetch_open_issues_concurrent(repos, max_workers=3)
646
+ self.assertLessEqual(state["peak"], 3)
647
+ self.assertGreater(state["peak"], 1)
648
+
649
+
547
650
  class GqlFieldSetsTest(unittest.TestCase):
548
651
  def test_lean_set_requests_labels(self):
549
652
  # The export path uses the lean set; without labels the in-progress
@@ -608,5 +711,44 @@ class NormalizeDepsTest(unittest.TestCase):
608
711
  self.assertFalse(out["deps_truncated"])
609
712
 
610
713
 
714
+ class TestRepoFullName(unittest.TestCase):
715
+ def test_returns_full_name_on_success(self):
716
+ fake = MagicMock(returncode=0, stdout="org/repo-renamed\n", stderr="")
717
+ with patch("lib.github_state.subprocess.run", return_value=fake) as m:
718
+ result = repo_full_name("org/repo-old")
719
+ self.assertEqual(result, "org/repo-renamed")
720
+ args = m.call_args[0][0]
721
+ self.assertEqual(args, ["gh", "api", "repos/org/repo-old", "--jq", ".full_name"])
722
+
723
+ def test_none_on_nonzero_exit(self):
724
+ fake = MagicMock(returncode=1, stdout="", stderr="not found")
725
+ with patch("lib.github_state.subprocess.run", return_value=fake):
726
+ self.assertIsNone(repo_full_name("org/gone"))
727
+
728
+ def test_none_on_invalid_slug(self):
729
+ self.assertIsNone(repo_full_name("not-a-slug"))
730
+
731
+ def test_none_on_subprocess_exception(self):
732
+ with patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
733
+ self.assertIsNone(repo_full_name("org/repo"))
734
+
735
+
736
+ def _gh_authenticated():
737
+ return gh_auth_status()["authenticated"]
738
+
739
+
740
+ class TestRepoFullNameLiveRedirect(unittest.TestCase):
741
+ def test_musical_family_trees_redirect_is_still_live(self):
742
+ # The actual repo this whole design's incident was about. If this ever
743
+ # stops returning the renamed slug, the redirect has lapsed and the
744
+ # design's core "gh api repos/<slug> follows GitHub's own rename
745
+ # redirect" assumption needs re-verifying before trusting doctor's
746
+ # output on real data.
747
+ if not _gh_authenticated():
748
+ self.skipTest("gh not authenticated — skipping live network check")
749
+ result = repo_full_name("evemcgivern/musical-family-trees")
750
+ self.assertEqual(result, "evemcgivern/soundstellation")
751
+
752
+
611
753
  if __name__ == "__main__":
612
754
  unittest.main()
@@ -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"
@@ -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
 
@@ -463,5 +463,42 @@ class DiscoverArchivedSharedTest(unittest.TestCase):
463
463
  self.assertNotIn("WARN:", stderr_buf.getvalue())
464
464
 
465
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
+
466
503
  if __name__ == "__main__":
467
504
  unittest.main()
@@ -61,6 +61,7 @@ SUBCOMMANDS = {
61
61
  "in-progress": "commands.in_progress",
62
62
  "export": "commands.export",
63
63
  "which-repo": "commands.which_repo",
64
+ "doctor": "commands.doctor",
64
65
  "auth-status": "commands.auth_status",
65
66
  "list-open-issues": "commands.list_open_issues",
66
67
  "set": "commands.set_field",
@@ -259,6 +260,17 @@ DESCRIPTIONS = [
259
260
  "Resolve the current directory to one configured repo — by local clone path first, then the git `origin` remote. Prints the matched config key + GitHub slug, or reports no match. Read-only. Underlies `brief` cwd auto-scope and the VS Code viewer's repo auto-focus.",
260
261
  "Rarely run by hand — it's the shared resolver the viewer and `brief` call. Useful to confirm which repo a checkout maps to.",
261
262
  "/work-plan which-repo --json"),
263
+ ("doctor", "[--json] [--fix]",
264
+ "Detect drift between config.yml, local git clones, GitHub, and notes_root track "
265
+ "frontmatter — a renamed local folder or GitHub repo that config.yml no longer "
266
+ "matches, a non-git local path, duplicate entries, an invalid/missing notes_root, "
267
+ "an orphaned notes folder, or a stale per-track github.repo. --fix corrects only "
268
+ "the two mechanically-safe cases (a GitHub-confirmed rename, a stale track slug) "
269
+ "and always re-scans afterward before deciding success.",
270
+ "Run right after renaming a project's local folder or its GitHub repo — this is "
271
+ "exactly the class of bug that silently breaks the VS Code viewer's Auto Focus "
272
+ "Repo setting with zero visible signal.",
273
+ "/work-plan doctor --fix"),
262
274
  ]
263
275
 
264
276
 
@@ -352,6 +364,14 @@ _READONLY_SUBCOMMANDS = frozenset({
352
364
  # plan-branch manages its OWN commits on the plan branch (init seeds +
353
365
  # commits the skeleton itself); the auto-commit hooks must not also fire.
354
366
  "plan-branch",
367
+ # doctor writes config.yml (unversioned, outside any repo) and private
368
+ # notes_root/ frontmatter under --fix, but never a repo's shared
369
+ # .work-plan/ tier — so it's read-only from the dispatcher's perspective
370
+ # too, sidestepping _shared_precommit_state's plan_worktree.ensure_worktree
371
+ # side effect. doctor --fix manages its own notes-vcs commit directly
372
+ # (see commands/doctor.py's dirty-file policy), same self-managed-commit
373
+ # precedent as plan-branch.
374
+ "doctor",
355
375
  })
356
376
 
357
377