@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.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- 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/slot.py +53 -24
- 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/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -2
- 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_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 +2 -2
|
@@ -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("
|
|
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
|
|
|
@@ -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
|
|
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]",
|