@stylusnexus/work-plan 2026.6.9

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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +36 -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 +119 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/brief.py +247 -0
  11. package/skills/work-plan/commands/canonicalize.py +122 -0
  12. package/skills/work-plan/commands/close.py +83 -0
  13. package/skills/work-plan/commands/duplicates.py +111 -0
  14. package/skills/work-plan/commands/export.py +69 -0
  15. package/skills/work-plan/commands/group.py +234 -0
  16. package/skills/work-plan/commands/handoff.py +855 -0
  17. package/skills/work-plan/commands/hygiene.py +104 -0
  18. package/skills/work-plan/commands/init.py +96 -0
  19. package/skills/work-plan/commands/init_repo.py +90 -0
  20. package/skills/work-plan/commands/list_cmd.py +39 -0
  21. package/skills/work-plan/commands/new_track.py +148 -0
  22. package/skills/work-plan/commands/plan_status.py +296 -0
  23. package/skills/work-plan/commands/reconcile.py +172 -0
  24. package/skills/work-plan/commands/refresh_md.py +132 -0
  25. package/skills/work-plan/commands/set_field.py +54 -0
  26. package/skills/work-plan/commands/set_notes_root.py +53 -0
  27. package/skills/work-plan/commands/slot.py +139 -0
  28. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  29. package/skills/work-plan/commands/where_was_i.py +325 -0
  30. package/skills/work-plan/lib/__init__.py +0 -0
  31. package/skills/work-plan/lib/closure.py +72 -0
  32. package/skills/work-plan/lib/config.py +82 -0
  33. package/skills/work-plan/lib/doc_discovery.py +41 -0
  34. package/skills/work-plan/lib/drift.py +32 -0
  35. package/skills/work-plan/lib/export_model.py +40 -0
  36. package/skills/work-plan/lib/frontmatter.py +48 -0
  37. package/skills/work-plan/lib/git_state.py +180 -0
  38. package/skills/work-plan/lib/github_state.py +296 -0
  39. package/skills/work-plan/lib/llm_evidence.py +45 -0
  40. package/skills/work-plan/lib/manifest.py +164 -0
  41. package/skills/work-plan/lib/new_issues.py +69 -0
  42. package/skills/work-plan/lib/next_up.py +98 -0
  43. package/skills/work-plan/lib/prompts.py +68 -0
  44. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  45. package/skills/work-plan/lib/render.py +83 -0
  46. package/skills/work-plan/lib/scratch.py +14 -0
  47. package/skills/work-plan/lib/session_log.py +39 -0
  48. package/skills/work-plan/lib/status_header.py +60 -0
  49. package/skills/work-plan/lib/status_table.py +227 -0
  50. package/skills/work-plan/lib/tracks.py +109 -0
  51. package/skills/work-plan/lib/verdict.py +51 -0
  52. package/skills/work-plan/lib/write_guard.py +39 -0
  53. package/skills/work-plan/tests/__init__.py +0 -0
  54. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  55. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  56. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  58. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  59. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  60. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  61. package/skills/work-plan/tests/test_close.py +273 -0
  62. package/skills/work-plan/tests/test_closure.py +51 -0
  63. package/skills/work-plan/tests/test_config.py +85 -0
  64. package/skills/work-plan/tests/test_config_seed.py +41 -0
  65. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  66. package/skills/work-plan/tests/test_drift.py +38 -0
  67. package/skills/work-plan/tests/test_export.py +91 -0
  68. package/skills/work-plan/tests/test_export_command.py +295 -0
  69. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  70. package/skills/work-plan/tests/test_git_state.py +51 -0
  71. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  72. package/skills/work-plan/tests/test_github_state.py +508 -0
  73. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  74. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  75. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  76. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  77. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  78. package/skills/work-plan/tests/test_init.py +289 -0
  79. package/skills/work-plan/tests/test_init_repo.py +251 -0
  80. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  81. package/skills/work-plan/tests/test_manifest.py +162 -0
  82. package/skills/work-plan/tests/test_new_issues.py +130 -0
  83. package/skills/work-plan/tests/test_new_track.py +445 -0
  84. package/skills/work-plan/tests/test_next_up.py +149 -0
  85. package/skills/work-plan/tests/test_plan_status.py +68 -0
  86. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  87. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  88. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  89. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  90. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  91. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  92. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  93. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  94. package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
  95. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  96. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  97. package/skills/work-plan/tests/test_render.py +110 -0
  98. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  99. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  100. package/skills/work-plan/tests/test_session_log.py +39 -0
  101. package/skills/work-plan/tests/test_set_field.py +77 -0
  102. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  103. package/skills/work-plan/tests/test_slot.py +243 -0
  104. package/skills/work-plan/tests/test_slot_move.py +128 -0
  105. package/skills/work-plan/tests/test_smoke.py +46 -0
  106. package/skills/work-plan/tests/test_status_header.py +79 -0
  107. package/skills/work-plan/tests/test_status_table.py +162 -0
  108. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  109. package/skills/work-plan/tests/test_tracks.py +56 -0
  110. package/skills/work-plan/tests/test_verdict.py +60 -0
  111. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  112. package/skills/work-plan/tests/test_write_guard.py +53 -0
  113. package/skills/work-plan/work_plan.py +210 -0
@@ -0,0 +1,51 @@
1
+ """Pure verdict classification over gathered evidence. No I/O — fully unit-testable.
2
+
3
+ Thresholds are module constants so a later phase can make them configurable
4
+ without touching call sites.
5
+ """
6
+ from dataclasses import dataclass
7
+ from datetime import date
8
+ from typing import Optional
9
+
10
+ SHIPPED_PCT = 80.0 # >= this % of declared files satisfied -> shipped
11
+ PARTIAL_PCT = 20.0 # >= this % -> partial
12
+ BOXES_STALE_PCT = 50.0 # checked-box % below this on a shipped plan -> "boxes stale"
13
+ DEAD_DAYS = 60 # 0 files satisfied AND untouched beyond this -> dead
14
+ FOREIGN_RATIO = 0.7 # >= this fraction of declared paths outside repo -> foreign
15
+
16
+
17
+ @dataclass
18
+ class Verdict:
19
+ label: str # shipped | partial | dead | foreign | manifest-less
20
+ glyph: str
21
+ rationale: str
22
+
23
+
24
+ def classify(
25
+ score,
26
+ checkbox_done: int,
27
+ checkbox_total: int,
28
+ last_touched: Optional[date],
29
+ today: date,
30
+ dead_days: int = DEAD_DAYS,
31
+ ) -> Verdict:
32
+ if score.total == 0:
33
+ return Verdict("manifest-less", "\U0001f47b",
34
+ "no file-manifest — needs LLM verdict (Phase 1b)")
35
+
36
+ pct = score.pct
37
+ files = f"{score.satisfied}/{score.total} declared files present"
38
+
39
+ if pct >= SHIPPED_PCT:
40
+ chk_pct = (checkbox_done / checkbox_total * 100.0) if checkbox_total else 0.0
41
+ stale = " (boxes stale)" if chk_pct < BOXES_STALE_PCT else ""
42
+ return Verdict("shipped", "✅", f"{files}{stale}")
43
+
44
+ if pct >= PARTIAL_PCT:
45
+ return Verdict("partial", "\U0001f7e1", files)
46
+
47
+ if last_touched is not None and (today - last_touched).days > dead_days:
48
+ age = (today - last_touched).days
49
+ return Verdict("dead", "\U0001f480", f"{files}, untouched {age}d")
50
+
51
+ return Verdict("partial", "\U0001f7e1", f"{files} (early)")
@@ -0,0 +1,39 @@
1
+ """Confirm-token gate so non-interactive callers (the VS Code extension) can
2
+ surface the public-repo heads-up as their own dialog instead of a TTY prompt.
3
+
4
+ needs_confirm() fails CLOSED: PUBLIC or unknown visibility both require confirm.
5
+ Unknown visibility can be opted out of via cfg["assume_private_when_unknown"]=True
6
+ (for all-private teams that want to avoid the prompt on transient gh failures).
7
+ PUBLIC is never suppressed by this flag — the leak guarantee is unconditional.
8
+
9
+ The token is a deterministic hash of (repo, track) — no randomness (3.9 stdlib,
10
+ and stable so the re-invocation matches). It is not a security boundary; it just
11
+ proves the caller saw the heads-up for THIS write."""
12
+ import hashlib
13
+ from lib.github_state import repo_visibility
14
+
15
+
16
+ def needs_confirm(repo: str, cfg: dict = None) -> bool:
17
+ """True when a write to `repo` needs the public-repo confirm heads-up.
18
+
19
+ PUBLIC → always True (never suppressed — the leak guarantee).
20
+ PRIVATE → False.
21
+ Unknown visibility (gh couldn't say / offline) → True (fail CLOSED) UNLESS
22
+ cfg opts out via `assume_private_when_unknown: true`, which lets an
23
+ all-private team avoid the prompt on transient gh-lookup failures. PUBLIC is
24
+ never affected by this flag."""
25
+ vis = repo_visibility(repo)
26
+ if vis == "PUBLIC":
27
+ return True
28
+ if vis == "PRIVATE":
29
+ return False
30
+ # vis is None → unknown / offline. Fail closed unless explicitly opted out.
31
+ if cfg and cfg.get("assume_private_when_unknown"):
32
+ return False
33
+ return True
34
+
35
+ def make_token(repo: str, track: str) -> str:
36
+ return hashlib.sha256(f"{repo}::{track}".encode("utf-8")).hexdigest()[:16]
37
+
38
+ def valid_token(token: str, repo: str, track: str) -> bool:
39
+ return bool(token) and token == make_token(repo, track)
File without changes
@@ -0,0 +1,10 @@
1
+ ---
2
+ track: old
3
+ status: shipped
4
+ launch_priority: P2
5
+ github:
6
+ repo: stylusnexus/CritForge
7
+ issues: [50]
8
+ ---
9
+
10
+ # Old shipped track
@@ -0,0 +1,11 @@
1
+ ---
2
+ track: example
3
+ status: active
4
+ launch_priority: P1
5
+ github:
6
+ repo: stylusnexus/CritForge
7
+ issues: [100, 200]
8
+ next_up: [100]
9
+ ---
10
+
11
+ # Example
@@ -0,0 +1,14 @@
1
+ ---
2
+ track: tabletop
3
+ status: active
4
+ launch_priority: P1
5
+ github:
6
+ repo: stylusnexus/CritForge
7
+ issues: [4254, 4127]
8
+ branches: []
9
+ next_up: [4254]
10
+ ---
11
+
12
+ # Tabletop
13
+
14
+ Body content.
@@ -0,0 +1,3 @@
1
+ # Some plan
2
+
3
+ Body only.
@@ -0,0 +1,9 @@
1
+ # Track
2
+
3
+ ## Issues
4
+
5
+ | # | Title | Status |
6
+ |---|---|---|
7
+ | #4254 | admin polls | 🔲 Open |
8
+ | #4127 | dice roller | ✅ Shipped |
9
+ | #925 | wild magic | 🟡 In PR (#4137) |
@@ -0,0 +1,273 @@
1
+ """Tests for the non-interactive close command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - close <track> --state=parked on a PRIVATE repo → status set to parked,
5
+ write_file called, shutil.move NOT called (stays in place), rc 0.
6
+ - --state=shipped on a private repo → status shipped, write_file called,
7
+ shutil.move called to archive/shipped/, rc 0.
8
+ - --state=abandoned → moves to archive/abandoned/.
9
+ - Missing --state → rc 2, no write.
10
+ - Invalid --state=bogus → rc 2, no write.
11
+ - --note="wrapped up" → the body passed to write_file contains the ## Wrap-up
12
+ section with the note.
13
+ - Public repo, no token → prints needs_confirm JSON, no write/move, rc 0;
14
+ token equals make_token(repo, track.name).
15
+ - Public repo with valid --confirm=<token> → performs the close (write/move
16
+ happen), rc 0.
17
+ - No input()/prompt_input is reached on the flagged path (patch them to raise).
18
+ """
19
+ import io
20
+ import json
21
+ import sys
22
+ import unittest
23
+ from contextlib import redirect_stdout
24
+ from pathlib import Path
25
+ from types import SimpleNamespace
26
+ from unittest.mock import patch
27
+
28
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
29
+ sys.path.insert(0, str(SKILL_ROOT))
30
+
31
+ from commands import close
32
+ from lib.write_guard import make_token
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _track(*, name="alpha", repo="ok/repo", status="active"):
40
+ return SimpleNamespace(
41
+ name=name,
42
+ path=Path(f"/tmp/fake/{name}.md"),
43
+ body="# fake body",
44
+ meta={
45
+ "track": name,
46
+ "status": status,
47
+ "github": {"repo": repo},
48
+ },
49
+ has_frontmatter=True,
50
+ repo=repo,
51
+ )
52
+
53
+
54
+ def _drive(args, track=None, vis="PRIVATE"):
55
+ """Run close.run(args) with all external I/O mocked.
56
+
57
+ vis controls what repo_visibility returns (used by needs_confirm).
58
+ track defaults to a single private-repo track named 'alpha'.
59
+ """
60
+ if track is None:
61
+ track = _track()
62
+ # notes_root must be a parent of the track path so relative_to() works
63
+ cfg = {"notes_root": "/tmp/fake", "repos": {"ok": {"github": "ok/repo"}}}
64
+
65
+ with patch("commands.close.load_config", return_value=cfg), \
66
+ patch("commands.close.discover_tracks", return_value=[track]), \
67
+ patch("commands.close.find_track_by_name", return_value=track), \
68
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
69
+ patch("commands.close.write_file") as mw, \
70
+ patch("commands.close.shutil") as ms, \
71
+ patch("pathlib.Path.mkdir"):
72
+ buf = io.StringIO()
73
+ with redirect_stdout(buf):
74
+ rc = close.run(args)
75
+ return rc, mw, ms, buf.getvalue()
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Test cases
80
+ # ---------------------------------------------------------------------------
81
+
82
+ class CloseNonInteractiveTest(unittest.TestCase):
83
+
84
+ # ------------------------------------------------------------------
85
+ # State: parked (stays in place)
86
+ # ------------------------------------------------------------------
87
+
88
+ def test_parked_private_write_no_move(self):
89
+ """close <track> --state=parked on PRIVATE repo → write_file called,
90
+ shutil.move NOT called, rc 0."""
91
+ rc, mw, ms, out = _drive(["alpha", "--state=parked"])
92
+ self.assertEqual(rc, 0)
93
+ mw.assert_called_once()
94
+ # status updated to parked
95
+ written_meta = mw.call_args[0][1]
96
+ self.assertEqual(written_meta["status"], "parked")
97
+ # move must NOT be called for parked
98
+ ms.move.assert_not_called()
99
+ self.assertIn("parked", out)
100
+
101
+ # ------------------------------------------------------------------
102
+ # State: shipped (moves to archive/shipped/)
103
+ # ------------------------------------------------------------------
104
+
105
+ def test_shipped_private_write_and_move(self):
106
+ """--state=shipped on private repo → write_file called, shutil.move
107
+ called to archive/shipped/, rc 0."""
108
+ rc, mw, ms, out = _drive(["alpha", "--state=shipped"])
109
+ self.assertEqual(rc, 0)
110
+ mw.assert_called_once()
111
+ written_meta = mw.call_args[0][1]
112
+ self.assertEqual(written_meta["status"], "shipped")
113
+ ms.move.assert_called_once()
114
+ # Destination path should contain archive/shipped
115
+ dest_arg = ms.move.call_args[0][1]
116
+ self.assertIn("archive", dest_arg)
117
+ self.assertIn("shipped", dest_arg)
118
+
119
+ # ------------------------------------------------------------------
120
+ # State: abandoned (moves to archive/abandoned/)
121
+ # ------------------------------------------------------------------
122
+
123
+ def test_abandoned_private_write_and_move(self):
124
+ """--state=abandoned → moves to archive/abandoned/."""
125
+ rc, mw, ms, out = _drive(["alpha", "--state=abandoned"])
126
+ self.assertEqual(rc, 0)
127
+ mw.assert_called_once()
128
+ written_meta = mw.call_args[0][1]
129
+ self.assertEqual(written_meta["status"], "abandoned")
130
+ ms.move.assert_called_once()
131
+ dest_arg = ms.move.call_args[0][1]
132
+ self.assertIn("archive", dest_arg)
133
+ self.assertIn("abandoned", dest_arg)
134
+
135
+ # ------------------------------------------------------------------
136
+ # Missing / invalid --state
137
+ # ------------------------------------------------------------------
138
+
139
+ def test_missing_state_returns_rc2(self):
140
+ """Missing --state → rc 2, no write."""
141
+ rc, mw, ms, out = _drive(["alpha"])
142
+ self.assertEqual(rc, 2)
143
+ mw.assert_not_called()
144
+ ms.move.assert_not_called()
145
+
146
+ def test_invalid_state_returns_rc2(self):
147
+ """Invalid --state=bogus → rc 2, no write."""
148
+ rc, mw, ms, out = _drive(["alpha", "--state=bogus"])
149
+ self.assertEqual(rc, 2)
150
+ mw.assert_not_called()
151
+ ms.move.assert_not_called()
152
+
153
+ def test_missing_track_name_returns_rc2(self):
154
+ """No positional args at all → rc 2 (usage error)."""
155
+ rc, mw, ms, out = _drive([])
156
+ self.assertEqual(rc, 2)
157
+ mw.assert_not_called()
158
+
159
+ # ------------------------------------------------------------------
160
+ # --note flag
161
+ # ------------------------------------------------------------------
162
+
163
+ def test_note_appended_to_body(self):
164
+ """--note='wrapped up' → body passed to write_file contains
165
+ ## Wrap-up section with the note."""
166
+ rc, mw, ms, out = _drive(["alpha", "--state=parked", "--note=wrapped up"])
167
+ self.assertEqual(rc, 0)
168
+ mw.assert_called_once()
169
+ written_body = mw.call_args[0][2]
170
+ self.assertIn("## Wrap-up", written_body)
171
+ self.assertIn("wrapped up", written_body)
172
+
173
+ def test_no_note_no_wrap_up_section(self):
174
+ """No --note flag → body does NOT contain ## Wrap-up section."""
175
+ rc, mw, ms, out = _drive(["alpha", "--state=parked"])
176
+ self.assertEqual(rc, 0)
177
+ mw.assert_called_once()
178
+ written_body = mw.call_args[0][2]
179
+ self.assertNotIn("## Wrap-up", written_body)
180
+
181
+ # ------------------------------------------------------------------
182
+ # Confirm-token gate (public repo)
183
+ # ------------------------------------------------------------------
184
+
185
+ def test_public_repo_no_token_returns_needs_confirm_json(self):
186
+ """Public repo, no token → prints needs_confirm JSON, no write/move,
187
+ rc 0; token equals make_token(repo, track.name)."""
188
+ track = _track(name="alpha", repo="ok/repo")
189
+ rc, mw, ms, out = _drive(["alpha", "--state=shipped"], track=track, vis="PUBLIC")
190
+ self.assertEqual(rc, 0)
191
+ mw.assert_not_called()
192
+ ms.move.assert_not_called()
193
+ data = json.loads(out.strip())
194
+ self.assertTrue(data["needs_confirm"])
195
+ self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
196
+
197
+ def test_public_repo_unknown_visibility_returns_needs_confirm(self):
198
+ """Unknown visibility (None) → also requires confirm."""
199
+ track = _track(name="alpha", repo="ok/repo")
200
+ rc, mw, ms, out = _drive(["alpha", "--state=parked"], track=track, vis=None)
201
+ self.assertEqual(rc, 0)
202
+ mw.assert_not_called()
203
+ data = json.loads(out.strip())
204
+ self.assertTrue(data["needs_confirm"])
205
+
206
+ def test_public_repo_with_valid_confirm_performs_close(self):
207
+ """Public repo with valid --confirm=<token> → performs the close
208
+ (write_file called), rc 0."""
209
+ track = _track(name="alpha", repo="ok/repo")
210
+ tok = make_token("ok/repo", "alpha")
211
+ rc, mw, ms, out = _drive(
212
+ ["alpha", "--state=parked", f"--confirm={tok}"],
213
+ track=track, vis="PUBLIC"
214
+ )
215
+ self.assertEqual(rc, 0)
216
+ mw.assert_called_once()
217
+
218
+ def test_public_repo_with_valid_confirm_shipped_moves(self):
219
+ """Public repo with valid --confirm=<token> + --state=shipped →
220
+ write_file AND shutil.move both called, rc 0."""
221
+ track = _track(name="alpha", repo="ok/repo")
222
+ tok = make_token("ok/repo", "alpha")
223
+ rc, mw, ms, out = _drive(
224
+ ["alpha", "--state=shipped", f"--confirm={tok}"],
225
+ track=track, vis="PUBLIC"
226
+ )
227
+ self.assertEqual(rc, 0)
228
+ mw.assert_called_once()
229
+ ms.move.assert_called_once()
230
+
231
+ def test_public_repo_wrong_token_blocks_write(self):
232
+ """Public repo with wrong confirm token → blocked, no write, rc 0."""
233
+ track = _track(name="alpha", repo="ok/repo")
234
+ rc, mw, ms, out = _drive(
235
+ ["alpha", "--state=shipped", "--confirm=wrongtoken"],
236
+ track=track, vis="PUBLIC"
237
+ )
238
+ self.assertEqual(rc, 0)
239
+ mw.assert_not_called()
240
+ data = json.loads(out.strip())
241
+ self.assertTrue(data["needs_confirm"])
242
+
243
+ # ------------------------------------------------------------------
244
+ # No input() on non-interactive path
245
+ # ------------------------------------------------------------------
246
+
247
+ def test_no_input_called_on_flagged_path(self):
248
+ """Flagged paths never call input() or prompt_input, even when
249
+ --state/--note are provided and the repo is private."""
250
+ track = _track(name="alpha", repo="ok/repo")
251
+
252
+ def _raise(*a, **kw):
253
+ raise AssertionError("input() must not be called on non-interactive path")
254
+
255
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
256
+
257
+ with patch("builtins.input", side_effect=_raise), \
258
+ patch("lib.prompts.prompt_input", side_effect=_raise):
259
+ with patch("commands.close.load_config", return_value=cfg), \
260
+ patch("commands.close.discover_tracks", return_value=[track]), \
261
+ patch("commands.close.find_track_by_name", return_value=track), \
262
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
263
+ patch("commands.close.write_file"), \
264
+ patch("commands.close.shutil"), \
265
+ patch("pathlib.Path.mkdir"):
266
+ buf = io.StringIO()
267
+ with redirect_stdout(buf):
268
+ rc = close.run(["alpha", "--state=parked", "--note=done"])
269
+ self.assertEqual(rc, 0)
270
+
271
+
272
+ if __name__ == "__main__":
273
+ 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,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()