@stylusnexus/work-plan 2026.7.13 → 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.
@@ -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()
@@ -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