@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,166 @@
1
+ """Tests for tier-aware archive display in close command (Phase C)."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from unittest.mock import patch
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import close
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def _shared_track(*, name="auth-flow", repo="org/myrepo"):
21
+ """Return a SimpleNamespace that looks like a shared Track."""
22
+ return SimpleNamespace(
23
+ name=name,
24
+ # Path is under a .work-plan/ dir, NOT under notes_root
25
+ path=Path(f"/home/user/projects/myrepo/.work-plan/{name}.md"),
26
+ body="# shared track body",
27
+ meta={
28
+ "track": name,
29
+ "status": "active",
30
+ "github": {"repo": repo},
31
+ },
32
+ has_frontmatter=True,
33
+ repo=repo,
34
+ tier="shared",
35
+ )
36
+
37
+
38
+ def _private_track(*, name="alpha", repo="ok/repo"):
39
+ """Return a SimpleNamespace for a private (notes_root) Track."""
40
+ return SimpleNamespace(
41
+ name=name,
42
+ path=Path(f"/tmp/fake-notes/ok/{name}.md"),
43
+ body="# private track body",
44
+ meta={
45
+ "track": name,
46
+ "status": "active",
47
+ "github": {"repo": repo},
48
+ },
49
+ has_frontmatter=True,
50
+ repo=repo,
51
+ tier="private",
52
+ )
53
+
54
+
55
+ def _drive(args, track, notes_root="/tmp/fake-notes", vis="PRIVATE"):
56
+ cfg = {
57
+ "notes_root": notes_root,
58
+ "repos": {"ok": {"github": "ok/repo"}, "myrepo": {"github": "org/myrepo"}},
59
+ }
60
+ with patch("commands.close.load_config", return_value=cfg), \
61
+ patch("commands.close.discover_tracks", return_value=[track]), \
62
+ patch("commands.close.find_track_by_name", return_value=track), \
63
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
64
+ patch("commands.close.write_file") as mw, \
65
+ patch("commands.close.shutil") as ms, \
66
+ patch("pathlib.Path.mkdir"):
67
+ buf = io.StringIO()
68
+ with redirect_stdout(buf):
69
+ rc = close.run(args)
70
+ return rc, mw, ms, buf.getvalue()
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Tests
75
+ # ---------------------------------------------------------------------------
76
+
77
+ class CloseTierTest(unittest.TestCase):
78
+
79
+ def test_shared_track_shipped_does_not_crash_on_relative_to(self):
80
+ """close on a shared track: archive is outside notes_root —
81
+ should NOT raise ValueError, falls back to absolute path display."""
82
+ track = _shared_track()
83
+ rc, mw, ms, out = _drive(
84
+ ["auth-flow", "--state=shipped"],
85
+ track=track,
86
+ notes_root="/tmp/fake-notes",
87
+ vis="PRIVATE",
88
+ )
89
+ self.assertEqual(rc, 0)
90
+ mw.assert_called_once()
91
+ ms.move.assert_called_once()
92
+ # Output should contain the track name and end state
93
+ self.assertIn("auth-flow", out)
94
+ self.assertIn("shipped", out)
95
+
96
+ def test_shared_track_shipped_prints_commit_hint(self):
97
+ """close on a shared track → output includes commit+push hint."""
98
+ track = _shared_track()
99
+ rc, mw, ms, out = _drive(
100
+ ["auth-flow", "--state=shipped"],
101
+ track=track,
102
+ notes_root="/tmp/fake-notes",
103
+ vis="PRIVATE",
104
+ )
105
+ self.assertEqual(rc, 0)
106
+ self.assertIn("shared track", out)
107
+ self.assertIn("commit + push", out)
108
+
109
+ def test_private_track_shipped_no_commit_hint(self):
110
+ """close on a private track → no commit+push hint in output."""
111
+ track = _private_track()
112
+ rc, mw, ms, out = _drive(
113
+ ["alpha", "--state=shipped"],
114
+ track=track,
115
+ notes_root="/tmp/fake-notes",
116
+ vis="PRIVATE",
117
+ )
118
+ self.assertEqual(rc, 0)
119
+ self.assertNotIn("commit + push", out)
120
+
121
+ def test_shared_track_abandoned_prints_commit_hint(self):
122
+ """close --state=abandoned on a shared track → commit+push hint."""
123
+ track = _shared_track(name="old-feature")
124
+ rc, mw, ms, out = _drive(
125
+ ["old-feature", "--state=abandoned"],
126
+ track=track,
127
+ notes_root="/tmp/fake-notes",
128
+ vis="PRIVATE",
129
+ )
130
+ self.assertEqual(rc, 0)
131
+ self.assertIn("shared track", out)
132
+ self.assertIn("commit + push", out)
133
+
134
+ def test_shared_track_parked_no_move_no_hint(self):
135
+ """close --state=parked on a shared track → parked (no move),
136
+ no commit+push hint (parked stays in place, returns early)."""
137
+ track = _shared_track()
138
+ rc, mw, ms, out = _drive(
139
+ ["auth-flow", "--state=parked"],
140
+ track=track,
141
+ notes_root="/tmp/fake-notes",
142
+ vis="PRIVATE",
143
+ )
144
+ self.assertEqual(rc, 0)
145
+ ms.move.assert_not_called()
146
+ # The commit hint is only printed after a move (archive operation)
147
+ self.assertNotIn("commit + push", out)
148
+
149
+ def test_private_track_shipped_display_uses_relative_path(self):
150
+ """Private track close shows path relative to notes_root (existing behaviour)."""
151
+ track = _private_track()
152
+ rc, mw, ms, out = _drive(
153
+ ["alpha", "--state=shipped"],
154
+ track=track,
155
+ notes_root="/tmp/fake-notes",
156
+ vis="PRIVATE",
157
+ )
158
+ self.assertEqual(rc, 0)
159
+ # Should not contain the absolute prefix for private tracks
160
+ # (it will contain 'ok/archive/shipped/alpha.md' or similar)
161
+ self.assertIn("shipped", out)
162
+ self.assertIn("alpha", out)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()
@@ -0,0 +1,51 @@
1
+ """Tests for closure detection."""
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.closure import is_closure_ready, ClosureSignals
10
+
11
+
12
+ class ClosureReadyTest(unittest.TestCase):
13
+ def test_all_signals_green(self):
14
+ signals = ClosureSignals(
15
+ all_issues_closed=True,
16
+ all_branches_done=True,
17
+ next_up_empty=True,
18
+ cold_14d=True,
19
+ no_recent_related_issues=True,
20
+ )
21
+ ready, reasons = is_closure_ready(signals)
22
+ self.assertTrue(ready)
23
+ self.assertEqual(reasons, [])
24
+
25
+ def test_open_issue_blocks_closure(self):
26
+ signals = ClosureSignals(
27
+ all_issues_closed=False,
28
+ all_branches_done=True,
29
+ next_up_empty=True,
30
+ cold_14d=True,
31
+ no_recent_related_issues=True,
32
+ )
33
+ ready, reasons = is_closure_ready(signals)
34
+ self.assertFalse(ready)
35
+ self.assertIn("open issues remain", " ".join(reasons))
36
+
37
+ def test_partial_signals_returns_count(self):
38
+ signals = ClosureSignals(
39
+ all_issues_closed=True,
40
+ all_branches_done=True,
41
+ next_up_empty=False,
42
+ cold_14d=False,
43
+ no_recent_related_issues=True,
44
+ )
45
+ ready, reasons = is_closure_ready(signals)
46
+ self.assertFalse(ready)
47
+ self.assertEqual(len(reasons), 2)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()
@@ -0,0 +1,85 @@
1
+ """Tests for config loader."""
2
+ import unittest
3
+ import tempfile
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.config import (
11
+ load_config, ConfigError,
12
+ resolve_github_for_folder, resolve_local_path_for_folder,
13
+ )
14
+
15
+
16
+ class LoadConfigTest(unittest.TestCase):
17
+ def _write(self, d, content):
18
+ path = Path(d) / "config.yml"
19
+ path.write_text(content, encoding="utf-8")
20
+ return path
21
+
22
+ def test_load_dict_shape(self):
23
+ with tempfile.TemporaryDirectory() as d:
24
+ path = self._write(d, (
25
+ "notes_root: /tmp/notes\n"
26
+ "repos:\n"
27
+ " critforge:\n"
28
+ " github: stylusnexus/CritForge\n"
29
+ " local: /Applications/Development/Projects/CritForge\n"
30
+ ))
31
+ cfg = load_config(path)
32
+ self.assertEqual(cfg["notes_root"], "/tmp/notes")
33
+ self.assertEqual(cfg["repos"]["critforge"]["github"], "stylusnexus/CritForge")
34
+ self.assertEqual(cfg["repos"]["critforge"]["local"],
35
+ "/Applications/Development/Projects/CritForge")
36
+
37
+ def test_load_string_shape_normalizes_to_dict(self):
38
+ # Backward-friendly: bare string is treated as github-only, no local
39
+ with tempfile.TemporaryDirectory() as d:
40
+ path = self._write(d, (
41
+ "notes_root: /tmp/notes\n"
42
+ "repos:\n"
43
+ " critforge: stylusnexus/CritForge\n"
44
+ ))
45
+ cfg = load_config(path)
46
+ self.assertEqual(cfg["repos"]["critforge"]["github"], "stylusnexus/CritForge")
47
+ self.assertIsNone(cfg["repos"]["critforge"]["local"])
48
+
49
+ def test_missing_file_self_seeds(self):
50
+ # No install hook exists for plugin installs, so a missing config is
51
+ # seeded on first load rather than raising.
52
+ with tempfile.TemporaryDirectory() as d:
53
+ path = Path(d) / "work-plan" / "config.yml"
54
+ cfg = load_config(path, notes_root=Path(d) / "notes")
55
+ self.assertTrue(path.is_file())
56
+ self.assertEqual(cfg["repos"], {})
57
+ self.assertIn("notes_root", cfg)
58
+
59
+ def test_missing_notes_root_raises(self):
60
+ with tempfile.TemporaryDirectory() as d:
61
+ path = self._write(d, "repos:\n foo: bar/baz\n")
62
+ with self.assertRaises(ConfigError) as ctx:
63
+ load_config(path)
64
+ self.assertIn("notes_root", str(ctx.exception))
65
+
66
+
67
+ class ResolveTest(unittest.TestCase):
68
+ def setUp(self):
69
+ self.cfg = {
70
+ "repos": {
71
+ "critforge": {"github": "stylusnexus/CritForge", "local": "/path/to/critforge"},
72
+ },
73
+ }
74
+
75
+ def test_resolve_github(self):
76
+ self.assertEqual(resolve_github_for_folder("critforge", self.cfg), "stylusnexus/CritForge")
77
+ self.assertIsNone(resolve_github_for_folder("unknown", self.cfg))
78
+
79
+ def test_resolve_local_path(self):
80
+ self.assertEqual(resolve_local_path_for_folder("critforge", self.cfg), Path("/path/to/critforge"))
81
+ self.assertIsNone(resolve_local_path_for_folder("unknown", self.cfg))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.main()
@@ -0,0 +1,41 @@
1
+ """Lazy config seeding — plugin installs run no install hook (issue: org-sharing).
2
+
3
+ The CLI must create a usable config.yml on first run when one is absent, at a
4
+ stable absolute path, idempotently. Offline; uses temp dirs (never the real HOME).
5
+ """
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from lib.config import load_config, ensure_config
15
+
16
+
17
+ class EnsureConfigTest(unittest.TestCase):
18
+ def test_load_config_seeds_when_missing(self):
19
+ with tempfile.TemporaryDirectory() as d:
20
+ cfg_path = Path(d) / "work-plan" / "config.yml"
21
+ notes = Path(d) / "notes"
22
+ cfg = load_config(cfg_path, notes_root=notes)
23
+ self.assertTrue(cfg_path.is_file(), "config.yml should be seeded")
24
+ self.assertEqual(cfg["repos"], {})
25
+ # notes_root is an ABSOLUTE path (no literal ~), and the dir exists.
26
+ self.assertEqual(cfg["notes_root"], str(notes))
27
+ self.assertFalse(cfg["notes_root"].startswith("~"))
28
+ self.assertTrue(notes.is_dir())
29
+
30
+ def test_ensure_config_idempotent(self):
31
+ with tempfile.TemporaryDirectory() as d:
32
+ cfg_path = Path(d) / "work-plan" / "config.yml"
33
+ notes = Path(d) / "notes"
34
+ self.assertTrue(ensure_config(cfg_path, notes_root=notes))
35
+ before = cfg_path.read_bytes()
36
+ self.assertFalse(ensure_config(cfg_path, notes_root=notes))
37
+ self.assertEqual(cfg_path.read_bytes(), before)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()
@@ -0,0 +1,57 @@
1
+ """Tests for is_valid_git_repo() in lib/config."""
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.config import is_valid_git_repo
11
+
12
+
13
+ class IsValidGitRepoTest(unittest.TestCase):
14
+ def test_returns_true_for_dir_with_dot_git(self):
15
+ with tempfile.TemporaryDirectory() as d:
16
+ base = Path(d)
17
+ (base / ".git").mkdir()
18
+ self.assertTrue(is_valid_git_repo(base))
19
+
20
+ def test_dot_git_can_be_a_file_worktree(self):
21
+ """Worktrees have .git as a file, not a dir — still truthy via .exists()."""
22
+ with tempfile.TemporaryDirectory() as d:
23
+ base = Path(d)
24
+ (base / ".git").write_text("gitdir: ../.git/worktrees/foo\n", encoding="utf-8")
25
+ self.assertTrue(is_valid_git_repo(base))
26
+
27
+ def test_returns_false_for_nonexistent_path(self):
28
+ self.assertFalse(is_valid_git_repo(Path("/tmp/nonexistent_12345")))
29
+
30
+ def test_returns_false_for_file(self):
31
+ with tempfile.TemporaryDirectory() as d:
32
+ f = Path(d) / "not_a_dir.txt"
33
+ f.write_text("hello", encoding="utf-8")
34
+ self.assertFalse(is_valid_git_repo(f))
35
+
36
+ def test_returns_false_for_dir_without_dot_git(self):
37
+ with tempfile.TemporaryDirectory() as d:
38
+ base = Path(d) / "plain_dir"
39
+ base.mkdir()
40
+ self.assertFalse(is_valid_git_repo(base))
41
+
42
+ def test_accepts_path_object(self):
43
+ with tempfile.TemporaryDirectory() as d:
44
+ base = Path(d)
45
+ (base / ".git").mkdir()
46
+ self.assertTrue(is_valid_git_repo(Path(d)))
47
+
48
+ def test_accepts_string_path(self):
49
+ with tempfile.TemporaryDirectory() as d:
50
+ base = Path(d)
51
+ (base / ".git").mkdir()
52
+ # is_valid_git_repo coerces to Path internally
53
+ self.assertTrue(is_valid_git_repo(d))
54
+
55
+
56
+ if __name__ == "__main__":
57
+ unittest.main()
@@ -0,0 +1,192 @@
1
+ """Tests for the coverage subcommand."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from unittest.mock import patch
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import coverage
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ def _make_cfg(repos=None):
21
+ if repos is None:
22
+ repos = {"myrepo": {"github": "org/myrepo", "local": "/tmp/myrepo"}}
23
+ return {"notes_root": "/tmp/notes", "repos": repos}
24
+
25
+
26
+ def _make_track(name, repo, issue_nums, status="active"):
27
+ return SimpleNamespace(
28
+ name=name,
29
+ repo=repo,
30
+ has_frontmatter=True,
31
+ meta={"status": status, "github": {"repo": repo, "issues": issue_nums}},
32
+ )
33
+
34
+
35
+ def _run(args, *, cfg, tracks, open_issues_by_repo):
36
+ """Run coverage.run with mocked config, tracks, and gh calls."""
37
+ def _mock_open_issues(repo, limit=1000):
38
+ return open_issues_by_repo.get(repo, [])
39
+
40
+ buf = io.StringIO()
41
+ with patch("commands.coverage.load_config", return_value=cfg), \
42
+ patch("commands.coverage.discover_tracks", return_value=tracks), \
43
+ patch("commands.coverage.fetch_open_issues", side_effect=_mock_open_issues), \
44
+ redirect_stdout(buf):
45
+ rc = coverage.run(args)
46
+ return rc, buf.getvalue()
47
+
48
+
49
+ def _issues(*numbers):
50
+ return [{"number": n, "title": f"Issue {n}", "state": "OPEN"} for n in numbers]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Tests
55
+ # ---------------------------------------------------------------------------
56
+
57
+ class CoverageBasicTest(unittest.TestCase):
58
+
59
+ def test_all_tracked_reports_full_coverage(self):
60
+ cfg = _make_cfg()
61
+ tracks = [_make_track("t1", "org/myrepo", [1, 2, 3])]
62
+ rc, out = _run([], cfg=cfg, tracks=tracks,
63
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
64
+ self.assertEqual(rc, 0)
65
+ self.assertIn("full coverage", out)
66
+ self.assertIn("Untracked: 0", out)
67
+
68
+ def test_partial_coverage_shows_count_and_percent(self):
69
+ cfg = _make_cfg()
70
+ tracks = [_make_track("t1", "org/myrepo", [1, 2])]
71
+ rc, out = _run([], cfg=cfg, tracks=tracks,
72
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3, 4)})
73
+ self.assertEqual(rc, 0)
74
+ self.assertIn("Untracked: 2", out)
75
+ self.assertIn("50%", out)
76
+
77
+ def test_no_open_issues_reports_zero(self):
78
+ cfg = _make_cfg()
79
+ tracks = [_make_track("t1", "org/myrepo", [1])]
80
+ rc, out = _run([], cfg=cfg, tracks=tracks,
81
+ open_issues_by_repo={"org/myrepo": []})
82
+ self.assertEqual(rc, 0)
83
+ self.assertIn("No open issues", out)
84
+
85
+ def test_no_tracks_everything_untracked(self):
86
+ cfg = _make_cfg()
87
+ rc, out = _run([], cfg=cfg, tracks=[],
88
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
89
+ self.assertEqual(rc, 0)
90
+ self.assertIn("Untracked: 3", out)
91
+ self.assertIn("100%", out)
92
+
93
+
94
+ class CoverageRepoFlagTest(unittest.TestCase):
95
+
96
+ def test_repo_flag_scopes_to_one_repo(self):
97
+ cfg = _make_cfg(repos={
98
+ "repoA": {"github": "org/repoA"},
99
+ "repoB": {"github": "org/repoB"},
100
+ })
101
+ tracks = [
102
+ _make_track("tA", "org/repoA", [1]),
103
+ _make_track("tB", "org/repoB", [2]),
104
+ ]
105
+ rc, out = _run(["--repo=repoA"], cfg=cfg, tracks=tracks,
106
+ open_issues_by_repo={"org/repoA": _issues(1, 99),
107
+ "org/repoB": _issues(2, 98)})
108
+ self.assertEqual(rc, 0)
109
+ self.assertIn("repoA", out)
110
+ self.assertNotIn("repoB", out)
111
+
112
+ def test_unknown_repo_flag_returns_error(self):
113
+ cfg = _make_cfg()
114
+ rc, out = _run(["--repo=nope"], cfg=cfg, tracks=[],
115
+ open_issues_by_repo={})
116
+ self.assertEqual(rc, 1)
117
+ self.assertIn("ERROR", out)
118
+
119
+
120
+ class CoverageListFlagTest(unittest.TestCase):
121
+
122
+ def test_list_flag_shows_issue_titles(self):
123
+ cfg = _make_cfg()
124
+ tracks = [_make_track("t1", "org/myrepo", [1])]
125
+ rc, out = _run(["--list"], cfg=cfg, tracks=tracks,
126
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
127
+ self.assertEqual(rc, 0)
128
+ self.assertIn("Issue 2", out)
129
+ self.assertIn("Issue 3", out)
130
+
131
+ def test_list_flag_truncates_at_default_20(self):
132
+ cfg = _make_cfg()
133
+ open_nums = list(range(1, 26)) # 25 issues, none tracked
134
+ rc, out = _run(["--list"], cfg=cfg, tracks=[],
135
+ open_issues_by_repo={"org/myrepo": _issues(*open_nums)})
136
+ self.assertEqual(rc, 0)
137
+ self.assertIn("and 5 more", out)
138
+
139
+ def test_limit_flag_overrides_default(self):
140
+ cfg = _make_cfg()
141
+ open_nums = list(range(1, 11)) # 10 issues
142
+ rc, out = _run(["--list", "--limit=3"], cfg=cfg, tracks=[],
143
+ open_issues_by_repo={"org/myrepo": _issues(*open_nums)})
144
+ self.assertEqual(rc, 0)
145
+ self.assertIn("and 7 more", out)
146
+
147
+ def test_without_list_flag_no_titles_shown(self):
148
+ cfg = _make_cfg()
149
+ tracks = [_make_track("t1", "org/myrepo", [1])]
150
+ rc, out = _run([], cfg=cfg, tracks=tracks,
151
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
152
+ self.assertEqual(rc, 0)
153
+ self.assertNotIn("Issue 2", out)
154
+ self.assertNotIn("Issue 3", out)
155
+ self.assertIn("--list", out) # hint printed
156
+
157
+ def test_exact_limit_no_remainder_line(self):
158
+ cfg = _make_cfg()
159
+ rc, out = _run(["--list", "--limit=3"], cfg=cfg, tracks=[],
160
+ open_issues_by_repo={"org/myrepo": _issues(1, 2, 3)})
161
+ self.assertEqual(rc, 0)
162
+ self.assertNotIn("more", out)
163
+
164
+
165
+ class CoverageMultiRepoTest(unittest.TestCase):
166
+
167
+ def test_all_repos_reported_when_no_repo_flag(self):
168
+ cfg = _make_cfg(repos={
169
+ "repoA": {"github": "org/repoA"},
170
+ "repoB": {"github": "org/repoB"},
171
+ })
172
+ tracks = [_make_track("tA", "org/repoA", [1])]
173
+ rc, out = _run([], cfg=cfg, tracks=tracks,
174
+ open_issues_by_repo={"org/repoA": _issues(1, 2),
175
+ "org/repoB": _issues(3, 4)})
176
+ self.assertEqual(rc, 0)
177
+ self.assertIn("repoA", out)
178
+ self.assertIn("repoB", out)
179
+
180
+ def test_tracks_without_frontmatter_ignored(self):
181
+ cfg = _make_cfg()
182
+ no_fm = SimpleNamespace(name="orphan", repo="org/myrepo",
183
+ has_frontmatter=False, meta={})
184
+ rc, out = _run([], cfg=cfg, tracks=[no_fm],
185
+ open_issues_by_repo={"org/myrepo": _issues(1, 2)})
186
+ self.assertEqual(rc, 0)
187
+ # Both issues should be untracked since the track has no frontmatter
188
+ self.assertIn("Untracked: 2", out)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ unittest.main()
@@ -0,0 +1,51 @@
1
+ """Tests for doc discovery + kind classification."""
2
+ import unittest
3
+ import sys
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.doc_discovery import classify_kind, discover_docs, Doc
11
+
12
+
13
+ class ClassifyKindTest(unittest.TestCase):
14
+ def test_superpowers_plan(self):
15
+ self.assertEqual(classify_kind("docs/superpowers/plans/2026-03-16-x.md"), "plan")
16
+
17
+ def test_superpowers_spec(self):
18
+ self.assertEqual(classify_kind("docs/superpowers/specs/2026-03-16-x-design.md"), "spec")
19
+
20
+ def test_design_suffix_is_spec(self):
21
+ self.assertEqual(classify_kind("docs/plans/2026-02-17-foo-design.md"), "spec")
22
+
23
+ def test_plain_docs_plan(self):
24
+ self.assertEqual(classify_kind("docs/plans/2026-02-17-foo.md"), "plan")
25
+
26
+ def test_other_is_adhoc(self):
27
+ self.assertEqual(classify_kind("notes/random.md"), "adhoc")
28
+
29
+
30
+ class DiscoverDocsTest(unittest.TestCase):
31
+ def test_finds_default_globs_and_dedupes(self):
32
+ with tempfile.TemporaryDirectory() as d:
33
+ root = Path(d)
34
+ (root / "docs/superpowers/plans").mkdir(parents=True)
35
+ (root / "docs/plans").mkdir(parents=True)
36
+ (root / "docs/superpowers/plans/2026-03-16-a.md").write_text("x")
37
+ (root / "docs/plans/2026-02-17-b-design.md").write_text("x")
38
+ (root / "docs/plans/README.txt").write_text("ignore") # not .md
39
+ docs = discover_docs(root)
40
+ rels = sorted(x.rel for x in docs)
41
+ self.assertEqual(rels, [
42
+ "docs/plans/2026-02-17-b-design.md",
43
+ "docs/superpowers/plans/2026-03-16-a.md",
44
+ ])
45
+ kinds = {x.rel: x.kind for x in docs}
46
+ self.assertEqual(kinds["docs/superpowers/plans/2026-03-16-a.md"], "plan")
47
+ self.assertEqual(kinds["docs/plans/2026-02-17-b-design.md"], "spec")
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()
@@ -0,0 +1,38 @@
1
+ """Tests for drift detection."""
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.drift import detect_drift
10
+
11
+
12
+ class DetectDriftTest(unittest.TestCase):
13
+ def test_no_drift_when_table_matches(self):
14
+ body = (
15
+ "| # | Title | Status |\n"
16
+ "|---|---|---|\n"
17
+ "| #1 | foo | ✅ Shipped |\n"
18
+ )
19
+ github_issues = [{"number": 1, "state": "CLOSED"}]
20
+ self.assertEqual(detect_drift(body, github_issues), [])
21
+
22
+ def test_drift_when_open_in_md_closed_in_github(self):
23
+ body = (
24
+ "| # | Title | Status |\n"
25
+ "|---|---|---|\n"
26
+ "| #1 | foo | 🔲 Open |\n"
27
+ )
28
+ github_issues = [{"number": 1, "state": "CLOSED"}]
29
+ drift = detect_drift(body, github_issues)
30
+ self.assertEqual(len(drift), 1)
31
+ self.assertEqual(drift[0]["issue"], 1)
32
+
33
+ def test_no_table_returns_empty(self):
34
+ self.assertEqual(detect_drift("# No table\n", [{"number": 1, "state": "CLOSED"}]), [])
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()