@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,52 @@
1
+ """Tests for the --repo=<key> filter shared by brief / refresh-md / reconcile / hygiene."""
2
+ import unittest
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ from lib.tracks import discover_tracks, filter_tracks_by_repo
10
+
11
+ FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
12
+
13
+
14
+ class FilterTracksByRepoTest(unittest.TestCase):
15
+ def setUp(self):
16
+ self.cfg = {
17
+ "notes_root": str(FIXTURES),
18
+ "repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
19
+ }
20
+ self.tracks = discover_tracks(self.cfg)
21
+
22
+ def test_matches_folder_key(self):
23
+ scoped = filter_tracks_by_repo(self.tracks, "critforge")
24
+ names = {t.name for t in scoped}
25
+ self.assertIn("example", names)
26
+ self.assertNotIn("loose_at_root", names)
27
+
28
+ def test_matches_github_slug(self):
29
+ scoped = filter_tracks_by_repo(self.tracks, "stylusnexus/CritForge")
30
+ names = {t.name for t in scoped}
31
+ self.assertIn("example", names)
32
+
33
+ def test_case_insensitive(self):
34
+ scoped = filter_tracks_by_repo(self.tracks, "CRITFORGE")
35
+ names = {t.name for t in scoped}
36
+ self.assertIn("example", names)
37
+
38
+ def test_unknown_key_returns_empty(self):
39
+ self.assertEqual(filter_tracks_by_repo(self.tracks, "nonexistent"), [])
40
+
41
+ def test_excludes_loose_filing_track(self):
42
+ scoped = filter_tracks_by_repo(self.tracks, "critforge")
43
+ for t in scoped:
44
+ self.assertFalse(t.needs_filing)
45
+
46
+ def test_track_folder_field_populated(self):
47
+ ex = next(t for t in self.tracks if t.name == "example")
48
+ self.assertEqual(ex.folder, "critforge")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ unittest.main()
@@ -0,0 +1,117 @@
1
+ """Regression tests for the /tmp planting hardening (#18).
2
+
3
+ Covers:
4
+ - lib.scratch.cache_dir() creates ~/.claude/work-plan/cache/ with mode 0700.
5
+ - commands.group._apply() rejects a batch whose `folder` is not in cfg.
6
+ - commands.suggest_priorities._apply() rejects a batch whose `repo` is not in cfg.
7
+ """
8
+ import json
9
+ import os
10
+ import stat
11
+ import sys
12
+ import tempfile
13
+ import unittest
14
+ from pathlib import Path
15
+ from unittest import mock
16
+
17
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(SKILL_ROOT))
19
+
20
+ from lib import scratch
21
+ from commands import group, suggest_priorities
22
+
23
+
24
+ # POSIX file mode bits aren't honored on Windows NTFS — os.chmod(0o700) is a
25
+ # no-op for directories there and stat.S_IMODE reports 0o777 regardless. The
26
+ # /tmp planting hardening these tests cover is itself a POSIX concern.
27
+ _POSIX_MODE_ONLY = unittest.skipIf(
28
+ sys.platform == "win32", "POSIX file mode bits not honored on Windows"
29
+ )
30
+
31
+
32
+ class CacheDirTest(unittest.TestCase):
33
+ @_POSIX_MODE_ONLY
34
+ def test_creates_with_mode_0700(self):
35
+ with tempfile.TemporaryDirectory() as td:
36
+ with mock.patch.object(scratch.Path, "home", return_value=Path(td)):
37
+ p = scratch.cache_dir()
38
+ self.assertTrue(p.is_dir())
39
+ self.assertEqual(p, Path(td) / ".claude" / "work-plan" / "cache")
40
+ mode = stat.S_IMODE(os.stat(p).st_mode)
41
+ self.assertEqual(mode, 0o700)
42
+
43
+ @_POSIX_MODE_ONLY
44
+ def test_tightens_existing_loose_perms(self):
45
+ with tempfile.TemporaryDirectory() as td:
46
+ existing = Path(td) / ".claude" / "work-plan" / "cache"
47
+ existing.mkdir(parents=True, mode=0o755)
48
+ self.assertEqual(stat.S_IMODE(os.stat(existing).st_mode), 0o755)
49
+ with mock.patch.object(scratch.Path, "home", return_value=Path(td)):
50
+ scratch.cache_dir()
51
+ self.assertEqual(stat.S_IMODE(os.stat(existing).st_mode), 0o700)
52
+
53
+
54
+ class GroupApplyValidationTest(unittest.TestCase):
55
+ def test_rejects_folder_not_in_cfg(self):
56
+ with tempfile.TemporaryDirectory() as td:
57
+ cache = Path(td) / "cache"
58
+ cache.mkdir()
59
+ (cache / "groups.json").write_text(json.dumps({
60
+ "repo": "x/y", "folder": "../../etc",
61
+ "milestone": "v1", "issues": [],
62
+ }))
63
+ (cache / "groups.answers.json").write_text("[]")
64
+ with mock.patch.object(group, "_batch_path", return_value=cache / "groups.json"), \
65
+ mock.patch.object(group, "_answers_path", return_value=cache / "groups.answers.json"):
66
+ cfg = {"notes_root": td, "repos": {"legitrepo": {"github": "ok/ok"}}}
67
+ rc = group._apply(cfg)
68
+ self.assertEqual(rc, 1)
69
+
70
+ def test_accepts_folder_in_cfg(self):
71
+ with tempfile.TemporaryDirectory() as td:
72
+ cache = Path(td) / "cache"
73
+ cache.mkdir()
74
+ notes = Path(td) / "notes"
75
+ (notes / "legitrepo").mkdir(parents=True)
76
+ (cache / "groups.json").write_text(json.dumps({
77
+ "repo": "ok/ok", "folder": "legitrepo",
78
+ "milestone": "v1", "issues": [],
79
+ }))
80
+ (cache / "groups.answers.json").write_text("[]")
81
+ with mock.patch.object(group, "_batch_path", return_value=cache / "groups.json"), \
82
+ mock.patch.object(group, "_answers_path", return_value=cache / "groups.answers.json"):
83
+ cfg = {"notes_root": str(notes), "repos": {"legitrepo": {"github": "ok/ok"}}}
84
+ rc = group._apply(cfg)
85
+ self.assertEqual(rc, 0)
86
+
87
+
88
+ class SuggestPrioritiesApplyValidationTest(unittest.TestCase):
89
+ def test_rejects_repo_not_in_cfg(self):
90
+ with tempfile.TemporaryDirectory() as td:
91
+ cache = Path(td) / "cache"
92
+ cache.mkdir()
93
+ (cache / "priorities.json").write_text(json.dumps({
94
+ "repo": "attacker/target", "issues": [],
95
+ }))
96
+ (cache / "priorities.answers.json").write_text("[]")
97
+ with mock.patch.object(suggest_priorities, "_batch_path", return_value=cache / "priorities.json"):
98
+ cfg = {"repos": {"legitrepo": {"github": "ok/ok"}}}
99
+ rc = suggest_priorities._apply(cfg)
100
+ self.assertEqual(rc, 1)
101
+
102
+ def test_accepts_repo_in_cfg(self):
103
+ with tempfile.TemporaryDirectory() as td:
104
+ cache = Path(td) / "cache"
105
+ cache.mkdir()
106
+ (cache / "priorities.json").write_text(json.dumps({
107
+ "repo": "ok/ok", "issues": [],
108
+ }))
109
+ (cache / "priorities.answers.json").write_text("[]")
110
+ with mock.patch.object(suggest_priorities, "_batch_path", return_value=cache / "priorities.json"):
111
+ cfg = {"repos": {"legitrepo": {"github": "ok/ok"}}}
112
+ rc = suggest_priorities._apply(cfg)
113
+ self.assertEqual(rc, 0)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()
@@ -0,0 +1,39 @@
1
+ """Tests for session_log."""
2
+ import unittest
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ from lib.session_log import append_session_log, SESSION_LOG_HEADER
10
+
11
+
12
+ class AppendSessionLogTest(unittest.TestCase):
13
+ def test_appends_under_existing_section(self):
14
+ body = (
15
+ "# Track\n\nProse.\n\n"
16
+ f"{SESSION_LOG_HEADER}\n\n"
17
+ "### Session — 2026-04-23 22:14\n\n- Touched: prior\n"
18
+ )
19
+ new = append_session_log(
20
+ body, timestamp="2026-04-28 18:30",
21
+ touched=["#4254 polls"], next_up=["#925 wmsr"], blockers=[],
22
+ )
23
+ self.assertIn("### Session — 2026-04-28 18:30", new)
24
+ self.assertIn("### Session — 2026-04-23 22:14", new)
25
+ self.assertIn("- Touched: #4254 polls", new)
26
+
27
+ def test_creates_section_when_missing(self):
28
+ body = "# Track\n\nProse.\n"
29
+ new = append_session_log(
30
+ body, timestamp="2026-04-28 18:30",
31
+ touched=["#1 foo"], next_up=["#2 bar"],
32
+ blockers=[{"number": 3, "reason": "waiting"}],
33
+ )
34
+ self.assertIn(SESSION_LOG_HEADER, new)
35
+ self.assertIn("- Blocker: #3 — waiting", new)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ unittest.main()
@@ -0,0 +1,77 @@
1
+ # tests/test_set_field.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 set_field
9
+ from lib.write_guard import make_token
10
+
11
+ def _t(name="ph", repo="o/r"):
12
+ return SimpleNamespace(name=name, repo=repo, path=Path(f"/tmp/{name}.md"),
13
+ has_frontmatter=True, meta={"status":"active","github":{"repo":repo}}, body="# b")
14
+
15
+ def _drive(args, vis="PRIVATE", cfg=None):
16
+ base_cfg = {"notes_root": "/tmp"}
17
+ if cfg is not None:
18
+ base_cfg.update(cfg)
19
+ with patch("commands.set_field.load_config", return_value=base_cfg), \
20
+ patch("commands.set_field.discover_tracks", return_value=[_t()]), \
21
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
22
+ patch("commands.set_field.write_file") as mw:
23
+ buf = io.StringIO()
24
+ with redirect_stdout(buf):
25
+ rc = set_field.run(args)
26
+ return rc, mw, buf.getvalue()
27
+
28
+ class SetFieldTest(unittest.TestCase):
29
+ def test_sets_status_private(self):
30
+ rc, mw, out = _drive(["ph", "status=parked"])
31
+ self.assertEqual(rc, 0); mw.assert_called_once()
32
+ self.assertEqual(mw.call_args[0][1]["status"], "parked")
33
+ def test_public_blocks_without_confirm(self):
34
+ rc, mw, out = _drive(["ph", "status=parked"], vis="PUBLIC")
35
+ self.assertEqual(rc, 0); mw.assert_not_called()
36
+ self.assertIn("needs_confirm", out)
37
+ def test_public_with_valid_confirm_writes(self):
38
+ tok = make_token("o/r", "ph")
39
+ rc, mw, out = _drive(["ph", "status=parked", f"--confirm={tok}"], vis="PUBLIC")
40
+ self.assertEqual(rc, 0); mw.assert_called_once()
41
+ self.assertEqual(mw.call_args[0][1]["status"], "parked")
42
+ def test_rejects_unknown_field(self):
43
+ rc, mw, out = _drive(["ph", "bogus=x"])
44
+ self.assertEqual(rc, 2); mw.assert_not_called()
45
+ def test_rejects_invalid_status(self):
46
+ rc, mw, out = _drive(["ph", "status=nonsense"])
47
+ self.assertEqual(rc, 2); mw.assert_not_called()
48
+ def test_rejects_non_integer_blockers(self):
49
+ rc, mw, out = _drive(["ph", "blockers=abc"])
50
+ self.assertEqual(rc, 2); mw.assert_not_called()
51
+ def test_repoless_track_writes_without_confirm(self):
52
+ with patch("commands.set_field.load_config", return_value={"notes_root":"/tmp"}), \
53
+ patch("commands.set_field.discover_tracks", return_value=[_t(repo=None)]), \
54
+ patch("commands.set_field.write_file") as mw:
55
+ buf = io.StringIO()
56
+ with redirect_stdout(buf):
57
+ rc = set_field.run(["ph", "status=parked"])
58
+ self.assertEqual(rc, 0); mw.assert_called_once()
59
+
60
+ # --- assume_private_when_unknown: caller-level integration ---
61
+ def test_unknown_vis_with_flag_writes(self):
62
+ """Unknown visibility + assume_private_when_unknown=True → write proceeds."""
63
+ rc, mw, out = _drive(
64
+ ["ph", "status=parked"],
65
+ vis=None,
66
+ cfg={"assume_private_when_unknown": True},
67
+ )
68
+ self.assertEqual(rc, 0)
69
+ mw.assert_called_once()
70
+ self.assertNotIn("needs_confirm", out)
71
+
72
+ def test_unknown_vis_without_flag_emits_needs_confirm(self):
73
+ """Unknown visibility + no flag → still emits needs_confirm JSON."""
74
+ rc, mw, out = _drive(["ph", "status=parked"], vis=None)
75
+ self.assertEqual(rc, 0)
76
+ mw.assert_not_called()
77
+ self.assertIn("needs_confirm", out)
@@ -0,0 +1,292 @@
1
+ """Tests for the non-interactive set-notes-root command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - set-notes-root /some/new/path → yq called with correct expression, mkdir
5
+ called, rc 0.
6
+ - Missing positional path → rc 2, no yq call.
7
+ - Orphan warning: current notes_root differs and discover_tracks returns ≥1
8
+ track → WARN line printed but rc 0 and yq still called.
9
+ - No warning when new path equals current notes_root.
10
+ - No warning when discover_tracks returns no tracks.
11
+ - yq failure (CalledProcessError) → rc 1.
12
+ - Subcommand registered in SUBCOMMANDS and DESCRIPTIONS.
13
+ - Non-interactive guard: input()/prompt_input patched to raise, must not fire.
14
+ """
15
+ import io
16
+ import subprocess
17
+ import sys
18
+ import unittest
19
+ from contextlib import redirect_stdout
20
+ from pathlib import Path
21
+ from unittest.mock import patch, MagicMock, call
22
+
23
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
24
+ sys.path.insert(0, str(SKILL_ROOT))
25
+
26
+ import work_plan
27
+ from commands import set_notes_root
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def _make_cfg(*, notes_root="/tmp/old-notes"):
35
+ return {"notes_root": notes_root, "repos": {}}
36
+
37
+
38
+ def _fake_track():
39
+ """Return a minimal Track-like object (just needs to be truthy in a list)."""
40
+ t = MagicMock()
41
+ t.has_frontmatter = True
42
+ return t
43
+
44
+
45
+ def _drive(args, *, cfg_notes_root="/tmp/old-notes", tracks=None, yq_raises=False):
46
+ """Run set_notes_root.run(args) with all external I/O mocked.
47
+
48
+ cfg_notes_root: the current notes_root recorded in config.
49
+ tracks: list returned by discover_tracks (default []).
50
+ yq_raises: if True, subprocess.run raises CalledProcessError.
51
+ """
52
+ cfg = _make_cfg(notes_root=cfg_notes_root)
53
+ if tracks is None:
54
+ tracks = []
55
+
56
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
57
+
58
+ if yq_raises:
59
+ err = subprocess.CalledProcessError(1, ["yq"], stderr="yq error")
60
+ sub_side = err
61
+ else:
62
+ sub_side = None
63
+
64
+ with patch("commands.set_notes_root.load_config", return_value=cfg), \
65
+ patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
66
+ patch("commands.set_notes_root.subprocess.run",
67
+ return_value=mock_proc,
68
+ side_effect=sub_side) as msub, \
69
+ patch("pathlib.Path.mkdir") as mmkdir:
70
+ buf = io.StringIO()
71
+ with redirect_stdout(buf):
72
+ rc = set_notes_root.run(args)
73
+ return rc, msub, mmkdir, buf.getvalue()
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Test cases
78
+ # ---------------------------------------------------------------------------
79
+
80
+ class SetNotesRootTest(unittest.TestCase):
81
+
82
+ # ------------------------------------------------------------------
83
+ # Happy path: updates config and creates dir
84
+ # ------------------------------------------------------------------
85
+
86
+ def test_happy_path_calls_yq_and_mkdir(self):
87
+ """set-notes-root /some/new/path → yq -i called with the absolute path
88
+ expression, mkdir called, rc 0."""
89
+ rc, msub, mmkdir, out = _drive(
90
+ ["/some/new/path"],
91
+ cfg_notes_root="/tmp/old-notes",
92
+ )
93
+ self.assertEqual(rc, 0)
94
+
95
+ # yq must have been called
96
+ msub.assert_called_once()
97
+ yq_args = msub.call_args[0][0]
98
+ self.assertEqual(yq_args[0], "yq")
99
+ self.assertEqual(yq_args[1], "-i")
100
+ # Expression must set .notes_root to the absolute path. The command
101
+ # resolves the input to an absolute path, which on Windows uses a drive
102
+ # letter + backslashes — so compare against the same resolution, not the
103
+ # raw POSIX input string.
104
+ expected = str(Path("/some/new/path").expanduser().resolve())
105
+ expr = yq_args[2]
106
+ self.assertIn(".notes_root", expr)
107
+ self.assertIn(expected, expr)
108
+
109
+ # mkdir must have been called (creates the dir)
110
+ mmkdir.assert_called_once()
111
+
112
+ # Success confirmation in output
113
+ self.assertIn("✓", out)
114
+ self.assertIn(expected, out)
115
+
116
+ def test_yq_receives_config_path_as_last_arg(self):
117
+ """yq -i call passes DEFAULT_CONFIG_PATH as the file argument."""
118
+ from lib.config import DEFAULT_CONFIG_PATH
119
+ rc, msub, mmkdir, out = _drive(["/new/path"])
120
+ self.assertEqual(rc, 0)
121
+ yq_args = msub.call_args[0][0]
122
+ self.assertEqual(yq_args[-1], str(DEFAULT_CONFIG_PATH))
123
+
124
+ # ------------------------------------------------------------------
125
+ # Missing positional → rc 2
126
+ # ------------------------------------------------------------------
127
+
128
+ def test_missing_path_returns_rc2(self):
129
+ """No positional argument → rc 2, yq NOT called."""
130
+ rc, msub, mmkdir, out = _drive([])
131
+ self.assertEqual(rc, 2)
132
+ msub.assert_not_called()
133
+ self.assertIn("usage", out.lower())
134
+
135
+ # ------------------------------------------------------------------
136
+ # Orphan warning: tracks exist at old root
137
+ # ------------------------------------------------------------------
138
+
139
+ def test_orphan_warning_when_tracks_exist_and_root_differs(self):
140
+ """New path differs from current, discover_tracks returns 1 track →
141
+ WARN line printed, rc 0, yq still called."""
142
+ tracks = [_fake_track()]
143
+ rc, msub, mmkdir, out = _drive(
144
+ ["/some/new/path"],
145
+ cfg_notes_root="/tmp/old-notes",
146
+ tracks=tracks,
147
+ )
148
+ self.assertEqual(rc, 0)
149
+ msub.assert_called_once()
150
+ self.assertIn("WARN", out)
151
+
152
+ def test_orphan_warning_names_count(self):
153
+ """Orphan warning mentions the track count."""
154
+ tracks = [_fake_track(), _fake_track(), _fake_track()]
155
+ rc, msub, mmkdir, out = _drive(
156
+ ["/brand/new/path"],
157
+ cfg_notes_root="/tmp/old-notes",
158
+ tracks=tracks,
159
+ )
160
+ self.assertEqual(rc, 0)
161
+ self.assertIn("3", out)
162
+
163
+ def test_orphan_warning_mentions_not_moved(self):
164
+ """Orphan warning states tracks will NOT be moved."""
165
+ tracks = [_fake_track()]
166
+ rc, msub, mmkdir, out = _drive(
167
+ ["/brand/new/path"],
168
+ cfg_notes_root="/tmp/old-notes",
169
+ tracks=tracks,
170
+ )
171
+ self.assertIn("WARN", out)
172
+ # The warning should communicate non-movement
173
+ out_lower = out.lower()
174
+ self.assertTrue(
175
+ "not" in out_lower or "won't" in out_lower or "manual" in out_lower,
176
+ f"Expected move-warning language in: {out!r}",
177
+ )
178
+
179
+ # ------------------------------------------------------------------
180
+ # No warning when new path equals current notes_root
181
+ # ------------------------------------------------------------------
182
+
183
+ def test_no_warning_when_path_unchanged(self):
184
+ """New path resolves to same location as current → no WARN, rc 0."""
185
+ tracks = [_fake_track()]
186
+ rc, msub, mmkdir, out = _drive(
187
+ ["/tmp/old-notes"],
188
+ cfg_notes_root="/tmp/old-notes",
189
+ tracks=tracks,
190
+ )
191
+ self.assertEqual(rc, 0)
192
+ self.assertNotIn("WARN", out)
193
+
194
+ # ------------------------------------------------------------------
195
+ # No warning when there are no tracks
196
+ # ------------------------------------------------------------------
197
+
198
+ def test_no_warning_when_no_tracks(self):
199
+ """Paths differ but no tracks → no WARN, rc 0."""
200
+ rc, msub, mmkdir, out = _drive(
201
+ ["/some/new/path"],
202
+ cfg_notes_root="/tmp/old-notes",
203
+ tracks=[],
204
+ )
205
+ self.assertEqual(rc, 0)
206
+ self.assertNotIn("WARN", out)
207
+
208
+ # ------------------------------------------------------------------
209
+ # yq failure → rc 1
210
+ # ------------------------------------------------------------------
211
+
212
+ def test_yq_failure_returns_rc1(self):
213
+ """CalledProcessError from yq → error message printed, rc 1."""
214
+ rc, msub, mmkdir, out = _drive(
215
+ ["/some/new/path"],
216
+ yq_raises=True,
217
+ )
218
+ self.assertEqual(rc, 1)
219
+ self.assertIn("ERROR", out)
220
+
221
+ # ------------------------------------------------------------------
222
+ # Subcommand registration
223
+ # ------------------------------------------------------------------
224
+
225
+ def test_subcommand_registered_in_subcommands(self):
226
+ """'set-notes-root' appears in work_plan.SUBCOMMANDS."""
227
+ self.assertIn("set-notes-root", work_plan.SUBCOMMANDS)
228
+
229
+ def test_subcommand_registered_in_descriptions(self):
230
+ """'set-notes-root' appears in work_plan.DESCRIPTIONS."""
231
+ names = [entry[0] for entry in work_plan.DESCRIPTIONS]
232
+ self.assertIn("set-notes-root", names)
233
+
234
+ def test_subcommand_module_path(self):
235
+ """SUBCOMMANDS['set-notes-root'] points to commands.set_notes_root."""
236
+ self.assertEqual(
237
+ work_plan.SUBCOMMANDS["set-notes-root"],
238
+ "commands.set_notes_root",
239
+ )
240
+
241
+ # ------------------------------------------------------------------
242
+ # Non-interactive guard
243
+ # ------------------------------------------------------------------
244
+
245
+ def test_no_input_called_happy_path(self):
246
+ """Happy path must not call input() or prompt_input."""
247
+ cfg = _make_cfg(notes_root="/tmp/old-notes")
248
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
249
+
250
+ def _raise(*a, **kw):
251
+ raise AssertionError(
252
+ "input() must not be called — command must be non-interactive"
253
+ )
254
+
255
+ with patch("builtins.input", side_effect=_raise), \
256
+ patch("lib.prompts.prompt_input", side_effect=_raise), \
257
+ patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
258
+ patch("commands.set_notes_root.load_config", return_value=cfg), \
259
+ patch("commands.set_notes_root.discover_tracks", return_value=[]), \
260
+ patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
261
+ patch("pathlib.Path.mkdir"):
262
+ buf = io.StringIO()
263
+ with redirect_stdout(buf):
264
+ rc = set_notes_root.run(["/some/path"])
265
+ self.assertEqual(rc, 0)
266
+
267
+ def test_no_input_called_with_tracks(self):
268
+ """Orphan warning path must also not call input()."""
269
+ cfg = _make_cfg(notes_root="/tmp/old-notes")
270
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
271
+ tracks = [_fake_track()]
272
+
273
+ def _raise(*a, **kw):
274
+ raise AssertionError(
275
+ "input() must not be called — command must be non-interactive"
276
+ )
277
+
278
+ with patch("builtins.input", side_effect=_raise), \
279
+ patch("lib.prompts.prompt_input", side_effect=_raise), \
280
+ patch("lib.prompts.prompt_yes_no", side_effect=_raise), \
281
+ patch("commands.set_notes_root.load_config", return_value=cfg), \
282
+ patch("commands.set_notes_root.discover_tracks", return_value=tracks), \
283
+ patch("commands.set_notes_root.subprocess.run", return_value=mock_proc), \
284
+ patch("pathlib.Path.mkdir"):
285
+ buf = io.StringIO()
286
+ with redirect_stdout(buf):
287
+ rc = set_notes_root.run(["/some/new/path"])
288
+ self.assertEqual(rc, 0)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ unittest.main()