@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,149 @@
1
+ """Tests for the next_up suggestion algorithm.
2
+
3
+ Covers the priority + recency sort, blocker exclusion, closed-issue filter,
4
+ top-N capping, and the `updatedAt`-missing fallback. The algorithm has one
5
+ home (lib/next_up.py) shared by handoff's --auto-next flag and brief's
6
+ next_up_auto: true frontmatter knob — so a regression here would surface
7
+ in both commands.
8
+ """
9
+ import sys
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from lib.next_up import suggest_next_up
17
+
18
+
19
+ def _issue(num, *, state="OPEN", priority=None, updated="2026-01-01T00:00:00Z",
20
+ title="", milestone=None):
21
+ """Build a minimal issue dict matching gh's --json output."""
22
+ labels = [{"name": f"priority/{priority}"}] if priority else []
23
+ ms_obj = {"title": milestone} if milestone else None
24
+ return {
25
+ "number": num, "state": state, "labels": labels,
26
+ "updatedAt": updated, "title": title or f"issue #{num}",
27
+ "milestone": ms_obj,
28
+ }
29
+
30
+
31
+ class SuggestNextUpTest(unittest.TestCase):
32
+ def test_empty_input_returns_empty(self):
33
+ self.assertEqual(suggest_next_up([], []), [])
34
+
35
+ def test_only_closed_returns_empty(self):
36
+ issues = [_issue(1, state="CLOSED", priority="P0"),
37
+ _issue(2, state="CLOSED", priority="P1")]
38
+ self.assertEqual(suggest_next_up(issues, []), [])
39
+
40
+ def test_priority_order(self):
41
+ # Same updatedAt across all — pure priority ranking. P0 < P1 < P2 < P3.
42
+ issues = [
43
+ _issue(3, priority="P3"),
44
+ _issue(0, priority="P0"),
45
+ _issue(2, priority="P2"),
46
+ _issue(1, priority="P1"),
47
+ ]
48
+ self.assertEqual(suggest_next_up(issues, []), [0, 1, 2]) # default n=3
49
+
50
+ def test_recency_within_priority_bucket(self):
51
+ # All P1 — most recently updated wins.
52
+ issues = [
53
+ _issue(10, priority="P1", updated="2026-01-01T00:00:00Z"),
54
+ _issue(20, priority="P1", updated="2026-04-30T00:00:00Z"), # newest
55
+ _issue(30, priority="P1", updated="2026-02-15T00:00:00Z"),
56
+ ]
57
+ self.assertEqual(suggest_next_up(issues, []), [20, 30, 10])
58
+
59
+ def test_priority_dominates_recency(self):
60
+ # P0 is older than P3 but still comes first.
61
+ issues = [
62
+ _issue(99, priority="P3", updated="2026-04-30T00:00:00Z"),
63
+ _issue(1, priority="P0", updated="2024-01-01T00:00:00Z"),
64
+ ]
65
+ self.assertEqual(suggest_next_up(issues, []), [1, 99])
66
+
67
+ def test_no_priority_label_defaults_to_p3(self):
68
+ # Unlabeled issues sort with P3, behind P0/P1/P2.
69
+ issues = [
70
+ _issue(50, priority=None, updated="2026-04-30T00:00:00Z"),
71
+ _issue(51, priority="P3", updated="2026-04-30T00:00:00Z"),
72
+ _issue(2, priority="P2"),
73
+ ]
74
+ result = suggest_next_up(issues, [])
75
+ # P2 first; the two P3 (one labeled, one defaulting) follow.
76
+ self.assertEqual(result[0], 2)
77
+ self.assertIn(50, result[1:])
78
+ self.assertIn(51, result[1:])
79
+
80
+ def test_blockers_excluded(self):
81
+ issues = [
82
+ _issue(1, priority="P0"),
83
+ _issue(2, priority="P1"),
84
+ _issue(3, priority="P2"),
85
+ ]
86
+ # #1 is blocked — should NOT appear, even though it's P0.
87
+ self.assertEqual(suggest_next_up(issues, [1]), [2, 3])
88
+
89
+ def test_top_n_caps_result(self):
90
+ issues = [_issue(i, priority="P0") for i in range(10)]
91
+ self.assertEqual(len(suggest_next_up(issues, [], n=2)), 2)
92
+ self.assertEqual(len(suggest_next_up(issues, [], n=5)), 5)
93
+
94
+ def test_default_n_is_3(self):
95
+ issues = [_issue(i, priority="P0") for i in range(10)]
96
+ self.assertEqual(len(suggest_next_up(issues, [])), 3)
97
+
98
+ def test_missing_updatedAt_treated_as_oldest(self):
99
+ # Within same priority, an issue without updatedAt should sort LAST.
100
+ issues = [
101
+ _issue(1, priority="P1", updated="2026-01-01T00:00:00Z"),
102
+ _issue(2, priority="P1", updated=""), # missing
103
+ _issue(3, priority="P1", updated="2026-04-30T00:00:00Z"),
104
+ ]
105
+ result = suggest_next_up(issues, [])
106
+ self.assertEqual(result[0], 3) # newest first
107
+ self.assertEqual(result[-1], 2) # missing-updated last
108
+
109
+ def test_track_milestone_aligned_outranks_other_milestone(self):
110
+ # Track is gated by v0.4.0. A P0 on v2.0.0 must sort BEHIND a P3 on v0.4.0
111
+ # because milestone alignment dominates priority — keeps post-launch
112
+ # work from polluting a launch-window auto-next.
113
+ issues = [
114
+ _issue(1, priority="P0", milestone="v2.0.0 — Post-Launch",
115
+ updated="2026-04-30T00:00:00Z"),
116
+ _issue(2, priority="P3", milestone="v0.4.0 — MVP",
117
+ updated="2026-01-01T00:00:00Z"),
118
+ ]
119
+ self.assertEqual(suggest_next_up(issues, [], track_milestone="v0.4.0"), [2, 1])
120
+
121
+ def test_track_milestone_unmilestoned_sorts_last(self):
122
+ # Items with no milestone fall behind any milestoned item.
123
+ issues = [
124
+ _issue(1, priority="P0", milestone=None),
125
+ _issue(2, priority="P3", milestone="v2.0.0"),
126
+ ]
127
+ self.assertEqual(suggest_next_up(issues, [], track_milestone="v0.4.0"), [2, 1])
128
+
129
+ def test_no_track_milestone_preserves_priority_order(self):
130
+ # Without a track milestone, behavior matches the legacy priority+recency sort:
131
+ # all milestone buckets collapse to "OTHER" so they tie, leaving priority to decide.
132
+ issues = [
133
+ _issue(1, priority="P0", milestone="v2.0.0"),
134
+ _issue(2, priority="P3", milestone="v0.4.0"),
135
+ ]
136
+ self.assertEqual(suggest_next_up(issues, []), [1, 2])
137
+
138
+ def test_unparsable_updatedAt_falls_back_gracefully(self):
139
+ # A garbage timestamp string should be treated like missing — not crash.
140
+ issues = [
141
+ _issue(1, priority="P0", updated="not-a-date"),
142
+ _issue(2, priority="P0", updated="2026-04-30T00:00:00Z"),
143
+ ]
144
+ result = suggest_next_up(issues, [])
145
+ self.assertEqual(result, [2, 1]) # parsable+newer wins; garbage trails
146
+
147
+
148
+ if __name__ == "__main__":
149
+ unittest.main()
@@ -0,0 +1,78 @@
1
+ """Tests for lib/notes_readme.py — seed_readme."""
2
+ import sys
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.notes_readme import seed_readme, README_CONTENT
11
+
12
+
13
+ class SeedReadmeTest(unittest.TestCase):
14
+
15
+ def setUp(self):
16
+ self._tmp = tempfile.TemporaryDirectory()
17
+ self.work_plan_dir = Path(self._tmp.name) / ".work-plan"
18
+ self.work_plan_dir.mkdir()
19
+
20
+ def tearDown(self):
21
+ self._tmp.cleanup()
22
+
23
+ def test_writes_readme_when_absent_returns_true(self):
24
+ """seed_readme writes README.md when it doesn't exist; returns True."""
25
+ result = seed_readme(self.work_plan_dir)
26
+ self.assertTrue(result)
27
+ readme = self.work_plan_dir / "README.md"
28
+ self.assertTrue(readme.exists())
29
+
30
+ def test_idempotent_existing_readme_returns_false(self):
31
+ """seed_readme skips when README.md already exists; returns False."""
32
+ readme = self.work_plan_dir / "README.md"
33
+ readme.write_text("existing content", encoding="utf-8")
34
+ result = seed_readme(self.work_plan_dir)
35
+ self.assertFalse(result)
36
+ # Content not overwritten
37
+ self.assertEqual(readme.read_text(encoding="utf-8"), "existing content")
38
+
39
+ def test_idempotent_second_call_returns_false(self):
40
+ """Calling seed_readme twice: first call True, second call False."""
41
+ first = seed_readme(self.work_plan_dir)
42
+ second = seed_readme(self.work_plan_dir)
43
+ self.assertTrue(first)
44
+ self.assertFalse(second)
45
+
46
+ def test_readme_content_contains_shared_tier(self):
47
+ """Written README contains 'shared tier'."""
48
+ seed_readme(self.work_plan_dir)
49
+ content = (self.work_plan_dir / "README.md").read_text(encoding="utf-8")
50
+ self.assertIn("shared tier", content)
51
+
52
+ def test_readme_content_contains_private_flag(self):
53
+ """Written README mentions '--private'."""
54
+ seed_readme(self.work_plan_dir)
55
+ content = (self.work_plan_dir / "README.md").read_text(encoding="utf-8")
56
+ self.assertIn("--private", content)
57
+
58
+ def test_readme_content_contains_work_plan_toolkit(self):
59
+ """Written README references 'work-plan-toolkit'."""
60
+ seed_readme(self.work_plan_dir)
61
+ content = (self.work_plan_dir / "README.md").read_text(encoding="utf-8")
62
+ self.assertIn("work-plan-toolkit", content)
63
+
64
+ def test_absent_readme_in_existing_folder_is_written(self):
65
+ """Caller's responsibility: seed_readme writes when README absent,
66
+ regardless of whether the dir is 'new' or 'existing'.
67
+ (The deletion-as-opt-out contract is enforced by callers who only
68
+ call seed_readme when creating a new directory, not by the function.)
69
+ """
70
+ # Simulate an existing dir that lost its README (from the function's POV,
71
+ # the file is just absent → it writes)
72
+ result = seed_readme(self.work_plan_dir)
73
+ self.assertTrue(result)
74
+ self.assertTrue((self.work_plan_dir / "README.md").exists())
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()
@@ -0,0 +1,68 @@
1
+ """Smoke + behavior test for the plan-status command (offline)."""
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
+ PLAN_BODY = (
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: do the thing\n"
22
+ "- [ ] Step 2: do the other thing\n"
23
+ )
24
+
25
+
26
+ class PlanStatusRunTest(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-03-16-idea-mode-ui.md").write_text(PLAN_BODY)
31
+ (root / "src").mkdir()
32
+ (root / "src/new.ts").write_text("export const x = 1") # 1 of 2 created
33
+ return root
34
+
35
+ def test_json_report_classifies_partial(self):
36
+ with tempfile.TemporaryDirectory() as d:
37
+ root = self._repo(d)
38
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
39
+ return_value=None), \
40
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
41
+ buf = io.StringIO()
42
+ with redirect_stdout(buf):
43
+ rc = plan_status.run(["--json"])
44
+ self.assertEqual(rc, 0)
45
+ data = json.loads(buf.getvalue())
46
+ self.assertEqual(len(data["docs"]), 1)
47
+ row = data["docs"][0]
48
+ self.assertEqual(row["files_present"], 1)
49
+ self.assertEqual(row["files_declared"], 2)
50
+ self.assertEqual(row["verdict"], "partial") # 50% -> partial
51
+
52
+ def test_human_report_runs(self):
53
+ with tempfile.TemporaryDirectory() as d:
54
+ root = self._repo(d)
55
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
56
+ return_value=None), \
57
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
58
+ buf = io.StringIO()
59
+ with redirect_stdout(buf):
60
+ rc = plan_status.run([])
61
+ self.assertEqual(rc, 0)
62
+ out = buf.getvalue()
63
+ self.assertIn("plan-status", out)
64
+ self.assertIn("partial", out)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
@@ -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()