@stylusnexus/work-plan 2026.6.16 → 2026.6.18

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.
@@ -0,0 +1,116 @@
1
+ """Tests for lib.membership_guard — the compare-and-swap guard (#241).
2
+
3
+ Covers:
4
+ - issues_fingerprint is order-independent and stable, and ignores non-issue
5
+ frontmatter (last_touched / body differences don't change it).
6
+ - guarded_membership_write merges add/remove onto the FRESH on-disk frontmatter.
7
+ - A concurrent body-only edit is preserved (the fresh body is written back).
8
+ - expect-match writes; expect-mismatch returns {stale} and does NOT write.
9
+ - expect=None never aborts (the manual single-writer path).
10
+
11
+ All file I/O is exercised against a real temp file (no yq mock needed — these go
12
+ through lib.frontmatter end to end), so the round-trip is real.
13
+ """
14
+ import sys
15
+ import unittest
16
+ from pathlib import Path
17
+ from tempfile import TemporaryDirectory
18
+
19
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
20
+ sys.path.insert(0, str(SKILL_ROOT))
21
+
22
+ from lib.frontmatter import parse_file, write_file
23
+ from lib.membership_guard import issues_fingerprint, guarded_membership_write
24
+
25
+
26
+ def _meta(issues, repo="ok/repo", **extra):
27
+ m = {"track": "alpha", "status": "active", "github": {"repo": repo, "issues": list(issues)}}
28
+ m.update(extra)
29
+ return m
30
+
31
+
32
+ class FingerprintTest(unittest.TestCase):
33
+
34
+ def test_order_independent(self):
35
+ self.assertEqual(
36
+ issues_fingerprint(_meta([3, 1, 2])),
37
+ issues_fingerprint(_meta([1, 2, 3])),
38
+ )
39
+
40
+ def test_changes_when_membership_changes(self):
41
+ self.assertNotEqual(
42
+ issues_fingerprint(_meta([1, 2])),
43
+ issues_fingerprint(_meta([1, 2, 3])),
44
+ )
45
+
46
+ def test_ignores_non_issue_frontmatter(self):
47
+ """last_touched / other fields must not affect the fingerprint, else the
48
+ guard would abort on unrelated concurrent edits."""
49
+ self.assertEqual(
50
+ issues_fingerprint(_meta([1, 2], last_touched="2026-01-01")),
51
+ issues_fingerprint(_meta([1, 2], last_touched="2026-06-17")),
52
+ )
53
+
54
+ def test_empty_and_missing_github_are_stable(self):
55
+ self.assertEqual(issues_fingerprint({}), issues_fingerprint(_meta([])))
56
+
57
+
58
+ class GuardedWriteTest(unittest.TestCase):
59
+
60
+ def setUp(self):
61
+ self._tmp = TemporaryDirectory()
62
+ self.path = Path(self._tmp.name) / "alpha.md"
63
+
64
+ def tearDown(self):
65
+ self._tmp.cleanup()
66
+
67
+ def _seed(self, issues, body="# body\n"):
68
+ write_file(self.path, _meta(issues), body)
69
+
70
+ def test_add_merges_onto_disk(self):
71
+ self._seed([10, 20])
72
+ res = guarded_membership_write(self.path, add_nums=[30])
73
+ self.assertEqual(res, {"written": [10, 20, 30]})
74
+ meta, _ = parse_file(self.path)
75
+ self.assertEqual(meta["github"]["issues"], [10, 20, 30])
76
+
77
+ def test_remove_merges_onto_disk(self):
78
+ self._seed([10, 20, 30])
79
+ res = guarded_membership_write(self.path, remove_nums=[20])
80
+ self.assertEqual(res, {"written": [10, 30]})
81
+
82
+ def test_preserves_concurrent_body_edit(self):
83
+ """Re-reads body at write time, so a body change made after the caller's
84
+ snapshot is preserved rather than clobbered."""
85
+ self._seed([10], body="# original\n")
86
+ # Simulate another process rewriting ONLY the body (e.g. handoff).
87
+ meta, _ = parse_file(self.path)
88
+ write_file(self.path, meta, "# rewritten by another writer\n")
89
+ guarded_membership_write(self.path, add_nums=[20])
90
+ _, body = parse_file(self.path)
91
+ self.assertIn("rewritten by another writer", body)
92
+
93
+ def test_expect_match_writes(self):
94
+ self._seed([10, 20])
95
+ fp = issues_fingerprint(_meta([10, 20]))
96
+ res = guarded_membership_write(self.path, add_nums=[30], expect=fp)
97
+ self.assertEqual(res, {"written": [10, 20, 30]})
98
+
99
+ def test_expect_mismatch_aborts_without_writing(self):
100
+ self._seed([10, 20])
101
+ stale_fp = issues_fingerprint(_meta([10])) # what the caller THOUGHT it saw
102
+ res = guarded_membership_write(self.path, add_nums=[30], expect=stale_fp)
103
+ self.assertTrue(res["stale"])
104
+ self.assertEqual(res["current"], [10, 20])
105
+ # Nothing was written — disk is unchanged.
106
+ meta, _ = parse_file(self.path)
107
+ self.assertEqual(meta["github"]["issues"], [10, 20])
108
+
109
+ def test_expect_none_never_aborts(self):
110
+ self._seed([10, 20])
111
+ res = guarded_membership_write(self.path, add_nums=[30], expect=None)
112
+ self.assertIn("written", res)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ unittest.main()
@@ -0,0 +1,154 @@
1
+ """Tests for the shared-tier rebase guard (#241 phase 2).
2
+
3
+ Covers:
4
+ - plan_worktree.rebase_onto_origin: clean rebase / nothing-to-do → True;
5
+ unpublished branch (no upstream) → True; conflict → abort + False;
6
+ git unavailable → False.
7
+ - membership_guard.shared_rebase_guard: private track → no-op; legacy shared
8
+ (no plan_branch) → no-op; shared + plan_branch clean rebase → ok; divergence →
9
+ (False, reason).
10
+ - slot integration: a shared track whose rebase diverges emits {needs_rebase}
11
+ and does NOT write.
12
+ """
13
+ import io
14
+ import json
15
+ import sys
16
+ import unittest
17
+ from contextlib import redirect_stdout
18
+ from pathlib import Path
19
+ from types import SimpleNamespace
20
+ from unittest.mock import MagicMock, patch
21
+
22
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
23
+ sys.path.insert(0, str(SKILL_ROOT))
24
+
25
+ from lib import plan_worktree
26
+ from lib.membership_guard import shared_rebase_guard
27
+
28
+
29
+ def _git_stub(*, rebase_rc=0, remote_exists=True, rebase_none=False):
30
+ """Build a fake plan_worktree._git dispatching on the git subcommand."""
31
+ calls = []
32
+
33
+ def _git(cwd, *args, **kw):
34
+ calls.append(tuple(args))
35
+ head = args[0]
36
+ if head == "fetch":
37
+ return MagicMock(returncode=0, stdout="", stderr="")
38
+ if head == "rev-parse":
39
+ # remote_branch_exists → refs/remotes/origin/<branch>
40
+ rc = 0 if remote_exists else 1
41
+ return MagicMock(returncode=rc, stdout="", stderr="")
42
+ if head == "rebase" and args[-1] != "--abort":
43
+ if rebase_none:
44
+ return None
45
+ return MagicMock(returncode=rebase_rc, stdout="", stderr="conflict")
46
+ if args == ("rebase", "--abort"):
47
+ return MagicMock(returncode=0, stdout="", stderr="")
48
+ return MagicMock(returncode=0, stdout="", stderr="")
49
+
50
+ return _git, calls
51
+
52
+
53
+ class RebaseOntoOriginTest(unittest.TestCase):
54
+
55
+ def test_clean_rebase_returns_true_with_autostash(self):
56
+ gitfn, calls = _git_stub(rebase_rc=0, remote_exists=True)
57
+ with patch("lib.plan_worktree._git", side_effect=gitfn):
58
+ self.assertTrue(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan"))
59
+ # --autostash so a dirty .work-plan/ (the normal write-then-commit flow)
60
+ # doesn't make the rebase refuse with a spurious needs_rebase.
61
+ self.assertIn(("rebase", "--autostash", "origin/work-plan/plan"), calls)
62
+ self.assertNotIn(("rebase", "--abort"), calls)
63
+
64
+ def test_unpublished_branch_returns_true_without_rebasing(self):
65
+ gitfn, calls = _git_stub(remote_exists=False)
66
+ with patch("lib.plan_worktree._git", side_effect=gitfn):
67
+ self.assertTrue(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan"))
68
+ self.assertNotIn(("rebase", "--autostash", "origin/work-plan/plan"), calls)
69
+
70
+ def test_conflict_aborts_and_returns_false(self):
71
+ gitfn, calls = _git_stub(rebase_rc=1, remote_exists=True)
72
+ with patch("lib.plan_worktree._git", side_effect=gitfn):
73
+ self.assertFalse(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan"))
74
+ # Conflict must leave the worktree clean.
75
+ self.assertIn(("rebase", "--abort"), calls)
76
+
77
+ def test_git_unavailable_returns_false(self):
78
+ gitfn, calls = _git_stub(rebase_none=True, remote_exists=True)
79
+ with patch("lib.plan_worktree._git", side_effect=gitfn):
80
+ self.assertFalse(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan"))
81
+
82
+
83
+ class SharedRebaseGuardTest(unittest.TestCase):
84
+
85
+ CFG = {"repos": {"ok": {"github": "ok/repo", "local": "/repo",
86
+ "plan_branch": "work-plan/plan"}}}
87
+
88
+ def _track(self, *, tier="shared", folder="ok", repo="ok/repo"):
89
+ return SimpleNamespace(name="alpha", tier=tier, folder=folder, repo=repo,
90
+ path=Path("/wt/.work-plan/alpha.md"))
91
+
92
+ def test_private_track_is_noop(self):
93
+ ok, reason = shared_rebase_guard(self._track(tier="private"), self.CFG)
94
+ self.assertTrue(ok)
95
+ self.assertIsNone(reason)
96
+
97
+ def test_legacy_shared_no_plan_branch_is_noop(self):
98
+ cfg = {"repos": {"ok": {"github": "ok/repo", "local": "/repo"}}} # no plan_branch
99
+ ok, reason = shared_rebase_guard(self._track(), cfg)
100
+ self.assertTrue(ok)
101
+
102
+ def test_shared_clean_rebase_ok(self):
103
+ with patch("lib.plan_worktree.ensure_worktree", return_value=Path("/wt")), \
104
+ patch("lib.plan_worktree.rebase_onto_origin", return_value=True):
105
+ ok, reason = shared_rebase_guard(self._track(), self.CFG)
106
+ self.assertTrue(ok)
107
+
108
+ def test_shared_divergence_blocks(self):
109
+ with patch("lib.plan_worktree.ensure_worktree", return_value=Path("/wt")), \
110
+ patch("lib.plan_worktree.rebase_onto_origin", return_value=False):
111
+ ok, reason = shared_rebase_guard(self._track(), self.CFG)
112
+ self.assertFalse(ok)
113
+ self.assertIn("diverged", reason)
114
+
115
+ def test_worktree_unavailable_degrades_to_proceed(self):
116
+ with patch("lib.plan_worktree.ensure_worktree", return_value=None):
117
+ ok, reason = shared_rebase_guard(self._track(), self.CFG)
118
+ self.assertTrue(ok)
119
+
120
+
121
+ class SlotNeedsRebaseTest(unittest.TestCase):
122
+
123
+ def test_slot_aborts_with_needs_rebase_json_no_write(self):
124
+ from commands import slot
125
+ track = SimpleNamespace(
126
+ name="alpha", tier="shared", folder="ok", repo="ok/repo",
127
+ path=Path("/wt/.work-plan/alpha.md"), body="# x",
128
+ meta={"track": "alpha", "status": "active",
129
+ "github": {"repo": "ok/repo", "issues": []}},
130
+ has_frontmatter=True,
131
+ )
132
+ cfg = {"notes_root": "/tmp/n",
133
+ "repos": {"ok": {"github": "ok/repo", "local": "/repo",
134
+ "plan_branch": "work-plan/plan"}}}
135
+ gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
136
+ with patch("commands.slot.load_config", return_value=cfg), \
137
+ patch("commands.slot.discover_tracks", return_value=[track]), \
138
+ patch("commands.slot.subprocess.run", return_value=gh_proc), \
139
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
140
+ patch("lib.plan_worktree.ensure_worktree", return_value=Path("/wt")), \
141
+ patch("lib.plan_worktree.rebase_onto_origin", return_value=False), \
142
+ patch("lib.membership_guard.write_file") as mw:
143
+ buf = io.StringIO()
144
+ with redirect_stdout(buf):
145
+ rc = slot.run(["30", "alpha"])
146
+ self.assertEqual(rc, 0)
147
+ mw.assert_not_called() # divergence → no write
148
+ data = json.loads(buf.getvalue().strip())
149
+ self.assertTrue(data["needs_rebase"])
150
+ self.assertEqual(data["track"], "alpha")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ unittest.main()
@@ -56,11 +56,22 @@ def _drive(args, tracks=None, vis="PRIVATE"):
56
56
  cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
57
57
  gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
58
58
 
59
+ # The write path now goes through lib.membership_guard, which re-reads the
60
+ # file (parse_file) and writes the merged result (write_file). Returning the
61
+ # track's own meta/body objects from parse_file lets the guard mutate them in
62
+ # place, so assertions on track.meta still observe the merge.
63
+ by_path = {str(t.path): t for t in tracks}
64
+
65
+ def fake_parse(p):
66
+ t = by_path[str(p)]
67
+ return (t.meta, t.body)
68
+
59
69
  with patch("commands.slot.load_config", return_value=cfg), \
60
70
  patch("commands.slot.discover_tracks", return_value=tracks), \
61
71
  patch("commands.slot.subprocess.run", return_value=gh_proc), \
62
72
  patch("lib.write_guard.repo_visibility", return_value=vis), \
63
- patch("commands.slot.write_file") as mw:
73
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
74
+ patch("lib.membership_guard.write_file") as mw:
64
75
  buf = io.StringIO()
65
76
  with redirect_stdout(buf):
66
77
  rc = slot.run(args)
@@ -223,6 +234,12 @@ class SlotNonInteractiveTest(unittest.TestCase):
223
234
  def _raise(*a, **kw):
224
235
  raise AssertionError("input() must not be called on non-interactive path")
225
236
 
237
+ by_path = {str(t.path): t for t in (source, target)}
238
+
239
+ def fake_parse(p):
240
+ t = by_path[str(p)]
241
+ return (t.meta, t.body)
242
+
226
243
  with patch("builtins.input", side_effect=_raise), \
227
244
  patch("lib.prompts.prompt_input", side_effect=_raise):
228
245
  cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
@@ -231,7 +248,8 @@ class SlotNonInteractiveTest(unittest.TestCase):
231
248
  patch("commands.slot.discover_tracks", return_value=[source, target]), \
232
249
  patch("commands.slot.subprocess.run", return_value=gh_proc), \
233
250
  patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
234
- patch("commands.slot.write_file"):
251
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
252
+ patch("lib.membership_guard.write_file"):
235
253
  buf = io.StringIO()
236
254
  with redirect_stdout(buf):
237
255
  # --move (prior owner path) + private repo (no confirm gate)
@@ -239,5 +257,79 @@ class SlotNonInteractiveTest(unittest.TestCase):
239
257
  self.assertEqual(rc, 0)
240
258
 
241
259
 
260
+ class SlotExpectGuardTest(unittest.TestCase):
261
+ """--expect compare-and-swap staleness guard (#241)."""
262
+
263
+ def test_expect_match_writes(self):
264
+ from lib.membership_guard import issues_fingerprint
265
+ track = _track(name="alpha", repo="ok/repo", issues=[10, 20])
266
+ fp = issues_fingerprint(track.meta)
267
+ rc, mw, out = _drive(["30", "alpha", f"--expect={fp}"],
268
+ tracks=[track], vis="PRIVATE")
269
+ self.assertEqual(rc, 0)
270
+ mw.assert_called_once()
271
+ self.assertIn(30, track.meta["github"]["issues"])
272
+
273
+ def test_expect_mismatch_aborts_with_stale_json(self):
274
+ import json
275
+ from lib.membership_guard import issues_fingerprint
276
+ track = _track(name="alpha", repo="ok/repo", issues=[10, 20])
277
+ stale_fp = issues_fingerprint({"github": {"issues": [10]}}) # what we thought
278
+ rc, mw, out = _drive(["30", "alpha", f"--expect={stale_fp}"],
279
+ tracks=[track], vis="PRIVATE")
280
+ self.assertEqual(rc, 0)
281
+ mw.assert_not_called()
282
+ data = json.loads(out.strip()) # stdout is pure JSON in --expect mode
283
+ self.assertTrue(data["stale"])
284
+ self.assertEqual(data["current"], [10, 20])
285
+ self.assertEqual(data["track"], "alpha")
286
+
287
+ def test_no_expect_never_aborts(self):
288
+ track = _track(name="alpha", repo="ok/repo", issues=[10, 20])
289
+ rc, mw, out = _drive(["30", "alpha"], tracks=[track], vis="PRIVATE")
290
+ self.assertEqual(rc, 0)
291
+ mw.assert_called_once()
292
+
293
+ def test_milestone_check_gh_missing_does_not_crash(self):
294
+ """The advisory `gh issue view` milestone check sits between the rebase
295
+ guard and the write — if gh is absent (FileNotFoundError) the slot must
296
+ still complete the write, not crash."""
297
+ track = _track(name="alpha", repo="ok/repo", issues=[10])
298
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
299
+ by_path = {str(track.path): track}
300
+
301
+ def fake_parse(p):
302
+ t = by_path[str(p)]
303
+ return (t.meta, t.body)
304
+
305
+ with patch("commands.slot.load_config", return_value=cfg), \
306
+ patch("commands.slot.discover_tracks", return_value=[track]), \
307
+ patch("commands.slot.subprocess.run", side_effect=FileNotFoundError("gh")), \
308
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
309
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
310
+ patch("lib.membership_guard.write_file") as mw:
311
+ buf = io.StringIO()
312
+ with redirect_stdout(buf):
313
+ rc = slot.run(["30", "alpha"])
314
+ self.assertEqual(rc, 0)
315
+ mw.assert_called_once() # write still happens despite the gh crash
316
+ self.assertIn(30, track.meta["github"]["issues"])
317
+
318
+ def test_confirm_then_stale_order(self):
319
+ """Public repo + valid confirm token + a stale --expect: the confirm gate
320
+ is satisfied first, then the staleness CAS still aborts at write time."""
321
+ import json
322
+ from lib.membership_guard import issues_fingerprint
323
+ track = _track(name="alpha", repo="ok/repo", issues=[10, 20])
324
+ tok = make_token("ok/repo", "alpha")
325
+ stale_fp = issues_fingerprint({"github": {"issues": [10]}})
326
+ rc, mw, out = _drive(["30", "alpha", f"--confirm={tok}", f"--expect={stale_fp}"],
327
+ tracks=[track], vis="PUBLIC")
328
+ self.assertEqual(rc, 0)
329
+ mw.assert_not_called()
330
+ data = json.loads(out.strip())
331
+ self.assertTrue(data["stale"])
332
+
333
+
242
334
  if __name__ == "__main__":
243
335
  unittest.main()
@@ -39,11 +39,20 @@ class SlotMoveTest(unittest.TestCase):
39
39
  cfg = {"notes_root": "/tmp/fake-notes",
40
40
  "repos": {"ok": {"github": "ok/ok"}}}
41
41
  gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
42
+ # Writes go through lib.membership_guard now; return each track's own
43
+ # meta/body from parse_file so the guard mutates them in place.
44
+ by_path = {str(t.path): t for t in tracks}
45
+
46
+ def fake_parse(p):
47
+ t = by_path[str(p)]
48
+ return (t.meta, t.body)
49
+
42
50
  with patch("commands.slot.subprocess.run", return_value=gh_proc), \
43
51
  patch("commands.slot.load_config", return_value=cfg), \
44
52
  patch("commands.slot.discover_tracks", return_value=tracks), \
45
53
  patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
46
- patch("commands.slot.write_file") as mw:
54
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
55
+ patch("lib.membership_guard.write_file") as mw:
47
56
  rc = slot.run(args)
48
57
  return rc, mw
49
58
 
@@ -129,8 +129,8 @@ DESCRIPTIONS = [
129
129
  "AI-cluster GitHub issues into thematic track files. --limit controls how many issues are shown in the prompt (default 100).",
130
130
  "ONE-TIME bulk organization of an unsorted milestone, or after a re-org.",
131
131
  "/work-plan group --milestone='v1.0.0 — Public Launch'"),
132
- ("auto-triage", "[--repo=<key>] [--apply] [--limit=N]",
133
- "AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist. --limit controls how many untracked issues are shown (default 100).",
132
+ ("auto-triage", "[--repo=<key>] [--apply] [--json] [--heuristic] [--limit=N]",
133
+ "AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints an AI prompt and writes a per-repo batch file stamped with a batch_id; --json emits that batch (+ prompt + answers path) as one JSON object on stdout for the VS Code viewer instead of the human prompt. --heuristic (#373) skips the LLM entirely: it scores each issue against the candidate tracks using local signals (milestone match, track-label overlap, title/scope keyword overlap), writes the v2 answers file itself (stamped source:\"heuristic\", abstain-first), so suggestions work with no Claude session — lower-trust, but offline. Step 2 (--apply [--repo=<key>]): reads the answers (v2 abstain-first shape preferred — only clear-margin suggestions are slotted; legacy v1 [{track,issues}] still accepted) and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist. --limit controls how many untracked issues are shown (default 100).",
134
134
  "Periodically — when new issues have piled up outside the track model. Run /work-plan coverage first to confirm there's a gap worth triaging.",
135
135
  "/work-plan auto-triage --repo=myproject"),
136
136
  ("reconcile", "<track> | --all | --repo=<key> [--draft] [--yes]",