@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,147 @@
1
+ """Tests for the dedupe-tiers command and its tracks.py helpers (#359)."""
2
+ import io
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from unittest.mock import patch
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import dedupe_tiers
15
+ from lib import tracks
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def _track(*, name, repo="org/repo", folder="repo", issues=None, body="", path=None,
23
+ tier="private"):
24
+ meta = {"track": name, "github": {"repo": repo}}
25
+ if issues is not None:
26
+ meta["github"]["issues"] = issues
27
+ return SimpleNamespace(
28
+ name=name,
29
+ path=Path(path) if path else Path(f"/tmp/notes/{folder}/{name}.md"),
30
+ repo=repo,
31
+ folder=folder,
32
+ meta=meta,
33
+ body=body,
34
+ tier=tier,
35
+ )
36
+
37
+
38
+ def _drive(args, pairs, *, notes_root="/tmp/notes"):
39
+ cfg = {"notes_root": notes_root, "repos": {"repo": {"github": "org/repo"}}}
40
+ buf = io.StringIO()
41
+ with patch("commands.dedupe_tiers.load_config", return_value=cfg), \
42
+ patch("commands.dedupe_tiers.find_tier_duplicates", return_value=pairs), \
43
+ redirect_stdout(buf):
44
+ rc = dedupe_tiers.run(args)
45
+ return rc, buf.getvalue()
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # issue_refs
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class TestIssueRefs(unittest.TestCase):
53
+ def test_unions_frontmatter_and_body(self):
54
+ t = _track(name="a", issues=[10, 20], body="see #20 and #30 here")
55
+ self.assertEqual(tracks.issue_refs(t), {10, 20, 30})
56
+
57
+ def test_empty_when_no_refs(self):
58
+ t = _track(name="a", issues=None, body="no refs at all")
59
+ self.assertEqual(tracks.issue_refs(t), set())
60
+
61
+ def test_ignores_non_int_frontmatter(self):
62
+ t = _track(name="a", issues=[1, "oops", None], body="")
63
+ self.assertEqual(tracks.issue_refs(t), {1})
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # find_tier_duplicates pairing (helpers patched)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class TestFindTierDuplicates(unittest.TestCase):
71
+ def test_pairs_only_colliding_active_tracks(self):
72
+ shared = [_track(name="dup", tier="shared"), _track(name="only-shared", tier="shared")]
73
+ private = [_track(name="dup"), _track(name="only-private")]
74
+ cfg = {"notes_root": "/tmp/does-not-exist-xyz", "repos": {}}
75
+ with patch.object(tracks, "_discover_shared_tracks") as ds, \
76
+ patch.object(tracks, "_discover_private_tracks", return_value=private):
77
+ # active call returns `shared`; archive-only call returns []
78
+ ds.side_effect = lambda cfg, include_archive=False, archive_only=False: (
79
+ [] if archive_only else shared)
80
+ pairs = tracks.find_tier_duplicates(cfg)
81
+ names = [(s.name, p.name) for (s, p) in pairs]
82
+ self.assertEqual(names, [("dup", "dup")])
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # command: report / apply / safety
87
+ # ---------------------------------------------------------------------------
88
+
89
+ class TestDedupeCommand(unittest.TestCase):
90
+ def test_no_pairs_reports_nothing(self):
91
+ rc, out = _drive([], [])
92
+ self.assertEqual(rc, 0)
93
+ self.assertIn("Nothing to dedupe", out)
94
+
95
+ def test_dry_run_removes_nothing(self):
96
+ with tempfile.TemporaryDirectory() as d:
97
+ pf = Path(d) / "dup.md"
98
+ pf.write_text("# dup\n")
99
+ shared = _track(name="dup", issues=[1, 2], tier="shared")
100
+ private = _track(name="dup", issues=[1], path=str(pf))
101
+ rc, out = _drive([], [(shared, private)])
102
+ self.assertTrue(pf.exists(), "dry run must not delete")
103
+ self.assertEqual(rc, 0)
104
+ self.assertIn("Dry run", out)
105
+ self.assertIn("--apply", out)
106
+
107
+ def test_apply_removes_subset_orphan(self):
108
+ with tempfile.TemporaryDirectory() as d:
109
+ pf = Path(d) / "dup.md"
110
+ pf.write_text("# dup\n")
111
+ shared = _track(name="dup", issues=[1, 2, 3], tier="shared")
112
+ private = _track(name="dup", issues=[1, 3], path=str(pf))
113
+ rc, out = _drive(["--apply"], [(shared, private)])
114
+ self.assertEqual(rc, 0)
115
+ self.assertFalse(pf.exists(), "subset orphan must be removed on --apply")
116
+ self.assertIn("Removed 1 private orphan", out)
117
+
118
+ def test_apply_keeps_diverged_orphan(self):
119
+ with tempfile.TemporaryDirectory() as d:
120
+ pf = Path(d) / "dup.md"
121
+ pf.write_text("# dup\n")
122
+ # private references #99 which the shared twin lacks → must be kept
123
+ shared = _track(name="dup", issues=[1, 2], tier="shared")
124
+ private = _track(name="dup", issues=[1], body="leftover #99", path=str(pf))
125
+ rc, out = _drive(["--apply"], [(shared, private)])
126
+ self.assertEqual(rc, 0)
127
+ self.assertTrue(pf.exists(), "diverged orphan must NOT be removed")
128
+ self.assertIn("#99", out)
129
+ self.assertIn("manual review", out)
130
+
131
+ def test_repo_filter_scopes_pairs(self):
132
+ a_shared = _track(name="a", repo="org/a", folder="a", issues=[1], tier="shared")
133
+ a_priv = _track(name="a", repo="org/a", folder="a", issues=[1])
134
+ b_shared = _track(name="b", repo="org/b", folder="b", issues=[1], tier="shared")
135
+ b_priv = _track(name="b", repo="org/b", folder="b", issues=[1])
136
+ rc, out = _drive(["--repo=a"], [(a_shared, a_priv), (b_shared, b_priv)])
137
+ self.assertEqual(rc, 0)
138
+ self.assertIn("a (repo org/a)", out)
139
+ self.assertNotIn("org/b", out)
140
+
141
+ def test_repo_flag_without_value_is_usage_error(self):
142
+ rc, _ = _drive(["--repo"], [])
143
+ self.assertEqual(rc, 2)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()
@@ -33,6 +33,38 @@ class DetectDriftTest(unittest.TestCase):
33
33
  def test_no_table_returns_empty(self):
34
34
  self.assertEqual(detect_drift("# No table\n", [{"number": 1, "state": "CLOSED"}]), [])
35
35
 
36
+ # --- OPEN-side + ambiguous-body cases: pin the *intentional asymmetry* ----
37
+ # CLOSED is terminal → broad check (anything not-closed drifts). OPEN is not
38
+ # terminal → narrow check (only an explicit closed marker drifts). These
39
+ # guard against someone "restoring symmetry" with a `not looks_open` open-side
40
+ # check, which would false-positive every in-progress row.
41
+
42
+ def _body(self, status: str) -> str:
43
+ return ("| # | Title | Status |\n"
44
+ "|---|---|---|\n"
45
+ f"| #1 | foo | {status} |\n")
46
+
47
+ def test_drift_when_closed_in_md_open_in_github(self):
48
+ # OPEN-side condition (was untested): body says shipped, GitHub reopened it.
49
+ drift = detect_drift(self._body("✅ Shipped"), [{"number": 1, "state": "OPEN"}])
50
+ self.assertEqual(len(drift), 1)
51
+ self.assertEqual(drift[0]["github_state"], "OPEN")
52
+
53
+ def test_no_drift_when_open_in_md_open_in_github(self):
54
+ self.assertEqual(detect_drift(self._body("🔲 Open"), [{"number": 1, "state": "OPEN"}]), [])
55
+
56
+ def test_open_with_ambiguous_status_is_NOT_drift(self):
57
+ # The deliberate narrow OPEN-side: an in-progress row must not be flagged.
58
+ self.assertEqual(
59
+ detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "OPEN"}]), [])
60
+
61
+ def test_closed_with_ambiguous_status_IS_drift(self):
62
+ # The deliberate broad CLOSED-side: a closed issue whose row doesn't read
63
+ # closed (here: ambiguous) is drift.
64
+ drift = detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "CLOSED"}])
65
+ self.assertEqual(len(drift), 1)
66
+ self.assertEqual(drift[0]["github_state"], "CLOSED")
67
+
36
68
 
37
69
  if __name__ == "__main__":
38
70
  unittest.main()
@@ -49,6 +49,69 @@ class BuildExportTest(unittest.TestCase):
49
49
  self.assertIsNone(out["tracks"][0]["path"])
50
50
  json.dumps(out) # null is serializable
51
51
 
52
+ class BuildExportNextUpAutoTest(unittest.TestCase):
53
+ """next_up_auto: true → export derives next_up live via the ranking preset."""
54
+
55
+ def _open(self, n, pri, ms="v1"):
56
+ return {"number": n, "state": "open", "title": f"#{n}",
57
+ "labels": [{"name": f"priority/{pri}"}], "milestone": {"title": ms},
58
+ "updatedAt": "2026-06-10T00:00:00Z", "blocked_by": [], "blocking": []}
59
+
60
+ def _track_auto(self, auto):
61
+ meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
62
+ "blockers": [], "next_up": [7], "depends_on": [],
63
+ "github": {"repo": "o/r", "issues": [1, 2, 3]}}
64
+ if auto:
65
+ meta["next_up_auto"] = True
66
+ return SimpleNamespace(name="t", repo="o/r", tier="private",
67
+ path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
68
+
69
+ def _issues(self):
70
+ # P0 #3, P1 #2, P2 #1 — flow ranks by priority within the same milestone.
71
+ return [self._open(1, "P2"), self._open(2, "P1"), self._open(3, "P0")]
72
+
73
+ def test_auto_derives_and_flags(self):
74
+ out = build_export([self._track_auto(True)],
75
+ {("o/r", "t"): self._issues()},
76
+ {"o/r": "PRIVATE"}, now="t")
77
+ tr = out["tracks"][0]
78
+ self.assertEqual(tr["next_up"], [3, 2, 1]) # P0 → P1 → P2 (ignores stored [7])
79
+ self.assertTrue(tr["next_up_auto"])
80
+
81
+ def test_no_auto_uses_stored_list(self):
82
+ out = build_export([self._track_auto(False)],
83
+ {("o/r", "t"): self._issues()},
84
+ {"o/r": "PRIVATE"}, now="t")
85
+ tr = out["tracks"][0]
86
+ self.assertEqual(tr["next_up"], [7]) # the curated list, unchanged
87
+ self.assertFalse(tr["next_up_auto"])
88
+
89
+ def test_auto_on_with_zero_open_issues_still_exports_flag_true(self):
90
+ """next_up_auto: true + zero fetched issues → next_up_auto=True in export (the
91
+ SETTING, not whether auto-derivation actually ran). Viewer toggle must show
92
+ On even when there are no issues to rank.
93
+
94
+ When no issues are fetched, the auto-derivation branch does not run (there's
95
+ nothing to rank); the export still emits next_up_auto=True so the viewer
96
+ knows the flag is on. The next_up list itself falls through to the curated
97
+ list (which may be non-empty — that's acceptable and a separate concern)."""
98
+ # Use a track with no stored next_up to isolate the flag assertion cleanly.
99
+ meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
100
+ "blockers": [], "next_up": [], "depends_on": [],
101
+ "next_up_auto": True,
102
+ "github": {"repo": "o/r", "issues": []}}
103
+ from types import SimpleNamespace
104
+ from pathlib import Path
105
+ t = SimpleNamespace(name="t", repo="o/r", tier="private",
106
+ path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
107
+ out = build_export([t],
108
+ {("o/r", "t"): []}, # zero issues fetched
109
+ {"o/r": "PRIVATE"}, now="t")
110
+ tr = out["tracks"][0]
111
+ self.assertTrue(tr["next_up_auto"]) # flag reflects setting, not derivation
112
+ self.assertEqual(tr["next_up"], []) # no issues → empty list
113
+
114
+
52
115
  class BuildExportNextUpFilterTest(unittest.TestCase):
53
116
  """next_up entries whose issue is closed in the fetched payload are filtered out."""
54
117
 
@@ -151,6 +151,49 @@ class ExportRunJsonTest(unittest.TestCase):
151
151
  self.assertEqual(rc, 0)
152
152
  self.assertEqual(out["tracks"][0]["issues"], [])
153
153
 
154
+ # --- tier_duplicates (#361) -------------------------------------------
155
+
156
+ def _dup_track(self, *, issues, body="", path):
157
+ return SimpleNamespace(
158
+ repo=_SHARED_REPO, folder="myrepo", name="dup", path=Path(path),
159
+ meta={"github": {"repo": _SHARED_REPO, "issues": issues}}, body=body,
160
+ )
161
+
162
+ def test_tier_duplicates_empty_when_none(self):
163
+ """With no notes_root in cfg (mock returns {}), the field is an empty
164
+ list — present but quiet, so the viewer can rely on its shape."""
165
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
166
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
167
+ self.assertEqual(rc, 0)
168
+ self.assertEqual(out["tier_duplicates"], [])
169
+
170
+ def test_tier_duplicate_subset_is_safe(self):
171
+ shared = self._dup_track(issues=[1, 2, 3], path="/repo/.work-plan/dup.md")
172
+ private = self._dup_track(issues=[1, 3], path="/notes/myrepo/dup.md")
173
+ with patch("commands.export.find_tier_duplicates",
174
+ return_value=[(shared, private)]):
175
+ rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP)
176
+ self.assertEqual(rc, 0)
177
+ td = out["tier_duplicates"]
178
+ self.assertEqual(len(td), 1)
179
+ self.assertEqual(td[0]["name"], "dup")
180
+ self.assertEqual(td[0]["repo"], _SHARED_REPO)
181
+ self.assertEqual(td[0]["folder"], "myrepo")
182
+ self.assertTrue(td[0]["safe"])
183
+ self.assertEqual(td[0]["shared_path"], str(Path("/repo/.work-plan/dup.md")))
184
+ self.assertEqual(td[0]["private_path"], str(Path("/notes/myrepo/dup.md")))
185
+
186
+ def test_tier_duplicate_diverged_is_unsafe(self):
187
+ # private references #99, which the shared twin lacks → not safe to remove
188
+ shared = self._dup_track(issues=[1, 2], path="/repo/.work-plan/dup.md")
189
+ private = self._dup_track(issues=[1], body="leftover #99",
190
+ path="/notes/myrepo/dup.md")
191
+ with patch("commands.export.find_tier_duplicates",
192
+ return_value=[(shared, private)]):
193
+ rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP)
194
+ self.assertEqual(rc, 0)
195
+ self.assertFalse(out["tier_duplicates"][0]["safe"])
196
+
154
197
  def test_visibility_included_in_output(self):
155
198
  tracks = [_track("alpha", _SHARED_REPO, [1])]
156
199
  rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP, vis={_SHARED_REPO: "PUBLIC"})
@@ -290,6 +333,19 @@ class ExportCommandUntrackedTest(unittest.TestCase):
290
333
  self.assertEqual(rc, 0)
291
334
  self.assertEqual(out["untracked"], [])
292
335
 
336
+ def test_empty_track_still_surfaces_untracked(self):
337
+ """A repo whose only track has issues:[] must still surface its open
338
+ issues as untracked (#342). repo_to_numbers omits such a track, so the
339
+ untracked loop must key off repos-with-tracks, not tracked issues."""
340
+ tracks = [_track("general", _SHARED_REPO, [])] # empty track
341
+ export_map = {} # no tracked issues to fetch
342
+ open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_C]} # but the repo has open issues
343
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
344
+ self.assertEqual(rc, 0)
345
+ entry = next(e for e in out["untracked"] if e["repo"] == _SHARED_REPO)
346
+ nums = sorted(i["number"] for i in entry["issues"])
347
+ self.assertEqual(nums, [1, 3]) # both open issues are untracked
348
+
293
349
  def test_schema_stays_1_with_untracked(self):
294
350
  tracks = [_track("alpha", _SHARED_REPO, [1])]
295
351
  open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
@@ -0,0 +1,130 @@
1
+ """Tests for `handoff --suggest-next` — the read-only JSON suggestion mode (#274).
2
+
3
+ The VS Code native auto-next picker needs the algorithmic next_up suggestion
4
+ WITHOUT a write or a TTY prompt (the CLI's prompt helpers no-op under VS Code's
5
+ non-TTY stdin). `--suggest-next` computes the same sibling-filtered suggestion as
6
+ `--auto-next` and prints it as JSON; the extension renders + confirms it and
7
+ writes back via `--set-next`. These tests assert: valid JSON shape, sibling-claim
8
+ skips surface in `skipped` (not written), no frontmatter is mutated, and the
9
+ soft-error payloads (no repo / no issues) stay parseable with exit 0.
10
+ """
11
+ import io
12
+ import json
13
+ import sys
14
+ import tempfile
15
+ import unittest
16
+ from contextlib import redirect_stdout
17
+ from pathlib import Path
18
+ from unittest import mock
19
+
20
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(SKILL_ROOT))
22
+
23
+ from commands import handoff
24
+ from lib.frontmatter import parse_file, write_file
25
+
26
+
27
+ def _make_track(dir_path: Path, slug: str, *, repo: str, status: str = "active",
28
+ next_up=None, issues=None) -> Path:
29
+ meta = {
30
+ "track": slug,
31
+ "status": status,
32
+ "launch_priority": "P1",
33
+ "github": {"repo": repo,
34
+ "issues": list([100, 200] if issues is None else issues),
35
+ "branches": []},
36
+ "next_up": list(next_up or []),
37
+ }
38
+ path = dir_path / f"{slug}.md"
39
+ write_file(path, meta, f"\n# {slug}\n\nBody.\n")
40
+ return path
41
+
42
+
43
+ def _open_issue(num: int, *, priority: str = "P1", milestone=None) -> dict:
44
+ return {
45
+ "number": num,
46
+ "title": f"issue-{num}",
47
+ "state": "OPEN",
48
+ "labels": [{"name": f"priority/{priority}"}],
49
+ "updatedAt": "2026-04-30T00:00:00Z",
50
+ "milestone": milestone,
51
+ }
52
+
53
+
54
+ class SuggestNextJsonTest(unittest.TestCase):
55
+ def setUp(self):
56
+ self.tmp = tempfile.TemporaryDirectory()
57
+ self.notes_root = Path(self.tmp.name) / "notes_root"
58
+ self.repo_dir = self.notes_root / "demo"
59
+ self.repo_dir.mkdir(parents=True)
60
+ self.cfg = {
61
+ "notes_root": str(self.notes_root),
62
+ "repos": {"demo": {"github": "stylusnexus/Demo"}},
63
+ }
64
+ self._patches = [
65
+ mock.patch("commands.handoff.load_config", return_value=self.cfg),
66
+ mock.patch("commands.handoff.has_uncommitted", return_value=False),
67
+ ]
68
+ for p in self._patches:
69
+ p.start()
70
+
71
+ def tearDown(self):
72
+ for p in self._patches:
73
+ p.stop()
74
+ self.tmp.cleanup()
75
+
76
+ def _run(self, track_name, *, issues_response):
77
+ buf = io.StringIO()
78
+ with mock.patch("commands.handoff.fetch_issues", return_value=issues_response), \
79
+ redirect_stdout(buf):
80
+ rc = handoff.run([track_name, "--suggest-next"])
81
+ return rc, buf.getvalue()
82
+
83
+ def test_emits_valid_json_with_decorated_suggestions(self):
84
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
85
+ issues=[100, 200])
86
+ rc, out = self._run("track-a",
87
+ issues_response=[_open_issue(100, milestone={"title": "v1.0.0"}),
88
+ _open_issue(200)])
89
+ self.assertEqual(rc, 0)
90
+ payload = json.loads(out)
91
+ self.assertEqual(payload["track"], "track-a")
92
+ self.assertEqual(payload["repo"], "stylusnexus/Demo")
93
+ nums = [s["number"] for s in payload["suggested"]]
94
+ self.assertEqual(sorted(nums), [100, 200])
95
+ first = next(s for s in payload["suggested"] if s["number"] == 100)
96
+ self.assertEqual(first["title"], "issue-100")
97
+ self.assertEqual(first["priority"], "P1")
98
+ self.assertEqual(first["milestone"], "v1.0.0")
99
+
100
+ def test_read_only_does_not_write_next_up(self):
101
+ target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
102
+ issues=[100, 200], next_up=[7])
103
+ rc, out = self._run("track-a",
104
+ issues_response=[_open_issue(100), _open_issue(200)])
105
+ self.assertEqual(rc, 0)
106
+ meta, _ = parse_file(target)
107
+ self.assertEqual(meta["next_up"], [7]) # untouched by a read-only suggest
108
+
109
+ def test_sibling_claimed_issue_lands_in_skipped_not_suggested(self):
110
+ _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo", issues=[100, 200])
111
+ _make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
112
+ rc, out = self._run("track-a",
113
+ issues_response=[_open_issue(100), _open_issue(200)])
114
+ payload = json.loads(out)
115
+ self.assertEqual([s["number"] for s in payload["suggested"]], [200])
116
+ self.assertEqual(payload["skipped"], [{"number": 100, "claimed_by": "track-b"}])
117
+
118
+ def test_no_issues_attached_is_soft_error_with_empty_suggested(self):
119
+ _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo", issues=[])
120
+ buf = io.StringIO()
121
+ with redirect_stdout(buf):
122
+ rc = handoff.run(["track-a", "--suggest-next"])
123
+ self.assertEqual(rc, 0)
124
+ payload = json.loads(buf.getvalue())
125
+ self.assertEqual(payload["suggested"], [])
126
+ self.assertIn("error", payload)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()
@@ -0,0 +1,114 @@
1
+ """Tests for lib.heuristic_triage — the offline (no-LLM) track scorer (#373).
2
+
3
+ Covers: milestone match, track-label overlap (incl. the track/<slug> default),
4
+ keyword overlap, abstain when nothing clears the bar, margin clear-vs-narrow from
5
+ the top-vs-runner gap, and the v2 entry shape.
6
+ """
7
+ import sys
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from lib.heuristic_triage import score_suggestions
15
+
16
+
17
+ def _iss(number, title="", milestone=None, labels=()):
18
+ return {
19
+ "number": number,
20
+ "title": title,
21
+ "milestone": {"title": milestone} if milestone else None,
22
+ "labels": [{"name": n} for n in labels],
23
+ }
24
+
25
+
26
+ def _trk(slug, name=None, milestone=None, scope="", labels=None):
27
+ return {"slug": slug, "name": name or slug, "milestone": milestone,
28
+ "scope": scope, "labels": labels}
29
+
30
+
31
+ class HeuristicScoreTest(unittest.TestCase):
32
+
33
+ def _one(self, entries):
34
+ self.assertEqual(len(entries), 1)
35
+ return entries[0]
36
+
37
+ def test_milestone_match_suggests(self):
38
+ e = self._one(score_suggestions(
39
+ [_iss(1, "rate limit", milestone="v0.4")],
40
+ [_trk("auth-flow", milestone="v0.4")],
41
+ ))
42
+ self.assertEqual(e["verdict"], "suggest")
43
+ self.assertEqual(e["track"], "auth-flow")
44
+ self.assertIn("milestone v0.4", e["rationale"])
45
+
46
+ def test_track_label_overlap_suggests(self):
47
+ e = self._one(score_suggestions(
48
+ [_iss(2, "something", labels=["area/auth"])],
49
+ [_trk("auth-flow", labels=["area/auth"])],
50
+ ))
51
+ self.assertEqual(e["verdict"], "suggest")
52
+ self.assertIn("label area/auth", e["rationale"])
53
+
54
+ def test_default_track_slug_label(self):
55
+ # No github.labels on the track → default `track/<slug>` is matched.
56
+ e = self._one(score_suggestions(
57
+ [_iss(3, "x", labels=["track/auth-flow"])],
58
+ [_trk("auth-flow", labels=None)],
59
+ ))
60
+ self.assertEqual(e["verdict"], "suggest")
61
+
62
+ def test_keyword_only_below_bar_abstains(self):
63
+ # A single keyword hit (0.1) is below the 0.3 suggest bar → abstain.
64
+ e = self._one(score_suggestions(
65
+ [_iss(4, "sessions cleanup")],
66
+ [_trk("tabletop-sessions", name="tabletop sessions")],
67
+ ))
68
+ self.assertEqual(e["verdict"], "abstain")
69
+
70
+ def test_no_signal_abstains(self):
71
+ e = self._one(score_suggestions(
72
+ [_iss(5, "totally unrelated billing thing")],
73
+ [_trk("auth-flow", milestone="v0.4")],
74
+ ))
75
+ self.assertEqual(e["verdict"], "abstain")
76
+ self.assertNotIn("track", e)
77
+
78
+ def test_margin_narrow_when_two_tracks_tie(self):
79
+ # Both tracks share the milestone → equal top score → narrow margin.
80
+ e = self._one(score_suggestions(
81
+ [_iss(6, "x", milestone="v0.4")],
82
+ [_trk("auth-flow", milestone="v0.4"), _trk("idea-mode", milestone="v0.4")],
83
+ ))
84
+ self.assertEqual(e["verdict"], "suggest")
85
+ self.assertEqual(e["margin"], "narrow")
86
+ self.assertIn("runner_up", e)
87
+
88
+ def test_margin_clear_when_one_track_dominates(self):
89
+ e = self._one(score_suggestions(
90
+ [_iss(7, "auth rate limit", milestone="v0.4", labels=["area/auth"])],
91
+ [_trk("auth-flow", name="auth flow", milestone="v0.4", labels=["area/auth"]),
92
+ _trk("idea-mode", milestone="v9.9")],
93
+ ))
94
+ self.assertEqual(e["margin"], "clear")
95
+ self.assertEqual(e["track"], "auth-flow")
96
+
97
+ def test_confidence_clamped_and_in_range(self):
98
+ e = self._one(score_suggestions(
99
+ [_iss(8, "auth flow rate limit session token scope",
100
+ milestone="v0.4", labels=["area/auth"])],
101
+ [_trk("auth-flow", name="auth flow", milestone="v0.4",
102
+ scope="auth rate limit session token scope", labels=["area/auth"])],
103
+ ))
104
+ self.assertLessEqual(e["confidence"], 1.0)
105
+ self.assertGreaterEqual(e["confidence"], 0.3)
106
+
107
+ def test_malformed_issue_number_skipped(self):
108
+ entries = score_suggestions([{"number": "not-an-int", "title": "x"}],
109
+ [_trk("a")])
110
+ self.assertEqual(entries, [])
111
+
112
+
113
+ if __name__ == "__main__":
114
+ unittest.main()