@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,61 @@
1
+ """--archive: previews under --draft; gated by confirmation otherwise (offline)."""
2
+ import io
3
+ import unittest
4
+ import sys
5
+ import tempfile
6
+ from datetime import datetime
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import plan_status
15
+
16
+ DEAD_PLAN = "# Dead Plan\n\n- Create: `src/never.ts`\n"
17
+
18
+
19
+ class ArchiveTest(unittest.TestCase):
20
+ def _repo(self, d):
21
+ root = Path(d)
22
+ (root / "docs/superpowers/plans").mkdir(parents=True)
23
+ self.rel = "docs/superpowers/plans/2026-01-01-dead.md"
24
+ (root / self.rel).write_text(DEAD_PLAN)
25
+ return root
26
+
27
+ def _run(self, root, args, mv_ok=True):
28
+ # stale last-commit (well beyond DEAD_DAYS) so the absent-file plan is dead
29
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
30
+ return_value=datetime(2026, 1, 1)), \
31
+ mock.patch("commands.plan_status.Path.cwd", return_value=root), \
32
+ mock.patch("commands.plan_status.git_state.git_mv",
33
+ return_value=mv_ok) as mv, \
34
+ mock.patch("commands.plan_status.prompt_yes_no", return_value=True):
35
+ buf = io.StringIO()
36
+ with redirect_stdout(buf):
37
+ rc = plan_status.run(args)
38
+ return rc, buf.getvalue(), mv
39
+
40
+ def test_draft_previews_without_moving(self):
41
+ with tempfile.TemporaryDirectory() as d:
42
+ root = self._repo(d)
43
+ rc, out, mv = self._run(root, ["--archive", "--draft"])
44
+ self.assertEqual(rc, 0)
45
+ self.assertIn("archive", out.lower())
46
+ mv.assert_not_called()
47
+
48
+ def test_apply_moves_after_confirm(self):
49
+ with tempfile.TemporaryDirectory() as d:
50
+ root = self._repo(d)
51
+ rc, out, mv = self._run(root, ["--archive"])
52
+ self.assertEqual(rc, 0)
53
+ mv.assert_called_once()
54
+ args = mv.call_args[0]
55
+ self.assertEqual(args[0], self.rel)
56
+ self.assertEqual(args[1],
57
+ "docs/superpowers/plans/archive/abandoned/2026-01-01-dead.md")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()
@@ -0,0 +1,55 @@
1
+ """A plan whose declared files live outside the repo -> foreign verdict."""
2
+ import io
3
+ import json
4
+ import unittest
5
+ import sys
6
+ import tempfile
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import plan_status
15
+
16
+ # All declared paths are ~/-rooted -> outside this repo.
17
+ FOREIGN_PLAN = (
18
+ "# Daily Work Planner\n\n"
19
+ "- Create: `~/.claude/skills/work-plan/work_plan.py`\n"
20
+ "- Create: `~/.claude/skills/work-plan/SKILL.md`\n"
21
+ )
22
+ # Declares in-repo src/ paths -> a real (partial) plan, NOT foreign.
23
+ LOCAL_PLAN = "# Real\n\n- Create: `src/here.ts`\n- Create: `src/gone.ts`\n"
24
+
25
+
26
+ class ForeignTest(unittest.TestCase):
27
+ def _repo(self, d):
28
+ root = Path(d)
29
+ (root / "docs/superpowers/plans").mkdir(parents=True)
30
+ (root / "docs/superpowers/plans/2026-04-28-daily-work-planner.md").write_text(FOREIGN_PLAN)
31
+ (root / "docs/superpowers/plans/2026-05-01-real.md").write_text(LOCAL_PLAN)
32
+ (root / "src").mkdir()
33
+ (root / "src/here.ts").write_text("x")
34
+ return root
35
+
36
+ def _json(self, root):
37
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
38
+ return_value=None), \
39
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
40
+ buf = io.StringIO()
41
+ with redirect_stdout(buf):
42
+ plan_status.run(["--json"])
43
+ return {r["rel"].split("/")[-1]: r for r in json.loads(buf.getvalue())["docs"]}
44
+
45
+ def test_out_of_tree_plan_is_foreign_local_is_not(self):
46
+ with tempfile.TemporaryDirectory() as d:
47
+ rows = self._json(self._repo(d))
48
+ self.assertEqual(rows["2026-04-28-daily-work-planner.md"]["verdict"], "foreign")
49
+ self.assertEqual(rows["2026-04-28-daily-work-planner.md"]["glyph"], "🧳")
50
+ # the in-repo plan is partial (1/2), not foreign
51
+ self.assertEqual(rows["2026-05-01-real.md"]["verdict"], "partial")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()
@@ -0,0 +1,61 @@
1
+ """--issues: previews under --draft; opens gh issues after confirm (offline)."""
2
+ import io
3
+ import unittest
4
+ import sys
5
+ import tempfile
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from unittest import mock
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import plan_status
14
+
15
+ # 1 of 2 files present -> partial.
16
+ PARTIAL_PLAN = "# Partial\n\n- Create: `src/here.ts`\n- Create: `src/gone.ts`\n"
17
+
18
+
19
+ class IssuesTest(unittest.TestCase):
20
+ def _repo(self, d):
21
+ root = Path(d)
22
+ (root / "docs/superpowers/plans").mkdir(parents=True)
23
+ (root / "docs/superpowers/plans/2026-05-01-partial.md").write_text(PARTIAL_PLAN)
24
+ (root / "src").mkdir()
25
+ (root / "src/here.ts").write_text("x") # gone.ts absent -> partial
26
+ return root
27
+
28
+ def _run(self, root, args, create_ret="https://github.com/o/r/issues/9"):
29
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
30
+ return_value=None), \
31
+ mock.patch("commands.plan_status._resolve_repo_root", return_value=root), \
32
+ mock.patch("commands.plan_status._repo_slug", return_value="o/r"), \
33
+ mock.patch("commands.plan_status.github_state.create_issue",
34
+ return_value=create_ret) as ci, \
35
+ mock.patch("commands.plan_status.prompt_yes_no", return_value=True):
36
+ buf = io.StringIO()
37
+ with redirect_stdout(buf):
38
+ rc = plan_status.run(args)
39
+ return rc, buf.getvalue(), ci
40
+
41
+ def test_draft_previews_without_creating(self):
42
+ with tempfile.TemporaryDirectory() as d:
43
+ root = self._repo(d)
44
+ rc, out, ci = self._run(root, ["--issues", "--draft", "--repo=critforge"])
45
+ self.assertEqual(rc, 0)
46
+ self.assertIn("gone.ts", out) # unsatisfied path shown in preview
47
+ ci.assert_not_called()
48
+
49
+ def test_apply_creates_issue_after_confirm(self):
50
+ with tempfile.TemporaryDirectory() as d:
51
+ root = self._repo(d)
52
+ rc, out, ci = self._run(root, ["--issues", "--repo=critforge"])
53
+ self.assertEqual(rc, 0)
54
+ ci.assert_called_once()
55
+ title, body = ci.call_args[0][1], ci.call_args[0][2]
56
+ self.assertIn("partial", body.lower())
57
+ self.assertIn("src/gone.ts", body)
58
+
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()
@@ -0,0 +1,71 @@
1
+ """--llm --apply: validates provenance, merges verdicts, optionally stamps."""
2
+ import io
3
+ import json
4
+ import unittest
5
+ import sys
6
+ import tempfile
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import plan_status
15
+
16
+ PROSE = "# Design Doc\n\nProse only.\n"
17
+
18
+
19
+ class LlmApplyTest(unittest.TestCase):
20
+ def _setup(self, d, cache, answers):
21
+ root = Path(d)
22
+ (root / "docs/superpowers/specs").mkdir(parents=True)
23
+ rel = "docs/superpowers/specs/2026-03-16-x-design.md"
24
+ (root / rel).write_text(PROSE)
25
+ batch = {"repo_root": str(root), "docs": [{"rel": rel}]}
26
+ (Path(cache) / "plan_status.json").write_text(json.dumps(batch))
27
+ (Path(cache) / "plan_status.answers.json").write_text(json.dumps(answers))
28
+ return root, rel
29
+
30
+ def _run(self, root, cache, args):
31
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
32
+ return_value=None), \
33
+ mock.patch("commands.plan_status.Path.cwd", return_value=root), \
34
+ mock.patch("commands.plan_status.cache_dir", return_value=Path(cache)):
35
+ buf = io.StringIO()
36
+ with redirect_stdout(buf):
37
+ rc = plan_status.run(args)
38
+ return rc, buf.getvalue()
39
+
40
+ def test_merges_verdict_into_report(self):
41
+ with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as cache:
42
+ rel = "docs/superpowers/specs/2026-03-16-x-design.md"
43
+ root, _ = self._setup(d, cache, [
44
+ {"rel": rel, "verdict": "shipped", "confidence": 0.9, "rationale": "done"}
45
+ ])
46
+ rc, out = self._run(root, cache, ["--llm", "--apply"])
47
+ self.assertEqual(rc, 0)
48
+ self.assertIn("shipped", out)
49
+ self.assertIn("done", out)
50
+
51
+ def test_rejects_rel_not_in_batch(self):
52
+ with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as cache:
53
+ root, rel = self._setup(d, cache, [
54
+ {"rel": "../evil.md", "verdict": "shipped", "confidence": 1, "rationale": "x"}
55
+ ])
56
+ rc, out = self._run(root, cache, ["--llm", "--apply"])
57
+ self.assertIn("skip", out.lower())
58
+
59
+ def test_rejects_repo_root_mismatch(self):
60
+ with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as cache:
61
+ root, rel = self._setup(d, cache, [])
62
+ bp = Path(cache) / "plan_status.json"
63
+ b = json.loads(bp.read_text()); b["repo_root"] = "/somewhere/else"
64
+ bp.write_text(json.dumps(b))
65
+ rc, out = self._run(root, cache, ["--llm", "--apply"])
66
+ self.assertEqual(rc, 1)
67
+ self.assertIn("repo_root", out.lower())
68
+
69
+
70
+ if __name__ == "__main__":
71
+ unittest.main()
@@ -0,0 +1,66 @@
1
+ """--llm step 1: writes a batch of candidate docs + prints a prompt."""
2
+ import io
3
+ import json
4
+ import unittest
5
+ import sys
6
+ import tempfile
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import plan_status
15
+
16
+ PROSE = "# Design Doc\n\nProse only, no file manifest here.\n"
17
+
18
+
19
+ class LlmPrepareTest(unittest.TestCase):
20
+ def _repo(self, d):
21
+ root = Path(d)
22
+ (root / "docs/superpowers/specs").mkdir(parents=True)
23
+ (root / "docs/superpowers/specs/2026-03-16-x-design.md").write_text(PROSE)
24
+ return root
25
+
26
+ def test_prepare_writes_batch_of_candidates(self):
27
+ with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as cache:
28
+ root = self._repo(d)
29
+ cache_file = Path(cache) / "plan_status.json"
30
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
31
+ return_value=None), \
32
+ mock.patch("commands.plan_status.Path.cwd", return_value=root), \
33
+ mock.patch("commands.plan_status.cache_dir", return_value=Path(cache)):
34
+ buf = io.StringIO()
35
+ with redirect_stdout(buf):
36
+ rc = plan_status.run(["--llm"])
37
+ self.assertEqual(rc, 0)
38
+ self.assertTrue(cache_file.exists())
39
+ batch = json.loads(cache_file.read_text())
40
+ self.assertEqual(batch["repo_root"], str(root))
41
+ rels = [d["rel"] for d in batch["docs"]]
42
+ self.assertIn("docs/superpowers/specs/2026-03-16-x-design.md", rels)
43
+ out = buf.getvalue()
44
+ self.assertIn("plan_status.answers.json", out)
45
+
46
+ def test_prepare_reports_when_no_candidates(self):
47
+ with tempfile.TemporaryDirectory() as d, tempfile.TemporaryDirectory() as cache:
48
+ root = Path(d)
49
+ (root / "docs/superpowers/plans").mkdir(parents=True)
50
+ (root / "docs/superpowers/plans/2026-01-01-done.md").write_text(
51
+ "# Done\n- Create: `src/a.py`\n")
52
+ (root / "src").mkdir()
53
+ (root / "src/a.py").write_text("x")
54
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
55
+ return_value=None), \
56
+ mock.patch("commands.plan_status.Path.cwd", return_value=root), \
57
+ mock.patch("commands.plan_status.cache_dir", return_value=Path(cache)):
58
+ buf = io.StringIO()
59
+ with redirect_stdout(buf):
60
+ rc = plan_status.run(["--llm"])
61
+ self.assertEqual(rc, 0)
62
+ self.assertIn("no docs need an llm verdict", buf.getvalue().lower())
63
+
64
+
65
+ if __name__ == "__main__":
66
+ unittest.main()
@@ -0,0 +1,70 @@
1
+ """Stamp / draft behaviour for plan-status (offline)."""
2
+ import io
3
+ import unittest
4
+ import sys
5
+ import tempfile
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from unittest import mock
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import plan_status
14
+ from lib.status_header import BEGIN
15
+
16
+ PLAN = (
17
+ "# Idea Mode Implementation Plan\n\n"
18
+ "**Files:**\n"
19
+ "- Create: `src/new.ts`\n"
20
+ "- Create: `src/missing.ts`\n"
21
+ "- [ ] Step 1\n"
22
+ )
23
+
24
+
25
+ class StampBehaviourTest(unittest.TestCase):
26
+ def _repo(self, d):
27
+ root = Path(d)
28
+ (root / "docs/superpowers/plans").mkdir(parents=True)
29
+ self.plan_path = root / "docs/superpowers/plans/2026-03-16-idea-mode-ui.md"
30
+ self.plan_path.write_text(PLAN)
31
+ (root / "src").mkdir()
32
+ (root / "src/new.ts").write_text("x")
33
+ return root
34
+
35
+ def _run(self, root, args):
36
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
37
+ return_value=None), \
38
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
39
+ buf = io.StringIO()
40
+ with redirect_stdout(buf):
41
+ rc = plan_status.run(args)
42
+ return rc, buf.getvalue()
43
+
44
+ def test_draft_does_not_write(self):
45
+ with tempfile.TemporaryDirectory() as d:
46
+ root = self._repo(d)
47
+ rc, out = self._run(root, ["--stamp", "--draft"])
48
+ self.assertEqual(rc, 0)
49
+ self.assertIn("would stamp", out)
50
+ self.assertNotIn(BEGIN, self.plan_path.read_text())
51
+
52
+ def test_stamp_writes_block(self):
53
+ with tempfile.TemporaryDirectory() as d:
54
+ root = self._repo(d)
55
+ rc, out = self._run(root, ["--stamp"])
56
+ self.assertEqual(rc, 0)
57
+ self.assertIn("stamped", out)
58
+ self.assertIn(BEGIN, self.plan_path.read_text())
59
+
60
+ def test_stamp_is_idempotent_on_disk(self):
61
+ with tempfile.TemporaryDirectory() as d:
62
+ root = self._repo(d)
63
+ self._run(root, ["--stamp"])
64
+ first = self.plan_path.read_text()
65
+ self._run(root, ["--stamp"])
66
+ self.assertEqual(first, self.plan_path.read_text())
67
+
68
+
69
+ if __name__ == "__main__":
70
+ unittest.main()
@@ -0,0 +1,38 @@
1
+ """Plugin manifest(s) parse, carry required fields, and match VERSION (CalVer).
2
+
3
+ Offline. The Codex manifest (.codex-plugin/plugin.json) lands in Phase 2; this
4
+ test tolerates its absence and only asserts equality when it exists.
5
+ """
6
+ import json
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[3]
11
+
12
+
13
+ def _load(rel):
14
+ return json.loads((REPO_ROOT / rel).read_text(encoding="utf-8"))
15
+
16
+
17
+ class ClaudeManifestTest(unittest.TestCase):
18
+ def test_required_fields(self):
19
+ m = _load(".claude-plugin/plugin.json")
20
+ self.assertEqual(m["name"], "work-plan")
21
+ self.assertTrue(m["description"])
22
+ self.assertEqual(m["license"], "MIT")
23
+
24
+ def test_version_matches_VERSION(self):
25
+ m = _load(".claude-plugin/plugin.json")
26
+ ver = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip()
27
+ self.assertEqual(m["version"], ver) # CalVer string, not semver
28
+
29
+ def test_codex_manifest_agrees_when_present(self):
30
+ codex = REPO_ROOT / ".codex-plugin" / "plugin.json"
31
+ if not codex.exists():
32
+ self.skipTest("Codex manifest is Phase 2")
33
+ self.assertEqual(_load(".codex-plugin/plugin.json")["version"],
34
+ _load(".claude-plugin/plugin.json")["version"])
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -0,0 +1,60 @@
1
+ """Tests for reconcile action selection + target/body construction + unsatisfied paths."""
2
+ import unittest
3
+ import sys
4
+ from datetime import date
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.manifest import DeclaredPath, unsatisfied_paths
11
+ from lib.reconcile_actions import dead_rows, partial_rows, archive_dest, issue_for
12
+
13
+
14
+ def _row(rel, verdict, present=0, declared=0):
15
+ return {"rel": rel, "verdict": verdict, "files_present": present,
16
+ "files_declared": declared, "glyph": "?", "rationale": ""}
17
+
18
+
19
+ class SelectionTest(unittest.TestCase):
20
+ def test_dead_and_partial_filters(self):
21
+ rows = [_row("a.md", "dead"), _row("b.md", "partial", 3, 9),
22
+ _row("c.md", "shipped", 9, 9)]
23
+ self.assertEqual([r["rel"] for r in dead_rows(rows)], ["a.md"])
24
+ self.assertEqual([r["rel"] for r in partial_rows(rows)], ["b.md"])
25
+
26
+
27
+ class ArchiveDestTest(unittest.TestCase):
28
+ def test_dest_under_archive_abandoned(self):
29
+ self.assertEqual(
30
+ archive_dest("docs/superpowers/plans/2026-01-01-x.md"),
31
+ "docs/superpowers/plans/archive/abandoned/2026-01-01-x.md")
32
+
33
+
34
+ class UnsatisfiedPathsTest(unittest.TestCase):
35
+ def test_returns_only_missing(self):
36
+ decls = [DeclaredPath("create", "src/here.ts"),
37
+ DeclaredPath("create", "src/gone.ts"),
38
+ DeclaredPath("modify", "src/old.ts")]
39
+ missing = unsatisfied_paths(
40
+ decls, Path("/repo"), date(2026, 3, 1),
41
+ exists=lambda rel: rel == "src/here.ts",
42
+ committed_since=lambda rel: False)
43
+ self.assertEqual({d.path for d in missing}, {"src/gone.ts", "src/old.ts"})
44
+
45
+
46
+ class IssueForTest(unittest.TestCase):
47
+ def test_title_and_body(self):
48
+ class Doc:
49
+ rel = "docs/superpowers/plans/2026-01-01-feature-x.md"
50
+ row = _row(Doc.rel, "partial", 2, 5)
51
+ missing = [DeclaredPath("create", "src/a.ts"), DeclaredPath("modify", "src/b.ts")]
52
+ title, body = issue_for(Doc(), row, missing)
53
+ self.assertIn("2026-01-01-feature-x", title)
54
+ self.assertIn("2/5", body)
55
+ self.assertIn("`src/a.ts`", body)
56
+ self.assertIn("`src/b.ts`", body)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ unittest.main()
@@ -0,0 +1,166 @@
1
+ """Read-only GitHub contract test for the reconcile subcommand.
2
+
3
+ The docstring at the top of commands/reconcile.py declares:
4
+ "reconcile only READS GitHub via `gh issue list`. It NEVER writes labels,
5
+ edits issues, or modifies remote state."
6
+
7
+ This test enforces that contract. Without it, the docstring is aspirational —
8
+ a future refactor could silently introduce a `gh issue edit` (or close, or
9
+ label, or comment) call and the existing test suite would still pass.
10
+
11
+ The test mocks subprocess.run, drives reconcile against a fake track, and
12
+ asserts every captured `gh` invocation matches an allowlist of read-only
13
+ verbs. It exercises both the default-label path (no `github.labels`
14
+ override) and the new override path from #32.
15
+ """
16
+ import json
17
+ import sys
18
+ import unittest
19
+ from pathlib import Path
20
+ from types import SimpleNamespace
21
+ from unittest.mock import MagicMock, patch
22
+
23
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
24
+ sys.path.insert(0, str(SKILL_ROOT))
25
+
26
+ from commands import reconcile
27
+
28
+ # Allowlist of `gh` subcommand pairs considered read-only.
29
+ # `gh api` is intentionally excluded — it can be GET or write depending on -X.
30
+ # If reconcile ever needs it, add a more specific check (require -X GET, etc.).
31
+ READ_ONLY_GH_VERBS = {
32
+ ("issue", "list"),
33
+ ("issue", "view"),
34
+ ("pr", "list"),
35
+ ("pr", "view"),
36
+ }
37
+
38
+
39
+ def _fake_track(*, slug, repo, labels=None, issues=None):
40
+ meta = {
41
+ "track": slug,
42
+ "status": "active",
43
+ "github": {"repo": repo, "issues": issues or []},
44
+ }
45
+ if labels is not None:
46
+ meta["github"]["labels"] = labels
47
+ return SimpleNamespace(
48
+ name=slug,
49
+ path=Path(f"/tmp/fake/{slug}.md"),
50
+ body="# fake",
51
+ meta=meta,
52
+ has_frontmatter=True,
53
+ repo=repo,
54
+ )
55
+
56
+
57
+ class ReadOnlyContractTest(unittest.TestCase):
58
+ def _drive(self, *, track, gh_response, user_choice, extra_args=None):
59
+ """Run reconcile.run against mocks; return (exit_code, captured_argvs, write_mock, prompt_mock)."""
60
+ captured = []
61
+
62
+ def fake_run(argv, *args, **kwargs):
63
+ captured.append(list(argv))
64
+ return MagicMock(returncode=0, stdout=json.dumps(gh_response), stderr="")
65
+
66
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/ok"}}}
67
+ # NOTE: find_track_by_name is intentionally NOT mocked. We let the real
68
+ # resolver run against the in-memory [track] list so a regression that
69
+ # broke the active-status filter (or the name-matching logic) would
70
+ # surface here — not just be silently bypassed by the mock.
71
+ with patch("commands.reconcile.subprocess.run", side_effect=fake_run), \
72
+ patch("commands.reconcile.load_config", return_value=cfg), \
73
+ patch("commands.reconcile.discover_tracks", return_value=[track]), \
74
+ patch("commands.reconcile.prompt_input", return_value=user_choice) as mock_prompt, \
75
+ patch("commands.reconcile.write_file") as mock_write:
76
+ args = [track.meta["track"]] + (extra_args or [])
77
+ rc = reconcile.run(args)
78
+ return rc, captured, mock_write, mock_prompt
79
+
80
+ def _assert_read_only(self, captured):
81
+ gh_calls = [a for a in captured if a and a[0] == "gh"]
82
+ self.assertGreater(len(gh_calls), 0,
83
+ "reconcile should have made at least one gh call")
84
+ for argv in gh_calls:
85
+ verb_pair = tuple(argv[1:3])
86
+ self.assertIn(
87
+ verb_pair, READ_ONLY_GH_VERBS,
88
+ f"reconcile invoked a non-read-only gh command: {' '.join(argv)}\n"
89
+ f"This violates the READ-ONLY GITHUB CONTRACT documented at the top "
90
+ f"of commands/reconcile.py. Writes must go through the local "
91
+ f"frontmatter file, never through gh.",
92
+ )
93
+
94
+ def test_default_label_path_is_read_only(self):
95
+ # Track without `github.labels` falls back to default `track/<slug>`.
96
+ track = _fake_track(slug="alpha", repo="ok/ok", labels=None, issues=[1, 2, 3])
97
+ gh_response = [
98
+ {"number": 1, "title": "one", "state": "OPEN"},
99
+ {"number": 4, "title": "four", "state": "OPEN"},
100
+ ]
101
+ rc, captured, mock_write, _ = self._drive(
102
+ track=track, gh_response=gh_response, user_choice="n",
103
+ )
104
+ self.assertEqual(rc, 0)
105
+ self._assert_read_only(captured)
106
+ mock_write.assert_not_called()
107
+
108
+ def test_label_override_path_is_read_only(self):
109
+ # Track WITH `github.labels` override (the new feature from #32).
110
+ # Each label produces a `gh issue list` AND a `gh pr list` — all must
111
+ # be read-only. PRs are queried so frontmatter entries pointing at
112
+ # labeled PRs aren't spuriously FLAGged.
113
+ track = _fake_track(slug="beta", repo="ok/ok",
114
+ labels=["storytelling", "campaigns"], issues=[10])
115
+ gh_response = [{"number": 10, "title": "x", "state": "OPEN"}]
116
+ rc, captured, mock_write, _ = self._drive(
117
+ track=track, gh_response=gh_response, user_choice="n",
118
+ )
119
+ self.assertEqual(rc, 0)
120
+ self._assert_read_only(captured)
121
+ # Two configured labels × two kinds (issue + pr) → four gh invocations
122
+ gh_calls = [a for a in captured if a and a[0] == "gh"]
123
+ self.assertEqual(len(gh_calls), 4,
124
+ f"expected one gh issue + one gh pr call per label, got {len(gh_calls)}")
125
+ kinds = sorted(c[1] for c in gh_calls)
126
+ self.assertEqual(kinds, ["issue", "issue", "pr", "pr"],
127
+ f"expected two issue + two pr calls, got {kinds}")
128
+
129
+ def test_user_accept_writes_local_file_only_not_gh(self):
130
+ # Even when the user accepts the proposed ADDs, the only write should
131
+ # be to the local frontmatter file via write_file — never via gh.
132
+ track = _fake_track(slug="gamma", repo="ok/ok", issues=[5])
133
+ gh_response = [
134
+ {"number": 5, "title": "x", "state": "OPEN"},
135
+ {"number": 99, "title": "new", "state": "OPEN"},
136
+ ]
137
+ rc, captured, mock_write, _ = self._drive(
138
+ track=track, gh_response=gh_response, user_choice="y",
139
+ )
140
+ self.assertEqual(rc, 0)
141
+ self._assert_read_only(captured)
142
+ mock_write.assert_called_once()
143
+
144
+ def test_draft_skips_user_prompt_and_write(self):
145
+ # --draft prints the analysis but never prompts and never writes.
146
+ # Even with proposed ADDs (so the report path is exercised), the user
147
+ # should not be interrupted and the local file should remain untouched.
148
+ # user_choice="y" would normally trigger a write — proves --draft
149
+ # short-circuits before the prompt is reached.
150
+ track = _fake_track(slug="delta", repo="ok/ok", issues=[5])
151
+ gh_response = [
152
+ {"number": 5, "title": "x", "state": "OPEN"},
153
+ {"number": 99, "title": "new", "state": "OPEN"},
154
+ ]
155
+ rc, captured, mock_write, mock_prompt = self._drive(
156
+ track=track, gh_response=gh_response,
157
+ user_choice="y", extra_args=["--draft"],
158
+ )
159
+ self.assertEqual(rc, 0)
160
+ self._assert_read_only(captured)
161
+ mock_prompt.assert_not_called()
162
+ mock_write.assert_not_called()
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()