@stylusnexus/work-plan 2026.6.15 → 2026.6.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/VERSION +1 -1
- package/package.json +1 -5
- package/skills/work-plan/SKILL.md +2 -0
- package/skills/work-plan/commands/auto_triage.py +187 -35
- package/skills/work-plan/commands/batch_slot.py +58 -33
- package/skills/work-plan/commands/brief.py +29 -3
- package/skills/work-plan/commands/dedupe_tiers.py +104 -0
- package/skills/work-plan/commands/export.py +29 -4
- package/skills/work-plan/commands/handoff.py +90 -26
- package/skills/work-plan/commands/hygiene.py +24 -9
- package/skills/work-plan/commands/set_next_up.py +64 -8
- package/skills/work-plan/commands/slot.py +53 -24
- package/skills/work-plan/commands/which_repo.py +52 -0
- package/skills/work-plan/lib/cwd_repo.py +133 -0
- package/skills/work-plan/lib/drift.py +6 -1
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/heuristic_triage.py +134 -0
- package/skills/work-plan/lib/membership_guard.py +152 -0
- package/skills/work-plan/lib/plan_worktree.py +37 -0
- package/skills/work-plan/lib/tracks.py +64 -2
- package/skills/work-plan/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -2
- package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
- package/skills/work-plan/tests/test_cwd_repo.py +164 -0
- package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
- package/skills/work-plan/tests/test_drift.py +32 -0
- package/skills/work-plan/tests/test_export.py +63 -0
- package/skills/work-plan/tests/test_export_command.py +56 -0
- package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
- package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
- package/skills/work-plan/tests/test_membership_guard.py +116 -0
- package/skills/work-plan/tests/test_register_which_repo.py +22 -0
- package/skills/work-plan/tests/test_set_next_up.py +95 -0
- package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
- package/skills/work-plan/tests/test_slot.py +94 -2
- package/skills/work-plan/tests/test_slot_move.py +10 -1
- package/skills/work-plan/work_plan.py +17 -7
- package/scripts/npm-check-deps.js +0 -44
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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,
|
|
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)
|