@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.
- package/README.md +14 -12
- package/VERSION +1 -1
- package/package.json +1 -5
- package/skills/work-plan/SKILL.md +2 -0
- package/skills/work-plan/commands/auto_triage.py +187 -35
- package/skills/work-plan/commands/batch_slot.py +58 -33
- package/skills/work-plan/commands/brief.py +29 -3
- package/skills/work-plan/commands/dedupe_tiers.py +104 -0
- package/skills/work-plan/commands/export.py +29 -4
- package/skills/work-plan/commands/handoff.py +90 -26
- package/skills/work-plan/commands/hygiene.py +24 -9
- package/skills/work-plan/commands/set_next_up.py +64 -8
- package/skills/work-plan/commands/slot.py +53 -24
- package/skills/work-plan/commands/which_repo.py +52 -0
- package/skills/work-plan/lib/cwd_repo.py +133 -0
- package/skills/work-plan/lib/drift.py +6 -1
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/heuristic_triage.py +134 -0
- package/skills/work-plan/lib/membership_guard.py +152 -0
- package/skills/work-plan/lib/plan_worktree.py +37 -0
- package/skills/work-plan/lib/tracks.py +64 -2
- package/skills/work-plan/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -2
- package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
- package/skills/work-plan/tests/test_cwd_repo.py +164 -0
- package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
- package/skills/work-plan/tests/test_drift.py +32 -0
- package/skills/work-plan/tests/test_export.py +63 -0
- package/skills/work-plan/tests/test_export_command.py +56 -0
- package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
- package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
- package/skills/work-plan/tests/test_membership_guard.py +116 -0
- package/skills/work-plan/tests/test_register_which_repo.py +22 -0
- package/skills/work-plan/tests/test_set_next_up.py +95 -0
- package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
- package/skills/work-plan/tests/test_slot.py +94 -2
- package/skills/work-plan/tests/test_slot_move.py +10 -1
- package/skills/work-plan/work_plan.py +17 -7
- 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("
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|