@stylusnexus/work-plan 2026.6.11 → 2026.6.13-2
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/README.md +38 -6
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +3 -0
- package/skills/work-plan/commands/auth_status.py +35 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +72 -3
- package/skills/work-plan/commands/group.py +5 -1
- package/skills/work-plan/commands/init_repo.py +84 -14
- package/skills/work-plan/commands/list_open_issues.py +52 -0
- package/skills/work-plan/commands/new_track.py +8 -2
- package/skills/work-plan/commands/plan_ack.py +71 -0
- package/skills/work-plan/commands/plan_baseline.py +85 -0
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +140 -9
- package/skills/work-plan/commands/push_track.py +156 -0
- package/skills/work-plan/commands/reconcile.py +49 -34
- package/skills/work-plan/commands/refresh_md.py +49 -1
- package/skills/work-plan/commands/remove_repo.py +69 -0
- package/skills/work-plan/commands/set_field.py +22 -3
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/github_state.py +63 -0
- package/skills/work-plan/lib/manifest.py +28 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_auth_status.py +98 -0
- package/skills/work-plan/tests/test_close_issue.py +121 -0
- package/skills/work-plan/tests/test_export.py +65 -0
- package/skills/work-plan/tests/test_export_command.py +95 -0
- package/skills/work-plan/tests/test_init_repo.py +100 -1
- package/skills/work-plan/tests/test_list_open_issues.py +83 -0
- package/skills/work-plan/tests/test_manifest.py +30 -1
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_ack.py +104 -0
- package/skills/work-plan/tests/test_plan_baseline.py +86 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/tests/test_push_track.py +131 -0
- package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
- package/skills/work-plan/tests/test_refresh_md.py +75 -0
- package/skills/work-plan/tests/test_remove_repo.py +77 -0
- package/skills/work-plan/tests/test_set_field.py +60 -0
- package/skills/work-plan/work_plan.py +125 -6
|
@@ -16,6 +16,8 @@ def _track(name, repo, issues, *, has_frontmatter=True, status="active"):
|
|
|
16
16
|
name=name,
|
|
17
17
|
repo=repo,
|
|
18
18
|
tier="private",
|
|
19
|
+
path=Path(f"/tmp/notes/{name}.md"),
|
|
20
|
+
folder="myrepo",
|
|
19
21
|
has_frontmatter=has_frontmatter,
|
|
20
22
|
meta={
|
|
21
23
|
"status": status,
|
|
@@ -71,6 +73,23 @@ class ExportRunJsonTest(unittest.TestCase):
|
|
|
71
73
|
self.assertEqual(rc, 0)
|
|
72
74
|
self.assertEqual(out["schema"], 1)
|
|
73
75
|
|
|
76
|
+
def test_track_file_path_is_emitted(self):
|
|
77
|
+
"""The export carries each track's .md path end-to-end (#211)."""
|
|
78
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
79
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
80
|
+
self.assertEqual(rc, 0)
|
|
81
|
+
# str(Path(...)) so the expected separator matches the platform (Windows
|
|
82
|
+
# backslashes). The path is whatever os.sep the fixture's Path produces.
|
|
83
|
+
self.assertEqual(out["tracks"][0]["path"], str(Path("/tmp/notes/alpha.md")))
|
|
84
|
+
|
|
85
|
+
def test_track_folder_key_is_emitted(self):
|
|
86
|
+
"""The export carries each track's config folder key end-to-end for the
|
|
87
|
+
Plans view's `plan-status --repo=<key>` arg (#164)."""
|
|
88
|
+
tracks = [_track("alpha", _SHARED_REPO, [1])]
|
|
89
|
+
rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
|
|
90
|
+
self.assertEqual(rc, 0)
|
|
91
|
+
self.assertEqual(out["tracks"][0]["folder"], "myrepo")
|
|
92
|
+
|
|
74
93
|
def test_track_issues_assembled_in_declared_order(self):
|
|
75
94
|
# Issues are milestone-sorted (#101): null-milestone group sorts by number.
|
|
76
95
|
tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
|
|
@@ -291,5 +310,81 @@ class ExportCommandGateTest(unittest.TestCase):
|
|
|
291
310
|
self.assertEqual(export_cmd.run([]), 2)
|
|
292
311
|
|
|
293
312
|
|
|
313
|
+
class ExportPlanBadgeTest(unittest.TestCase):
|
|
314
|
+
"""`_plan_badge` resolves a track's declared plan link into an execution
|
|
315
|
+
badge using the same evaluator as plan-status (#285). Real temp repo so the
|
|
316
|
+
manifest/checkbox/frontmatter read is exercised; git date is mocked."""
|
|
317
|
+
|
|
318
|
+
import tempfile as _tempfile
|
|
319
|
+
|
|
320
|
+
def _repo_with_plan(self, d, plan_text):
|
|
321
|
+
root = Path(d)
|
|
322
|
+
(root / "docs/plans").mkdir(parents=True)
|
|
323
|
+
(root / "docs/plans/p.md").write_text(plan_text)
|
|
324
|
+
(root / "src").mkdir()
|
|
325
|
+
(root / "src/new.ts").write_text("export const x = 1") # 1/1 declared present
|
|
326
|
+
return root
|
|
327
|
+
|
|
328
|
+
def _track_with_plan(self, rel="docs/plans/p.md", folder="demo"):
|
|
329
|
+
return SimpleNamespace(
|
|
330
|
+
name="alpha", repo="o/r", tier="private", folder=folder,
|
|
331
|
+
path=Path("/tmp/notes/alpha.md"), has_frontmatter=True,
|
|
332
|
+
meta={"status": "active", "plan": rel, "github": {"repo": "o/r", "issues": []}})
|
|
333
|
+
|
|
334
|
+
def _badge(self, track, root):
|
|
335
|
+
with patch("commands.export.resolve_local_path_for_folder", return_value=root), \
|
|
336
|
+
patch("commands.plan_status.git_state.path_last_commit_date", return_value=None):
|
|
337
|
+
from datetime import date
|
|
338
|
+
return export_cmd._plan_badge(track, {"notes_root": "/tmp"}, date(2026, 6, 13), 60, 14)
|
|
339
|
+
|
|
340
|
+
# A shipped-by-files plan with 0/2 boxes -> lie_gap unless overridden.
|
|
341
|
+
BODY = ("# P\n\n**Files:**\n- Create: `src/new.ts`\n- [ ] Step 1\n- [ ] Step 2\n")
|
|
342
|
+
|
|
343
|
+
def test_no_plan_returns_none(self):
|
|
344
|
+
t = self._track_with_plan()
|
|
345
|
+
t.meta.pop("plan")
|
|
346
|
+
self.assertIsNone(self._badge(t, Path("/tmp")))
|
|
347
|
+
|
|
348
|
+
def test_resolved_badge_with_lie_gap(self):
|
|
349
|
+
import tempfile
|
|
350
|
+
with tempfile.TemporaryDirectory() as d:
|
|
351
|
+
root = self._repo_with_plan(d, self.BODY)
|
|
352
|
+
badge = self._badge(self._track_with_plan(), root)
|
|
353
|
+
self.assertTrue(badge["resolved"])
|
|
354
|
+
self.assertEqual(badge["verdict"], "shipped")
|
|
355
|
+
self.assertEqual(badge["files_present"], 1)
|
|
356
|
+
self.assertEqual(badge["files_declared"], 1)
|
|
357
|
+
self.assertTrue(badge["lie_gap"])
|
|
358
|
+
self.assertIsNone(badge["override"])
|
|
359
|
+
|
|
360
|
+
def test_override_silences_lie_gap_in_badge(self):
|
|
361
|
+
import tempfile
|
|
362
|
+
with tempfile.TemporaryDirectory() as d:
|
|
363
|
+
root = self._repo_with_plan(d, f"---\nverdict_override: shipped\n---\n{self.BODY}")
|
|
364
|
+
badge = self._badge(self._track_with_plan(), root)
|
|
365
|
+
self.assertEqual(badge["override"], "shipped")
|
|
366
|
+
self.assertFalse(badge["lie_gap"])
|
|
367
|
+
|
|
368
|
+
def test_unresolved_when_no_local_clone(self):
|
|
369
|
+
t = self._track_with_plan()
|
|
370
|
+
with patch("commands.export.resolve_local_path_for_folder", return_value=None):
|
|
371
|
+
from datetime import date
|
|
372
|
+
badge = export_cmd._plan_badge(t, {"notes_root": "/tmp"}, date(2026, 6, 13), 60, 14)
|
|
373
|
+
self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
|
|
374
|
+
|
|
375
|
+
def test_unresolved_when_file_absent(self):
|
|
376
|
+
import tempfile
|
|
377
|
+
with tempfile.TemporaryDirectory() as d:
|
|
378
|
+
root = Path(d) # empty repo — declared plan file does not exist
|
|
379
|
+
badge = self._badge(self._track_with_plan(rel="docs/plans/missing.md"), root)
|
|
380
|
+
self.assertEqual(badge, {"rel": "docs/plans/missing.md", "resolved": False})
|
|
381
|
+
|
|
382
|
+
def test_no_folder_is_unresolved(self):
|
|
383
|
+
t = self._track_with_plan(folder=None)
|
|
384
|
+
from datetime import date
|
|
385
|
+
badge = export_cmd._plan_badge(t, {"notes_root": "/tmp"}, date(2026, 6, 13), 60, 14)
|
|
386
|
+
self.assertEqual(badge, {"rel": "docs/plans/p.md", "resolved": False})
|
|
387
|
+
|
|
388
|
+
|
|
294
389
|
if __name__ == "__main__":
|
|
295
390
|
unittest.main()
|
|
@@ -136,12 +136,55 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
|
136
136
|
# ------------------------------------------------------------------
|
|
137
137
|
|
|
138
138
|
def test_repo_already_exists_returns_rc1(self):
|
|
139
|
-
"""Key already in config.repos → rc 1, yq NOT called
|
|
139
|
+
"""Key already in config.repos (no --update) → rc 1, yq NOT called,
|
|
140
|
+
and the message points at --update."""
|
|
140
141
|
existing = {"mykey": {"github": "org/myrepo", "local": None}}
|
|
141
142
|
rc, msub, out = _drive(["mykey", "--github=org/myrepo"], existing_repos=existing)
|
|
142
143
|
self.assertEqual(rc, 1)
|
|
143
144
|
msub.assert_not_called()
|
|
144
145
|
self.assertIn("already exists", out)
|
|
146
|
+
self.assertIn("--update", out)
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# --update on an existing key → updates its local path
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def test_update_existing_sets_local(self):
|
|
153
|
+
"""Existing key + --update --local=/new/path → yq merges local into the
|
|
154
|
+
existing block; rc 0; no 'already exists' error."""
|
|
155
|
+
existing = {"mykey": {"github": "org/myrepo"}}
|
|
156
|
+
rc, msub, out = _drive(
|
|
157
|
+
["mykey", "--github=org/myrepo", "--local=/new/path", "--update"],
|
|
158
|
+
existing_repos=existing,
|
|
159
|
+
)
|
|
160
|
+
self.assertEqual(rc, 0)
|
|
161
|
+
msub.assert_called_once()
|
|
162
|
+
yq_args = msub.call_args[0][0]
|
|
163
|
+
self.assertEqual(yq_args[0], "yq")
|
|
164
|
+
self.assertEqual(yq_args[1], "-i")
|
|
165
|
+
# Merge expression preserves other keys via `* env(...)`.
|
|
166
|
+
expr = yq_args[2]
|
|
167
|
+
self.assertIn(".repos.mykey", expr)
|
|
168
|
+
self.assertIn("env(WP_REPO_UPDATES)", expr)
|
|
169
|
+
updates = json.loads(msub.call_args.kwargs["env"]["WP_REPO_UPDATES"])
|
|
170
|
+
self.assertEqual(updates["local"], "/new/path")
|
|
171
|
+
self.assertIn("Updated", out)
|
|
172
|
+
self.assertNotIn("already exists", out)
|
|
173
|
+
|
|
174
|
+
def test_update_nonexistent_key_falls_back_to_add(self):
|
|
175
|
+
"""--update on a key NOT in config → behaves as a plain add (creates the
|
|
176
|
+
block via the add path), rc 0."""
|
|
177
|
+
rc, msub, out = _drive(
|
|
178
|
+
["mykey", "--github=org/myrepo", "--local=/some/path", "--update"],
|
|
179
|
+
existing_repos={},
|
|
180
|
+
)
|
|
181
|
+
self.assertEqual(rc, 0)
|
|
182
|
+
msub.assert_called_once()
|
|
183
|
+
expr = msub.call_args[0][0][2]
|
|
184
|
+
# Add path uses the assignment form, not the merge form.
|
|
185
|
+
self.assertEqual(expr, ".repos.mykey = env(WP_REPO_BLOCK)")
|
|
186
|
+
block = json.loads(msub.call_args.kwargs["env"]["WP_REPO_BLOCK"])
|
|
187
|
+
self.assertEqual(block["local"], "/some/path")
|
|
145
188
|
|
|
146
189
|
# ------------------------------------------------------------------
|
|
147
190
|
# No key → rc 2
|
|
@@ -153,6 +196,62 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
|
153
196
|
self.assertEqual(rc, 2)
|
|
154
197
|
msub.assert_not_called()
|
|
155
198
|
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# --clear-local: sets local to null on an existing key
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def test_clear_local_sets_local_null(self):
|
|
204
|
+
"""--update --clear-local on an existing key → yq merges {local: null};
|
|
205
|
+
rc 0; '✓ Cleared local path' printed."""
|
|
206
|
+
existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
|
|
207
|
+
rc, msub, out = _drive(
|
|
208
|
+
["mykey", "--update", "--clear-local"],
|
|
209
|
+
existing_repos=existing,
|
|
210
|
+
)
|
|
211
|
+
self.assertEqual(rc, 0)
|
|
212
|
+
msub.assert_called_once()
|
|
213
|
+
expr = msub.call_args[0][0][2]
|
|
214
|
+
self.assertIn(".repos.mykey", expr)
|
|
215
|
+
self.assertIn("env(WP_REPO_UPDATES)", expr)
|
|
216
|
+
updates = json.loads(msub.call_args.kwargs["env"]["WP_REPO_UPDATES"])
|
|
217
|
+
self.assertIn("local", updates)
|
|
218
|
+
self.assertIsNone(updates["local"])
|
|
219
|
+
# github not passed → not in the merge (other fields preserved by yq).
|
|
220
|
+
self.assertNotIn("github", updates)
|
|
221
|
+
self.assertIn("Cleared local path", out)
|
|
222
|
+
|
|
223
|
+
def test_clear_local_with_local_is_mutually_exclusive(self):
|
|
224
|
+
"""--clear-local + --local=<path> → rc 2, yq NOT called."""
|
|
225
|
+
existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
|
|
226
|
+
rc, msub, out = _drive(
|
|
227
|
+
["mykey", "--update", "--clear-local", "--local=/new/path"],
|
|
228
|
+
existing_repos=existing,
|
|
229
|
+
)
|
|
230
|
+
self.assertEqual(rc, 2)
|
|
231
|
+
msub.assert_not_called()
|
|
232
|
+
self.assertIn("mutually exclusive", out)
|
|
233
|
+
|
|
234
|
+
def test_clear_local_without_update_returns_rc2(self):
|
|
235
|
+
"""--clear-local without --update → rc 2, yq NOT called."""
|
|
236
|
+
existing = {"mykey": {"github": "org/myrepo", "local": "/old/path"}}
|
|
237
|
+
rc, msub, out = _drive(
|
|
238
|
+
["mykey", "--clear-local"],
|
|
239
|
+
existing_repos=existing,
|
|
240
|
+
)
|
|
241
|
+
self.assertEqual(rc, 2)
|
|
242
|
+
msub.assert_not_called()
|
|
243
|
+
self.assertIn("requires --update", out)
|
|
244
|
+
|
|
245
|
+
def test_clear_local_nonexistent_key_returns_rc1(self):
|
|
246
|
+
"""--update --clear-local on a key NOT in config → rc 1, yq NOT called."""
|
|
247
|
+
rc, msub, out = _drive(
|
|
248
|
+
["mykey", "--update", "--clear-local"],
|
|
249
|
+
existing_repos={},
|
|
250
|
+
)
|
|
251
|
+
self.assertEqual(rc, 1)
|
|
252
|
+
msub.assert_not_called()
|
|
253
|
+
self.assertIn("not found", out)
|
|
254
|
+
|
|
156
255
|
# ------------------------------------------------------------------
|
|
157
256
|
# Invalid key format → rc 2
|
|
158
257
|
# ------------------------------------------------------------------
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Tests for the list-open-issues subcommand (#282).
|
|
2
|
+
|
|
3
|
+
Mocks fetch_open_issues — runs offline, never touches the network.
|
|
4
|
+
"""
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import unittest
|
|
9
|
+
from contextlib import redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
|
+
|
|
16
|
+
from commands import list_open_issues
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _row(number, title="t", state="OPEN", logins=(), milestone=None):
|
|
20
|
+
"""A raw gh issue row as fetch_open_issues returns."""
|
|
21
|
+
d = {"number": number, "title": title, "state": state,
|
|
22
|
+
"assignees": [{"login": l} for l in logins]}
|
|
23
|
+
if milestone:
|
|
24
|
+
d["milestone"] = {"title": milestone}
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run(args, rows):
|
|
29
|
+
with patch("commands.list_open_issues.fetch_open_issues", return_value=rows):
|
|
30
|
+
buf = io.StringIO()
|
|
31
|
+
with redirect_stdout(buf):
|
|
32
|
+
rc = list_open_issues.run(args)
|
|
33
|
+
out = buf.getvalue()
|
|
34
|
+
try:
|
|
35
|
+
parsed = json.loads(out)
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
parsed = None
|
|
38
|
+
return rc, parsed
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ListOpenIssuesTest(unittest.TestCase):
|
|
42
|
+
def test_emits_repo_and_normalized_issues(self):
|
|
43
|
+
rows = [_row(91, "Rate-limit login", "OPEN", ["eve"], "v0.6"),
|
|
44
|
+
_row(87, "Fix auth", "OPEN")]
|
|
45
|
+
rc, out = _run(["--repo=o/r"], rows)
|
|
46
|
+
self.assertEqual(rc, 0)
|
|
47
|
+
self.assertEqual(out["repo"], "o/r")
|
|
48
|
+
# Same Issue shape as the export (number/title/state/assignee/milestone).
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
out["issues"][0],
|
|
51
|
+
{"number": 91, "title": "Rate-limit login", "state": "open",
|
|
52
|
+
"assignee": "@eve", "milestone": "v0.6"},
|
|
53
|
+
)
|
|
54
|
+
self.assertEqual(out["issues"][1],
|
|
55
|
+
{"number": 87, "title": "Fix auth", "state": "open",
|
|
56
|
+
"assignee": "—", "milestone": None})
|
|
57
|
+
|
|
58
|
+
def test_exclude_filters_given_numbers(self):
|
|
59
|
+
rows = [_row(1), _row(2), _row(3)]
|
|
60
|
+
rc, out = _run(["--repo=o/r", "--exclude=1,3"], rows)
|
|
61
|
+
self.assertEqual(rc, 0)
|
|
62
|
+
self.assertEqual([i["number"] for i in out["issues"]], [2])
|
|
63
|
+
|
|
64
|
+
def test_exclude_tolerates_blanks_and_nonnumeric(self):
|
|
65
|
+
rows = [_row(1), _row(2)]
|
|
66
|
+
rc, out = _run(["--repo=o/r", "--exclude=1, ,x,"], rows)
|
|
67
|
+
self.assertEqual(rc, 0)
|
|
68
|
+
self.assertEqual([i["number"] for i in out["issues"]], [2])
|
|
69
|
+
|
|
70
|
+
def test_missing_repo_is_usage_error(self):
|
|
71
|
+
rc, out = _run([], [])
|
|
72
|
+
self.assertEqual(rc, 2)
|
|
73
|
+
self.assertIn("error", out)
|
|
74
|
+
|
|
75
|
+
def test_empty_fetch_yields_empty_issue_list(self):
|
|
76
|
+
# fetch_open_issues returns [] on a bad/unreachable repo — not an error.
|
|
77
|
+
rc, out = _run(["--repo=o/r"], [])
|
|
78
|
+
self.assertEqual(rc, 0)
|
|
79
|
+
self.assertEqual(out, {"repo": "o/r", "issues": []})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
unittest.main()
|
|
@@ -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()
|
|
@@ -308,5 +308,82 @@ class DispatcherHookTest(unittest.TestCase):
|
|
|
308
308
|
self.assertIsNone(work_plan._notes_precommit_state("slot"))
|
|
309
309
|
|
|
310
310
|
|
|
311
|
+
class SharedDispatcherHookTest(unittest.TestCase):
|
|
312
|
+
"""The shared-tier (#260) auto-commit hook mirrors the notes one: snapshot
|
|
313
|
+
each plan_branch repo's dirty .work-plan/ paths BEFORE the command, then
|
|
314
|
+
commit only that repo's delta — never another repo's pre-existing edits.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _cfg_two():
|
|
319
|
+
return {"notes_root": "/tmp/notes", "repos": {
|
|
320
|
+
"alpha": {"github": "o/alpha", "local": "/tmp/alpha", "plan_branch": "wp-plan"},
|
|
321
|
+
"beta": {"github": "o/beta", "local": "/tmp/beta", "plan_branch": "wp-plan"},
|
|
322
|
+
"gamma": {"github": "o/gamma", "local": "/tmp/gamma"}, # legacy, no plan_branch
|
|
323
|
+
}}
|
|
324
|
+
|
|
325
|
+
def test_commits_only_the_touched_repo(self):
|
|
326
|
+
cfg = self._cfg_two()
|
|
327
|
+
# alpha gains a file; beta has a pre-existing dirty file that must NOT
|
|
328
|
+
# be committed; gamma has no plan_branch so it's never visited. Keyed by
|
|
329
|
+
# Path.name (not str) so the test is OS path-separator agnostic.
|
|
330
|
+
dirty = {
|
|
331
|
+
"alpha": [[], [".work-plan/x.md"]], # before, after
|
|
332
|
+
"beta": [[".work-plan/stale.md"], [".work-plan/stale.md"]],
|
|
333
|
+
}
|
|
334
|
+
def fake_ensure(local, branch):
|
|
335
|
+
return Path(local) # use the repo's local path as the worktree handle
|
|
336
|
+
def fake_dirty(wt):
|
|
337
|
+
return dirty[Path(wt).name].pop(0)
|
|
338
|
+
commits = []
|
|
339
|
+
def fake_commit(wt, msg, paths):
|
|
340
|
+
commits.append((Path(wt).name, tuple(paths)))
|
|
341
|
+
return "deadbee"
|
|
342
|
+
with patch("lib.config.load_config", return_value=cfg), \
|
|
343
|
+
patch("lib.plan_worktree.ensure_worktree", side_effect=fake_ensure), \
|
|
344
|
+
patch("lib.plan_worktree.dirty_work_plan_paths", side_effect=fake_dirty), \
|
|
345
|
+
patch("lib.plan_worktree.commit_shared_tier", side_effect=fake_commit):
|
|
346
|
+
err = io.StringIO()
|
|
347
|
+
with redirect_stderr(err):
|
|
348
|
+
pre = work_plan._shared_precommit_state("slot")
|
|
349
|
+
work_plan._commit_shared_writes(pre, ["slot", "1", "x"])
|
|
350
|
+
out = err.getvalue()
|
|
351
|
+
# Only alpha committed, and only its new path.
|
|
352
|
+
self.assertEqual(commits, [("alpha", (".work-plan/x.md",))])
|
|
353
|
+
self.assertIn("alpha", out)
|
|
354
|
+
self.assertNotIn("beta", out)
|
|
355
|
+
|
|
356
|
+
def test_skips_read_only_command(self):
|
|
357
|
+
with patch("lib.config.load_config", return_value=self._cfg_two()) as mload:
|
|
358
|
+
self.assertIsNone(work_plan._shared_precommit_state("brief"))
|
|
359
|
+
mload.assert_not_called()
|
|
360
|
+
|
|
361
|
+
def test_none_when_no_plan_branch_repos(self):
|
|
362
|
+
cfg = {"notes_root": "/tmp/n", "repos": {
|
|
363
|
+
"gamma": {"github": "o/gamma", "local": "/tmp/gamma"}}}
|
|
364
|
+
with patch("lib.config.load_config", return_value=cfg):
|
|
365
|
+
self.assertIsNone(work_plan._shared_precommit_state("slot"))
|
|
366
|
+
|
|
367
|
+
def test_precommit_never_raises_on_failure(self):
|
|
368
|
+
with patch("lib.config.load_config", side_effect=RuntimeError("boom")):
|
|
369
|
+
self.assertIsNone(work_plan._shared_precommit_state("slot"))
|
|
370
|
+
|
|
371
|
+
def test_warns_when_changes_present_but_commit_refused(self):
|
|
372
|
+
cfg = {"notes_root": "/tmp/n", "repos": {
|
|
373
|
+
"alpha": {"github": "o/alpha", "local": "/tmp/alpha", "plan_branch": "wp"}}}
|
|
374
|
+
seq = {"alpha": [[], [".work-plan/x.md"]]}
|
|
375
|
+
with patch("lib.config.load_config", return_value=cfg), \
|
|
376
|
+
patch("lib.plan_worktree.ensure_worktree",
|
|
377
|
+
side_effect=lambda l, b: Path(l)), \
|
|
378
|
+
patch("lib.plan_worktree.dirty_work_plan_paths",
|
|
379
|
+
side_effect=lambda wt: seq[Path(wt).name].pop(0)), \
|
|
380
|
+
patch("lib.plan_worktree.commit_shared_tier", return_value=None):
|
|
381
|
+
err = io.StringIO()
|
|
382
|
+
with redirect_stderr(err):
|
|
383
|
+
pre = work_plan._shared_precommit_state("slot")
|
|
384
|
+
work_plan._commit_shared_writes(pre, ["slot", "1", "x"])
|
|
385
|
+
self.assertIn("NOT", err.getvalue())
|
|
386
|
+
|
|
387
|
+
|
|
311
388
|
if __name__ == "__main__":
|
|
312
389
|
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()
|