@stylusnexus/work-plan 2026.6.13 → 2026.6.14

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 (44) hide show
  1. package/README.md +19 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/brief.py +12 -0
  7. package/skills/work-plan/commands/close_issue.py +82 -0
  8. package/skills/work-plan/commands/export.py +70 -5
  9. package/skills/work-plan/commands/in_progress.py +110 -0
  10. package/skills/work-plan/commands/plan_ack.py +71 -0
  11. package/skills/work-plan/commands/plan_baseline.py +85 -0
  12. package/skills/work-plan/commands/plan_confirm.py +83 -0
  13. package/skills/work-plan/commands/plan_status.py +65 -1
  14. package/skills/work-plan/commands/push_track.py +156 -0
  15. package/skills/work-plan/commands/set_field.py +22 -3
  16. package/skills/work-plan/commands/where_was_i.py +30 -2
  17. package/skills/work-plan/lib/export_model.py +42 -5
  18. package/skills/work-plan/lib/git_state.py +32 -0
  19. package/skills/work-plan/lib/github_state.py +132 -4
  20. package/skills/work-plan/lib/in_progress.py +23 -0
  21. package/skills/work-plan/lib/manifest.py +18 -0
  22. package/skills/work-plan/lib/plan_fm.py +71 -0
  23. package/skills/work-plan/lib/render.py +5 -0
  24. package/skills/work-plan/lib/status_header.py +6 -2
  25. package/skills/work-plan/tests/test_auth_status.py +98 -0
  26. package/skills/work-plan/tests/test_close_issue.py +121 -0
  27. package/skills/work-plan/tests/test_export.py +161 -8
  28. package/skills/work-plan/tests/test_export_command.py +103 -0
  29. package/skills/work-plan/tests/test_git_state.py +38 -1
  30. package/skills/work-plan/tests/test_github_state.py +66 -0
  31. package/skills/work-plan/tests/test_in_progress.py +43 -0
  32. package/skills/work-plan/tests/test_in_progress_command.py +166 -0
  33. package/skills/work-plan/tests/test_list_open_issues.py +8 -3
  34. package/skills/work-plan/tests/test_manifest.py +30 -1
  35. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  36. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  37. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  38. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  39. package/skills/work-plan/tests/test_push_track.py +131 -0
  40. package/skills/work-plan/tests/test_register_in_progress.py +22 -0
  41. package/skills/work-plan/tests/test_render.py +48 -0
  42. package/skills/work-plan/tests/test_set_field.py +60 -0
  43. package/skills/work-plan/tests/test_where_was_i.py +80 -0
  44. package/skills/work-plan/work_plan.py +36 -1
@@ -0,0 +1,166 @@
1
+ """in-progress label write (#271). Offline — gh subprocess mocked."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout, redirect_stderr
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
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 lib import github_state
14
+
15
+
16
+ def _proc(rc, stdout="", stderr=""):
17
+ return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
18
+
19
+
20
+ class SetIssueInProgressHelperTest(unittest.TestCase):
21
+ def test_add_creates_label_then_adds_with_repo(self):
22
+ calls = []
23
+ def fake_run(args, **kw):
24
+ calls.append(args)
25
+ return _proc(0)
26
+ with mock.patch("lib.github_state.subprocess.run", side_effect=fake_run):
27
+ ok, msg = github_state.set_issue_in_progress("o/r", 271)
28
+ self.assertTrue(ok)
29
+ self.assertEqual(calls[0], [
30
+ "gh", "label", "create", "work-plan:in-progress", "--repo", "o/r",
31
+ "--color", "FBCA04", "--description", "Actively being worked (work-plan)",
32
+ "--force"])
33
+ self.assertEqual(calls[1], [
34
+ "gh", "issue", "edit", "271", "--repo", "o/r",
35
+ "--add-label", "work-plan:in-progress"])
36
+
37
+ def test_clear_removes_label_without_creating(self):
38
+ calls = []
39
+ with mock.patch("lib.github_state.subprocess.run",
40
+ side_effect=lambda args, **kw: calls.append(args) or _proc(0)):
41
+ ok, msg = github_state.set_issue_in_progress("o/r", 271, clear=True)
42
+ self.assertTrue(ok)
43
+ self.assertEqual(calls, [[
44
+ "gh", "issue", "edit", "271", "--repo", "o/r",
45
+ "--remove-label", "work-plan:in-progress"]])
46
+
47
+ def test_invalid_repo_rejected(self):
48
+ ok, msg = github_state.set_issue_in_progress("not-a-slug", 5)
49
+ self.assertFalse(ok)
50
+ self.assertIn("invalid repo", msg)
51
+
52
+ def test_gh_failure_surfaces_stderr(self):
53
+ with mock.patch("lib.github_state.subprocess.run",
54
+ return_value=_proc(1, stderr="no write access")):
55
+ ok, msg = github_state.set_issue_in_progress("o/r", 5)
56
+ self.assertFalse(ok)
57
+ self.assertIn("no write access", msg)
58
+
59
+ def test_never_raises(self):
60
+ with mock.patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
61
+ ok, msg = github_state.set_issue_in_progress("o/r", 5)
62
+ self.assertFalse(ok)
63
+
64
+
65
+ from commands import in_progress as inprog_cmd
66
+
67
+
68
+ def _track(name, repo, issues):
69
+ return SimpleNamespace(name=name, repo=repo, folder=name,
70
+ has_frontmatter=True,
71
+ meta={"github": {"issues": issues}, "track": name})
72
+
73
+
74
+ class InProgressCommandTest(unittest.TestCase):
75
+ def _drive(self, args, tracks, vis="PRIVATE", write_ret=(True, "ok")):
76
+ with mock.patch("commands.in_progress.load_config", return_value={"repos": {}}), \
77
+ mock.patch("commands.in_progress.discover_tracks", return_value=tracks), \
78
+ mock.patch("commands.in_progress.needs_confirm", return_value=(vis != "PRIVATE")), \
79
+ mock.patch("commands.in_progress.set_issue_in_progress",
80
+ return_value=write_ret) as mw:
81
+ out, err = io.StringIO(), io.StringIO()
82
+ with redirect_stdout(out), redirect_stderr(err):
83
+ rc = inprog_cmd.run(args)
84
+ return rc, out.getvalue(), err.getvalue(), mw
85
+
86
+ def test_marks_resolving_repo_from_single_track(self):
87
+ rc, out, err, mw = self._drive(["271"], [_track("alpha", "o/r", [271])])
88
+ self.assertEqual(rc, 0)
89
+ mw.assert_called_once_with("o/r", 271, clear=False)
90
+
91
+ def test_clear_flag(self):
92
+ rc, out, err, mw = self._drive(["271", "--clear"], [_track("alpha", "o/r", [271])])
93
+ self.assertEqual(rc, 0)
94
+ mw.assert_called_once_with("o/r", 271, clear=True)
95
+
96
+ def test_ambiguous_number_across_repos_rejected(self):
97
+ rc, out, err, mw = self._drive(
98
+ ["271"], [_track("a", "o/r1", [271]), _track("b", "o/r2", [271])])
99
+ self.assertEqual(rc, 1)
100
+ mw.assert_not_called()
101
+ self.assertIn("ambiguous", (out + err).lower())
102
+
103
+ def test_repo_flag_disambiguates(self):
104
+ rc, out, err, mw = self._drive(
105
+ ["271", "--repo=o/r2"],
106
+ [_track("a", "o/r1", [271]), _track("b", "o/r2", [271])])
107
+ self.assertEqual(rc, 0)
108
+ mw.assert_called_once_with("o/r2", 271, clear=False)
109
+
110
+ def test_public_repo_without_token_emits_needs_confirm(self):
111
+ rc, out, err, mw = self._drive(["271"], [_track("alpha", "o/r", [271])], vis="PUBLIC")
112
+ self.assertEqual(rc, 0)
113
+ self.assertIn("needs_confirm", out)
114
+ mw.assert_not_called()
115
+
116
+ def test_public_repo_with_valid_token_writes(self):
117
+ from lib.write_guard import make_token
118
+ token = make_token("o/r", "271")
119
+ rc, out, err, mw = self._drive(
120
+ [f"--confirm={token}", "271"], [_track("alpha", "o/r", [271])], vis="PUBLIC")
121
+ self.assertEqual(rc, 0)
122
+ mw.assert_called_once_with("o/r", 271, clear=False)
123
+
124
+ def test_non_integer_rejected(self):
125
+ rc, out, err, mw = self._drive(["abc"], [_track("alpha", "o/r", [271])])
126
+ self.assertEqual(rc, 2)
127
+ mw.assert_not_called()
128
+
129
+ def test_unresolvable_number_returns_1(self):
130
+ rc, out, err, mw = self._drive(["999"], [_track("alpha", "o/r", [271])])
131
+ self.assertEqual(rc, 1)
132
+ mw.assert_not_called()
133
+
134
+ # --- _resolve_repo --repo validation tests ---
135
+
136
+ def test_repo_flag_matching_tracked_repo_allowed(self):
137
+ """--repo=o/r2 when 271 is tracked in o/r2 → allowed (legit disambiguation)."""
138
+ rc, out, err, mw = self._drive(
139
+ ["271", "--repo=o/r2"],
140
+ [_track("a", "o/r1", []), _track("b", "o/r2", [271])])
141
+ self.assertEqual(rc, 0)
142
+ mw.assert_called_once_with("o/r2", 271, clear=False)
143
+
144
+ def test_repo_flag_pointing_to_untracked_repo_rejected(self):
145
+ """--repo=o/r2 when 271 is tracked only in o/r1 → rejected (typo guard)."""
146
+ rc, out, err, mw = self._drive(
147
+ ["271", "--repo=o/r2"],
148
+ [_track("a", "o/r1", [271]), _track("b", "o/r2", [])])
149
+ self.assertEqual(rc, 1)
150
+ mw.assert_not_called()
151
+ combined = (out + err).lower()
152
+ self.assertTrue(
153
+ "refusing" in combined or "not" in combined,
154
+ f"expected 'refusing' or 'not' in stderr/stdout, got: {out!r} {err!r}")
155
+
156
+ def test_repo_flag_for_issue_tracked_nowhere_allowed(self):
157
+ """--repo=o/r9 when 271 is not in any track → allowed (explicit target)."""
158
+ rc, out, err, mw = self._drive(
159
+ ["271", "--repo=o/r9"],
160
+ [_track("a", "o/r1", [99])])
161
+ self.assertEqual(rc, 0)
162
+ mw.assert_called_once_with("o/r9", 271, clear=False)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()
@@ -45,15 +45,20 @@ class ListOpenIssuesTest(unittest.TestCase):
45
45
  rc, out = _run(["--repo=o/r"], rows)
46
46
  self.assertEqual(rc, 0)
47
47
  self.assertEqual(out["repo"], "o/r")
48
- # Same Issue shape as the export (number/title/state/assignee/milestone).
48
+ # Same Issue shape as the export (number/title/state/assignee/milestone,
49
+ # plus the always-present in_progress flag — #271 keeps the two surfaces
50
+ # identical, so list-open-issues carries it too, default False here since
51
+ # this command has no track/branch context).
49
52
  self.assertEqual(
50
53
  out["issues"][0],
51
54
  {"number": 91, "title": "Rate-limit login", "state": "open",
52
- "assignee": "@eve", "milestone": "v0.6"},
55
+ "assignee": "@eve", "milestone": "v0.6", "in_progress": False,
56
+ "in_progress_label": False, "blocked_by": [], "blocking": []},
53
57
  )
54
58
  self.assertEqual(out["issues"][1],
55
59
  {"number": 87, "title": "Fix auth", "state": "open",
56
- "assignee": "—", "milestone": None})
60
+ "assignee": "—", "milestone": None, "in_progress": False,
61
+ "in_progress_label": False, "blocked_by": [], "blocking": []})
57
62
 
58
63
  def test_exclude_filters_given_numbers(self):
59
64
  rows = [_row(1), _row(2), _row(3)]
@@ -11,7 +11,7 @@ from lib.manifest import (
11
11
  DeclaredPath, strip_range, parse_declared_paths,
12
12
  count_checkboxes, plan_date_from_filename,
13
13
  ManifestScore, score_manifest,
14
- is_in_tree, out_of_tree_ratio,
14
+ is_in_tree, out_of_tree_ratio, offtree_declared_paths,
15
15
  )
16
16
 
17
17
 
@@ -158,5 +158,34 @@ class ScoreManifestTest(unittest.TestCase):
158
158
  self.assertIsNone(score.pct)
159
159
 
160
160
 
161
+ class OfftreeDeclaredPathsTest(unittest.TestCase):
162
+ ROOT = "/repo"
163
+
164
+ def _decls(self, *paths):
165
+ return [DeclaredPath(kind="create", path=p) for p in paths]
166
+
167
+ def test_in_tree_paths_are_not_flagged(self):
168
+ decls = self._decls("src/a.ts", "src/b.ts")
169
+ self.assertEqual(offtree_declared_paths(decls, self.ROOT), [])
170
+
171
+ def test_flags_absolute_tilde_and_escape(self):
172
+ decls = self._decls("src/ok.ts", "/etc/passwd", "~/secrets.txt", "../sibling/x.ts")
173
+ self.assertEqual(
174
+ offtree_declared_paths(decls, self.ROOT),
175
+ ["/etc/passwd", "~/secrets.txt", "../sibling/x.ts"],
176
+ )
177
+
178
+ def test_flags_junk_root_slash(self):
179
+ # The literal `/` #164's smoke caught — resolves to the filesystem root.
180
+ self.assertEqual(offtree_declared_paths(self._decls("/x/y"), self.ROOT), ["/x/y"])
181
+
182
+ def test_dedups_preserving_first_seen_order(self):
183
+ decls = self._decls("../x.ts", "src/ok.ts", "../x.ts")
184
+ self.assertEqual(offtree_declared_paths(decls, self.ROOT), ["../x.ts"])
185
+
186
+ def test_empty_decls(self):
187
+ self.assertEqual(offtree_declared_paths([], self.ROOT), [])
188
+
189
+
161
190
  if __name__ == "__main__":
162
191
  unittest.main()
@@ -0,0 +1,104 @@
1
+ """plan-ack (#286 slice 1): durable, frontmatter-only acknowledgment. Real temp
2
+ repo so the write round-trips through real yq; config + visibility are mocked."""
3
+ import io
4
+ import json
5
+ import unittest
6
+ import sys
7
+ import tempfile
8
+ from contextlib import redirect_stdout, redirect_stderr
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import plan_ack
16
+ from lib import frontmatter
17
+ from lib.write_guard import make_token
18
+
19
+ REL = "docs/superpowers/plans/p.md"
20
+ BODY = "# Plan\n\nbody the writer must never touch\n"
21
+
22
+
23
+ class PlanAckTest(unittest.TestCase):
24
+ def _repo(self, d, doc_text=BODY):
25
+ root = Path(d)
26
+ (root / "docs/superpowers/plans").mkdir(parents=True)
27
+ (root / REL).write_text(doc_text)
28
+ return root
29
+
30
+ def _drive(self, root, args, slug=None, vis="PRIVATE"):
31
+ cfg = {"notes_root": str(root), "repos": {}}
32
+ with mock.patch("commands.plan_ack.config_mod.load_config", return_value=cfg), \
33
+ mock.patch("commands.plan_ack.config_mod.resolve_local_path_for_folder",
34
+ return_value=root), \
35
+ mock.patch("commands.plan_ack.config_mod.resolve_github_for_folder",
36
+ return_value=slug), \
37
+ mock.patch("lib.write_guard.repo_visibility", return_value=vis):
38
+ out, err = io.StringIO(), io.StringIO()
39
+ with redirect_stdout(out), redirect_stderr(err):
40
+ rc = plan_ack.run(args)
41
+ return rc, out.getvalue(), err.getvalue()
42
+
43
+ def test_writes_acknowledged_preserving_body(self):
44
+ with tempfile.TemporaryDirectory() as d:
45
+ root = self._repo(d)
46
+ rc, out, err = self._drive(root, ["--repo=k", "--", REL])
47
+ self.assertEqual(rc, 0)
48
+ meta, body = frontmatter.parse_file(root / REL)
49
+ self.assertIs(meta["acknowledged"], True)
50
+ self.assertEqual(body, BODY)
51
+ self.assertIn("frontmatter only", out)
52
+
53
+ def test_clear_removes_acknowledged(self):
54
+ with tempfile.TemporaryDirectory() as d:
55
+ root = self._repo(d, f"---\nacknowledged: true\n---\n{BODY}")
56
+ rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
57
+ self.assertEqual(rc, 0)
58
+ meta, body = frontmatter.parse_file(root / REL)
59
+ self.assertNotIn("acknowledged", meta)
60
+ self.assertEqual(body, BODY)
61
+
62
+ def test_clear_when_absent_is_noop(self):
63
+ with tempfile.TemporaryDirectory() as d:
64
+ root = self._repo(d)
65
+ rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
66
+ self.assertEqual(rc, 0)
67
+ self.assertIn("nothing to clear", out)
68
+
69
+ def test_public_repo_no_token_returns_needs_confirm(self):
70
+ with tempfile.TemporaryDirectory() as d:
71
+ root = self._repo(d)
72
+ rc, out, err = self._drive(root, ["--repo=k", "--", REL],
73
+ slug="org/pub", vis="PUBLIC")
74
+ self.assertEqual(rc, 0)
75
+ data = json.loads(out)
76
+ self.assertTrue(data["needs_confirm"])
77
+ self.assertEqual(data["token"], make_token("org/pub", REL))
78
+ meta, _ = frontmatter.parse_file(root / REL)
79
+ self.assertNotIn("acknowledged", meta) # no write happened
80
+
81
+ def test_public_repo_with_valid_token_writes(self):
82
+ with tempfile.TemporaryDirectory() as d:
83
+ root = self._repo(d)
84
+ token = make_token("org/pub", REL)
85
+ rc, out, err = self._drive(root, ["--repo=k", f"--confirm={token}", "--", REL],
86
+ slug="org/pub", vis="PUBLIC")
87
+ self.assertEqual(rc, 0)
88
+ meta, _ = frontmatter.parse_file(root / REL)
89
+ self.assertIs(meta["acknowledged"], True)
90
+
91
+ def test_path_escape_rejected(self):
92
+ with tempfile.TemporaryDirectory() as d:
93
+ root = self._repo(d)
94
+ rc, out, err = self._drive(root, ["--repo=k", "--", "../../etc/passwd"])
95
+ self.assertEqual(rc, 1)
96
+ self.assertIn("not a file inside", err)
97
+
98
+ def test_missing_repo_flag_rejected(self):
99
+ rc, out, err = self._drive(Path("/tmp"), ["--", REL])
100
+ self.assertEqual(rc, 2)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ unittest.main()
@@ -0,0 +1,86 @@
1
+ """plan-baseline (#286 slice 2): stamp the computed verdict to frontmatter as a
2
+ drift baseline. Real temp repo (real yq); config + git date mocked."""
3
+ import io
4
+ import json
5
+ import unittest
6
+ import sys
7
+ import tempfile
8
+ from contextlib import redirect_stdout, redirect_stderr
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import plan_baseline
16
+ from lib import frontmatter
17
+ from lib.write_guard import make_token
18
+
19
+ REL = "docs/superpowers/plans/p.md"
20
+ # A plan whose one declared file exists → mechanical verdict "shipped".
21
+ BODY = "# Plan\n\n**Files:**\n- Create: `src/new.ts`\n- [ ] Step 1\n"
22
+
23
+
24
+ class PlanBaselineTest(unittest.TestCase):
25
+ def _repo(self, d, with_file=True, doc_text=BODY):
26
+ root = Path(d)
27
+ (root / "docs/superpowers/plans").mkdir(parents=True)
28
+ (root / REL).write_text(doc_text)
29
+ if with_file:
30
+ (root / "src").mkdir()
31
+ (root / "src/new.ts").write_text("export const x = 1")
32
+ return root
33
+
34
+ def _drive(self, root, args, slug=None, vis="PRIVATE"):
35
+ cfg = {"notes_root": str(root), "repos": {}}
36
+ with mock.patch("commands.plan_baseline.config_mod.load_config", return_value=cfg), \
37
+ mock.patch("commands.plan_baseline.config_mod.resolve_local_path_for_folder",
38
+ return_value=root), \
39
+ mock.patch("commands.plan_baseline.config_mod.resolve_github_for_folder",
40
+ return_value=slug), \
41
+ mock.patch("commands.plan_status.git_state.path_last_commit_date",
42
+ return_value=None), \
43
+ mock.patch("lib.write_guard.repo_visibility", return_value=vis):
44
+ out, err = io.StringIO(), io.StringIO()
45
+ with redirect_stdout(out), redirect_stderr(err):
46
+ rc = plan_baseline.run(args)
47
+ return rc, out.getvalue(), err.getvalue()
48
+
49
+ def test_stamps_computed_verdict(self):
50
+ with tempfile.TemporaryDirectory() as d:
51
+ root = self._repo(d) # file present → shipped
52
+ rc, out, err = self._drive(root, ["--repo=k", "--", REL])
53
+ self.assertEqual(rc, 0)
54
+ meta, body = frontmatter.parse_file(root / REL)
55
+ self.assertEqual(meta["verdict_baseline"], "shipped")
56
+ self.assertIn("shipped", out)
57
+
58
+ def test_clear_removes_baseline(self):
59
+ with tempfile.TemporaryDirectory() as d:
60
+ root = self._repo(d, doc_text=f"---\nverdict_baseline: shipped\n---\n{BODY}")
61
+ rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
62
+ self.assertEqual(rc, 0)
63
+ meta, _ = frontmatter.parse_file(root / REL)
64
+ self.assertNotIn("verdict_baseline", meta)
65
+
66
+ def test_public_repo_no_token_returns_needs_confirm(self):
67
+ with tempfile.TemporaryDirectory() as d:
68
+ root = self._repo(d)
69
+ rc, out, err = self._drive(root, ["--repo=k", "--", REL],
70
+ slug="org/pub", vis="PUBLIC")
71
+ self.assertEqual(rc, 0)
72
+ data = json.loads(out)
73
+ self.assertEqual(data["token"], make_token("org/pub", REL))
74
+ meta, _ = frontmatter.parse_file(root / REL)
75
+ self.assertNotIn("verdict_baseline", meta)
76
+
77
+ def test_path_escape_rejected(self):
78
+ with tempfile.TemporaryDirectory() as d:
79
+ root = self._repo(d)
80
+ rc, out, err = self._drive(root, ["--repo=k", "--", "../../etc/passwd"])
81
+ self.assertEqual(rc, 1)
82
+ self.assertIn("not a file inside", err)
83
+
84
+
85
+ if __name__ == "__main__":
86
+ unittest.main()
@@ -0,0 +1,109 @@
1
+ """plan-confirm (#286): frontmatter-only verdict_override writes, with the
2
+ public-repo confirm-token gate. Uses a real temp repo so the frontmatter write
3
+ round-trips through real yq; config + visibility are mocked (offline)."""
4
+ import io
5
+ import json
6
+ import unittest
7
+ import sys
8
+ import tempfile
9
+ from contextlib import redirect_stdout, redirect_stderr
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from commands import plan_confirm
17
+ from lib import frontmatter
18
+ from lib.write_guard import make_token
19
+
20
+ REL = "docs/superpowers/plans/p.md"
21
+ BODY = "# Idea Mode UI\n\nbody text the writer must never touch\n"
22
+
23
+
24
+ class PlanConfirmTest(unittest.TestCase):
25
+ def _repo(self, d, doc_text=BODY):
26
+ root = Path(d)
27
+ (root / "docs/superpowers/plans").mkdir(parents=True)
28
+ (root / REL).write_text(doc_text)
29
+ return root
30
+
31
+ def _drive(self, root, args, slug=None, vis="PRIVATE"):
32
+ cfg = {"notes_root": str(root), "repos": {}}
33
+ with mock.patch("commands.plan_confirm.config_mod.load_config", return_value=cfg), \
34
+ mock.patch("commands.plan_confirm.config_mod.resolve_local_path_for_folder",
35
+ return_value=root), \
36
+ mock.patch("commands.plan_confirm.config_mod.resolve_github_for_folder",
37
+ return_value=slug), \
38
+ mock.patch("lib.write_guard.repo_visibility", return_value=vis):
39
+ out, err = io.StringIO(), io.StringIO()
40
+ with redirect_stdout(out), redirect_stderr(err):
41
+ rc = plan_confirm.run(args)
42
+ # stdout carries the machine surfaces (needs_confirm JSON, success line);
43
+ # stderr carries usage/validation errors. Tests assert on either.
44
+ return rc, out.getvalue(), err.getvalue()
45
+
46
+ def test_writes_override_to_frontmatter_preserving_body(self):
47
+ with tempfile.TemporaryDirectory() as d:
48
+ root = self._repo(d)
49
+ rc, out, err = self._drive(root, ["--repo=k", "--verdict=shipped", "--", REL])
50
+ self.assertEqual(rc, 0)
51
+ meta, body = frontmatter.parse_file(root / REL)
52
+ self.assertEqual(meta["verdict_override"], "shipped")
53
+ self.assertEqual(body, BODY) # body byte-preserved
54
+ self.assertIn("frontmatter only", out)
55
+
56
+ def test_clear_removes_override(self):
57
+ with tempfile.TemporaryDirectory() as d:
58
+ root = self._repo(d, f"---\nverdict_override: shipped\n---\n{BODY}")
59
+ rc, out, err = self._drive(root, ["--repo=k", "--clear", "--", REL])
60
+ self.assertEqual(rc, 0)
61
+ meta, body = frontmatter.parse_file(root / REL)
62
+ self.assertNotIn("verdict_override", meta)
63
+ self.assertEqual(body, BODY)
64
+
65
+ def test_public_repo_no_token_returns_needs_confirm(self):
66
+ with tempfile.TemporaryDirectory() as d:
67
+ root = self._repo(d)
68
+ rc, out, err = self._drive(root, ["--repo=k", "--verdict=shipped", "--", REL],
69
+ slug="org/pub", vis="PUBLIC")
70
+ self.assertEqual(rc, 0)
71
+ data = json.loads(out)
72
+ self.assertTrue(data["needs_confirm"])
73
+ self.assertEqual(data["token"], make_token("org/pub", REL))
74
+ # NO write happened.
75
+ meta, _ = frontmatter.parse_file(root / REL)
76
+ self.assertNotIn("verdict_override", meta)
77
+
78
+ def test_public_repo_with_valid_token_writes(self):
79
+ with tempfile.TemporaryDirectory() as d:
80
+ root = self._repo(d)
81
+ token = make_token("org/pub", REL)
82
+ rc, out, err = self._drive(
83
+ root, ["--repo=k", "--verdict=shipped", f"--confirm={token}", "--", REL],
84
+ slug="org/pub", vis="PUBLIC")
85
+ self.assertEqual(rc, 0)
86
+ meta, _ = frontmatter.parse_file(root / REL)
87
+ self.assertEqual(meta["verdict_override"], "shipped")
88
+
89
+ def test_path_escape_rejected(self):
90
+ with tempfile.TemporaryDirectory() as d:
91
+ root = self._repo(d)
92
+ rc, out, err = self._drive(root, ["--repo=k", "--verdict=shipped", "--",
93
+ "../../etc/passwd"])
94
+ self.assertEqual(rc, 1)
95
+ self.assertIn("not a file inside", err)
96
+
97
+ def test_invalid_verdict_rejected(self):
98
+ with tempfile.TemporaryDirectory() as d:
99
+ root = self._repo(d)
100
+ rc, out, err = self._drive(root, ["--repo=k", "--verdict=done", "--", REL])
101
+ self.assertEqual(rc, 2)
102
+
103
+ def test_missing_repo_flag_rejected(self):
104
+ rc, out, err = self._drive(Path("/tmp"), ["--verdict=shipped", "--", REL])
105
+ self.assertEqual(rc, 2)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ unittest.main()