@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.
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- 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()
|