@stylusnexus/work-plan 2026.6.15 → 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.
Files changed (39) hide show
  1. package/README.md +14 -12
  2. package/VERSION +1 -1
  3. package/package.json +1 -5
  4. package/skills/work-plan/SKILL.md +2 -0
  5. package/skills/work-plan/commands/auto_triage.py +187 -35
  6. package/skills/work-plan/commands/batch_slot.py +58 -33
  7. package/skills/work-plan/commands/brief.py +29 -3
  8. package/skills/work-plan/commands/dedupe_tiers.py +104 -0
  9. package/skills/work-plan/commands/export.py +29 -4
  10. package/skills/work-plan/commands/handoff.py +90 -26
  11. package/skills/work-plan/commands/hygiene.py +24 -9
  12. package/skills/work-plan/commands/set_next_up.py +64 -8
  13. package/skills/work-plan/commands/slot.py +53 -24
  14. package/skills/work-plan/commands/which_repo.py +52 -0
  15. package/skills/work-plan/lib/cwd_repo.py +133 -0
  16. package/skills/work-plan/lib/drift.py +6 -1
  17. package/skills/work-plan/lib/export_model.py +27 -4
  18. package/skills/work-plan/lib/heuristic_triage.py +134 -0
  19. package/skills/work-plan/lib/membership_guard.py +152 -0
  20. package/skills/work-plan/lib/plan_worktree.py +37 -0
  21. package/skills/work-plan/lib/tracks.py +64 -2
  22. package/skills/work-plan/tests/test_auto_triage.py +103 -0
  23. package/skills/work-plan/tests/test_batch_slot.py +19 -2
  24. package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
  25. package/skills/work-plan/tests/test_cwd_repo.py +164 -0
  26. package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
  27. package/skills/work-plan/tests/test_drift.py +32 -0
  28. package/skills/work-plan/tests/test_export.py +63 -0
  29. package/skills/work-plan/tests/test_export_command.py +56 -0
  30. package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
  31. package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
  32. package/skills/work-plan/tests/test_membership_guard.py +116 -0
  33. package/skills/work-plan/tests/test_register_which_repo.py +22 -0
  34. package/skills/work-plan/tests/test_set_next_up.py +95 -0
  35. package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
  36. package/skills/work-plan/tests/test_slot.py +94 -2
  37. package/skills/work-plan/tests/test_slot_move.py +10 -1
  38. package/skills/work-plan/work_plan.py +17 -7
  39. package/scripts/npm-check-deps.js +0 -44
@@ -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,22 @@
1
+ """which-repo is dispatchable + documented (#358/#357 Phase 1)."""
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ import work_plan
10
+
11
+
12
+ class RegisterWhichRepoTest(unittest.TestCase):
13
+ def test_in_subcommands(self):
14
+ self.assertEqual(work_plan.SUBCOMMANDS["which-repo"], "commands.which_repo")
15
+
16
+ def test_in_descriptions(self):
17
+ names = {row[0] for row in work_plan.DESCRIPTIONS}
18
+ self.assertIn("which-repo", names)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ unittest.main()
@@ -197,5 +197,100 @@ class SetNextUpTest(unittest.TestCase):
197
197
  self.assertIn("--order is ignored", err.getvalue())
198
198
 
199
199
 
200
+ class SetNextUpAutoFlagTest(unittest.TestCase):
201
+ """Tests for --auto=on|off flag on set-next-up."""
202
+
203
+ def _drive_with_stderr(self, args, vis="PRIVATE", cfg=None, track=None):
204
+ """Like _drive but also captures stderr."""
205
+ base_cfg = {"notes_root": "/tmp"}
206
+ if cfg is not None:
207
+ base_cfg.update(cfg)
208
+ t = track if track is not None else _t()
209
+ with patch("commands.set_next_up.load_config", return_value=base_cfg), \
210
+ patch("commands.set_next_up.discover_tracks", return_value=[t]), \
211
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
212
+ patch("commands.set_next_up.write_file") as mw:
213
+ out, err = io.StringIO(), io.StringIO()
214
+ with redirect_stdout(out), redirect_stderr(err):
215
+ rc = set_next_up.run(args)
216
+ return rc, mw, out.getvalue(), err.getvalue()
217
+
218
+ def test_auto_on_writes_next_up_auto_true(self):
219
+ """--auto=on sets next_up_auto: True in track meta."""
220
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"])
221
+ self.assertEqual(rc, 0)
222
+ mw.assert_called_once()
223
+ meta = mw.call_args[0][1]
224
+ self.assertTrue(meta.get("next_up_auto"))
225
+
226
+ def test_auto_off_removes_next_up_auto(self):
227
+ """--auto=off removes next_up_auto from track meta."""
228
+ t = _t(meta={"status": "active", "next_up_auto": True})
229
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=off"], track=t)
230
+ self.assertEqual(rc, 0)
231
+ mw.assert_called_once()
232
+ meta = mw.call_args[0][1]
233
+ self.assertNotIn("next_up_auto", meta)
234
+
235
+ def test_auto_off_on_track_without_key_still_succeeds(self):
236
+ """--auto=off on a track with no next_up_auto key still writes ok (no KeyError)."""
237
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=off"])
238
+ self.assertEqual(rc, 0)
239
+ mw.assert_called_once()
240
+ meta = mw.call_args[0][1]
241
+ self.assertNotIn("next_up_auto", meta)
242
+
243
+ def test_auto_bogus_returns_rc2_no_write(self):
244
+ """--auto=bogus → rc=2 and no write."""
245
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=bogus"])
246
+ self.assertEqual(rc, 2)
247
+ mw.assert_not_called()
248
+
249
+ def test_auto_standalone_private_writes(self):
250
+ """--auto=on alone (no --preset/--order/--clear) is accepted on private repo."""
251
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"])
252
+ self.assertEqual(rc, 0)
253
+ mw.assert_called_once()
254
+
255
+ def test_auto_standalone_public_needs_confirm(self):
256
+ """--auto=on alone on a PUBLIC repo → needs_confirm, no write."""
257
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"], vis="PUBLIC")
258
+ self.assertEqual(rc, 0)
259
+ mw.assert_not_called()
260
+ self.assertIn("needs_confirm", out)
261
+
262
+ def test_auto_standalone_public_with_valid_confirm_writes(self):
263
+ """--auto=on alone on PUBLIC repo with valid --confirm token proceeds to write."""
264
+ tok = make_token("o/r", "ph")
265
+ rc, mw, out, err = self._drive_with_stderr(
266
+ ["ph", "--auto=on", f"--confirm={tok}"], vis="PUBLIC"
267
+ )
268
+ self.assertEqual(rc, 0)
269
+ mw.assert_called_once()
270
+ meta = mw.call_args[0][1]
271
+ self.assertTrue(meta.get("next_up_auto"))
272
+
273
+ def test_auto_on_combined_with_preset_writes_both(self):
274
+ """--auto=on --preset=backlog sets BOTH next_up_auto AND next_up_order in one write."""
275
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on", "--preset=backlog"])
276
+ self.assertEqual(rc, 0)
277
+ mw.assert_called_once()
278
+ meta = mw.call_args[0][1]
279
+ self.assertTrue(meta.get("next_up_auto"))
280
+ self.assertEqual(meta.get("next_up_order"), {"preset": "backlog"})
281
+
282
+ def test_auto_on_prints_success_message(self):
283
+ """--auto=on prints a clear success line."""
284
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"])
285
+ self.assertIn("next_up_auto", out)
286
+ self.assertIn("true", out.lower())
287
+
288
+ def test_auto_off_prints_success_message(self):
289
+ """--auto=off prints a clear success line."""
290
+ t = _t(meta={"status": "active", "next_up_auto": True})
291
+ rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=off"], track=t)
292
+ self.assertIn("next_up_auto", out)
293
+
294
+
200
295
  if __name__ == "__main__":
201
296
  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
 
@@ -47,6 +47,7 @@ SUBCOMMANDS = {
47
47
  "duplicates": "commands.duplicates",
48
48
  "coverage": "commands.coverage",
49
49
  "canonicalize": "commands.canonicalize",
50
+ "dedupe-tiers": "commands.dedupe_tiers",
50
51
  "hygiene": "commands.hygiene",
51
52
  "--hygiene": "commands.hygiene", # flag-style alias
52
53
  "plan-status": "commands.plan_status",
@@ -57,6 +58,7 @@ SUBCOMMANDS = {
57
58
  "close-issue": "commands.close_issue",
58
59
  "in-progress": "commands.in_progress",
59
60
  "export": "commands.export",
61
+ "which-repo": "commands.which_repo",
60
62
  "auth-status": "commands.auth_status",
61
63
  "list-open-issues": "commands.list_open_issues",
62
64
  "set": "commands.set_field",
@@ -127,8 +129,8 @@ DESCRIPTIONS = [
127
129
  "AI-cluster GitHub issues into thematic track files. --limit controls how many issues are shown in the prompt (default 100).",
128
130
  "ONE-TIME bulk organization of an unsorted milestone, or after a re-org.",
129
131
  "/work-plan group --milestone='v1.0.0 — Public Launch'"),
130
- ("auto-triage", "[--repo=<key>] [--apply] [--limit=N]",
131
- "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).",
132
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.",
133
135
  "/work-plan auto-triage --repo=myproject"),
134
136
  ("reconcile", "<track> | --all | --repo=<key> [--draft] [--yes]",
@@ -147,8 +149,12 @@ DESCRIPTIONS = [
147
149
  "Insert a canonical master issue table at the top of a track. The table has a Milestone column and is ordered active-milestone-first (the track's milestone_alignment milestone, then other milestones grouped with a blank divider row, then no-milestone last) so near-term work sits above someday work (#101). Refresh-md then targets ONLY this table, re-deriving it (so the order self-heals) and leaving narrative tables alone. Use --repo=<key> or track@repo to disambiguate; with --all, --repo=<key> scopes to one repo.",
148
150
  "ONE-TIME for hand-written tracks with multiple narrative tables, OR after restructuring a track.",
149
151
  "/work-plan canonicalize ux-redesign"),
152
+ ("dedupe-tiers", "[--repo=<key>] [--apply]",
153
+ "Remove private track copies that a shared twin in a repo's .work-plan/ supersedes (#359). When a track is promoted to the shared tier, the private original under notes_root is sometimes left behind (bulk/manual promotion, or a failed unlink) — discover_tracks then warns 'exists in both shared and private' on every run with no cleanup path. This removes the safe orphans and REFUSES any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is issue_refs(private) ⊆ issue_refs(shared)). Covers active and archived tiers. Default is a dry-run report; --apply deletes (auto-committed to notes_root, so undoable).",
154
+ "When `exists in both shared and private` warnings appear, or after a bulk promote that left private originals behind.",
155
+ "/work-plan dedupe-tiers --repo=critforge --apply"),
150
156
  ("hygiene", "[--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]",
151
- "Weekly cleanup wrapper: refresh-md + reconcile + duplicates. With --repo=<key>, steps 1 and 2 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).",
157
+ "Weekly cleanup wrapper: refresh-md + reconcile + dedupe-tiers (report-only) + duplicates. With --repo=<key>, steps 1–3 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).",
152
158
  "WEEKLY — runs all three hygiene commands in sequence so you don't have to remember each. Use --repo=<key> to clean up one project without touching the others.",
153
159
  "/work-plan hygiene --repo=myproject"),
154
160
  ("export", "--json",
@@ -167,10 +173,10 @@ DESCRIPTIONS = [
167
173
  "Guarded edit of a track's frontmatter fields (status, launch_priority, milestone_alignment, blockers, next_up). Validates field names + status values; blockers/next_up take comma-separated issue numbers. Setting `next_up` here writes ONLY the frontmatter field — for next_up plus a session-log entry (and a body refresh), use `handoff --set-next` instead. Writes into a PUBLIC repo only with a confirm token: without one it prints {needs_confirm, reason, token} and makes no change (the VS Code viewer surfaces that as a modal, then re-invokes with --confirm=<token>).",
168
174
  "Programmatic/GUI edits that have no dedicated verb — e.g. the VS Code extension changing a status or blockers list. On the terminal you'll usually use the named verbs instead.",
169
175
  "/work-plan set ux-redesign status=parked"),
170
- ("set-next-up", "<track | track@repo> (--preset=<name> | --order=a,b,c | --clear) [--repo=<key>] [--confirm=<token>]",
171
- "Configure the ranking preset for a track's auto next_up suggestion. --preset sets one of the named presets (flow, priority-driven, backlog) or 'custom' (which requires --order). --order=a,b,c sets a custom comma-separated criterion list (milestone, dependency, priority, recency, aging). --clear reverts to the global or default preset. Writes next_up_order into the track's frontmatter (does NOT touch the next_up issue list). Public-repo gated: without --confirm it prints {needs_confirm, reason, token} and makes no change.",
172
- "When you want a track to use a different ranking order than the default (flow). Use priority-driven for pure backlog work with no milestones, backlog to surface oldest stalled issues first.",
173
- "/work-plan set-next-up my-track --preset=priority-driven"),
176
+ ("set-next-up", "<track | track@repo> (--preset=<name> | --order=a,b,c | --clear | --auto=on|off) [--repo=<key>] [--confirm=<token>]",
177
+ "Configure the ranking preset and/or auto-derivation flag for a track's next_up list. --preset sets one of the named presets (flow, priority-driven, backlog) or 'custom' (which requires --order). --order=a,b,c sets a custom comma-separated criterion list (milestone, dependency, priority, recency, aging). --clear reverts to the global or default preset. --auto=on activates auto-derivation (brief/orient/export derive next-up live via the ranking preset instead of the curated list); --auto=off reverts to the curated list. --auto can be used standalone or combined with --preset/--order/--clear. Writes next_up_order and/or next_up_auto into the track's frontmatter (does NOT touch the next_up issue list). Public-repo gated: without --confirm it prints {needs_confirm, reason, token} and makes no change.",
178
+ "When you want a track to use a different ranking order than the default (flow), or to activate auto-derivation so the viewer always shows a freshly-ranked list. Use priority-driven for pure backlog work with no milestones, backlog to surface oldest stalled issues first.",
179
+ "/work-plan set-next-up my-track --preset=priority-driven --auto=on"),
174
180
  ("new-track", "<repo> <slug> [--priority=P0..P3] [--milestone=<m>] [--private] [--confirm=<token>]",
175
181
  "Create a brand-new track file under notes_root in one headless call. <repo> is either a configured key (e.g. 'myproject') or a bare org/repo slug (e.g. 'your-org/myproject'). Writes frontmatter with status=active and optional priority/milestone. Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token> to proceed.",
176
182
  "When a new feature branch or initiative starts and you want the track file created immediately — especially from a non-terminal caller like the VS Code extension that can't interactively run init.",
@@ -219,6 +225,10 @@ DESCRIPTIONS = [
219
225
  "Promote a PRIVATE track (local-only, in notes_root) to the repo's SHARED tier and publish it (#306). Moves the track's `.md` into the repo's `.work-plan/` (on its `plan_branch`, via a worktree), removes the private copy so it isn't duplicated, commits to the plan branch, and pushes — unless `--no-push` (keeps it local). The tier is derived from location, so this is a file move, not a frontmatter edit. Requires the repo to have a local clone + a `plan_branch` (else hints `plan-branch init`). Pushing to a PUBLIC repo makes the track world-visible, so the push is confirm-token gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`).",
220
226
  "When a private track is ready to share with teammates — promote it to the shared plan branch in one step instead of hand-moving the file.",
221
227
  "/work-plan push-track my-feature --repo=myproject"),
228
+ ("which-repo", "[--json]",
229
+ "Resolve the current directory to one configured repo — by local clone path first, then the git `origin` remote. Prints the matched config key + GitHub slug, or reports no match. Read-only. Underlies `brief` cwd auto-scope and the VS Code viewer's repo auto-focus.",
230
+ "Rarely run by hand — it's the shared resolver the viewer and `brief` call. Useful to confirm which repo a checkout maps to.",
231
+ "/work-plan which-repo --json"),
222
232
  ]
223
233
 
224
234