@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
@@ -1,10 +1,11 @@
1
1
  """slot subcommand."""
2
2
  import json
3
3
  import subprocess
4
+ import sys
4
5
 
5
6
  from lib.config import load_config, ConfigError
6
7
  from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
7
- from lib.frontmatter import write_file
8
+ from lib.membership_guard import guarded_membership_write, shared_rebase_guard
8
9
  from lib.write_guard import needs_confirm, make_token, valid_token
9
10
  from lib.prompts import parse_flags, prompt_input
10
11
 
@@ -28,7 +29,7 @@ def run(args: list[str]) -> int:
28
29
  # --confirm uses equals form: --confirm=<token>
29
30
  # --move / --no-move are bare flags
30
31
  # --repo uses equals form: --repo=<key>
31
- flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo"})
32
+ flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo", "--expect"})
32
33
  if not positional:
33
34
  print("usage: work_plan.py slot <issue-num> [track | track@repo] [--repo=<key>]")
34
35
  return 2
@@ -116,39 +117,67 @@ def run(args: list[str]) -> int:
116
117
  }))
117
118
  return 0
118
119
 
120
+ # Shared-tier rebase (#241): for a track pinned to a plan_branch, pull +
121
+ # rebase the worktree onto origin before writing so a teammate's pushed plan
122
+ # change isn't clobbered. A divergence we can't auto-rebase aborts cleanly.
123
+ ok, reason = shared_rebase_guard(target, cfg)
124
+ if not ok:
125
+ print(json.dumps({"needs_rebase": True, "reason": reason, "track": target.name}))
126
+ return 0
127
+
119
128
  # Determine move behavior from flags.
120
129
  # --move: remove issue from prior owners.
121
130
  # Default / --no-move: add-only; print a note naming prior owners.
122
131
  do_move = "--move" in flags
123
132
 
124
- sources = _find_prior_owners(issue_num, target.repo, target.name, tracks)
133
+ # --expect=<fp> opts into the compare-and-swap staleness guard (#241). When
134
+ # present (the assisted/viewer path), advisory notes go to stderr so stdout
135
+ # stays pure for the {stale} abort signal the caller parses.
136
+ expect = flags.get("--expect")
137
+ expect = expect if isinstance(expect, str) else None
138
+ notes = sys.stderr if expect is not None else sys.stdout
125
139
 
126
- issues.append(issue_num)
127
- target.meta.setdefault("github", {})["issues"] = sorted(issues)
128
-
129
- proc = subprocess.run(
130
- ["gh", "issue", "view", str(issue_num),
131
- "--repo", target.repo, "--json", "milestone"],
132
- capture_output=True, text=True,
133
- )
134
- if proc.returncode == 0:
135
- info = json.loads(proc.stdout)
136
- m = info.get("milestone", {})
137
- if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"):
138
- print(f"⚠ #{issue_num} is on milestone '{m['title']}', "
139
- f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.")
140
+ sources = _find_prior_owners(issue_num, target.repo, target.name, tracks)
140
141
 
142
+ # Milestone mismatch is advisory only — never let gh being absent/odd crash
143
+ # the command (it sits between the rebase guard and the write).
144
+ try:
145
+ proc = subprocess.run(
146
+ ["gh", "issue", "view", str(issue_num),
147
+ "--repo", target.repo, "--json", "milestone"],
148
+ capture_output=True, text=True,
149
+ )
150
+ if proc.returncode == 0:
151
+ info = json.loads(proc.stdout)
152
+ m = info.get("milestone", {})
153
+ if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"):
154
+ print(f"⚠ #{issue_num} is on milestone '{m['title']}', "
155
+ f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.",
156
+ file=notes)
157
+ except (OSError, json.JSONDecodeError):
158
+ pass
159
+
160
+ # Move sources first (each re-read + merged onto fresh disk), then the
161
+ # target. The target write carries `expect`: on a detected concurrent change
162
+ # to the membership list it aborts with {stale} instead of clobbering.
141
163
  if sources and do_move:
142
164
  for src in sources:
143
- src_issues = [n for n in (src.meta.get("github", {}).get("issues") or [])
144
- if n != issue_num]
145
- src.meta.setdefault("github", {})["issues"] = src_issues
146
- write_file(src.path, src.meta, src.body)
147
- print(f" ✓ Removed #{issue_num} from '{src.name}'.")
165
+ guarded_membership_write(src.path, remove_nums=[issue_num])
166
+ print(f" ✓ Removed #{issue_num} from '{src.name}'.", file=notes)
148
167
  elif sources and not do_move:
149
168
  names = ", ".join(f"'{t.name}'" for t in sources)
150
- print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate.")
169
+ print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate.",
170
+ file=notes)
171
+
172
+ result = guarded_membership_write(target.path, add_nums=[issue_num], expect=expect)
173
+ if result.get("stale"):
174
+ print(json.dumps({
175
+ "stale": True,
176
+ "reason": result["reason"],
177
+ "current": result["current"],
178
+ "track": target.name,
179
+ }))
180
+ return 0
151
181
 
152
- write_file(target.path, target.meta, target.body)
153
182
  print(f"✓ Slotted #{issue_num} into '{target.name}'.")
154
183
  return 0
@@ -0,0 +1,52 @@
1
+ """which-repo — resolve the current directory to a configured repo.
2
+
3
+ Prints the matched repo's config key + GitHub slug, or reports no match. The
4
+ VS Code viewer spawns this with cwd set to the workspace folder to auto-focus its
5
+ repo lens (#357); `brief` calls the underlying resolver directly for cwd
6
+ auto-scope (#358). Read-only — never mutates anything.
7
+
8
+ Exit codes (human form): 0 on a match, 1 on no match — so a shell caller can
9
+ gate on it. The `--json` form always exits 0 and prints a `{"key": ...}` payload
10
+ (key is null on no match) for the viewer to parse.
11
+ """
12
+ import json
13
+ import os
14
+
15
+ from lib.config import load_config, ConfigError
16
+ from lib.cwd_repo import resolve_repo_for_dir
17
+ from lib.prompts import parse_flags
18
+
19
+
20
+ def run(args: list) -> int:
21
+ flags, _ = parse_flags(args, {"--json"})
22
+ want_json = bool(flags.get("--json"))
23
+
24
+ try:
25
+ cfg = load_config()
26
+ except ConfigError as e:
27
+ if want_json:
28
+ print(json.dumps({"key": None}))
29
+ return 0
30
+ print(f"ERROR: {e}")
31
+ return 1
32
+
33
+ match = resolve_repo_for_dir(cfg, os.getcwd())
34
+
35
+ if want_json:
36
+ if match:
37
+ print(json.dumps({
38
+ "key": match["key"],
39
+ "github": match.get("github"),
40
+ "matched_by": match["matched_by"],
41
+ }))
42
+ else:
43
+ print(json.dumps({"key": None}))
44
+ return 0
45
+
46
+ if match:
47
+ how = "local clone path" if match["matched_by"] == "local" else "git remote"
48
+ print(f"Resolved to repo '{match['key']}' (matched by {how}).")
49
+ return 0
50
+
51
+ print("No configured repo matches the current directory.")
52
+ return 1
@@ -0,0 +1,133 @@
1
+ """Resolve a directory to a configured repo (config key + GitHub slug).
2
+
3
+ Shared substrate for two sibling features: `brief` cwd auto-scope (#358) and the
4
+ VS Code viewer auto-focus (#357). Both need the same "which configured repo is
5
+ this directory?" answer, so it lives here once — exposed to the viewer through
6
+ the `which-repo` command and called directly by `brief`.
7
+
8
+ Resolution order: the local clone path is the primary signal (it's the most
9
+ explicit thing the user configured); the git `origin` remote is the fallback for
10
+ repos registered with `local: null`. If both resolve but to different keys (which
11
+ shouldn't happen in practice), the local-path key wins.
12
+
13
+ Read-only and never raises — every git call goes through the bounded `_git`
14
+ wrapper, which returns None on failure, and a no-match returns None so callers
15
+ fall back to their current all-repos behavior unchanged.
16
+ """
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from lib.git_state import _git
22
+
23
+
24
+ def _normalize_remote_url(url: str) -> Optional[str]:
25
+ """Normalize a git remote URL to a lowercased `org/repo` slug, or None.
26
+
27
+ Handles the forms git emits for GitHub remotes:
28
+ git@github.com:org/repo.git -> org/repo (scp-like)
29
+ ssh://git@github.com/org/repo.git -> org/repo
30
+ https://github.com/org/repo.git -> org/repo
31
+ https://github.com/org/repo -> org/repo
32
+
33
+ A trailing `.git`, surrounding whitespace, and trailing slashes are stripped.
34
+ Returns None for anything it can't parse into a host-path.
35
+ """
36
+ if not url:
37
+ return None
38
+ u = url.strip()
39
+
40
+ # scp-like syntax: [user@]host:path (no scheme, single colon before path)
41
+ m = re.match(r"^[\w.+-]+@[\w.-]+:(.+)$", u)
42
+ if m:
43
+ path = m.group(1)
44
+ else:
45
+ # url syntax: scheme://[user@]host[:port]/path
46
+ m = re.match(r"^[\w.+-]+://(?:[^/@]+@)?[^/]+/(.+)$", u)
47
+ if not m:
48
+ return None
49
+ path = m.group(1)
50
+
51
+ path = path.strip().strip("/")
52
+ if path.endswith(".git"):
53
+ path = path[:-len(".git")]
54
+ path = path.strip("/")
55
+ return path.lower() or None
56
+
57
+
58
+ def _toplevel(start_dir) -> Optional[Path]:
59
+ """The git work-tree root containing `start_dir`, resolved absolute, or None.
60
+
61
+ Uses `rev-parse --show-toplevel` (not the raw dir) so resolution works from
62
+ any nested subdirectory and a configured `local` that merely *contains* the
63
+ cwd can't false-match.
64
+ """
65
+ proc = _git(start_dir, "rev-parse", "--show-toplevel")
66
+ if proc is None or proc.returncode != 0:
67
+ return None
68
+ out = proc.stdout.strip()
69
+ if not out:
70
+ return None
71
+ try:
72
+ return Path(out).resolve()
73
+ except OSError:
74
+ return None
75
+
76
+
77
+ def _origin_slug(start_dir) -> Optional[str]:
78
+ """The `org/repo` slug of `start_dir`'s `origin` remote, or None."""
79
+ proc = _git(start_dir, "remote", "get-url", "origin")
80
+ if proc is None or proc.returncode != 0:
81
+ return None
82
+ return _normalize_remote_url(proc.stdout.strip())
83
+
84
+
85
+ def resolve_repo_for_dir(cfg: dict, start_dir) -> Optional[dict]:
86
+ """Resolve `start_dir` to a single configured repo.
87
+
88
+ Returns `{"key", "github", "matched_by"}` (matched_by is "local" or
89
+ "remote") when exactly one repo matches, else None. None covers: no repos
90
+ configured, dir isn't a git repo, no match, or an ambiguous (>1) match —
91
+ callers treat all of these as "don't auto-scope."
92
+ """
93
+ repos = cfg.get("repos") or {}
94
+ if not repos:
95
+ return None
96
+
97
+ # --- local clone path (primary) ---
98
+ top = _toplevel(start_dir)
99
+ if top is not None:
100
+ local_keys = []
101
+ for key, entry in repos.items():
102
+ local_raw = (entry or {}).get("local")
103
+ if not local_raw:
104
+ continue
105
+ try:
106
+ cfg_root = Path(local_raw).expanduser().resolve()
107
+ except OSError:
108
+ continue
109
+ if cfg_root == top:
110
+ local_keys.append(key)
111
+ if len(local_keys) == 1:
112
+ key = local_keys[0]
113
+ return {"key": key,
114
+ "github": (repos[key] or {}).get("github"),
115
+ "matched_by": "local"}
116
+ if len(local_keys) > 1:
117
+ # Ambiguous local config — refuse to guess.
118
+ return None
119
+
120
+ # --- git origin remote (fallback) ---
121
+ slug = _origin_slug(start_dir)
122
+ if slug:
123
+ remote_keys = [
124
+ key for key, entry in repos.items()
125
+ if entry and entry.get("github") and entry["github"].lower() == slug
126
+ ]
127
+ if len(remote_keys) == 1:
128
+ key = remote_keys[0]
129
+ return {"key": key,
130
+ "github": repos[key].get("github"),
131
+ "matched_by": "remote"}
132
+
133
+ return None
@@ -23,8 +23,13 @@ def detect_drift(body: str, github_issues: list[dict]) -> list[dict]:
23
23
  continue
24
24
  gh_state = state_by_num[num]
25
25
  looks_closed = any(k in body_status for k in ("✅", "shipped", "merged", "closed"))
26
- looks_open = "🔲" in body_status or "open" in body_status
27
26
 
27
+ # Asymmetric by design: CLOSED is terminal, so a closed issue whose
28
+ # row doesn't explicitly read closed (open marker, ambiguous, or empty)
29
+ # is drift. OPEN is not terminal — an open issue legitimately sits in
30
+ # many states (in-progress, blocked, todo), so only an explicit closed
31
+ # marker contradicts it. A broad open-side check (`not looks_open`)
32
+ # would false-positive every in-progress row, which is why we don't.
28
33
  if gh_state == "CLOSED" and not looks_closed:
29
34
  drift.append({"issue": num, "body_status": body_status, "github_state": "CLOSED"})
30
35
  elif gh_state == "OPEN" and looks_closed:
@@ -1,6 +1,6 @@
1
1
  """Build the versioned viewer export structure from tracks + fetched issues."""
2
2
  from lib.github_state import format_assignees, short_milestone
3
- from lib.next_up import resolve_next_up_order
3
+ from lib.next_up import resolve_next_up_order, suggest_next_up
4
4
 
5
5
  SCHEMA = 1
6
6
 
@@ -86,7 +86,7 @@ def normalize_issue(i: dict, in_progress: bool = False,
86
86
  def build_export(tracks, issues_by_track, visibility, now: str,
87
87
  untracked_by_repo=None, config_repos=None,
88
88
  plan_by_track=None, hot_by_track=None,
89
- next_up_default=None) -> dict:
89
+ next_up_default=None, tier_duplicates=None) -> dict:
90
90
  plan_by_track = plan_by_track or {}
91
91
  hot_by_track = hot_by_track or {}
92
92
  out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
@@ -110,9 +110,22 @@ def build_export(tracks, issues_by_track, visibility, now: str,
110
110
  issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
111
111
  opened = sum(1 for i in issues if i["state"] == "open")
112
112
  closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
113
- next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
114
113
  track_path = getattr(t, "path", None)
115
- next_up_preset_name, _ = resolve_next_up_order(t.meta, next_up_default)
114
+ next_up_preset_name, next_up_order = resolve_next_up_order(t.meta, next_up_default)
115
+ # When `next_up_auto: true`, derive the next-up list live from the track's
116
+ # open issues using the resolved ranking preset — same as brief/orient —
117
+ # instead of reading the curated `next_up` frontmatter list. This is what
118
+ # surfaces the ranking in the viewer (which only reads this export). #326.
119
+ if t.meta.get("next_up_auto") and raw:
120
+ in_progress_set = {i["number"] for i in raw if issue_in_progress(i, hot)}
121
+ next_up = suggest_next_up(
122
+ raw, t.meta.get("blockers") or [],
123
+ track_milestone=milestone_alignment or None,
124
+ in_progress_nums=in_progress_set,
125
+ order=next_up_order,
126
+ )
127
+ else:
128
+ next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
116
129
  out["tracks"].append({
117
130
  "name": t.name,
118
131
  "repo": t.repo,
@@ -140,6 +153,11 @@ def build_export(tracks, issues_by_track, visibility, now: str,
140
153
  "plan": plan_by_track.get(t.name),
141
154
  # Effective next_up ranking preset for this track (#326 Phase 2).
142
155
  "next_up_preset": next_up_preset_name,
156
+ # True when the track has `next_up_auto: true` set in its frontmatter,
157
+ # meaning the next-up list is auto-derived from the ranking preset (#326).
158
+ # Reflects the SETTING, not whether derivation actually ran (so the
159
+ # viewer toggle shows On even when a track has zero open issues).
160
+ "next_up_auto": bool(t.meta.get("next_up_auto")),
143
161
  })
144
162
  out["untracked"] = [
145
163
  {"repo": repo, "issues": [normalize_issue(r) for r in rows]}
@@ -151,4 +169,9 @@ def build_export(tracks, issues_by_track, visibility, now: str,
151
169
  # the starting point for adding fresh tracks. Each entry:
152
170
  # {folder, repo(slug), local, has_local, visibility}.
153
171
  out["repos"] = list(config_repos or [])
172
+ # Shared/private tier duplicates (#361, additive): tracks that exist in both
173
+ # a repo's shared .work-plan/ and the private notes_root tier. Read-only
174
+ # health signal for the viewer; resolved with the `dedupe-tiers` CLI verb.
175
+ # Each entry: {repo, folder, name, shared_path, private_path, safe}.
176
+ out["tier_duplicates"] = list(tier_duplicates or [])
154
177
  return out
@@ -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)