@stylusnexus/work-plan 2026.6.16 → 2026.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ """Deterministic, offline track suggestions for untracked issues (#373).
2
+
3
+ The LLM path (`auto-triage` → a Claude session writes the answers) is the
4
+ higher-quality source, but it only produces suggestions when an agent is driving.
5
+ This module is the no-LLM fallback: it scores each untracked issue against each
6
+ candidate track using only signals already in hand (no network, no model), so the
7
+ VS Code Suggested bucket works standalone.
8
+
9
+ It emits the SAME v2 answer entries the LLM path does
10
+ (`{issue, verdict, track, runner_up, confidence, margin, rationale}`) so the
11
+ viewer and `auto-triage --apply` consume it unchanged — abstain-first, with a
12
+ grounded rationale naming the concrete signal that matched.
13
+
14
+ Signals (all local, all explainable):
15
+ * milestone match — issue.milestone == track.milestone_alignment (strong)
16
+ * track-label match — issue labels ∩ the track's reconcile labels
17
+ (github.labels, else the default `track/<slug>`) (strong)
18
+ * keyword overlap — issue-title tokens ∩ the track's slug/name/scope tokens
19
+ (medium; capped)
20
+
21
+ Confidence here is a heuristic score, NOT a calibrated probability — the answer
22
+ doc is stamped `source: "heuristic"` so the viewer can flag it as lower-trust.
23
+ """
24
+ import re
25
+
26
+ # Weights are deliberately simple and sum-clamped to 1.0. Tuned so a single
27
+ # strong signal alone stays below the default suggest bar (0.3 < 0.4/0.5), i.e.
28
+ # one weak coincidence won't auto-suggest, but a strong signal does clear it.
29
+ _W_MILESTONE = 0.5
30
+ _W_LABEL = 0.4
31
+ _W_KEYWORD_EACH = 0.1
32
+ _W_KEYWORD_CAP = 0.3
33
+
34
+ # Short / structural words that carry no track-matching signal.
35
+ _STOPWORDS = frozenset((
36
+ "the", "a", "an", "and", "or", "to", "of", "for", "in", "on", "with", "is",
37
+ "add", "fix", "update", "support", "make", "use", "new", "issue", "bug",
38
+ "feat", "feature", "error", "when", "after", "before", "via", "from", "into",
39
+ ))
40
+
41
+
42
+ def _tokens(text):
43
+ """Lowercase alphanumeric tokens of length ≥ 3, minus stopwords."""
44
+ if not text:
45
+ return set()
46
+ return {
47
+ t for t in re.split(r"[^a-z0-9]+", str(text).lower())
48
+ if len(t) >= 3 and t not in _STOPWORDS
49
+ }
50
+
51
+
52
+ def _track_labels(track):
53
+ """A track's effective reconcile labels (lowercased): github.labels if set,
54
+ else the default `track/<slug>` — mirrors reconcile's resolution (#373)."""
55
+ labels = (track.get("labels") or [])
56
+ if labels:
57
+ return {str(x).lower() for x in labels}
58
+ slug = track.get("slug") or ""
59
+ return {f"track/{slug}".lower()} if slug else set()
60
+
61
+
62
+ def score_suggestions(untracked, tracks, *, min_score=0.3, margin_gap=0.15):
63
+ """Score each untracked issue against the candidate tracks and return v2
64
+ suggestion entries (abstain-first).
65
+
66
+ `untracked`: [{"number", "title", "milestone": {"title"} | None,
67
+ "labels": [{"name"}]}] (the auto-triage batch shape).
68
+ `tracks`: [{"slug", "name", "milestone", "scope", "labels": [..]}].
69
+ `min_score`: a track must clear this for a non-abstain suggestion.
70
+ `margin_gap`: top must beat the runner-up by at least this for margin "clear".
71
+ """
72
+ out = []
73
+ for iss in untracked:
74
+ try:
75
+ num = int(iss.get("number"))
76
+ except (TypeError, ValueError):
77
+ continue
78
+
79
+ title_tokens = _tokens(iss.get("title"))
80
+ ms = iss.get("milestone") or {}
81
+ iss_ms = ms.get("title") if isinstance(ms, dict) else None
82
+ iss_labels = {str(lb.get("name", "")).lower()
83
+ for lb in (iss.get("labels") or []) if isinstance(lb, dict)}
84
+
85
+ scored = [] # (score, slug, rationale-parts)
86
+ for t in tracks:
87
+ slug = t.get("slug")
88
+ if not slug:
89
+ continue
90
+ score = 0.0
91
+ reasons = []
92
+
93
+ t_ms = t.get("milestone")
94
+ if iss_ms and t_ms and iss_ms == t_ms:
95
+ score += _W_MILESTONE
96
+ reasons.append(f"milestone {iss_ms}")
97
+
98
+ shared_labels = iss_labels & _track_labels(t)
99
+ if shared_labels:
100
+ score += _W_LABEL
101
+ reasons.append("label " + ", ".join(sorted(shared_labels)))
102
+
103
+ t_kw = _tokens(slug) | _tokens(t.get("name")) | _tokens(t.get("scope"))
104
+ shared_kw = title_tokens & t_kw
105
+ if shared_kw:
106
+ score += min(_W_KEYWORD_CAP, _W_KEYWORD_EACH * len(shared_kw))
107
+ reasons.append("keyword " + ", ".join(sorted(shared_kw)))
108
+
109
+ if score > 0:
110
+ scored.append((round(min(score, 1.0), 2), slug, reasons))
111
+
112
+ scored.sort(key=lambda x: x[0], reverse=True)
113
+
114
+ if not scored or scored[0][0] < min_score:
115
+ out.append({
116
+ "issue": num,
117
+ "verdict": "abstain",
118
+ "rationale": "no track clears the heuristic bar",
119
+ })
120
+ continue
121
+
122
+ top = scored[0]
123
+ runner = scored[1] if len(scored) > 1 else None
124
+ clear = runner is None or (top[0] - runner[0]) >= margin_gap
125
+ out.append({
126
+ "issue": num,
127
+ "verdict": "suggest",
128
+ "track": top[1],
129
+ **({"runner_up": runner[1]} if runner else {}),
130
+ "confidence": top[0],
131
+ "margin": "clear" if clear else "narrow",
132
+ "rationale": "; ".join(top[2]),
133
+ })
134
+ return out
@@ -0,0 +1,152 @@
1
+ """Compare-and-swap guard for track-membership writes (#241).
2
+
3
+ Slotting an issue into a track edits `meta["github"]["issues"]`. Shared-tier
4
+ tracks travel via git push/pull, so an assisted or background write can race
5
+ another session or a teammate's pull. `lib.frontmatter.write_file` is a blind
6
+ overwrite, so without a guard the last writer silently wins.
7
+
8
+ This module adds two things:
9
+
10
+ * `issues_fingerprint(meta)` — a deterministic digest of the membership list
11
+ ONLY. We fingerprint just `github.issues`, not the whole frontmatter or the
12
+ body: those carry fields other commands legitimately rewrite concurrently
13
+ (`refresh-md` stamps `last_touched`, `handoff` rewrites the body table), and
14
+ fingerprinting them would abort on changes that have nothing to do with the
15
+ list we're about to overwrite. The membership list is exactly the CAS surface.
16
+
17
+ * `guarded_membership_write(...)` — ALWAYS re-reads the file from disk
18
+ immediately before writing, applies the membership delta to the FRESH
19
+ frontmatter, and writes back the rest of the frontmatter and the body
20
+ unchanged. So a concurrent edit to the body OR to other frontmatter fields
21
+ (status, last_touched, depends_on, …) is preserved — only the issues list is
22
+ replaced. When `expect` is supplied and the on-disk membership no longer
23
+ matches it, the write is ABORTED and a `{"stale": ...}` signal is returned
24
+ instead of overwriting — the caller re-prompts on fresh state. When `expect`
25
+ is None (the manual single-writer path) the abort is skipped, but the
26
+ re-read + merge still happens: strictly safer than a blind overwrite, with
27
+ the same observable result for a lone writer.
28
+
29
+ Scope of the guarantee (deliberately narrow — don't oversell it):
30
+ - This is a check-then-act, not a locked atomic CAS: `parse_file` then
31
+ `write_file` are separate syscalls with no file lock, so a writer landing in
32
+ the sub-millisecond window between them isn't caught. `expect` narrows the
33
+ window vs. a blind overwrite; it does not eliminate it. Adequate for the real
34
+ usage (interactive single user + occasional same-machine background).
35
+ - `shared_rebase_guard` lands a shared-tier write on top of origin AS OF THE
36
+ LAST FETCH; it is NOT a cross-machine atomic CAS against origin. A teammate
37
+ pushing between the rebase and the eventual (separate) push is reconciled by
38
+ the non-fast-forward push being rejected + rebase-on-next-write, not by this
39
+ write path.
40
+ - `frontmatter.write_file` re-serializes the frontmatter via yq on every
41
+ write, so body-only edits round-trip verbatim but YAML comments / key order
42
+ are normalized (a pre-existing property, not introduced here).
43
+ """
44
+ import hashlib
45
+ import json
46
+ from pathlib import Path
47
+
48
+ from lib.frontmatter import parse_file, write_file
49
+
50
+
51
+ def _issue_set(meta: dict) -> set:
52
+ """The frontmatter's github.issues as a set of ints (malformed entries
53
+ dropped — the file may be hand-edited)."""
54
+ out = set()
55
+ for n in (meta.get("github", {}).get("issues") or []):
56
+ try:
57
+ out.add(int(n))
58
+ except (TypeError, ValueError):
59
+ continue
60
+ return out
61
+
62
+
63
+ def issues_fingerprint(meta: dict) -> str:
64
+ """Deterministic sha256[:16] of the sorted github.issues list.
65
+
66
+ Order-independent (sorted) and stable across runs (no randomness — 3.9
67
+ stdlib). Two metas with the same membership produce the same fingerprint
68
+ regardless of list order or unrelated frontmatter differences.
69
+ """
70
+ payload = json.dumps(sorted(_issue_set(meta)), separators=(",", ":"))
71
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
72
+
73
+
74
+ def guarded_membership_write(path, *, add_nums=(), remove_nums=(), expect=None):
75
+ """Re-read `path`, apply the membership delta to the fresh frontmatter, and
76
+ write back the fresh body unchanged.
77
+
78
+ Returns one of:
79
+ {"stale": True, "reason": str, "current": [int]}
80
+ — `expect` was supplied and the on-disk membership no longer matches
81
+ it; NO write happened. `current` is the fresh on-disk list so the
82
+ caller can re-offer against it.
83
+ {"written": [int]}
84
+ — wrote successfully; the value is the final sorted membership list.
85
+
86
+ `expect` is the `issues_fingerprint` the caller captured when it built the
87
+ operation (e.g. when the viewer rendered the offer). Pass None for the
88
+ unguarded manual path.
89
+ """
90
+ fresh_meta, fresh_body = parse_file(Path(path))
91
+
92
+ if expect is not None and issues_fingerprint(fresh_meta) != expect:
93
+ return {
94
+ "stale": True,
95
+ "reason": "track membership changed since the operation was prepared",
96
+ "current": sorted(_issue_set(fresh_meta)),
97
+ }
98
+
99
+ issues = _issue_set(fresh_meta)
100
+ issues |= {int(n) for n in add_nums}
101
+ issues -= {int(n) for n in remove_nums}
102
+ final = sorted(issues)
103
+ fresh_meta.setdefault("github", {})["issues"] = final
104
+ write_file(Path(path), fresh_meta, fresh_body)
105
+ return {"written": final}
106
+
107
+
108
+ def shared_rebase_guard(target, cfg):
109
+ """Before writing a SHARED-tier track pinned to a `plan_branch`, fetch +
110
+ rebase its worktree onto origin so the write lands on top of a teammate's
111
+ pushed plan changes (#241). Best-effort: this reduces the cross-machine
112
+ (git push/pull) race, it does not make the write atomic against origin — a
113
+ teammate pushing after the rebase is reconciled by the non-fast-forward push
114
+ rejection + rebase-on-next-write, not here. The fingerprint CAS covers the
115
+ same-machine race.
116
+
117
+ Returns (ok: bool, reason: str | None):
118
+ (True, None) — safe to proceed: the track is private, a legacy
119
+ shared track (no plan_branch → working-tree tier), or
120
+ the worktree rebased cleanly / had no upstream.
121
+ (False, reason) — the shared branch diverged and could NOT auto-rebase;
122
+ the caller MUST abort and surface {needs_rebase}
123
+ rather than blind-write over a diverged branch.
124
+
125
+ Never raises — an unexpected guard error degrades to "proceed" (consistent
126
+ with the toolkit's never-break-the-command VCS philosophy; the underlying
127
+ plan_worktree ops are themselves never-raise).
128
+ """
129
+ try:
130
+ if getattr(target, "tier", None) != "shared":
131
+ return (True, None)
132
+ repos = (cfg or {}).get("repos", {}) or {}
133
+ entry = repos.get(getattr(target, "folder", None))
134
+ if entry is None:
135
+ for e in repos.values():
136
+ if e and e.get("github") == getattr(target, "repo", None):
137
+ entry = e
138
+ break
139
+ branch = entry.get("plan_branch") if entry else None
140
+ local = entry.get("local") if entry else None
141
+ if not branch or not local:
142
+ return (True, None) # legacy shared tier (working tree) — no rebase
143
+ from lib import plan_worktree
144
+ worktree = plan_worktree.ensure_worktree(Path(local).expanduser(), branch)
145
+ if worktree is None:
146
+ return (True, None) # can't ensure the worktree — degrade, proceed
147
+ if not plan_worktree.rebase_onto_origin(worktree, branch):
148
+ return (False, f"shared plan branch '{branch}' diverged and could not "
149
+ f"auto-rebase; resolve manually")
150
+ return (True, None)
151
+ except Exception:
152
+ return (True, None)
@@ -267,6 +267,43 @@ def is_published(local_path: Path, branch: str) -> bool:
267
267
  return remote_branch_exists(local_path, branch)
268
268
 
269
269
 
270
+ def rebase_onto_origin(worktree: Path, branch: str) -> bool:
271
+ """Fetch origin/<branch> and rebase the worktree (checked out at <branch>)
272
+ onto it, so a shared-tier write lands on top of any teammate's pushed plan
273
+ changes instead of diverging (#241).
274
+
275
+ Returns:
276
+ True — the worktree is now at-or-ahead of origin: rebase succeeded,
277
+ there was nothing to replay, or the branch isn't published yet
278
+ (local is authoritative — nothing to rebase onto).
279
+ False — the rebase hit a conflict (it is ABORTED so the worktree is left
280
+ clean, never half-rebased) or git couldn't run. The caller must
281
+ NOT write — it surfaces {needs_rebase} and bails.
282
+
283
+ Never raises.
284
+ """
285
+ wt = Path(worktree).expanduser()
286
+ # Fetch so origin/<branch> is authoritative before we compare.
287
+ fetch_branch(wt, branch)
288
+ if not remote_branch_exists(wt, branch):
289
+ return True # unpublished — no upstream to rebase onto; local wins
290
+ # --autostash: the normal flow is write-file-then-commit, so the worktree's
291
+ # .work-plan/ is routinely dirty when we rebase. Without autostash git would
292
+ # refuse the rebase on the dirty precondition and we'd report a spurious
293
+ # {needs_rebase}; autostash shelves the local edits, rebases, and reapplies
294
+ # them on top — so a clean rebase succeeds even with pending plan edits.
295
+ proc = _git(wt, "rebase", "--autostash", f"origin/{branch}")
296
+ if proc is None:
297
+ return False
298
+ if proc.returncode == 0:
299
+ return True
300
+ # True conflict (autostash reapply or replayed commits): abort so the
301
+ # worktree is never left in a partially-rebased state. A blind write over a
302
+ # diverged shared branch is exactly what this guard exists to prevent.
303
+ _git(wt, "rebase", "--abort")
304
+ return False
305
+
306
+
270
307
  def unpushed_oneline(local_path: Path, branch: str) -> list:
271
308
  """One-line summaries of commits on local `branch` not yet on origin
272
309
  (`origin/<branch>..<branch>`). If origin/<branch> doesn't exist, every commit
@@ -347,5 +347,108 @@ class AutoTriageApplyTest(unittest.TestCase):
347
347
  self.assertIn("0 issue(s) assigned", buf.getvalue())
348
348
 
349
349
 
350
+ class AutoTriageJsonScanTest(unittest.TestCase):
351
+ """--json scan mode (#241): emit a machine batch with a batch_id."""
352
+
353
+ def test_json_mode_emits_batch_with_id_and_prompt(self):
354
+ cfg = _make_cfg()
355
+ tracks = [_make_track("auth-flow", "org/myrepo", [])]
356
+ rc, out, _ = _drive_prepare(["--json"], cfg=cfg, tracks=tracks,
357
+ open_issues=_open_issues(4501, 4502))
358
+ self.assertEqual(rc, 0)
359
+ data = json.loads(out.strip()) # stdout is a single JSON object
360
+ self.assertIn("batch_id", data)
361
+ self.assertTrue(data["batch_id"])
362
+ self.assertEqual({i["number"] for i in data["untracked"]}, {4501, 4502})
363
+ self.assertIn("prompt", data)
364
+ self.assertIn("answers_path", data)
365
+ # Track entries carry scope text for grounded matching.
366
+ self.assertIn("scope", data["tracks"][0])
367
+
368
+
369
+ class AutoTriageHeuristicTest(unittest.TestCase):
370
+ """--heuristic (#373): the CLI writes the v2 answers file itself, no LLM."""
371
+
372
+ def test_heuristic_writes_answers_file_with_source_and_batch_id(self):
373
+ cfg = _make_cfg()
374
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
375
+ track.meta["milestone_alignment"] = "v0.4"
376
+ open_issues = [{"number": 4501, "title": "auth thing", "state": "OPEN",
377
+ "milestone": {"title": "v0.4"}, "labels": []}]
378
+ with tempfile.TemporaryDirectory() as tmp:
379
+ batch_file = Path(tmp) / "auto_triage.org_myrepo.json"
380
+ answers_file = Path(tmp) / "auto_triage.org_myrepo.answers.json"
381
+ with patch("commands.auto_triage.load_config", return_value=cfg), \
382
+ patch("commands.auto_triage.discover_tracks", return_value=[track]), \
383
+ patch("commands.auto_triage.fetch_open_issues", return_value=open_issues), \
384
+ patch("commands.auto_triage._batch_path", return_value=batch_file), \
385
+ patch("commands.auto_triage._answers_path", return_value=answers_file):
386
+ buf = io.StringIO()
387
+ with redirect_stdout(buf):
388
+ rc = auto_triage.run(["--heuristic"])
389
+ self.assertEqual(rc, 0)
390
+ self.assertTrue(answers_file.exists(), "heuristic must write the answers file")
391
+ doc = json.loads(answers_file.read_text())
392
+ self.assertEqual(doc["version"], 2)
393
+ self.assertEqual(doc["source"], "heuristic")
394
+ batch = json.loads(batch_file.read_text())
395
+ self.assertEqual(doc["batch_id"], batch["batch_id"]) # correlates
396
+ sug = doc["suggestions"][0]
397
+ self.assertEqual(sug["issue"], 4501)
398
+ self.assertEqual(sug["verdict"], "suggest")
399
+ self.assertEqual(sug["track"], "auth-flow")
400
+
401
+
402
+ class AutoTriageV2AnswersTest(unittest.TestCase):
403
+ """v2 abstain-first answers schema (#241) + back-compat with v1."""
404
+
405
+ def _batch(self, nums, batch_id="abc123"):
406
+ return {"batch_id": batch_id, "repo": "org/myrepo", "folder": "myrepo",
407
+ "untracked": [{"number": n} for n in nums]}
408
+
409
+ def test_v2_applies_only_clear_suggestions(self):
410
+ cfg = _make_cfg()
411
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
412
+ answers = {"version": 2, "batch_id": "abc123", "suggestions": [
413
+ {"issue": 4501, "verdict": "suggest", "track": "auth-flow",
414
+ "margin": "clear", "confidence": 0.9, "rationale": "label area/auth"},
415
+ {"issue": 4502, "verdict": "suggest", "track": "auth-flow",
416
+ "margin": "narrow", "confidence": 0.55, "rationale": "maybe"},
417
+ {"issue": 4507, "verdict": "abstain", "rationale": "no fit"},
418
+ ]}
419
+ rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
420
+ batch=self._batch([4501, 4502, 4507]),
421
+ answers=answers)
422
+ self.assertEqual(rc, 0)
423
+ mwrite.assert_called_once() # only the clear suggestion is written
424
+ written = mwrite.call_args[0][1]["github"]["issues"]
425
+ self.assertIn(4501, written)
426
+ self.assertNotIn(4502, written) # narrow margin → left untracked
427
+ self.assertNotIn(4507, written) # abstained → left untracked
428
+
429
+ def test_v1_answers_still_apply(self):
430
+ cfg = _make_cfg()
431
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
432
+ answers = [{"track": "auth-flow", "issues": [4501]}] # legacy shape
433
+ rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
434
+ batch=self._batch([4501]), answers=answers)
435
+ self.assertEqual(rc, 0)
436
+ mwrite.assert_called_once()
437
+ self.assertIn(4501, mwrite.call_args[0][1]["github"]["issues"])
438
+
439
+ def test_batch_id_mismatch_warns_but_applies(self):
440
+ cfg = _make_cfg()
441
+ track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow")
442
+ answers = {"version": 2, "batch_id": "STALE", "suggestions": [
443
+ {"issue": 4501, "verdict": "suggest", "track": "auth-flow",
444
+ "margin": "clear", "rationale": "label area/auth"}]}
445
+ rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track],
446
+ batch=self._batch([4501], batch_id="abc123"),
447
+ answers=answers)
448
+ self.assertEqual(rc, 0)
449
+ self.assertIn("older scan", out)
450
+ mwrite.assert_called_once()
451
+
452
+
350
453
  if __name__ == "__main__":
351
454
  unittest.main()
@@ -52,11 +52,21 @@ def _drive(args, tracks=None, vis="PRIVATE"):
52
52
  cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
53
53
  gh_proc = MagicMock(returncode=0, stdout="{}", stderr="")
54
54
 
55
+ # Writes go through lib.membership_guard (re-read via parse_file, write via
56
+ # write_file). Returning each track's own meta/body lets the guard mutate
57
+ # them in place, so assertions on track.meta still observe the merge.
58
+ by_path = {str(t.path): t for t in tracks}
59
+
60
+ def fake_parse(p):
61
+ t = by_path[str(p)]
62
+ return (t.meta, t.body)
63
+
55
64
  with patch("commands.batch_slot.load_config", return_value=cfg), \
56
65
  patch("commands.batch_slot.discover_tracks", return_value=tracks), \
57
66
  patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
58
67
  patch("lib.write_guard.repo_visibility", return_value=vis), \
59
- patch("commands.batch_slot.write_file") as mw:
68
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
69
+ patch("lib.membership_guard.write_file") as mw:
60
70
  buf = io.StringIO()
61
71
  with redirect_stdout(buf):
62
72
  rc = batch_slot.run(args)
@@ -272,6 +282,12 @@ class BatchSlotTest(unittest.TestCase):
272
282
  def _raise(*a, **kw):
273
283
  raise AssertionError("input() must not be called on non-interactive path")
274
284
 
285
+ by_path = {str(t.path): t for t in (source, target)}
286
+
287
+ def fake_parse(p):
288
+ t = by_path[str(p)]
289
+ return (t.meta, t.body)
290
+
275
291
  with patch("builtins.input", side_effect=_raise), \
276
292
  patch("lib.prompts.prompt_input", side_effect=_raise):
277
293
  cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
@@ -280,7 +296,8 @@ class BatchSlotTest(unittest.TestCase):
280
296
  patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \
281
297
  patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \
282
298
  patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
283
- patch("commands.batch_slot.write_file"):
299
+ patch("lib.membership_guard.parse_file", side_effect=fake_parse), \
300
+ patch("lib.membership_guard.write_file"):
284
301
  buf = io.StringIO()
285
302
  with redirect_stdout(buf):
286
303
  rc = batch_slot.run(["42", "beta", "--move"])
@@ -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()