@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,382 @@
1
+ """Tests for the redesigned `where-was-i` / `orient` paste-block output.
2
+
3
+ The contract: ~15 lines total, header rule + meta + Track/Local paths +
4
+ last session timestamp+summary + next pick + behind-it (optional) +
5
+ local git (optional) + new-issues (optional) + bottom rule. NEVER a
6
+ dump of all open/closed issues.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import sys
12
+ import tempfile
13
+ import unittest
14
+ from contextlib import redirect_stdout
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
19
+ sys.path.insert(0, str(SKILL_ROOT))
20
+
21
+ from commands import where_was_i
22
+ from lib.frontmatter import write_file
23
+
24
+
25
+ def _make_track_file(dir_path: Path, slug: str = "demo-track",
26
+ *, with_session: bool = True,
27
+ next_up: list[int] | None = None,
28
+ last_handoff: str | None = "2026-04-28T19:36") -> Path:
29
+ """Build a minimal track .md the where-was-i command can resolve."""
30
+ meta = {
31
+ "track": slug,
32
+ "status": "active",
33
+ "launch_priority": "P0",
34
+ "milestone_alignment": "v0.4.0",
35
+ "github": {
36
+ "repo": "stylusnexus/Demo",
37
+ "issues": [4167, 4148, 4149, 4150],
38
+ "branches": [],
39
+ },
40
+ "next_up": next_up if next_up is not None else [4167, 4148, 4149, 4150],
41
+ "last_touched": "2026-04-28T19:36",
42
+ "last_handoff": last_handoff,
43
+ }
44
+ body_parts = ["", "# Demo track", ""]
45
+ if with_session:
46
+ body_parts.extend([
47
+ "### Session — 2026-04-28 19:36",
48
+ "",
49
+ "- Touched: (no git activity attributed; 32 open from GitHub)",
50
+ "- Next: #4167",
51
+ "",
52
+ ])
53
+ body = "\n".join(body_parts)
54
+ path = dir_path / f"{slug}.md"
55
+ write_file(path, meta, body)
56
+ return path
57
+
58
+
59
+ def _fake_issues(nums: list[int], milestone: str | None = None) -> list[dict]:
60
+ titles = {
61
+ 4167: "feat(library): Armory Slice 3 — ArmoryCard + data-source cutover",
62
+ 4148: "fix(dashboard): CreditsPill shows '0 left' for super admin",
63
+ 4149: "fix(dashboard): Studio tier should show 'Unlimited'",
64
+ 4150: "fix(error): /contact 404 — replace with form",
65
+ }
66
+ ms_obj = {"title": milestone} if milestone else None
67
+ return [{"number": n, "title": titles.get(n, f"issue {n}"), "state": "OPEN",
68
+ "labels": [], "milestone": ms_obj, "url": "", "closedAt": None,
69
+ "body": ""} for n in nums]
70
+
71
+
72
+ class WhereWasIBaseCase(unittest.TestCase):
73
+ """Default fixture: full output with all sections present."""
74
+
75
+ def setUp(self):
76
+ self.tmp = tempfile.TemporaryDirectory()
77
+ self.notes_root = Path(self.tmp.name) / "notes_root"
78
+ self.repo_dir = self.notes_root / "demo"
79
+ self.repo_dir.mkdir(parents=True)
80
+ self.track_path = _make_track_file(self.repo_dir)
81
+
82
+ self.cfg = {
83
+ "notes_root": str(self.notes_root),
84
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
85
+ }
86
+ self._patches = [
87
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
88
+ mock.patch("commands.where_was_i.fetch_issues",
89
+ side_effect=lambda repo, nums: _fake_issues(list(nums))),
90
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks",
91
+ return_value={}),
92
+ # No local clone in test fixtures — make git helpers no-op cleanly.
93
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
94
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
95
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
96
+ ]
97
+ for p in self._patches:
98
+ p.start()
99
+
100
+ def tearDown(self):
101
+ for p in self._patches:
102
+ p.stop()
103
+ self.tmp.cleanup()
104
+
105
+ def _run(self, args=None) -> str:
106
+ buf = io.StringIO()
107
+ with redirect_stdout(buf):
108
+ rc = where_was_i.run(args or ["demo-track"])
109
+ self.assertEqual(rc, 0)
110
+ return buf.getvalue()
111
+
112
+ def test_full_output_contains_all_required_sections(self):
113
+ out = self._run()
114
+ # Header rule + slug
115
+ self.assertIn("─── demo-track ", out)
116
+ # Meta line
117
+ self.assertIn("Priority: P0", out)
118
+ self.assertIn("Milestone: v0.4.0", out)
119
+ self.assertIn("Repo: stylusnexus/Demo", out)
120
+ # Track path
121
+ self.assertIn("Track: ", out)
122
+ self.assertIn("demo-track.md", out)
123
+ # Last session — timestamp + summary line
124
+ self.assertIn("Last session (2026-04-28 19:36):", out)
125
+ self.assertIn("(no git activity attributed; 32 open from GitHub)", out)
126
+ # Next pick
127
+ self.assertIn("Next pick: #4167", out)
128
+ self.assertIn("Armory Slice 3", out)
129
+ # Behind it (3 items)
130
+ self.assertIn("Behind it:", out)
131
+ self.assertIn("#4148", out)
132
+ self.assertIn("#4149", out)
133
+ self.assertIn("#4150", out)
134
+
135
+ def test_output_is_under_25_lines(self):
136
+ """Paste-block contract: tight summary, not a dump."""
137
+ out = self._run()
138
+ line_count = len(out.splitlines())
139
+ self.assertLess(line_count, 25,
140
+ f"output should be a tight paste block; got {line_count} lines:\n{out}")
141
+
142
+ def test_output_has_no_closed_issue_dump(self):
143
+ """The whole point of the redesign — no 'Current issue state' section."""
144
+ out = self._run()
145
+ self.assertNotIn("Current issue state", out)
146
+ self.assertNotIn("closed", out.lower())
147
+ self.assertNotIn("merged", out.lower())
148
+
149
+
150
+ class WhereWasIEmptyNextUpCase(unittest.TestCase):
151
+ def setUp(self):
152
+ self.tmp = tempfile.TemporaryDirectory()
153
+ self.notes_root = Path(self.tmp.name) / "notes_root"
154
+ self.repo_dir = self.notes_root / "demo"
155
+ self.repo_dir.mkdir(parents=True)
156
+ self.track_path = _make_track_file(self.repo_dir, next_up=[])
157
+
158
+ self.cfg = {
159
+ "notes_root": str(self.notes_root),
160
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
161
+ }
162
+ self._patches = [
163
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
164
+ mock.patch("commands.where_was_i.fetch_issues", return_value=[]),
165
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks",
166
+ return_value={}),
167
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
168
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
169
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
170
+ ]
171
+ for p in self._patches:
172
+ p.start()
173
+
174
+ def tearDown(self):
175
+ for p in self._patches:
176
+ p.stop()
177
+ self.tmp.cleanup()
178
+
179
+ def test_empty_next_up_shows_helpful_prompt(self):
180
+ buf = io.StringIO()
181
+ with redirect_stdout(buf):
182
+ rc = where_was_i.run(["demo-track"])
183
+ self.assertEqual(rc, 0)
184
+ out = buf.getvalue()
185
+ self.assertIn("Next pick: (none set", out)
186
+ # Should NOT show "Behind it:" when next_up is empty
187
+ self.assertNotIn("Behind it:", out)
188
+
189
+
190
+ class WhereWasINoLocalPathCase(unittest.TestCase):
191
+ """Track resolves but has no local clone — Local: line should be omitted."""
192
+
193
+ def setUp(self):
194
+ self.tmp = tempfile.TemporaryDirectory()
195
+ self.notes_root = Path(self.tmp.name) / "notes_root"
196
+ self.repo_dir = self.notes_root / "demo"
197
+ self.repo_dir.mkdir(parents=True)
198
+ self.track_path = _make_track_file(self.repo_dir)
199
+
200
+ # No `local:` configured for the demo repo — track.local_path is None.
201
+ self.cfg = {
202
+ "notes_root": str(self.notes_root),
203
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
204
+ }
205
+ self._patches = [
206
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
207
+ mock.patch("commands.where_was_i.fetch_issues",
208
+ side_effect=lambda repo, nums: _fake_issues(list(nums))),
209
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks",
210
+ return_value={}),
211
+ ]
212
+ for p in self._patches:
213
+ p.start()
214
+
215
+ def tearDown(self):
216
+ for p in self._patches:
217
+ p.stop()
218
+ self.tmp.cleanup()
219
+
220
+ def test_no_local_path_omits_local_lines(self):
221
+ buf = io.StringIO()
222
+ with redirect_stdout(buf):
223
+ rc = where_was_i.run(["demo-track"])
224
+ self.assertEqual(rc, 0)
225
+ out = buf.getvalue()
226
+ # The "Local: <path>" header line should NOT appear
227
+ self.assertNotIn("Local: /", out)
228
+ # Nor the local-git status footer ("Local: on <branch>")
229
+ self.assertNotIn("Local: on ", out)
230
+
231
+
232
+ class WhereWasIClosedNextUpCase(unittest.TestCase):
233
+ """next_up references issues that have shipped — orient must surface (closed)."""
234
+
235
+ def setUp(self):
236
+ self.tmp = tempfile.TemporaryDirectory()
237
+ self.notes_root = Path(self.tmp.name) / "notes_root"
238
+ self.repo_dir = self.notes_root / "demo"
239
+ self.repo_dir.mkdir(parents=True)
240
+ self.track_path = _make_track_file(self.repo_dir, next_up=[4348, 4349])
241
+
242
+ self.cfg = {
243
+ "notes_root": str(self.notes_root),
244
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
245
+ }
246
+
247
+ def _closed_issues(repo, nums):
248
+ return [{"number": n, "title": f"shipped spec {n}", "state": "CLOSED",
249
+ "labels": [], "milestone": None, "url": "", "closedAt": "2026-05-02",
250
+ "body": ""} for n in nums]
251
+
252
+ self._patches = [
253
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
254
+ mock.patch("commands.where_was_i.fetch_issues", side_effect=_closed_issues),
255
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks", return_value={}),
256
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
257
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
258
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
259
+ ]
260
+ for p in self._patches:
261
+ p.start()
262
+
263
+ def tearDown(self):
264
+ for p in self._patches:
265
+ p.stop()
266
+ self.tmp.cleanup()
267
+
268
+ def test_closed_next_up_annotated_inline(self):
269
+ buf = io.StringIO()
270
+ with redirect_stdout(buf):
271
+ rc = where_was_i.run(["demo-track"])
272
+ self.assertEqual(rc, 0)
273
+ out = buf.getvalue()
274
+ self.assertIn("Next pick: #4348", out)
275
+ self.assertIn("(closed)", out)
276
+ # Behind-it line should also pick up state.
277
+ self.assertIn("#4349", out)
278
+ self.assertEqual(out.count("(closed)"), 2)
279
+
280
+ def test_closed_next_pick_shows_rotate_hint(self):
281
+ buf = io.StringIO()
282
+ with redirect_stdout(buf):
283
+ where_was_i.run(["demo-track"])
284
+ out = buf.getvalue()
285
+ self.assertIn("next_up:[0] has shipped", out)
286
+ self.assertIn("/work-plan handoff demo-track", out)
287
+
288
+
289
+ class WhereWasIMilestoneTagCase(unittest.TestCase):
290
+ """next_up issues with milestones — orient must surface [vX.Y.Z] inline."""
291
+
292
+ def setUp(self):
293
+ self.tmp = tempfile.TemporaryDirectory()
294
+ self.notes_root = Path(self.tmp.name) / "notes_root"
295
+ self.repo_dir = self.notes_root / "demo"
296
+ self.repo_dir.mkdir(parents=True)
297
+ self.track_path = _make_track_file(self.repo_dir, next_up=[4167, 4148])
298
+
299
+ self.cfg = {
300
+ "notes_root": str(self.notes_root),
301
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
302
+ }
303
+
304
+ def _milestoned(repo, nums):
305
+ issues = _fake_issues(list(nums))
306
+ for i in issues:
307
+ if i["number"] == 4167:
308
+ i["milestone"] = {"title": "v0.4.0 — MVP Go-Live Gate"}
309
+ elif i["number"] == 4148:
310
+ i["milestone"] = {"title": "v2.0.0 — Post-Launch"}
311
+ return issues
312
+
313
+ self._patches = [
314
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
315
+ mock.patch("commands.where_was_i.fetch_issues", side_effect=_milestoned),
316
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks", return_value={}),
317
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
318
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
319
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
320
+ ]
321
+ for p in self._patches:
322
+ p.start()
323
+
324
+ def tearDown(self):
325
+ for p in self._patches:
326
+ p.stop()
327
+ self.tmp.cleanup()
328
+
329
+ def test_milestone_prefix_on_next_pick_and_behind_it(self):
330
+ buf = io.StringIO()
331
+ with redirect_stdout(buf):
332
+ rc = where_was_i.run(["demo-track"])
333
+ self.assertEqual(rc, 0)
334
+ out = buf.getvalue()
335
+ self.assertIn("Next pick: #4167 [v0.4.0]", out)
336
+ self.assertIn("#4148 [v2.0.0]", out)
337
+
338
+
339
+ class WhereWasINoSessionLogCase(unittest.TestCase):
340
+ """No prior `### Session — …` block — print '(none yet)' fallback."""
341
+
342
+ def setUp(self):
343
+ self.tmp = tempfile.TemporaryDirectory()
344
+ self.notes_root = Path(self.tmp.name) / "notes_root"
345
+ self.repo_dir = self.notes_root / "demo"
346
+ self.repo_dir.mkdir(parents=True)
347
+ self.track_path = _make_track_file(self.repo_dir, with_session=False,
348
+ last_handoff=None)
349
+
350
+ self.cfg = {
351
+ "notes_root": str(self.notes_root),
352
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
353
+ }
354
+ self._patches = [
355
+ mock.patch("commands.where_was_i.load_config", return_value=self.cfg),
356
+ mock.patch("commands.where_was_i.fetch_issues",
357
+ side_effect=lambda repo, nums: _fake_issues(list(nums))),
358
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks",
359
+ return_value={}),
360
+ mock.patch("commands.where_was_i.current_branch", return_value=None),
361
+ mock.patch("commands.where_was_i.commits_ahead", return_value=0),
362
+ mock.patch("commands.where_was_i.uncommitted_file_count", return_value=0),
363
+ ]
364
+ for p in self._patches:
365
+ p.start()
366
+
367
+ def tearDown(self):
368
+ for p in self._patches:
369
+ p.stop()
370
+ self.tmp.cleanup()
371
+
372
+ def test_no_session_log_says_none_yet(self):
373
+ buf = io.StringIO()
374
+ with redirect_stdout(buf):
375
+ rc = where_was_i.run(["demo-track"])
376
+ self.assertEqual(rc, 0)
377
+ out = buf.getvalue()
378
+ self.assertIn("Last session: (none yet)", out)
379
+
380
+
381
+ if __name__ == "__main__":
382
+ unittest.main()
@@ -0,0 +1,53 @@
1
+ # tests/test_write_guard.py
2
+ import sys, unittest
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+ SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
6
+ from lib.write_guard import needs_confirm, make_token, valid_token
7
+
8
+ class WriteGuardTest(unittest.TestCase):
9
+ # --- Existing cases (no cfg arg — backward-compatible) ---
10
+ @patch("lib.write_guard.repo_visibility", return_value="PUBLIC")
11
+ def test_public_needs_confirm(self, _):
12
+ self.assertTrue(needs_confirm("o/r"))
13
+ @patch("lib.write_guard.repo_visibility", return_value="PRIVATE")
14
+ def test_private_ok(self, _):
15
+ self.assertFalse(needs_confirm("o/r"))
16
+ @patch("lib.write_guard.repo_visibility", return_value=None)
17
+ def test_unknown_fails_closed(self, _):
18
+ self.assertTrue(needs_confirm("o/r")) # fail closed
19
+
20
+ # --- PUBLIC is never suppressed, even with assume_private_when_unknown ---
21
+ @patch("lib.write_guard.repo_visibility", return_value="PUBLIC")
22
+ def test_public_never_suppressed_by_flag(self, _):
23
+ self.assertTrue(needs_confirm("o/r", cfg={"assume_private_when_unknown": True}))
24
+
25
+ # --- PRIVATE → False regardless of cfg ---
26
+ @patch("lib.write_guard.repo_visibility", return_value="PRIVATE")
27
+ def test_private_false_without_cfg(self, _):
28
+ self.assertFalse(needs_confirm("o/r", cfg=None))
29
+ @patch("lib.write_guard.repo_visibility", return_value="PRIVATE")
30
+ def test_private_false_with_cfg(self, _):
31
+ self.assertFalse(needs_confirm("o/r", cfg={"assume_private_when_unknown": True}))
32
+
33
+ # --- Unknown (None) — fail-closed by default ---
34
+ @patch("lib.write_guard.repo_visibility", return_value=None)
35
+ def test_unknown_no_cfg_fails_closed(self, _):
36
+ self.assertTrue(needs_confirm("o/r", cfg=None))
37
+ @patch("lib.write_guard.repo_visibility", return_value=None)
38
+ def test_unknown_empty_cfg_fails_closed(self, _):
39
+ self.assertTrue(needs_confirm("o/r", cfg={}))
40
+ @patch("lib.write_guard.repo_visibility", return_value=None)
41
+ def test_unknown_flag_false_fails_closed(self, _):
42
+ self.assertTrue(needs_confirm("o/r", cfg={"assume_private_when_unknown": False}))
43
+
44
+ # --- Unknown (None) — opted out ---
45
+ @patch("lib.write_guard.repo_visibility", return_value=None)
46
+ def test_unknown_flag_true_skips_confirm(self, _):
47
+ self.assertFalse(needs_confirm("o/r", cfg={"assume_private_when_unknown": True}))
48
+
49
+ def test_token_roundtrip(self):
50
+ tok = make_token("o/r", "platform-health")
51
+ self.assertTrue(valid_token(tok, "o/r", "platform-health"))
52
+ self.assertFalse(valid_token(tok, "o/r", "other"))
53
+ self.assertFalse(valid_token("", "o/r", "platform-health"))
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """Daily work planner CLI."""
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ def _load_version() -> str:
8
+ # Walk upward from this file looking for a VERSION file. Handles two layouts:
9
+ # installed (VERSION sits next to work_plan.py via install.sh) and source
10
+ # (VERSION at the repo root, two parents up). Walks to the filesystem root
11
+ # rather than a fixed depth so vendored copies and unusual checkout layouts
12
+ # still resolve.
13
+ p = Path(__file__).resolve().parent
14
+ while True:
15
+ candidate = p / "VERSION"
16
+ if candidate.is_file():
17
+ return candidate.read_text(encoding="utf-8").strip() or "unknown"
18
+ if p.parent == p:
19
+ return "unknown"
20
+ p = p.parent
21
+
22
+
23
+ VERSION = _load_version()
24
+
25
+ SUBCOMMANDS = {
26
+ "brief": "commands.brief",
27
+ "--brief": "commands.brief", # flag-style alias
28
+ "handoff": "commands.handoff",
29
+ "--handoff": "commands.handoff", # flag-style alias
30
+ "where-was-i": "commands.where_was_i",
31
+ "orient": "commands.where_was_i",
32
+ "--orient": "commands.where_was_i", # flag-style alias
33
+ "slot": "commands.slot",
34
+ "close": "commands.close",
35
+ "refresh-md": "commands.refresh_md",
36
+ "list": "commands.list_cmd",
37
+ "init": "commands.init",
38
+ "init-repo": "commands.init_repo",
39
+ "suggest-priorities": "commands.suggest_priorities",
40
+ "group": "commands.group",
41
+ "auto-triage": "commands.auto_triage",
42
+ "reconcile": "commands.reconcile",
43
+ "--reconcile": "commands.reconcile", # flag-style alias
44
+ "duplicates": "commands.duplicates",
45
+ "coverage": "commands.coverage",
46
+ "canonicalize": "commands.canonicalize",
47
+ "hygiene": "commands.hygiene",
48
+ "--hygiene": "commands.hygiene", # flag-style alias
49
+ "plan-status": "commands.plan_status",
50
+ "--plan-status": "commands.plan_status", # flag-style alias
51
+ "export": "commands.export",
52
+ "set": "commands.set_field",
53
+ "new-track": "commands.new_track",
54
+ "set-notes-root": "commands.set_notes_root",
55
+ }
56
+
57
+ DESCRIPTIONS = [
58
+ # (name, args, what, when, example)
59
+ ("brief", "[--repo=<key>]",
60
+ "Multi-track snapshot with time-aware framing. --repo scopes the brief (and the archived-reopen callouts) to one configured repo.",
61
+ "Starting a work session, after a gap, or any time you want a status snapshot. Use --repo when you only want to think about one project today.",
62
+ "/work-plan brief --repo=critforge"),
63
+ ("handoff", "[track] [--set-next 1,2,3 | --auto-next] [--interactive]",
64
+ "Wrap up a session: capture touched/next/blockers, update body status table. Use --set-next to set the next_up list explicitly. Use --auto-next to suggest a priority-sorted list from open issues (interactive: apply / edit / skip).",
65
+ "Ending a work block — before stepping away, going to bed, or switching tracks. Use --auto-next when you don't want to hand-pick issue numbers.",
66
+ "/work-plan handoff tabletop --auto-next"),
67
+ ("where-was-i", "[track] [--pick]",
68
+ "Re-orient. With a track name: track paste-block. With no args: cwd snapshot (branch, recent commits, modified files). Add --pick to force the interactive track picker.",
69
+ "Switching to a fresh Claude Code session — either on a known track or in a directory that doesn't yet belong to one.",
70
+ "/work-plan where-was-i ux-redesign (or just `/work-plan orient` for cwd snapshot)"),
71
+ ("slot", "<issue-num> [track | track@repo] [--repo=<key>]",
72
+ "Add a GitHub issue to a track's frontmatter. If the issue is already in another active track in the same repo, prompts to move it (remove from source) rather than duplicate. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
73
+ "When a new GitHub issue is filed and you want it associated with a track — or when an existing issue was relabeled and needs to move tracks.",
74
+ "/work-plan slot 4234 tabletop"),
75
+ ("close", "<track | track@repo> [--repo=<key>]",
76
+ "Retire a track: shipped / parked / abandoned. Moves to archive/. Use --repo=<key> or track@repo to disambiguate when the same track slug exists in multiple repos.",
77
+ "When a track is done, paused, or won't ship — frees mental space.",
78
+ "/work-plan close tabletop"),
79
+ ("refresh-md", "<track> | --all | --repo=<key> [--yes]",
80
+ "Update issue STATE (open/closed, status labels) inside the track body's status table. Does not change track membership.",
81
+ "Usually NOT needed directly: `handoff` already refreshes the body table for its own track, and `brief` reads GitHub live. Reach for this when a sibling track has drifted because you haven't `handoff`'d it lately. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo (also runs as part of weekly `hygiene`).",
82
+ "/work-plan refresh-md --repo=critforge"),
83
+ ("list", "[--all]",
84
+ "List active tracks (or all including parked/archived).",
85
+ "Quick scan of what tracks exist; --all to see archived.",
86
+ "/work-plan list --all"),
87
+ ("init", "<path-to-md>",
88
+ "Add frontmatter to an existing track .md file.",
89
+ "After moving/creating a new .md file in Project Notes/<repo>/ that has no frontmatter.",
90
+ "/work-plan init '<notes_root>/<repo-key>/foo.md'"),
91
+ ("init-repo", "<key> --github=<org/repo> [--local=<path>]",
92
+ "Bootstrap a new repo: create <notes_root>/<key>/archive/{shipped,abandoned}/ and add the repo block to your config.",
93
+ "When you start tracking a new GitHub repo. Replaces the old 'copy the example folder' setup.",
94
+ "/work-plan init-repo myproject --github=your-org/myproject"),
95
+ ("suggest-priorities", "[--repo=<folder>] [--apply]",
96
+ "AI-assisted batch backfill of priority/PN labels.",
97
+ "ONE-TIME setup, or whenever a wave of new unlabeled issues piles up.",
98
+ "/work-plan suggest-priorities --repo=myproject"),
99
+ ("group", "[--milestone=X] [--label=Y] [--repo=Z] [--apply]",
100
+ "AI-cluster GitHub issues into thematic track files.",
101
+ "ONE-TIME bulk organization of an unsorted milestone, or after a re-org.",
102
+ "/work-plan group --milestone='v1.0.0 — Public Launch'"),
103
+ ("auto-triage", "[--repo=<key>] [--apply]",
104
+ "AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist.",
105
+ "Periodically — when new issues have piled up outside the track model. Run /work-plan coverage first to confirm there's a gap worth triaging.",
106
+ "/work-plan auto-triage --repo=critforge"),
107
+ ("reconcile", "<track> | --all | --repo=<key> [--draft]",
108
+ "Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing it against a GitHub label. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter. Read-only on GitHub. Add --draft to preview proposed ADDs/FLAGs without prompting or writing. NOT for hand-curated tracks — see `refresh-md` if you only want to update issue state.",
109
+ "WEEKLY hygiene on label-driven tracks — pulls labeled issues into their tracks, flags un-labeled ones. Use --repo=<key> to scope the sweep to one repo. Skip on hand-curated tracks (it'll propose dropping curated issues every run).",
110
+ "/work-plan reconcile --repo=critforge --draft"),
111
+ ("duplicates", "[--min-similarity=0.7] [--limit=20] [--state=open] [--timeout=N]",
112
+ "Find likely-duplicate issues by title similarity.",
113
+ "WEEKLY hygiene, or before a milestone planning session — find consolidation candidates.",
114
+ "/work-plan duplicates --min-similarity=0.85"),
115
+ ("coverage", "[--repo=<key>] [--list] [--limit=N]",
116
+ "Report how many open issues are not referenced by any track (per repo). --list prints issue titles (default: show 20; override with --limit=N). Read-only; derives live from gh.",
117
+ "On-demand: measure how much of a repo's backlog has fallen outside the planning layer. Pairs with /work-plan group to bulk-cluster the orphans.",
118
+ "/work-plan coverage --repo=critforge --list"),
119
+ ("canonicalize", "<track | track@repo> | --all [--force] [--repo=<key>]",
120
+ "Insert a canonical master issue table at the top of a track. Refresh-md then targets ONLY this table, leaving narrative tables alone. Use --repo=<key> or track@repo to disambiguate; with --all, --repo=<key> scopes to one repo.",
121
+ "ONE-TIME for hand-written tracks with multiple narrative tables, OR after restructuring a track.",
122
+ "/work-plan canonicalize ux-redesign"),
123
+ ("hygiene", "[--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]",
124
+ "Weekly cleanup wrapper: refresh-md + reconcile + duplicates. With --repo=<key>, steps 1 and 2 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).",
125
+ "WEEKLY — runs all three hygiene commands in sequence so you don't have to remember each. Use --repo=<key> to clean up one project without touching the others.",
126
+ "/work-plan hygiene --repo=critforge"),
127
+ ("export", "--json",
128
+ "Emit the viewer-ready JSON read surface (schema 1): every frontmatter'd track with repo, tier, status, visibility, blockers, next_up, an open/closed rollup, and per-issue state/assignee/milestone. Read-only; derives live from gh. Consumed by the VS Code extension.",
129
+ "When a tool (the VS Code viewer, or any script) needs structured track state instead of the human-facing brief/orient text.",
130
+ "/work-plan export --json"),
131
+ ("set", "<track | track@repo> field=value [field=value …] [--repo=<key>] [--confirm=<token>]",
132
+ "Guarded edit of a track's frontmatter fields (status, launch_priority, milestone_alignment, blockers, next_up). Validates field names + status values; blockers/next_up take comma-separated issue numbers. Writes into a PUBLIC repo only with a confirm token: without one it prints {needs_confirm, reason, token} and makes no change (the VS Code viewer surfaces that as a modal, then re-invokes with --confirm=<token>).",
133
+ "Programmatic/GUI edits that have no dedicated verb — e.g. the VS Code extension changing a status or blockers list. On the terminal you'll usually use the named verbs instead.",
134
+ "/work-plan set ux-redesign status=parked"),
135
+ ("new-track", "<repo> <slug> [--priority=P0..P3] [--milestone=<m>] [--private] [--confirm=<token>]",
136
+ "Create a brand-new track file under notes_root in one headless call. <repo> is either a configured key (e.g. 'critforge') or a bare org/repo slug (e.g. 'stylusnexus/critforge'). Writes frontmatter with status=active and optional priority/milestone. Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token> to proceed.",
137
+ "When a new feature branch or initiative starts and you want the track file created immediately — especially from a non-terminal caller like the VS Code extension that can't interactively run init.",
138
+ "/work-plan new-track stylusnexus/work-plan-toolkit my-feature"),
139
+ ("plan-status", "[--repo=<key>] [--json] [--stamp [--draft]] [--llm [--apply]] [--archive | --issues] [--draft] [--since-days=N] [--type=plan|spec]",
140
+ "Reach a verdict on every plan/spec doc in a repo by correlating each plan's declared file-manifest (Create/Modify/Test paths) against the filesystem + git — not the unreliable checkboxes. Read-only: reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. --json for machine output. Add --stamp to write each verdict into its doc as an idempotent status header (--draft previews without writing). Add --llm for a two-step AI pass that judges prose/ambiguous docs (writes a prompt; you save JSON to the cache; re-run with --llm --apply). --archive moves dead plans to archive/abandoned/ (gated); --issues opens a GitHub issue per partial plan listing its unsatisfied files (gated). Both honor --draft.",
141
+ "When you point at a repo and need to know what's actually done vs. half-done vs. dead among accumulated plans. Run from inside the repo, or use --repo=<key> for a configured one.",
142
+ "/work-plan plan-status --repo=critforge"),
143
+ ("set-notes-root", "<path>",
144
+ "Update notes_root in ~/.claude/work-plan/config.yml to an absolute path. Creates the target directory if absent. Prints a WARN if existing frontmatter'd tracks live at the old location (they won't be moved — manual migration required). Non-interactive: safe to call from a GUI or script.",
145
+ "VS Code viewer cold-start: user has picked a folder for their private track notes and the extension invokes this to persist the choice. Also useful on the CLI to relocate notes without hand-editing config.yml.",
146
+ "/work-plan set-notes-root ~/Documents/work-plan-notes"),
147
+ ]
148
+
149
+
150
+ def _print_help() -> int:
151
+ print("work_plan.py — track-aware daily work planning\n")
152
+ print("Two ways to invoke:")
153
+ print(" In Claude Code: /work-plan <subcommand> [args...] (preferred)")
154
+ print(" In a terminal: python3 ~/.claude/skills/work-plan/work_plan.py <subcommand> [args...]\n")
155
+ print("=" * 80)
156
+ print("SUBCOMMANDS\n")
157
+ for name, args, what, when, example in DESCRIPTIONS:
158
+ print(f" {name} {args}".rstrip())
159
+ print(f" What: {what}")
160
+ print(f" When: {when}")
161
+ print(f" Example: {example}")
162
+ print()
163
+ print("=" * 80)
164
+ print("DAILY RHYTHM (suggested)\n")
165
+ print(" Starting a session → /work-plan --brief")
166
+ print(" Re-orient on a track → /work-plan --orient <track>")
167
+ print(" Ending a work block → /work-plan --handoff <track>")
168
+ print(" New issue filed → /work-plan slot <#>")
169
+ print(" Track shipped/done → /work-plan close <track>")
170
+ print()
171
+ print(" Need to remember? You only need 5 flags: --brief · --handoff · --orient · --reconcile · --hygiene")
172
+ print(" (Subcommand form also works: brief, handoff, orient, reconcile, hygiene)")
173
+ print()
174
+ print("WEEKLY HYGIENE\n")
175
+ print(" All-in-one (recommended) → /work-plan --hygiene")
176
+ print(" Scope to one repo → /work-plan hygiene --repo=<key>")
177
+ print(" Or individually:")
178
+ print(" Drift in status tables → /work-plan refresh-md --all (or --repo=<key>)")
179
+ print(" Sync labels ↔ tracks → /work-plan reconcile --all (or --repo=<key>)")
180
+ print(" Find duplicate issues → /work-plan duplicates")
181
+ print()
182
+ print("FOCUS ON ONE PROJECT\n")
183
+ print(" Daily snapshot, one repo → /work-plan brief --repo=<key>")
184
+ print(" Weekly cleanup, one repo → /work-plan hygiene --repo=<key>")
185
+ print(" (<key> is the folder name under notes_root, e.g. 'critforge'.)")
186
+ print()
187
+ print("ONE-TIME SETUP\n")
188
+ print(" Bulk-cluster milestone → /work-plan group --milestone='v1.0.0 — Public Launch'")
189
+ print(" Backfill priorities → /work-plan suggest-priorities --repo=myproject")
190
+ print()
191
+ print("=" * 80)
192
+ print(f"Config: ~/.claude/work-plan/config.yml (or ~/.agents/work-plan/config.yml on Codex)")
193
+ print(f"Docs: See the toolkit README for full setup, requirements, and platform-specific install.")
194
+ print(f"Meta: --help / -h · --version / -v")
195
+ return 0
196
+
197
+
198
+ def main(argv: list[str]) -> int:
199
+ if len(argv) < 2:
200
+ return _print_help() or 2
201
+ sub = argv[1]
202
+ if sub in ("--help", "-h", "help"):
203
+ return _print_help()
204
+ if sub in ("--version", "-v"):
205
+ print(f"work-plan {VERSION}")
206
+ return 0
207
+ if sub not in SUBCOMMANDS:
208
+ print(f"unknown subcommand '{sub}'", file=sys.stderr)
209
+ print(f"Run 'python3 work_plan.py --help' for usage.", file=sys.stderr)
210
+ return 2
211
+ try:
212
+ module = __import__(SUBCOMMANDS[sub], fromlist=["run"])
213
+ except ImportError as e:
214
+ print(f"subcommand '{sub}' not implemented yet ({e})", file=sys.stderr)
215
+ return 1
216
+ return module.run(argv[2:])
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main(sys.argv))