diff --git a/README.md b/README.md index a8cc708..500803b 100644 --- a/README.md +++ b/README.md @@ -514,7 +514,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h | `which-repo [--json]` | Resolve the current directory to one configured repo — by local clone path first, then the git `origin` remote. Prints the matched config key + GitHub slug, or reports no match. Read-only; it's the shared resolver behind `brief`'s cwd auto-scope and the VS Code viewer's repo auto-focus. | | `handoff [--auto-next \| --set-next 1,2,3]` | Wrap up a work block. Writes a `### Session — ` entry. `--auto-next` suggests a priority-sorted top-3 from open issues (interactive: apply / edit / skip). `--set-next 1,2,3` is the explicit form — note it writes the session entry too; for a field-only `next_up` change with no session log, use `set next_up=…`. Without either flag, just captures the session summary and reads any pre-existing `next_up`. | | `orient [track]` (alias: `where-was-i`) | Read-only paste block. With a track name: ~15-line track summary (priority, last session, next pick, git state). With no track: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. | -| `slot [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=` (public-repo gate, see below). | +| `slot [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=` (public-repo gate, see below), `--expect=` (compare-and-swap: the fingerprint of the track's issue list as the caller last saw it — if the on-disk list changed since, the write aborts with a `{stale}` JSON signal instead of clobbering; used by the viewer's assisted-slot flow, #241). The write always re-reads the file and merges onto fresh disk; for a shared-tier track on a `plan_branch` it fetch+rebases the worktree first and aborts with `{needs_rebase}` on an un-rebasable divergence. | | `close [--state=shipped\|parked\|abandoned] [--note=]` | Mark track shipped, parked, or abandoned. Moves to `archive//` for shipped/abandoned. Pass `--state=` (and an optional `--note=`) to run without prompts. | | `refresh-md ` `\|` `--all` `\|` `--repo=` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. If the live fetch comes back incomplete (GitHub timeout/permission error, or a frontmatter issue that no longer resolves), that track is **skipped and left untouched** rather than rewriting valid rows as `(not fetched)`, and the command exits nonzero so sweeps can flag the degraded run. `--all` sweeps every active track; `--repo=` scopes the sweep to one repo. | | `hygiene [--repo=]` | Weekly all-in-one: `refresh-md` + `reconcile` + `dedupe-tiers` (report-only) + `duplicates`. With `--repo=`, steps 1–3 scope to that repo and the global `duplicates` step is skipped. | @@ -532,7 +532,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h | `plan-branch [--branch=] [--confirm=] [--dry-run] [--json]` | Set up and share a repo's canonical **shared-tier** plan branch. The `.work-plan/` tier is pinned to ONE per-repo `plan_branch`, read/written through a dedicated git worktree, so planning never diverges across code branches or pollutes PR / deploy diffs. `init` creates that branch + a `.work-plan/` skeleton (default an **orphan** `work-plan/plan` — zero shared history with code, like `gh-pages`; override with `--branch`) and records `plan_branch` in config — or **connects** to a teammate's already-published branch if one exists. `init` is **local only** (no push). `status` reports whether the branch exists, is published to origin, and how many commits are unpushed (`--json` for the machine shape). `push` shares it: on a **public** repo it prints a confirm heads-up + token and exits (re-run with `--confirm=`); `--dry-run` previews the commits that would push. Requires a repo registered via `init-repo` with a local clone path. | | `suggest-priorities --repo=` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. | | `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). | -| `auto-triage [--repo=] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). | +| `auto-triage [--repo=] [--apply] [--json] [--heuristic] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`); the scan stamps a `batch_id` and writes a per-repo cache file. `--json` emits the batch (+ prompt + answers path) as one JSON object on stdout for the VS Code viewer's Suggested bucket (#241) instead of the human prompt. `--heuristic` (#373) skips the LLM: it scores each issue against the candidate tracks with local signals (milestone match, track-label overlap, title/scope keyword overlap) and writes the v2 answers file itself (`source: "heuristic"`, abstain-first) — so suggestions work with no Claude session (lower-trust, offline). `--apply` accepts the **v2** abstain-first answers shape (`{version,batch_id,suggestions:[{issue,verdict,track,confidence,margin,rationale}]}` — only clear-margin `suggest` verdicts are slotted; abstains/narrow stay untracked) as well as the legacy v1 `[{track,issues}]`. Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). | | `coverage [--repo=] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. | | `reconcile ` `\|` `--all` `\|` `--repo= [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews the label drift (ADDs/MOVEs/FLAGs) without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. | | `duplicates [--repo=]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. | diff --git a/skills/work-plan/SKILL.md b/skills/work-plan/SKILL.md index cff68f9..1738a60 100644 --- a/skills/work-plan/SKILL.md +++ b/skills/work-plan/SKILL.md @@ -75,6 +75,8 @@ All three follow the same pattern: Show the proposed labels/clusters/assignments BEFORE applying. The user may want to override. +**`auto-triage` answers — write atomically and copy the `batch_id`.** The VS Code viewer watches the answers file and reads suggestions live (#241), so a half-written file would be read mid-write. Write to a `.tmp` sibling and then rename it onto the final `auto_triage..answers.json` path the CLI printed (the Write tool's create-then-replace is fine; never append). Use the **v2** answers shape — `{"version": 2, "batch_id": "", "suggestions": [...]}` — and copy the `batch_id` the scan printed so the viewer can tell fresh suggestions from a stale older scan. Prefer `"verdict": "abstain"` for issues with no clear home: most untracked issues genuinely have none, and a wrong suggestion a human rubber-stamps is worse than silence. + **Which one to use:** - `group` — issues need to be *clustered into new track files* (run once per milestone or after a re-org) - `auto-triage` — untracked issues need to be *assigned to existing tracks* (run after `coverage` shows a gap) diff --git a/skills/work-plan/commands/auto_triage.py b/skills/work-plan/commands/auto_triage.py index 01f0c56..1396cd8 100644 --- a/skills/work-plan/commands/auto_triage.py +++ b/skills/work-plan/commands/auto_triage.py @@ -9,14 +9,30 @@ Use --repo= to scope to one configured repo. When the config has a single repo, --repo is inferred automatically. -Answers JSON format (written to cache/auto_triage.answers.json): - [ - {"track": "auth-flow", "issues": [4501, 4502]}, - {"track": "tabletop-sessions", "issues": [4503]} - ] -Issues omitted from every list are left untracked (no error). +Answers JSON — two accepted shapes (the reader sniffs which one): + + v1 (legacy, still accepted): + [ + {"track": "auth-flow", "issues": [4501, 4502]}, + {"track": "tabletop-sessions", "issues": [4503]} + ] + + v2 (preferred — abstain-first, per-issue, carries confidence/rationale the + VS Code viewer renders; #241): + {"version": 2, "batch_id": "", "suggestions": [ + {"issue": 4501, "verdict": "suggest", "track": "auth-flow", + "runner_up": "tabletop-sessions", "confidence": 0.82, "margin": "clear", + "rationale": "shares milestone v0.4.0 and label area/auth"}, + {"issue": 4507, "verdict": "abstain", "rationale": "no track covers billing"} + ]} + +In v2 `--apply` slots only verdict=="suggest" assignments whose margin is "clear" +(narrow-margin / abstained issues stay untracked — the safe default). Issues +omitted entirely are left untracked (no error). """ +import hashlib import json +import os import subprocess import sys from datetime import datetime @@ -29,36 +45,61 @@ from lib.github_state import fetch_open_issues -def _batch_path() -> Path: - return cache_dir() / "auto_triage.json" +def _repo_slug(repo) -> str: + """Filesystem-safe slug for a repo's per-repo cache files (#241): two repos + never collide on the single fixed cache path (a multi-repo clobber race).""" + return (repo or "").replace("/", "_") -def _answers_path() -> Path: - return cache_dir() / "auto_triage.answers.json" +def _batch_path(repo=None) -> Path: + name = f"auto_triage.{_repo_slug(repo)}.json" if repo else "auto_triage.json" + return cache_dir() / name -PROMPT_TEMPLATE = """\ -You have a list of EXISTING tracks and a list of UNTRACKED open issues. -Assign each issue to the most appropriate existing track. +def _answers_path(repo=None) -> Path: + name = f"auto_triage.{_repo_slug(repo)}.answers.json" if repo else "auto_triage.answers.json" + return cache_dir() / name -Return JSON — an array of assignment objects: -[ - {"track": "", "issues": []}, - ... -] + +def _make_batch_id(repo: str) -> str: + """A short id correlating an answers file to the batch that produced it. The + viewer checks it so a stale answers file from an older scan isn't rendered as + current (mtime alone can't tell — answers are always newer than the batch).""" + stamp = datetime.now().strftime("%Y%m%dT%H%M%S") + return hashlib.sha256(f"{repo}:{stamp}".encode("utf-8")).hexdigest()[:12] + + +PROMPT_TEMPLATE = """\ +For EACH untracked issue below, decide whether one of the EXISTING tracks is a +clearly correct home — and if not, ABSTAIN. Most untracked issues will NOT have +a clear home; that is normal and correct. Only suggest a track when the issue is +unmistakably about that track's scope. + +Return JSON in this exact shape: +{"version": 2, "batch_id": "", "suggestions": [ + {"issue": , "verdict": "suggest", "track": "", + "runner_up": "", "confidence": <0.0-1.0>, + "margin": "clear" | "narrow", + "rationale": ""}, + {"issue": , "verdict": "abstain", "rationale": ""} +]} Rules: - Use ONLY the track slugs listed under "Existing tracks" below. -- An issue can appear in AT MOST ONE track assignment. -- Omit issues that genuinely don't fit any existing track (they stay untracked). +- Name your top choice AND your runner-up. If you cannot clearly distinguish + them, set "margin": "narrow" — that means neither is clearly right. +- "rationale" must cite a CONCRETE shared signal (a label, a milestone, a scope + keyword). "Generally related" is not a valid reason — abstain instead. +- When in doubt, ABSTAIN (verdict "abstain", no track). A wrong suggestion a + human rubber-stamps is worse than no suggestion. - Do NOT invent new tracks — that's /work-plan group's job. -- Do NOT include empty assignments (issues: []). """ def run(args: list[str]) -> int: apply_mode = "--apply" in args + heuristic = "--heuristic" in args repo_arg = next((a for a in args if a.startswith("--repo=")), None) limit = 100 @@ -77,7 +118,13 @@ def run(args: list[str]) -> int: return 1 if apply_mode: - return _apply(cfg) + # Resolve repo for per-repo cache files; no --repo falls back to the + # legacy fixed filenames (back-compat with the pre-#241 terminal flow). + apply_repo = None + if repo_arg: + folder = repo_arg.split("=", 1)[1] + apply_repo = cfg.get("repos", {}).get(folder, {}).get("github") + return _apply(cfg, apply_repo) # ----------------------------------------------------------------------- # Step 1: fetch untracked issues + print AI prompt @@ -115,7 +162,8 @@ def run(args: list[str]) -> int: if t.repo == repo and t.has_frontmatter: tracked_nums.update(t.meta.get("github", {}).get("issues") or []) - print(f"Fetching open issues from {repo}...") + # Progress goes to stderr so --json keeps stdout a single clean JSON object. + print(f"Fetching open issues from {repo}...", file=sys.stderr) open_issues = fetch_open_issues(repo, limit=500) untracked = [i for i in open_issues if i.get("number") not in tracked_nums] @@ -123,28 +171,69 @@ def run(args: list[str]) -> int: print(f"No untracked issues found for {repo} — full coverage!") return 0 - batch_path = _batch_path() - batch_path.write_text(json.dumps({ + batch_id = _make_batch_id(repo) + batch_obj = { + "batch_id": batch_id, "repo": repo, "folder": folder, "untracked": untracked, "tracks": [{"slug": t.meta.get("track", t.name), "name": t.name, "milestone": t.meta.get("milestone_alignment"), - "priority": t.meta.get("launch_priority")} + "priority": t.meta.get("launch_priority"), + # Scope/description grounds the match on what the track is + # FOR, not just its slug string (#241, ai-engineer review). + "scope": t.meta.get("scope") or t.meta.get("description") or ""} for t in active_tracks], - }, indent=2)) + } + batch_path = _batch_path(repo) + batch_path.write_text(json.dumps(batch_obj, indent=2)) + + # --heuristic (#373): compute suggestions deterministically (no LLM) and + # write the v2 answers file ourselves, so the Suggested bucket works with no + # Claude session. Same schema the LLM path produces, stamped + # source:"heuristic" so the viewer can flag it lower-trust. Atomic + # (.tmp + os.replace) since the viewer watches this path live. + if heuristic: + from lib.heuristic_triage import score_suggestions + suggestions = score_suggestions( + untracked, + [{"slug": t.meta.get("track", t.name), "name": t.name, + "milestone": t.meta.get("milestone_alignment"), + "scope": t.meta.get("scope") or t.meta.get("description") or "", + "labels": (t.meta.get("github", {}) or {}).get("labels") or []} + for t in active_tracks], + ) + answers_path = _answers_path(repo) + tmp = answers_path.with_suffix(answers_path.suffix + ".tmp") + tmp.write_text(json.dumps( + {"version": 2, "source": "heuristic", "batch_id": batch_id, + "suggestions": suggestions}, indent=2)) + os.replace(tmp, answers_path) + + # --json: emit the batch (+ prompt + answers path) as one machine-readable + # object for the VS Code viewer, which captures batch_id to correlate the + # answers a Claude session writes back (#241). No human prose on stdout. + if "--json" in args: + print(json.dumps({**batch_obj, + "prompt": PROMPT_TEMPLATE, + "answers_path": str(_answers_path(repo))})) + return 0 print(f"Found {len(untracked)} untracked issues ({len(active_tracks)} active tracks).") print() print("=" * 60) print(PROMPT_TEMPLATE) + print(f"batch_id: {batch_id} (copy into the answers JSON)") + print() print("Existing tracks:") for t in active_tracks: slug = t.meta.get("track", t.name) milestone = t.meta.get("milestone_alignment", "—") priority = t.meta.get("launch_priority", "—") - print(f" {slug} [{priority}, {milestone}]") + scope = t.meta.get("scope") or t.meta.get("description") or "" + scope_txt = f" — {scope}" if scope else "" + print(f" {slug} [{priority}, {milestone}]{scope_txt}") print() print("Untracked issues to assign:") @@ -162,16 +251,75 @@ def run(args: list[str]) -> int: print("=" * 60) print() - print(f"After the agent returns assignment JSON, save it to:") - print(f" {_answers_path()}") + if heuristic: + print(f"Heuristic suggestions written to:") + print(f" {_answers_path(repo)}") + print("They appear in the VS Code Suggested bucket, or apply from the terminal:") + print(f" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply --repo={folder}") + return 0 + print(f"After the agent returns assignment JSON, save it (atomically — write") + print(f"a .tmp then rename) to:") + print(f" {_answers_path(repo)}") print("Then run:") - print(" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply") + print(f" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply --repo={folder}") return 0 -def _apply(cfg: dict) -> int: - answers_path = _answers_path() - batch_path = _batch_path() +def _normalize_answers(answers, batch_id=None): + """Collapse either answers shape into v1 assignment objects [{track, issues}]. + + - v2 (dict with "suggestions"): keep only verdict=="suggest" whose margin is + not "narrow" (abstains and narrow-margin items stay untracked — the safe + default), group by track. confidence/rationale are for the viewer; the + write ignores them. + - v1 (list): passed through. + + The file is model-authored/untrusted, so every field is hardened: issue + numbers int-coerced, malformed entries skipped, unknown shapes ignored. + Returns (assignments, batch_mismatch: bool). + """ + mismatch = False + if isinstance(answers, dict) and "suggestions" in answers: + if batch_id and answers.get("batch_id") and answers["batch_id"] != batch_id: + mismatch = True + by_track: dict = {} + for s in answers.get("suggestions") or []: + if not isinstance(s, dict): + continue + if s.get("verdict") != "suggest": + continue + if s.get("margin") == "narrow": + continue + slug = (s.get("track") or "").strip() + if not slug: + continue + try: + num = int(s.get("issue")) + except (TypeError, ValueError): + continue + by_track.setdefault(slug, []).append(num) + return ([{"track": k, "issues": v} for k, v in by_track.items()], mismatch) + + # v1 legacy list. + out = [] + for a in answers if isinstance(answers, list) else []: + if not isinstance(a, dict): + continue + slug = (a.get("track") or "").strip() + nums = [] + for n in a.get("issues") or []: + try: + nums.append(int(n)) + except (TypeError, ValueError): + continue + if slug and nums: + out.append({"track": slug, "issues": nums}) + return (out, mismatch) + + +def _apply(cfg: dict, repo: str = None) -> int: + answers_path = _answers_path(repo) + batch_path = _batch_path(repo) if not answers_path.exists(): print(f"ERROR: {answers_path} not found. Run without --apply first.") return 1 @@ -186,7 +334,11 @@ def _apply(cfg: dict) -> int: print(f"ERROR: batch folder '{folder}' not in config.yml repos.") return 1 - answers = json.loads(answers_path.read_text()) + raw_answers = json.loads(answers_path.read_text()) + answers, batch_mismatch = _normalize_answers(raw_answers, batch.get("batch_id")) + if batch_mismatch: + print("⚠ answers batch_id does not match the current batch — these " + "suggestions may be from an older scan. Re-run without --apply to refresh.") tracks = discover_tracks(cfg) tracks_by_slug = {} diff --git a/skills/work-plan/commands/batch_slot.py b/skills/work-plan/commands/batch_slot.py index c2d1cba..bd49b7d 100644 --- a/skills/work-plan/commands/batch_slot.py +++ b/skills/work-plan/commands/batch_slot.py @@ -1,10 +1,11 @@ """batch-slot subcommand — slot multiple issues into a track at once.""" import json import subprocess +import sys from lib.config import load_config, ConfigError from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError -from lib.frontmatter import write_file +from lib.membership_guard import guarded_membership_write, shared_rebase_guard from lib.write_guard import needs_confirm, make_token, valid_token from lib.prompts import parse_flags @@ -25,7 +26,7 @@ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks): def run(args: list[str]) -> int: flags, positional = parse_flags( - args, {"--confirm", "--move", "--no-move", "--repo"} + args, {"--confirm", "--move", "--no-move", "--repo", "--expect"} ) if len(positional) < 2: @@ -99,8 +100,22 @@ def run(args: list[str]) -> int: ) return 0 + # Shared-tier rebase (#241): pull + rebase the plan_branch worktree onto + # origin before writing; an un-rebasable divergence aborts cleanly. + ok, reason = shared_rebase_guard(target, cfg) + if not ok: + print(json.dumps({"needs_rebase": True, "reason": reason, "track": target.name})) + return 0 + do_move = "--move" in flags + # --expect= opts into the compare-and-swap staleness guard (#241). When + # present (the assisted/viewer path) advisory notes go to stderr so stdout + # stays pure for the {stale} abort signal the caller parses. + expect = flags.get("--expect") + expect = expect if isinstance(expect, str) else None + notes = sys.stderr if expect is not None else sys.stdout + # Collect source tracks that need issue removal (consolidated per source). source_removals: dict[str, tuple] = {} # source_name -> (source_track, set[issue_num]) @@ -113,24 +128,29 @@ def run(args: list[str]) -> int: skipped.append(issue_num) continue - # Milestone mismatch check (non-blocking warning). - proc = subprocess.run( - ["gh", "issue", "view", str(issue_num), - "--repo", target.repo, "--json", "milestone"], - capture_output=True, text=True, - ) - if proc.returncode == 0: - info = json.loads(proc.stdout) - m = info.get("milestone", {}) - if ( - m and m.get("title") - and m["title"] != target.meta.get("milestone_alignment") - ): - print( - f"⚠ #{issue_num} is on milestone '{m['title']}', " - f"track '{target.name}' aligned to" - f" '{target.meta.get('milestone_alignment')}'." - ) + # Milestone mismatch check (non-blocking warning). Never let gh being + # absent/odd crash the command — it's advisory and sits before the write. + try: + proc = subprocess.run( + ["gh", "issue", "view", str(issue_num), + "--repo", target.repo, "--json", "milestone"], + capture_output=True, text=True, + ) + if proc.returncode == 0: + info = json.loads(proc.stdout) + m = info.get("milestone", {}) + if ( + m and m.get("title") + and m["title"] != target.meta.get("milestone_alignment") + ): + print( + f"⚠ #{issue_num} is on milestone '{m['title']}', " + f"track '{target.name}' aligned to" + f" '{target.meta.get('milestone_alignment')}'.", + file=notes, + ) + except (OSError, json.JSONDecodeError): + pass # Prior-owner detection. sources = _find_prior_owners( @@ -149,7 +169,8 @@ def run(args: list[str]) -> int: names = ", ".join(f"'{t.name}'" for t in sources) print( f"ℹ #{issue_num} still listed in {names}" - f" — re-run with --move to relocate." + f" — re-run with --move to relocate.", + file=notes, ) if not slotted: @@ -160,21 +181,25 @@ def run(args: list[str]) -> int: ) return 0 - # Write source tracks (consolidated removals). + # Write source tracks (consolidated removals), each re-read + merged onto + # fresh disk so a concurrent edit to a source track isn't clobbered. if do_move: for src_name, (src, removals) in source_removals.items(): - src_issues = [ - n for n in (src.meta.get("github", {}).get("issues") or []) - if n not in removals - ] - src.meta.setdefault("github", {})["issues"] = src_issues - write_file(src.path, src.meta, src.body) + guarded_membership_write(src.path, remove_nums=removals) removed_str = ", ".join(f"#{n}" for n in sorted(removals)) - print(f" ✓ Removed {removed_str} from '{src_name}'.") - - # Write target track once. - target.meta.setdefault("github", {})["issues"] = sorted(issues) - write_file(target.path, target.meta, target.body) + print(f" ✓ Removed {removed_str} from '{src_name}'.", file=notes) + + # Write target track once. Carries `expect`: on a detected concurrent change + # to the membership list it aborts with {stale} instead of clobbering. + result = guarded_membership_write(target.path, add_nums=slotted, expect=expect) + if result.get("stale"): + print(json.dumps({ + "stale": True, + "reason": result["reason"], + "current": result["current"], + "track": target.name, + })) + return 0 slotted_str = ", ".join(f"#{n}" for n in slotted) print(f"✓ Slotted {slotted_str} into '{target.name}'.") diff --git a/skills/work-plan/commands/slot.py b/skills/work-plan/commands/slot.py index d5e6869..a9e0453 100644 --- a/skills/work-plan/commands/slot.py +++ b/skills/work-plan/commands/slot.py @@ -1,10 +1,11 @@ """slot subcommand.""" import json import subprocess +import sys from lib.config import load_config, ConfigError from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError -from lib.frontmatter import write_file +from lib.membership_guard import guarded_membership_write, shared_rebase_guard from lib.write_guard import needs_confirm, make_token, valid_token from lib.prompts import parse_flags, prompt_input @@ -28,7 +29,7 @@ def run(args: list[str]) -> int: # --confirm uses equals form: --confirm= # --move / --no-move are bare flags # --repo uses equals form: --repo= - flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo"}) + flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo", "--expect"}) if not positional: print("usage: work_plan.py slot [track | track@repo] [--repo=]") return 2 @@ -116,39 +117,67 @@ def run(args: list[str]) -> int: })) return 0 + # Shared-tier rebase (#241): for a track pinned to a plan_branch, pull + + # rebase the worktree onto origin before writing so a teammate's pushed plan + # change isn't clobbered. A divergence we can't auto-rebase aborts cleanly. + ok, reason = shared_rebase_guard(target, cfg) + if not ok: + print(json.dumps({"needs_rebase": True, "reason": reason, "track": target.name})) + return 0 + # Determine move behavior from flags. # --move: remove issue from prior owners. # Default / --no-move: add-only; print a note naming prior owners. do_move = "--move" in flags - sources = _find_prior_owners(issue_num, target.repo, target.name, tracks) + # --expect= opts into the compare-and-swap staleness guard (#241). When + # present (the assisted/viewer path), advisory notes go to stderr so stdout + # stays pure for the {stale} abort signal the caller parses. + expect = flags.get("--expect") + expect = expect if isinstance(expect, str) else None + notes = sys.stderr if expect is not None else sys.stdout - issues.append(issue_num) - target.meta.setdefault("github", {})["issues"] = sorted(issues) - - proc = subprocess.run( - ["gh", "issue", "view", str(issue_num), - "--repo", target.repo, "--json", "milestone"], - capture_output=True, text=True, - ) - if proc.returncode == 0: - info = json.loads(proc.stdout) - m = info.get("milestone", {}) - if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"): - print(f"⚠ #{issue_num} is on milestone '{m['title']}', " - f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.") + sources = _find_prior_owners(issue_num, target.repo, target.name, tracks) + # Milestone mismatch is advisory only — never let gh being absent/odd crash + # the command (it sits between the rebase guard and the write). + try: + proc = subprocess.run( + ["gh", "issue", "view", str(issue_num), + "--repo", target.repo, "--json", "milestone"], + capture_output=True, text=True, + ) + if proc.returncode == 0: + info = json.loads(proc.stdout) + m = info.get("milestone", {}) + if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"): + print(f"⚠ #{issue_num} is on milestone '{m['title']}', " + f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.", + file=notes) + except (OSError, json.JSONDecodeError): + pass + + # Move sources first (each re-read + merged onto fresh disk), then the + # target. The target write carries `expect`: on a detected concurrent change + # to the membership list it aborts with {stale} instead of clobbering. if sources and do_move: for src in sources: - src_issues = [n for n in (src.meta.get("github", {}).get("issues") or []) - if n != issue_num] - src.meta.setdefault("github", {})["issues"] = src_issues - write_file(src.path, src.meta, src.body) - print(f" ✓ Removed #{issue_num} from '{src.name}'.") + guarded_membership_write(src.path, remove_nums=[issue_num]) + print(f" ✓ Removed #{issue_num} from '{src.name}'.", file=notes) elif sources and not do_move: names = ", ".join(f"'{t.name}'" for t in sources) - print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate.") + print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate.", + file=notes) + + result = guarded_membership_write(target.path, add_nums=[issue_num], expect=expect) + if result.get("stale"): + print(json.dumps({ + "stale": True, + "reason": result["reason"], + "current": result["current"], + "track": target.name, + })) + return 0 - write_file(target.path, target.meta, target.body) print(f"✓ Slotted #{issue_num} into '{target.name}'.") return 0 diff --git a/skills/work-plan/lib/heuristic_triage.py b/skills/work-plan/lib/heuristic_triage.py new file mode 100644 index 0000000..0736832 --- /dev/null +++ b/skills/work-plan/lib/heuristic_triage.py @@ -0,0 +1,134 @@ +"""Deterministic, offline track suggestions for untracked issues (#373). + +The LLM path (`auto-triage` → a Claude session writes the answers) is the +higher-quality source, but it only produces suggestions when an agent is driving. +This module is the no-LLM fallback: it scores each untracked issue against each +candidate track using only signals already in hand (no network, no model), so the +VS Code Suggested bucket works standalone. + +It emits the SAME v2 answer entries the LLM path does +(`{issue, verdict, track, runner_up, confidence, margin, rationale}`) so the +viewer and `auto-triage --apply` consume it unchanged — abstain-first, with a +grounded rationale naming the concrete signal that matched. + +Signals (all local, all explainable): + * milestone match — issue.milestone == track.milestone_alignment (strong) + * track-label match — issue labels ∩ the track's reconcile labels + (github.labels, else the default `track/`) (strong) + * keyword overlap — issue-title tokens ∩ the track's slug/name/scope tokens + (medium; capped) + +Confidence here is a heuristic score, NOT a calibrated probability — the answer +doc is stamped `source: "heuristic"` so the viewer can flag it as lower-trust. +""" +import re + +# Weights are deliberately simple and sum-clamped to 1.0. Tuned so a single +# strong signal alone stays below the default suggest bar (0.3 < 0.4/0.5), i.e. +# one weak coincidence won't auto-suggest, but a strong signal does clear it. +_W_MILESTONE = 0.5 +_W_LABEL = 0.4 +_W_KEYWORD_EACH = 0.1 +_W_KEYWORD_CAP = 0.3 + +# Short / structural words that carry no track-matching signal. +_STOPWORDS = frozenset(( + "the", "a", "an", "and", "or", "to", "of", "for", "in", "on", "with", "is", + "add", "fix", "update", "support", "make", "use", "new", "issue", "bug", + "feat", "feature", "error", "when", "after", "before", "via", "from", "into", +)) + + +def _tokens(text): + """Lowercase alphanumeric tokens of length ≥ 3, minus stopwords.""" + if not text: + return set() + return { + t for t in re.split(r"[^a-z0-9]+", str(text).lower()) + if len(t) >= 3 and t not in _STOPWORDS + } + + +def _track_labels(track): + """A track's effective reconcile labels (lowercased): github.labels if set, + else the default `track/` — mirrors reconcile's resolution (#373).""" + labels = (track.get("labels") or []) + if labels: + return {str(x).lower() for x in labels} + slug = track.get("slug") or "" + return {f"track/{slug}".lower()} if slug else set() + + +def score_suggestions(untracked, tracks, *, min_score=0.3, margin_gap=0.15): + """Score each untracked issue against the candidate tracks and return v2 + suggestion entries (abstain-first). + + `untracked`: [{"number", "title", "milestone": {"title"} | None, + "labels": [{"name"}]}] (the auto-triage batch shape). + `tracks`: [{"slug", "name", "milestone", "scope", "labels": [..]}]. + `min_score`: a track must clear this for a non-abstain suggestion. + `margin_gap`: top must beat the runner-up by at least this for margin "clear". + """ + out = [] + for iss in untracked: + try: + num = int(iss.get("number")) + except (TypeError, ValueError): + continue + + title_tokens = _tokens(iss.get("title")) + ms = iss.get("milestone") or {} + iss_ms = ms.get("title") if isinstance(ms, dict) else None + iss_labels = {str(lb.get("name", "")).lower() + for lb in (iss.get("labels") or []) if isinstance(lb, dict)} + + scored = [] # (score, slug, rationale-parts) + for t in tracks: + slug = t.get("slug") + if not slug: + continue + score = 0.0 + reasons = [] + + t_ms = t.get("milestone") + if iss_ms and t_ms and iss_ms == t_ms: + score += _W_MILESTONE + reasons.append(f"milestone {iss_ms}") + + shared_labels = iss_labels & _track_labels(t) + if shared_labels: + score += _W_LABEL + reasons.append("label " + ", ".join(sorted(shared_labels))) + + t_kw = _tokens(slug) | _tokens(t.get("name")) | _tokens(t.get("scope")) + shared_kw = title_tokens & t_kw + if shared_kw: + score += min(_W_KEYWORD_CAP, _W_KEYWORD_EACH * len(shared_kw)) + reasons.append("keyword " + ", ".join(sorted(shared_kw))) + + if score > 0: + scored.append((round(min(score, 1.0), 2), slug, reasons)) + + scored.sort(key=lambda x: x[0], reverse=True) + + if not scored or scored[0][0] < min_score: + out.append({ + "issue": num, + "verdict": "abstain", + "rationale": "no track clears the heuristic bar", + }) + continue + + top = scored[0] + runner = scored[1] if len(scored) > 1 else None + clear = runner is None or (top[0] - runner[0]) >= margin_gap + out.append({ + "issue": num, + "verdict": "suggest", + "track": top[1], + **({"runner_up": runner[1]} if runner else {}), + "confidence": top[0], + "margin": "clear" if clear else "narrow", + "rationale": "; ".join(top[2]), + }) + return out diff --git a/skills/work-plan/lib/membership_guard.py b/skills/work-plan/lib/membership_guard.py new file mode 100644 index 0000000..bd8e238 --- /dev/null +++ b/skills/work-plan/lib/membership_guard.py @@ -0,0 +1,152 @@ +"""Compare-and-swap guard for track-membership writes (#241). + +Slotting an issue into a track edits `meta["github"]["issues"]`. Shared-tier +tracks travel via git push/pull, so an assisted or background write can race +another session or a teammate's pull. `lib.frontmatter.write_file` is a blind +overwrite, so without a guard the last writer silently wins. + +This module adds two things: + + * `issues_fingerprint(meta)` — a deterministic digest of the membership list + ONLY. We fingerprint just `github.issues`, not the whole frontmatter or the + body: those carry fields other commands legitimately rewrite concurrently + (`refresh-md` stamps `last_touched`, `handoff` rewrites the body table), and + fingerprinting them would abort on changes that have nothing to do with the + list we're about to overwrite. The membership list is exactly the CAS surface. + + * `guarded_membership_write(...)` — ALWAYS re-reads the file from disk + immediately before writing, applies the membership delta to the FRESH + frontmatter, and writes back the rest of the frontmatter and the body + unchanged. So a concurrent edit to the body OR to other frontmatter fields + (status, last_touched, depends_on, …) is preserved — only the issues list is + replaced. When `expect` is supplied and the on-disk membership no longer + matches it, the write is ABORTED and a `{"stale": ...}` signal is returned + instead of overwriting — the caller re-prompts on fresh state. When `expect` + is None (the manual single-writer path) the abort is skipped, but the + re-read + merge still happens: strictly safer than a blind overwrite, with + the same observable result for a lone writer. + +Scope of the guarantee (deliberately narrow — don't oversell it): + - This is a check-then-act, not a locked atomic CAS: `parse_file` then + `write_file` are separate syscalls with no file lock, so a writer landing in + the sub-millisecond window between them isn't caught. `expect` narrows the + window vs. a blind overwrite; it does not eliminate it. Adequate for the real + usage (interactive single user + occasional same-machine background). + - `shared_rebase_guard` lands a shared-tier write on top of origin AS OF THE + LAST FETCH; it is NOT a cross-machine atomic CAS against origin. A teammate + pushing between the rebase and the eventual (separate) push is reconciled by + the non-fast-forward push being rejected + rebase-on-next-write, not by this + write path. + - `frontmatter.write_file` re-serializes the frontmatter via yq on every + write, so body-only edits round-trip verbatim but YAML comments / key order + are normalized (a pre-existing property, not introduced here). +""" +import hashlib +import json +from pathlib import Path + +from lib.frontmatter import parse_file, write_file + + +def _issue_set(meta: dict) -> set: + """The frontmatter's github.issues as a set of ints (malformed entries + dropped — the file may be hand-edited).""" + out = set() + for n in (meta.get("github", {}).get("issues") or []): + try: + out.add(int(n)) + except (TypeError, ValueError): + continue + return out + + +def issues_fingerprint(meta: dict) -> str: + """Deterministic sha256[:16] of the sorted github.issues list. + + Order-independent (sorted) and stable across runs (no randomness — 3.9 + stdlib). Two metas with the same membership produce the same fingerprint + regardless of list order or unrelated frontmatter differences. + """ + payload = json.dumps(sorted(_issue_set(meta)), separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def guarded_membership_write(path, *, add_nums=(), remove_nums=(), expect=None): + """Re-read `path`, apply the membership delta to the fresh frontmatter, and + write back the fresh body unchanged. + + Returns one of: + {"stale": True, "reason": str, "current": [int]} + — `expect` was supplied and the on-disk membership no longer matches + it; NO write happened. `current` is the fresh on-disk list so the + caller can re-offer against it. + {"written": [int]} + — wrote successfully; the value is the final sorted membership list. + + `expect` is the `issues_fingerprint` the caller captured when it built the + operation (e.g. when the viewer rendered the offer). Pass None for the + unguarded manual path. + """ + fresh_meta, fresh_body = parse_file(Path(path)) + + if expect is not None and issues_fingerprint(fresh_meta) != expect: + return { + "stale": True, + "reason": "track membership changed since the operation was prepared", + "current": sorted(_issue_set(fresh_meta)), + } + + issues = _issue_set(fresh_meta) + issues |= {int(n) for n in add_nums} + issues -= {int(n) for n in remove_nums} + final = sorted(issues) + fresh_meta.setdefault("github", {})["issues"] = final + write_file(Path(path), fresh_meta, fresh_body) + return {"written": final} + + +def shared_rebase_guard(target, cfg): + """Before writing a SHARED-tier track pinned to a `plan_branch`, fetch + + rebase its worktree onto origin so the write lands on top of a teammate's + pushed plan changes (#241). Best-effort: this reduces the cross-machine + (git push/pull) race, it does not make the write atomic against origin — a + teammate pushing after the rebase is reconciled by the non-fast-forward push + rejection + rebase-on-next-write, not here. The fingerprint CAS covers the + same-machine race. + + Returns (ok: bool, reason: str | None): + (True, None) — safe to proceed: the track is private, a legacy + shared track (no plan_branch → working-tree tier), or + the worktree rebased cleanly / had no upstream. + (False, reason) — the shared branch diverged and could NOT auto-rebase; + the caller MUST abort and surface {needs_rebase} + rather than blind-write over a diverged branch. + + Never raises — an unexpected guard error degrades to "proceed" (consistent + with the toolkit's never-break-the-command VCS philosophy; the underlying + plan_worktree ops are themselves never-raise). + """ + try: + if getattr(target, "tier", None) != "shared": + return (True, None) + repos = (cfg or {}).get("repos", {}) or {} + entry = repos.get(getattr(target, "folder", None)) + if entry is None: + for e in repos.values(): + if e and e.get("github") == getattr(target, "repo", None): + entry = e + break + branch = entry.get("plan_branch") if entry else None + local = entry.get("local") if entry else None + if not branch or not local: + return (True, None) # legacy shared tier (working tree) — no rebase + from lib import plan_worktree + worktree = plan_worktree.ensure_worktree(Path(local).expanduser(), branch) + if worktree is None: + return (True, None) # can't ensure the worktree — degrade, proceed + if not plan_worktree.rebase_onto_origin(worktree, branch): + return (False, f"shared plan branch '{branch}' diverged and could not " + f"auto-rebase; resolve manually") + return (True, None) + except Exception: + return (True, None) diff --git a/skills/work-plan/lib/plan_worktree.py b/skills/work-plan/lib/plan_worktree.py index ea85588..2e94d50 100644 --- a/skills/work-plan/lib/plan_worktree.py +++ b/skills/work-plan/lib/plan_worktree.py @@ -267,6 +267,43 @@ def is_published(local_path: Path, branch: str) -> bool: return remote_branch_exists(local_path, branch) +def rebase_onto_origin(worktree: Path, branch: str) -> bool: + """Fetch origin/ and rebase the worktree (checked out at ) + onto it, so a shared-tier write lands on top of any teammate's pushed plan + changes instead of diverging (#241). + + Returns: + True — the worktree is now at-or-ahead of origin: rebase succeeded, + there was nothing to replay, or the branch isn't published yet + (local is authoritative — nothing to rebase onto). + False — the rebase hit a conflict (it is ABORTED so the worktree is left + clean, never half-rebased) or git couldn't run. The caller must + NOT write — it surfaces {needs_rebase} and bails. + + Never raises. + """ + wt = Path(worktree).expanduser() + # Fetch so origin/ is authoritative before we compare. + fetch_branch(wt, branch) + if not remote_branch_exists(wt, branch): + return True # unpublished — no upstream to rebase onto; local wins + # --autostash: the normal flow is write-file-then-commit, so the worktree's + # .work-plan/ is routinely dirty when we rebase. Without autostash git would + # refuse the rebase on the dirty precondition and we'd report a spurious + # {needs_rebase}; autostash shelves the local edits, rebases, and reapplies + # them on top — so a clean rebase succeeds even with pending plan edits. + proc = _git(wt, "rebase", "--autostash", f"origin/{branch}") + if proc is None: + return False + if proc.returncode == 0: + return True + # True conflict (autostash reapply or replayed commits): abort so the + # worktree is never left in a partially-rebased state. A blind write over a + # diverged shared branch is exactly what this guard exists to prevent. + _git(wt, "rebase", "--abort") + return False + + def unpushed_oneline(local_path: Path, branch: str) -> list: """One-line summaries of commits on local `branch` not yet on origin (`origin/..`). If origin/ doesn't exist, every commit diff --git a/skills/work-plan/tests/test_auto_triage.py b/skills/work-plan/tests/test_auto_triage.py index 9fa4b42..6321c9c 100644 --- a/skills/work-plan/tests/test_auto_triage.py +++ b/skills/work-plan/tests/test_auto_triage.py @@ -347,5 +347,108 @@ def test_apply_empty_answers_does_nothing(self): self.assertIn("0 issue(s) assigned", buf.getvalue()) +class AutoTriageJsonScanTest(unittest.TestCase): + """--json scan mode (#241): emit a machine batch with a batch_id.""" + + def test_json_mode_emits_batch_with_id_and_prompt(self): + cfg = _make_cfg() + tracks = [_make_track("auth-flow", "org/myrepo", [])] + rc, out, _ = _drive_prepare(["--json"], cfg=cfg, tracks=tracks, + open_issues=_open_issues(4501, 4502)) + self.assertEqual(rc, 0) + data = json.loads(out.strip()) # stdout is a single JSON object + self.assertIn("batch_id", data) + self.assertTrue(data["batch_id"]) + self.assertEqual({i["number"] for i in data["untracked"]}, {4501, 4502}) + self.assertIn("prompt", data) + self.assertIn("answers_path", data) + # Track entries carry scope text for grounded matching. + self.assertIn("scope", data["tracks"][0]) + + +class AutoTriageHeuristicTest(unittest.TestCase): + """--heuristic (#373): the CLI writes the v2 answers file itself, no LLM.""" + + def test_heuristic_writes_answers_file_with_source_and_batch_id(self): + cfg = _make_cfg() + track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow") + track.meta["milestone_alignment"] = "v0.4" + open_issues = [{"number": 4501, "title": "auth thing", "state": "OPEN", + "milestone": {"title": "v0.4"}, "labels": []}] + with tempfile.TemporaryDirectory() as tmp: + batch_file = Path(tmp) / "auto_triage.org_myrepo.json" + answers_file = Path(tmp) / "auto_triage.org_myrepo.answers.json" + with patch("commands.auto_triage.load_config", return_value=cfg), \ + patch("commands.auto_triage.discover_tracks", return_value=[track]), \ + patch("commands.auto_triage.fetch_open_issues", return_value=open_issues), \ + patch("commands.auto_triage._batch_path", return_value=batch_file), \ + patch("commands.auto_triage._answers_path", return_value=answers_file): + buf = io.StringIO() + with redirect_stdout(buf): + rc = auto_triage.run(["--heuristic"]) + self.assertEqual(rc, 0) + self.assertTrue(answers_file.exists(), "heuristic must write the answers file") + doc = json.loads(answers_file.read_text()) + self.assertEqual(doc["version"], 2) + self.assertEqual(doc["source"], "heuristic") + batch = json.loads(batch_file.read_text()) + self.assertEqual(doc["batch_id"], batch["batch_id"]) # correlates + sug = doc["suggestions"][0] + self.assertEqual(sug["issue"], 4501) + self.assertEqual(sug["verdict"], "suggest") + self.assertEqual(sug["track"], "auth-flow") + + +class AutoTriageV2AnswersTest(unittest.TestCase): + """v2 abstain-first answers schema (#241) + back-compat with v1.""" + + def _batch(self, nums, batch_id="abc123"): + return {"batch_id": batch_id, "repo": "org/myrepo", "folder": "myrepo", + "untracked": [{"number": n} for n in nums]} + + def test_v2_applies_only_clear_suggestions(self): + cfg = _make_cfg() + track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow") + answers = {"version": 2, "batch_id": "abc123", "suggestions": [ + {"issue": 4501, "verdict": "suggest", "track": "auth-flow", + "margin": "clear", "confidence": 0.9, "rationale": "label area/auth"}, + {"issue": 4502, "verdict": "suggest", "track": "auth-flow", + "margin": "narrow", "confidence": 0.55, "rationale": "maybe"}, + {"issue": 4507, "verdict": "abstain", "rationale": "no fit"}, + ]} + rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track], + batch=self._batch([4501, 4502, 4507]), + answers=answers) + self.assertEqual(rc, 0) + mwrite.assert_called_once() # only the clear suggestion is written + written = mwrite.call_args[0][1]["github"]["issues"] + self.assertIn(4501, written) + self.assertNotIn(4502, written) # narrow margin → left untracked + self.assertNotIn(4507, written) # abstained → left untracked + + def test_v1_answers_still_apply(self): + cfg = _make_cfg() + track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow") + answers = [{"track": "auth-flow", "issues": [4501]}] # legacy shape + rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track], + batch=self._batch([4501]), answers=answers) + self.assertEqual(rc, 0) + mwrite.assert_called_once() + self.assertIn(4501, mwrite.call_args[0][1]["github"]["issues"]) + + def test_batch_id_mismatch_warns_but_applies(self): + cfg = _make_cfg() + track = _make_track("auth-flow", "org/myrepo", [], slug="auth-flow") + answers = {"version": 2, "batch_id": "STALE", "suggestions": [ + {"issue": 4501, "verdict": "suggest", "track": "auth-flow", + "margin": "clear", "rationale": "label area/auth"}]} + rc, mwrite, out = _drive_apply(cfg=cfg, tracks=[track], + batch=self._batch([4501], batch_id="abc123"), + answers=answers) + self.assertEqual(rc, 0) + self.assertIn("older scan", out) + mwrite.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/skills/work-plan/tests/test_batch_slot.py b/skills/work-plan/tests/test_batch_slot.py index bc60717..8e83254 100644 --- a/skills/work-plan/tests/test_batch_slot.py +++ b/skills/work-plan/tests/test_batch_slot.py @@ -52,11 +52,21 @@ def _drive(args, tracks=None, vis="PRIVATE"): cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}} gh_proc = MagicMock(returncode=0, stdout="{}", stderr="") + # Writes go through lib.membership_guard (re-read via parse_file, write via + # write_file). Returning each track's own meta/body lets the guard mutate + # them in place, so assertions on track.meta still observe the merge. + by_path = {str(t.path): t for t in tracks} + + def fake_parse(p): + t = by_path[str(p)] + return (t.meta, t.body) + with patch("commands.batch_slot.load_config", return_value=cfg), \ patch("commands.batch_slot.discover_tracks", return_value=tracks), \ patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \ patch("lib.write_guard.repo_visibility", return_value=vis), \ - patch("commands.batch_slot.write_file") as mw: + patch("lib.membership_guard.parse_file", side_effect=fake_parse), \ + patch("lib.membership_guard.write_file") as mw: buf = io.StringIO() with redirect_stdout(buf): rc = batch_slot.run(args) @@ -272,6 +282,12 @@ def test_no_input_called_on_flagged_paths(self): def _raise(*a, **kw): raise AssertionError("input() must not be called on non-interactive path") + by_path = {str(t.path): t for t in (source, target)} + + def fake_parse(p): + t = by_path[str(p)] + return (t.meta, t.body) + with patch("builtins.input", side_effect=_raise), \ patch("lib.prompts.prompt_input", side_effect=_raise): cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}} @@ -280,7 +296,8 @@ def _raise(*a, **kw): patch("commands.batch_slot.discover_tracks", return_value=[source, target]), \ patch("commands.batch_slot.subprocess.run", return_value=gh_proc), \ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \ - patch("commands.batch_slot.write_file"): + patch("lib.membership_guard.parse_file", side_effect=fake_parse), \ + patch("lib.membership_guard.write_file"): buf = io.StringIO() with redirect_stdout(buf): rc = batch_slot.run(["42", "beta", "--move"]) diff --git a/skills/work-plan/tests/test_heuristic_triage.py b/skills/work-plan/tests/test_heuristic_triage.py new file mode 100644 index 0000000..9ea4856 --- /dev/null +++ b/skills/work-plan/tests/test_heuristic_triage.py @@ -0,0 +1,114 @@ +"""Tests for lib.heuristic_triage — the offline (no-LLM) track scorer (#373). + +Covers: milestone match, track-label overlap (incl. the track/ default), +keyword overlap, abstain when nothing clears the bar, margin clear-vs-narrow from +the top-vs-runner gap, and the v2 entry shape. +""" +import sys +import unittest +from pathlib import Path + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT)) + +from lib.heuristic_triage import score_suggestions + + +def _iss(number, title="", milestone=None, labels=()): + return { + "number": number, + "title": title, + "milestone": {"title": milestone} if milestone else None, + "labels": [{"name": n} for n in labels], + } + + +def _trk(slug, name=None, milestone=None, scope="", labels=None): + return {"slug": slug, "name": name or slug, "milestone": milestone, + "scope": scope, "labels": labels} + + +class HeuristicScoreTest(unittest.TestCase): + + def _one(self, entries): + self.assertEqual(len(entries), 1) + return entries[0] + + def test_milestone_match_suggests(self): + e = self._one(score_suggestions( + [_iss(1, "rate limit", milestone="v0.4")], + [_trk("auth-flow", milestone="v0.4")], + )) + self.assertEqual(e["verdict"], "suggest") + self.assertEqual(e["track"], "auth-flow") + self.assertIn("milestone v0.4", e["rationale"]) + + def test_track_label_overlap_suggests(self): + e = self._one(score_suggestions( + [_iss(2, "something", labels=["area/auth"])], + [_trk("auth-flow", labels=["area/auth"])], + )) + self.assertEqual(e["verdict"], "suggest") + self.assertIn("label area/auth", e["rationale"]) + + def test_default_track_slug_label(self): + # No github.labels on the track → default `track/` is matched. + e = self._one(score_suggestions( + [_iss(3, "x", labels=["track/auth-flow"])], + [_trk("auth-flow", labels=None)], + )) + self.assertEqual(e["verdict"], "suggest") + + def test_keyword_only_below_bar_abstains(self): + # A single keyword hit (0.1) is below the 0.3 suggest bar → abstain. + e = self._one(score_suggestions( + [_iss(4, "sessions cleanup")], + [_trk("tabletop-sessions", name="tabletop sessions")], + )) + self.assertEqual(e["verdict"], "abstain") + + def test_no_signal_abstains(self): + e = self._one(score_suggestions( + [_iss(5, "totally unrelated billing thing")], + [_trk("auth-flow", milestone="v0.4")], + )) + self.assertEqual(e["verdict"], "abstain") + self.assertNotIn("track", e) + + def test_margin_narrow_when_two_tracks_tie(self): + # Both tracks share the milestone → equal top score → narrow margin. + e = self._one(score_suggestions( + [_iss(6, "x", milestone="v0.4")], + [_trk("auth-flow", milestone="v0.4"), _trk("idea-mode", milestone="v0.4")], + )) + self.assertEqual(e["verdict"], "suggest") + self.assertEqual(e["margin"], "narrow") + self.assertIn("runner_up", e) + + def test_margin_clear_when_one_track_dominates(self): + e = self._one(score_suggestions( + [_iss(7, "auth rate limit", milestone="v0.4", labels=["area/auth"])], + [_trk("auth-flow", name="auth flow", milestone="v0.4", labels=["area/auth"]), + _trk("idea-mode", milestone="v9.9")], + )) + self.assertEqual(e["margin"], "clear") + self.assertEqual(e["track"], "auth-flow") + + def test_confidence_clamped_and_in_range(self): + e = self._one(score_suggestions( + [_iss(8, "auth flow rate limit session token scope", + milestone="v0.4", labels=["area/auth"])], + [_trk("auth-flow", name="auth flow", milestone="v0.4", + scope="auth rate limit session token scope", labels=["area/auth"])], + )) + self.assertLessEqual(e["confidence"], 1.0) + self.assertGreaterEqual(e["confidence"], 0.3) + + def test_malformed_issue_number_skipped(self): + entries = score_suggestions([{"number": "not-an-int", "title": "x"}], + [_trk("a")]) + self.assertEqual(entries, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/tests/test_membership_guard.py b/skills/work-plan/tests/test_membership_guard.py new file mode 100644 index 0000000..80ff9a2 --- /dev/null +++ b/skills/work-plan/tests/test_membership_guard.py @@ -0,0 +1,116 @@ +"""Tests for lib.membership_guard — the compare-and-swap guard (#241). + +Covers: +- issues_fingerprint is order-independent and stable, and ignores non-issue + frontmatter (last_touched / body differences don't change it). +- guarded_membership_write merges add/remove onto the FRESH on-disk frontmatter. +- A concurrent body-only edit is preserved (the fresh body is written back). +- expect-match writes; expect-mismatch returns {stale} and does NOT write. +- expect=None never aborts (the manual single-writer path). + +All file I/O is exercised against a real temp file (no yq mock needed — these go +through lib.frontmatter end to end), so the round-trip is real. +""" +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT)) + +from lib.frontmatter import parse_file, write_file +from lib.membership_guard import issues_fingerprint, guarded_membership_write + + +def _meta(issues, repo="ok/repo", **extra): + m = {"track": "alpha", "status": "active", "github": {"repo": repo, "issues": list(issues)}} + m.update(extra) + return m + + +class FingerprintTest(unittest.TestCase): + + def test_order_independent(self): + self.assertEqual( + issues_fingerprint(_meta([3, 1, 2])), + issues_fingerprint(_meta([1, 2, 3])), + ) + + def test_changes_when_membership_changes(self): + self.assertNotEqual( + issues_fingerprint(_meta([1, 2])), + issues_fingerprint(_meta([1, 2, 3])), + ) + + def test_ignores_non_issue_frontmatter(self): + """last_touched / other fields must not affect the fingerprint, else the + guard would abort on unrelated concurrent edits.""" + self.assertEqual( + issues_fingerprint(_meta([1, 2], last_touched="2026-01-01")), + issues_fingerprint(_meta([1, 2], last_touched="2026-06-17")), + ) + + def test_empty_and_missing_github_are_stable(self): + self.assertEqual(issues_fingerprint({}), issues_fingerprint(_meta([]))) + + +class GuardedWriteTest(unittest.TestCase): + + def setUp(self): + self._tmp = TemporaryDirectory() + self.path = Path(self._tmp.name) / "alpha.md" + + def tearDown(self): + self._tmp.cleanup() + + def _seed(self, issues, body="# body\n"): + write_file(self.path, _meta(issues), body) + + def test_add_merges_onto_disk(self): + self._seed([10, 20]) + res = guarded_membership_write(self.path, add_nums=[30]) + self.assertEqual(res, {"written": [10, 20, 30]}) + meta, _ = parse_file(self.path) + self.assertEqual(meta["github"]["issues"], [10, 20, 30]) + + def test_remove_merges_onto_disk(self): + self._seed([10, 20, 30]) + res = guarded_membership_write(self.path, remove_nums=[20]) + self.assertEqual(res, {"written": [10, 30]}) + + def test_preserves_concurrent_body_edit(self): + """Re-reads body at write time, so a body change made after the caller's + snapshot is preserved rather than clobbered.""" + self._seed([10], body="# original\n") + # Simulate another process rewriting ONLY the body (e.g. handoff). + meta, _ = parse_file(self.path) + write_file(self.path, meta, "# rewritten by another writer\n") + guarded_membership_write(self.path, add_nums=[20]) + _, body = parse_file(self.path) + self.assertIn("rewritten by another writer", body) + + def test_expect_match_writes(self): + self._seed([10, 20]) + fp = issues_fingerprint(_meta([10, 20])) + res = guarded_membership_write(self.path, add_nums=[30], expect=fp) + self.assertEqual(res, {"written": [10, 20, 30]}) + + def test_expect_mismatch_aborts_without_writing(self): + self._seed([10, 20]) + stale_fp = issues_fingerprint(_meta([10])) # what the caller THOUGHT it saw + res = guarded_membership_write(self.path, add_nums=[30], expect=stale_fp) + self.assertTrue(res["stale"]) + self.assertEqual(res["current"], [10, 20]) + # Nothing was written — disk is unchanged. + meta, _ = parse_file(self.path) + self.assertEqual(meta["github"]["issues"], [10, 20]) + + def test_expect_none_never_aborts(self): + self._seed([10, 20]) + res = guarded_membership_write(self.path, add_nums=[30], expect=None) + self.assertIn("written", res) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/tests/test_shared_rebase_guard.py b/skills/work-plan/tests/test_shared_rebase_guard.py new file mode 100644 index 0000000..5c4b7f0 --- /dev/null +++ b/skills/work-plan/tests/test_shared_rebase_guard.py @@ -0,0 +1,154 @@ +"""Tests for the shared-tier rebase guard (#241 phase 2). + +Covers: +- plan_worktree.rebase_onto_origin: clean rebase / nothing-to-do → True; + unpublished branch (no upstream) → True; conflict → abort + False; + git unavailable → False. +- membership_guard.shared_rebase_guard: private track → no-op; legacy shared + (no plan_branch) → no-op; shared + plan_branch clean rebase → ok; divergence → + (False, reason). +- slot integration: a shared track whose rebase diverges emits {needs_rebase} + and does NOT write. +""" +import io +import json +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT)) + +from lib import plan_worktree +from lib.membership_guard import shared_rebase_guard + + +def _git_stub(*, rebase_rc=0, remote_exists=True, rebase_none=False): + """Build a fake plan_worktree._git dispatching on the git subcommand.""" + calls = [] + + def _git(cwd, *args, **kw): + calls.append(tuple(args)) + head = args[0] + if head == "fetch": + return MagicMock(returncode=0, stdout="", stderr="") + if head == "rev-parse": + # remote_branch_exists → refs/remotes/origin/ + rc = 0 if remote_exists else 1 + return MagicMock(returncode=rc, stdout="", stderr="") + if head == "rebase" and args[-1] != "--abort": + if rebase_none: + return None + return MagicMock(returncode=rebase_rc, stdout="", stderr="conflict") + if args == ("rebase", "--abort"): + return MagicMock(returncode=0, stdout="", stderr="") + return MagicMock(returncode=0, stdout="", stderr="") + + return _git, calls + + +class RebaseOntoOriginTest(unittest.TestCase): + + def test_clean_rebase_returns_true_with_autostash(self): + gitfn, calls = _git_stub(rebase_rc=0, remote_exists=True) + with patch("lib.plan_worktree._git", side_effect=gitfn): + self.assertTrue(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan")) + # --autostash so a dirty .work-plan/ (the normal write-then-commit flow) + # doesn't make the rebase refuse with a spurious needs_rebase. + self.assertIn(("rebase", "--autostash", "origin/work-plan/plan"), calls) + self.assertNotIn(("rebase", "--abort"), calls) + + def test_unpublished_branch_returns_true_without_rebasing(self): + gitfn, calls = _git_stub(remote_exists=False) + with patch("lib.plan_worktree._git", side_effect=gitfn): + self.assertTrue(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan")) + self.assertNotIn(("rebase", "--autostash", "origin/work-plan/plan"), calls) + + def test_conflict_aborts_and_returns_false(self): + gitfn, calls = _git_stub(rebase_rc=1, remote_exists=True) + with patch("lib.plan_worktree._git", side_effect=gitfn): + self.assertFalse(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan")) + # Conflict must leave the worktree clean. + self.assertIn(("rebase", "--abort"), calls) + + def test_git_unavailable_returns_false(self): + gitfn, calls = _git_stub(rebase_none=True, remote_exists=True) + with patch("lib.plan_worktree._git", side_effect=gitfn): + self.assertFalse(plan_worktree.rebase_onto_origin(Path("/wt"), "work-plan/plan")) + + +class SharedRebaseGuardTest(unittest.TestCase): + + CFG = {"repos": {"ok": {"github": "ok/repo", "local": "/repo", + "plan_branch": "work-plan/plan"}}} + + def _track(self, *, tier="shared", folder="ok", repo="ok/repo"): + return SimpleNamespace(name="alpha", tier=tier, folder=folder, repo=repo, + path=Path("/wt/.work-plan/alpha.md")) + + def test_private_track_is_noop(self): + ok, reason = shared_rebase_guard(self._track(tier="private"), self.CFG) + self.assertTrue(ok) + self.assertIsNone(reason) + + def test_legacy_shared_no_plan_branch_is_noop(self): + cfg = {"repos": {"ok": {"github": "ok/repo", "local": "/repo"}}} # no plan_branch + ok, reason = shared_rebase_guard(self._track(), cfg) + self.assertTrue(ok) + + def test_shared_clean_rebase_ok(self): + with patch("lib.plan_worktree.ensure_worktree", return_value=Path("/wt")), \ + patch("lib.plan_worktree.rebase_onto_origin", return_value=True): + ok, reason = shared_rebase_guard(self._track(), self.CFG) + self.assertTrue(ok) + + def test_shared_divergence_blocks(self): + with patch("lib.plan_worktree.ensure_worktree", return_value=Path("/wt")), \ + patch("lib.plan_worktree.rebase_onto_origin", return_value=False): + ok, reason = shared_rebase_guard(self._track(), self.CFG) + self.assertFalse(ok) + self.assertIn("diverged", reason) + + def test_worktree_unavailable_degrades_to_proceed(self): + with patch("lib.plan_worktree.ensure_worktree", return_value=None): + ok, reason = shared_rebase_guard(self._track(), self.CFG) + self.assertTrue(ok) + + +class SlotNeedsRebaseTest(unittest.TestCase): + + def test_slot_aborts_with_needs_rebase_json_no_write(self): + from commands import slot + track = SimpleNamespace( + name="alpha", tier="shared", folder="ok", repo="ok/repo", + path=Path("/wt/.work-plan/alpha.md"), body="# x", + meta={"track": "alpha", "status": "active", + "github": {"repo": "ok/repo", "issues": []}}, + has_frontmatter=True, + ) + cfg = {"notes_root": "/tmp/n", + "repos": {"ok": {"github": "ok/repo", "local": "/repo", + "plan_branch": "work-plan/plan"}}} + gh_proc = MagicMock(returncode=0, stdout="{}", stderr="") + with patch("commands.slot.load_config", return_value=cfg), \ + patch("commands.slot.discover_tracks", return_value=[track]), \ + patch("commands.slot.subprocess.run", return_value=gh_proc), \ + patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \ + patch("lib.plan_worktree.ensure_worktree", return_value=Path("/wt")), \ + patch("lib.plan_worktree.rebase_onto_origin", return_value=False), \ + patch("lib.membership_guard.write_file") as mw: + buf = io.StringIO() + with redirect_stdout(buf): + rc = slot.run(["30", "alpha"]) + self.assertEqual(rc, 0) + mw.assert_not_called() # divergence → no write + data = json.loads(buf.getvalue().strip()) + self.assertTrue(data["needs_rebase"]) + self.assertEqual(data["track"], "alpha") + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/tests/test_slot.py b/skills/work-plan/tests/test_slot.py index 1085fd3..6b53ad7 100644 --- a/skills/work-plan/tests/test_slot.py +++ b/skills/work-plan/tests/test_slot.py @@ -56,11 +56,22 @@ def _drive(args, tracks=None, vis="PRIVATE"): cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}} gh_proc = MagicMock(returncode=0, stdout="{}", stderr="") + # The write path now goes through lib.membership_guard, which re-reads the + # file (parse_file) and writes the merged result (write_file). Returning the + # track's own meta/body objects from parse_file lets the guard mutate them in + # place, so assertions on track.meta still observe the merge. + by_path = {str(t.path): t for t in tracks} + + def fake_parse(p): + t = by_path[str(p)] + return (t.meta, t.body) + with patch("commands.slot.load_config", return_value=cfg), \ patch("commands.slot.discover_tracks", return_value=tracks), \ patch("commands.slot.subprocess.run", return_value=gh_proc), \ patch("lib.write_guard.repo_visibility", return_value=vis), \ - patch("commands.slot.write_file") as mw: + patch("lib.membership_guard.parse_file", side_effect=fake_parse), \ + patch("lib.membership_guard.write_file") as mw: buf = io.StringIO() with redirect_stdout(buf): rc = slot.run(args) @@ -223,6 +234,12 @@ def test_no_input_called_on_flagged_paths(self): def _raise(*a, **kw): raise AssertionError("input() must not be called on non-interactive path") + by_path = {str(t.path): t for t in (source, target)} + + def fake_parse(p): + t = by_path[str(p)] + return (t.meta, t.body) + with patch("builtins.input", side_effect=_raise), \ patch("lib.prompts.prompt_input", side_effect=_raise): cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}} @@ -231,7 +248,8 @@ def _raise(*a, **kw): patch("commands.slot.discover_tracks", return_value=[source, target]), \ patch("commands.slot.subprocess.run", return_value=gh_proc), \ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \ - patch("commands.slot.write_file"): + patch("lib.membership_guard.parse_file", side_effect=fake_parse), \ + patch("lib.membership_guard.write_file"): buf = io.StringIO() with redirect_stdout(buf): # --move (prior owner path) + private repo (no confirm gate) @@ -239,5 +257,79 @@ def _raise(*a, **kw): self.assertEqual(rc, 0) +class SlotExpectGuardTest(unittest.TestCase): + """--expect compare-and-swap staleness guard (#241).""" + + def test_expect_match_writes(self): + from lib.membership_guard import issues_fingerprint + track = _track(name="alpha", repo="ok/repo", issues=[10, 20]) + fp = issues_fingerprint(track.meta) + rc, mw, out = _drive(["30", "alpha", f"--expect={fp}"], + tracks=[track], vis="PRIVATE") + self.assertEqual(rc, 0) + mw.assert_called_once() + self.assertIn(30, track.meta["github"]["issues"]) + + def test_expect_mismatch_aborts_with_stale_json(self): + import json + from lib.membership_guard import issues_fingerprint + track = _track(name="alpha", repo="ok/repo", issues=[10, 20]) + stale_fp = issues_fingerprint({"github": {"issues": [10]}}) # what we thought + rc, mw, out = _drive(["30", "alpha", f"--expect={stale_fp}"], + tracks=[track], vis="PRIVATE") + self.assertEqual(rc, 0) + mw.assert_not_called() + data = json.loads(out.strip()) # stdout is pure JSON in --expect mode + self.assertTrue(data["stale"]) + self.assertEqual(data["current"], [10, 20]) + self.assertEqual(data["track"], "alpha") + + def test_no_expect_never_aborts(self): + track = _track(name="alpha", repo="ok/repo", issues=[10, 20]) + rc, mw, out = _drive(["30", "alpha"], tracks=[track], vis="PRIVATE") + self.assertEqual(rc, 0) + mw.assert_called_once() + + def test_milestone_check_gh_missing_does_not_crash(self): + """The advisory `gh issue view` milestone check sits between the rebase + guard and the write — if gh is absent (FileNotFoundError) the slot must + still complete the write, not crash.""" + track = _track(name="alpha", repo="ok/repo", issues=[10]) + cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}} + by_path = {str(track.path): track} + + def fake_parse(p): + t = by_path[str(p)] + return (t.meta, t.body) + + with patch("commands.slot.load_config", return_value=cfg), \ + patch("commands.slot.discover_tracks", return_value=[track]), \ + patch("commands.slot.subprocess.run", side_effect=FileNotFoundError("gh")), \ + patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \ + patch("lib.membership_guard.parse_file", side_effect=fake_parse), \ + patch("lib.membership_guard.write_file") as mw: + buf = io.StringIO() + with redirect_stdout(buf): + rc = slot.run(["30", "alpha"]) + self.assertEqual(rc, 0) + mw.assert_called_once() # write still happens despite the gh crash + self.assertIn(30, track.meta["github"]["issues"]) + + def test_confirm_then_stale_order(self): + """Public repo + valid confirm token + a stale --expect: the confirm gate + is satisfied first, then the staleness CAS still aborts at write time.""" + import json + from lib.membership_guard import issues_fingerprint + track = _track(name="alpha", repo="ok/repo", issues=[10, 20]) + tok = make_token("ok/repo", "alpha") + stale_fp = issues_fingerprint({"github": {"issues": [10]}}) + rc, mw, out = _drive(["30", "alpha", f"--confirm={tok}", f"--expect={stale_fp}"], + tracks=[track], vis="PUBLIC") + self.assertEqual(rc, 0) + mw.assert_not_called() + data = json.loads(out.strip()) + self.assertTrue(data["stale"]) + + if __name__ == "__main__": unittest.main() diff --git a/skills/work-plan/tests/test_slot_move.py b/skills/work-plan/tests/test_slot_move.py index 73ed53d..9c2310f 100644 --- a/skills/work-plan/tests/test_slot_move.py +++ b/skills/work-plan/tests/test_slot_move.py @@ -39,11 +39,20 @@ def _drive(self, *, tracks, args): cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/ok"}}} gh_proc = MagicMock(returncode=0, stdout="{}", stderr="") + # Writes go through lib.membership_guard now; return each track's own + # meta/body from parse_file so the guard mutates them in place. + by_path = {str(t.path): t for t in tracks} + + def fake_parse(p): + t = by_path[str(p)] + return (t.meta, t.body) + with patch("commands.slot.subprocess.run", return_value=gh_proc), \ patch("commands.slot.load_config", return_value=cfg), \ patch("commands.slot.discover_tracks", return_value=tracks), \ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \ - patch("commands.slot.write_file") as mw: + patch("lib.membership_guard.parse_file", side_effect=fake_parse), \ + patch("lib.membership_guard.write_file") as mw: rc = slot.run(args) return rc, mw diff --git a/skills/work-plan/work_plan.py b/skills/work-plan/work_plan.py index 043c2e1..f2fd89b 100755 --- a/skills/work-plan/work_plan.py +++ b/skills/work-plan/work_plan.py @@ -129,8 +129,8 @@ def _load_version() -> str: "AI-cluster GitHub issues into thematic track files. --limit controls how many issues are shown in the prompt (default 100).", "ONE-TIME bulk organization of an unsorted milestone, or after a re-org.", "/work-plan group --milestone='v1.0.0 — Public Launch'"), - ("auto-triage", "[--repo=] [--apply] [--limit=N]", - "AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist. --limit controls how many untracked issues are shown (default 100).", + ("auto-triage", "[--repo=] [--apply] [--json] [--heuristic] [--limit=N]", + "AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints an AI prompt and writes a per-repo batch file stamped with a batch_id; --json emits that batch (+ prompt + answers path) as one JSON object on stdout for the VS Code viewer instead of the human prompt. --heuristic (#373) skips the LLM entirely: it scores each issue against the candidate tracks using local signals (milestone match, track-label overlap, title/scope keyword overlap), writes the v2 answers file itself (stamped source:\"heuristic\", abstain-first), so suggestions work with no Claude session — lower-trust, but offline. Step 2 (--apply [--repo=]): reads the answers (v2 abstain-first shape preferred — only clear-margin suggestions are slotted; legacy v1 [{track,issues}] still accepted) and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist. --limit controls how many untracked issues are shown (default 100).", "Periodically — when new issues have piled up outside the track model. Run /work-plan coverage first to confirm there's a gap worth triaging.", "/work-plan auto-triage --repo=myproject"), ("reconcile", " | --all | --repo= [--draft] [--yes]", diff --git a/vscode/README.md b/vscode/README.md index d93d41e..7262799 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -11,6 +11,7 @@ The human face of the [`work-plan`](https://github.com/stylusnexus/work-plan-too - A **Plan link** on the detail panel (#285): when a track declares its plan/spec doc (`plan:` in frontmatter), the panel shows that doc's execution badge — verdict glyph + files/phases, with ✋ confirmed / ⚠ lie-gap / stalled markers — as a one-click button that opens the plan. The badge is computed by the same evaluator as the Plans view, so the two never disagree; only the *declared* link is shown (no fuzzy name-matching). An unresolvable link reads as a quiet "not found" note. - **Lenses** (filter by repo / milestone / status — active, shipped, parked — / blocked) and **sort** (default / blocked / most-open / name). Each milestone band in the detail panel has a **filter** button that applies that milestone's lens to the whole view (the band header itself collapses); the resulting filter is clearable straight from its confirmation toast. **Repo auto-focus (#357):** when the open workspace folder *is* a configured repo, the repo lens is selected automatically on load (and on folder change) so you start scoped to the project you're in — a manual lens choice always wins, and it's off-switchable via `workPlan.autoFocusRepo`. - An **"Untracked" bucket** under each repo: open GitHub issues that no track references — click to open on GitHub, or right-click to slot one into a track. For a **registered repo with no tracks** (whose issues `export` doesn't pull automatically), a **Fetch open issues** affordance under the repo pulls them on demand (#303) and renders them as that repo's Untracked bucket — also available as a right-click on the repo to refresh. +- **Auto-slot suggestions (#241, opt-in).** Right-click the **Untracked** bucket (or a repo) → **Suggest Tracks for Untracked Issues** scans the repo and relays an AI prompt to the Work Plan output channel; ask Claude to write the suggestions JSON to the path shown, and a **Suggested** sub-bucket (high-confidence, clear-margin) and a **Needs review** sub-bucket (lower-confidence / close calls) appear under Untracked. A **Suggested** issue is a one-click **Accept** (pick/confirm the track, then it's slotted with a compare-and-swap staleness guard so a track that changed since the scan re-offers instead of clobbering); a **Needs review** issue just opens the issue. **Accept All** and **Dismiss** / **Dismiss All** round it out. Each item leads with the rationale + target track; the confidence percentage lives in the tooltip. No Claude session? **Suggest Tracks (offline heuristic)** (#373) scores matches locally (milestone / label / keyword overlap) and fills the buckets immediately — flagged "heuristic" and lower-trust, but it works standalone. Enable via `workPlan.autoSlotSuggestions`; tune the one-click cutoff with `workPlan.autoSlotConfidenceThreshold` (default 0.7). - A **read-only tier-duplicate advisory** (#361): if a track is left in *both* a repo's shared `.work-plan/` and the private notes tier (a stray copy after promotion), a ⚠ row appears under the repo — `N tracks duplicated across tiers` — naming the `dedupe-tiers` command to resolve it (with a safe-vs-needs-review breakdown in the tooltip). It surfaces a condition that otherwise only warns to stderr; the actual cleanup stays in the CLI, so the viewer never deletes anything. **Act** — every action runs the CLI under the hood: @@ -189,7 +190,7 @@ The webview loads **`dist/mermaid.min.js`** — the **UMD bundle** from Mermaid ## Status -**Published — v0.13.0 on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=stylusnexus.work-plan-viewer) and [Open VSX](https://open-vsx.org/extension/stylusnexus/work-plan-viewer)** (publisher `stylusnexus`). v0.13.0 adds **repo auto-focus** (#357): when the open workspace folder is a configured repo, the Tracks view defaults its lens to that repo (resolved by clone path, then git remote) so you don't read another repo's issues by accident — a manual lens choice always wins, it re-arms on folder change, and `workPlan.autoFocusRepo` (default on) turns it off. Plus a **one-click Apply on Check Label Drift** (#221): the reconcile preview now offers an **Apply reconcile** action that performs the write (through the public-repo leak guard) instead of sending you to a terminal. And a **read-only tier-duplicate advisory** (#361): when a track is left in *both* a repo's shared `.work-plan/` and the private notes tier, a ⚠ row under the repo names the `dedupe-tiers` CLI command to resolve it. CLI floor unchanged (≥ `2026.06.15`); all three degrade gracefully on an older CLI. v0.12.0 is a feature batch: the **dependency graph gains zoom / pan / fit-to-width + Export as SVG/PNG** (#216) so dense maps stay navigable (scroll-wheel + drag, or header buttons; vanilla, CSP-clean); the **Plans view auto-updates on git activity** (#287) — committing a stalled plan's declared files clears its verdict without a manual Refresh (per-repo `.git` watcher, debounced; `workPlan.plansAutoRefresh` toggle), with time-relative staleness re-evaluated on focus; a native **Suggest Next-Up (auto)** picker (#274) brings the CLI's `--auto-next` to the viewer (read-only `handoff --suggest-next` feed → multi-select confirm → audited `handoff --set-next` write), since the CLI's TTY prompt can't run under the extension; the **Plans verdict icons get a legend** (#348) — an ℹ️ title-bar button opens a self-demonstrating QuickPick decoding each icon, with plain labels in the tooltip and two sharpened shapes (stalled→clock, drift→issue-reopened); and a **gear button** (#352, last nav icon in the Tracks title bar) opens the Settings UI scoped to this extension. Requires CLI ≥ `2026.06.15` (the native auto-next picker needs the new `handoff --suggest-next`). v0.11.1 makes the **Edit Track Fields** affordances for `launch_priority` and `milestone_alignment` consistent with **New Track** (#213): editing priority now offers the same `P0`–`P3` QuickPick instead of a free-text box (so the edit path can't write arbitrary priority strings), and editing milestone offers a QuickPick of the milestones already present in the export (deduped + sorted) with a "Type a new milestone…" escape hatch and a "Clear milestone" option. v0.11.0 adds **toggle auto next-up on/off from the Set Next-Up Order… picker** (#338): the QuickPick now shows "Auto next-up: ON / OFF" items at the top (with ✓ on the current state) above a separator, writing `set-next-up --auto=on|off` through the existing confirm flow; the detail panel's "Next-up order:" row appends `· auto` when auto is enabled. v0.10.1 adds a **Set Next-Up button in the track detail panel** — a small "Set Next-Up" button inline in the "Next up:" section fires the existing `workPlan.setNext` command so the next-up flow is reachable from the detail view without right-clicking the Tracks sidebar. v0.10.0 adds per-track next-up ordering presets — **Set Next-Up Order…** command + detail-panel indicator (#326): a right-click context menu entry on any track opens a QuickPick of `flow` / `priority-driven` / `backlog` presets (plus a custom-order help and a clear option); the selected preset is written via `set-next-up --preset=` through the existing public-repo confirm flow; the active preset name appears in the detail panel's track meta when `workPlan.showNextUpPreset` is on (default true). Requires CLI ≥ `2026.06.14` for the write; degrades gracefully on older CLIs (no indicator, write unavailable). v0.9.2 hardens the detail-panel issue links: they now carry a real GitHub `href` (instead of `href="#"`) so clicking an issue number opens GitHub even if the webview script is blocked/stale/errored — previously such a click silently scrolled the panel to the top. Also adds a `font-src` CSP directive (the panel was emitting a blocked-`data:`-font console warning). v0.9.1 is a hotfix: v0.9.0 set its minimum-CLI gate one day ahead of the release (`2026.06.15` vs the `2026.06.14` CLI it shipped beside), so every updated user got a false "CLI version may be incompatible" warning — v0.9.1 corrects the gate to `2026.06.14` and adds a guard test so the gate can never again exceed the repo's own CLI `VERSION`. v0.9.0 adds GitHub-native **blocked-by / blocking** surfacing (#257, read-only): a same-repo blocked-by edge in the focused dependency graph, and an expandable ⛓ dependency disclosure on detail-panel issue rows showing ⊘ blocked-by / ⇒ blocking chips. Requires CLI ≥ `2026.06.14`. v0.8.0 adds **per-issue in-progress badge + toggle** (#271): a `work-plan:in-progress` label appears as a live badge on tracked issues in the detail panel, with a toggle action to mark or clear it; the viewer also detects in-progress automatically from a hot `feat/-`/`fix/-` branch (no label needed). Also fixes the v0.7.0 regressions carried into v0.7.1: the **Close-on-GitHub** button in the detail panel renders correctly, and the **open-plan webview** button in the Plans view no longer errors when the plan file path isn't resolvable. Requires CLI ≥ `2026.06.14`. v0.7.1 was an accessibility + polish follow-up to v0.7.0: a **dark-mode contrast pass** — the tree's status/verdict icons moved off the muted `charts.*` tints to theme-tuned, list-semantic tokens that meet WCAG non-text contrast, and the faint detail-panel action icons (Move / Close) are legible at rest — plus two small features: a **per-track open/closed progress bar** in the detail card with a `closed/total` count in the tree (#220), and an **activity-bar badge** showing blocked-track (else total-open) count (#215). Also fixes two v0.7.0 regressions: **Fetch Open Issues** now excludes already-tracked issues (a tracked issue no longer shows under Untracked), and the detail-panel **Close-on-GitHub** button renders correctly. v0.7.0 was a large feature batch that made the Plans view *act*, not just report, and hardened the GitHub path. **Plan frontmatter writes** (all confirm-gated, frontmatter-only): **Confirm Verdict** pins a human verdict to silence a false lie-gap; **Acknowledge & Save to Doc** persists a durable, shared ack; **Stamp Baseline — Watch for Drift** records the verdict and flags a once-shipped plan that silently **regressed**; plus a read-only **off-tree manifest** flag. **Track ↔ plan link**: a track's detail panel shows its linked plan's execution badge, one click to open. **GitHub-aware**: a **fast-fail "Not signed in to GitHub" banner** with a Sign-in path (no more silently-empty tree), **Fetch Open Issues** for trackless repos, and **Close Issue on GitHub** (the viewer's gated issue-close). **Push to Shared Tier** promotes a private track to the repo's shared plan branch. Requires CLI ≥ `2026.06.13`. v0.6.3 made the Tracks/Plans split self-explaining from the other side: a repo that has **tracks but no registered local clone** (no `repos:` entry) now shows in the **Plans** view as a greyed **"not registered"** row instead of being silently absent — click it to **Add Repo** (prefilled with the slug) and it becomes a scannable repo. Pairs with v0.6.2, which fixed the inverse — a **registered repo with no tracks** (e.g. a just-added `agent-armor`) showed in Plans but was missing from **Tracks**. The Tracks view renders from the lens-filtered export, and the lens filter was silently dropping the configured-repos list — so the empty-repo seeding had nothing to seed, under every lens including "All". Empty registered repos appear in Tracks again (right-click → **New Track** to start). v0.6.1 polishes the new Plans view: the **Plans** section is **collapsed by default** (Tracks stays the hot path), **"Scan All Plans"** is now a `$(telescope)` icon (no longer a magnifying-glass that read as Search) **and the empty-state itself is clickable** to run it, and the title bar is trimmed (Show-Acknowledged moved to the `…` overflow). v0.6.0 was the feature release. A new **Plans view** — a read-only second tree that surfaces plan/spec docs and their `plan-status` health, making **stalled** (a `partial` plan whose declared manifest files have gone cold — "started executing, drifted off") and **lie-gap** (scored shipped but its own phase checkboxes aren't ticked) loud across repos, with a cross-repo **"Scan All"** stalled roll-up, lazy per-repo scanning, acknowledge/dismiss, a `workPlan.stallDays` threshold setting, and click-to-open. **Registered repos are now first-class**: a configured repo appears in the sidebar even with no tracks (right-click → **New Track** to start), with **Add Repo / Remove Repo / Clear Local Path** management (clear blocking modals on the destructive actions) and honest add-repo feedback. Also new: **Open Track File** (open a track's underlying `.md`), and **pick-from-a-list** for **Move**, **Set Next-Up**, and **Add Issue to Track** (no more retyping issue numbers — pick from the known list, with filtering). Requires CLI ≥ `2026.06.13` (the Plans view + registered-repo listing need its export/plan-status fields). v0.5.1 was a small fix to the **Daily Brief** title-bar button: a clearer `$(checklist)` icon (the previous hamburger glyph read as a generic menu) and a re-entrancy guard so repeat-clicks no longer stack concurrent brief runs. v0.5.0 was a daily-driver + discoverability release: a new **Search Issues** command (title-bar `$(search)` + palette) finds issues by title across every track and the Untracked bucket with `%wildcard%` substitution (`%depends%` contains, `fix%` starts-with, `%audit` ends-with), case-insensitive, opening matches in a dedicated **Issue Search** tab grouped by repo (open-first) with click-to-open-on-GitHub and reveal-in-tree; the **Daily Brief / Re-orient / Wrap Up Session (Handoff)** verbatim-relay verbs are now runnable from the title bar and track menus; the **active lens + sort** are surfaced inline under the Tracks view title (e.g. `milestone: v2.0.0 · blocked-first`); and milestone entries in the **Select View** filter now sort numeric-aware (`v0.5.0` before `v0.10.0`). v0.4.2 fixed the **visibility × tier badge** rendering — the codicon tokens (`$(globe)`/`$(lock)`/`$(cloud)`) leaked as literal text in the tree because `TreeItem.description` is plain text and never resolves `$(icon)` syntax; the badge now uses Unicode glyphs (🌐 / 🔒 / ☁️, ⚠️ for the exposed state) so it renders as intended. v0.4.1 added the per-track **visibility × tier badge** (🔒 private / 🌐 public repo, ☁ shared tier) on every tree item — flagging the one **exposed** state where a plan committed to a *public* repo's shared tier is world-visible (pairs with the CLI's new `plan-branch` canonical-plan-branch workflow). v0.4.0 was a broad UX + accessibility pass: a **de-noised command palette** (category-namespaced commands) with clearer names (**Sync Issue States from GitHub**, **Check Label Drift**, **Add Issue to Track**), a **frequency-grouped track menu** with confirmation modals on the destructive actions, **editor-theme-adaptive** graph + detail panel (light/dark/high-contrast), a **per-milestone filter** in the detail panel, progress feedback on every write, and an accessibility sweep (distinct status-icon shapes, keyboard-operable disclosures and chips, table semantics, graph alt text). Earlier v0.3.x added the Local History command, Rename Track, milestone bands, Move Issue from Track, and cross-track dependency chips. +**Published — v0.14.0 on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=stylusnexus.work-plan-viewer) and [Open VSX](https://open-vsx.org/extension/stylusnexus/work-plan-viewer)** (publisher `stylusnexus`). v0.14.0 adds **proactive auto-slot suggestions** (#241, opt-in via `workPlan.autoSlotSuggestions`): right-click the **Untracked** bucket → **Suggest Tracks** scans the repo and an AI session fills a **Suggested** sub-bucket (one-click Accept, with a compare-and-swap staleness guard + shared-tier rebase so a track that changed since the scan re-offers instead of clobbering) and a **Needs review** sub-bucket (open-only); **Suggest Tracks (offline heuristic)** (#373) fills them with no AI, scoring matches locally (milestone / label / keyword) and flagging them lower-trust. Each item leads with the rationale; confidence lives in the tooltip; Accept All / Dismiss / Dismiss All round it out. Also a **critical webview fix** (#374): escaped quotes in an inline-script template literal had been collapsing to a syntax error since 0.9.0, killing *every* webview click handler (track select, focus toggle, and the 0.12.0 graph zoom/pan/export controls) — now fixed, with a parse-guard test so an inline-script syntax error fails CI instead of shipping dead. CLI floor unchanged (≥ `2026.06.15`); auto-slot needs the CLI from this same deploy and fails loudly (not silently) on an older one. v0.13.0 added **repo auto-focus** (#357): when the open workspace folder is a configured repo, the Tracks view defaults its lens to that repo (resolved by clone path, then git remote) so you don't read another repo's issues by accident — a manual lens choice always wins, it re-arms on folder change, and `workPlan.autoFocusRepo` (default on) turns it off. Plus a **one-click Apply on Check Label Drift** (#221): the reconcile preview now offers an **Apply reconcile** action that performs the write (through the public-repo leak guard) instead of sending you to a terminal. And a **read-only tier-duplicate advisory** (#361): when a track is left in *both* a repo's shared `.work-plan/` and the private notes tier, a ⚠ row under the repo names the `dedupe-tiers` CLI command to resolve it. CLI floor unchanged (≥ `2026.06.15`); all three degrade gracefully on an older CLI. v0.12.0 is a feature batch: the **dependency graph gains zoom / pan / fit-to-width + Export as SVG/PNG** (#216) so dense maps stay navigable (scroll-wheel + drag, or header buttons; vanilla, CSP-clean); the **Plans view auto-updates on git activity** (#287) — committing a stalled plan's declared files clears its verdict without a manual Refresh (per-repo `.git` watcher, debounced; `workPlan.plansAutoRefresh` toggle), with time-relative staleness re-evaluated on focus; a native **Suggest Next-Up (auto)** picker (#274) brings the CLI's `--auto-next` to the viewer (read-only `handoff --suggest-next` feed → multi-select confirm → audited `handoff --set-next` write), since the CLI's TTY prompt can't run under the extension; the **Plans verdict icons get a legend** (#348) — an ℹ️ title-bar button opens a self-demonstrating QuickPick decoding each icon, with plain labels in the tooltip and two sharpened shapes (stalled→clock, drift→issue-reopened); and a **gear button** (#352, last nav icon in the Tracks title bar) opens the Settings UI scoped to this extension. Requires CLI ≥ `2026.06.15` (the native auto-next picker needs the new `handoff --suggest-next`). v0.11.1 makes the **Edit Track Fields** affordances for `launch_priority` and `milestone_alignment` consistent with **New Track** (#213): editing priority now offers the same `P0`–`P3` QuickPick instead of a free-text box (so the edit path can't write arbitrary priority strings), and editing milestone offers a QuickPick of the milestones already present in the export (deduped + sorted) with a "Type a new milestone…" escape hatch and a "Clear milestone" option. v0.11.0 adds **toggle auto next-up on/off from the Set Next-Up Order… picker** (#338): the QuickPick now shows "Auto next-up: ON / OFF" items at the top (with ✓ on the current state) above a separator, writing `set-next-up --auto=on|off` through the existing confirm flow; the detail panel's "Next-up order:" row appends `· auto` when auto is enabled. v0.10.1 adds a **Set Next-Up button in the track detail panel** — a small "Set Next-Up" button inline in the "Next up:" section fires the existing `workPlan.setNext` command so the next-up flow is reachable from the detail view without right-clicking the Tracks sidebar. v0.10.0 adds per-track next-up ordering presets — **Set Next-Up Order…** command + detail-panel indicator (#326): a right-click context menu entry on any track opens a QuickPick of `flow` / `priority-driven` / `backlog` presets (plus a custom-order help and a clear option); the selected preset is written via `set-next-up --preset=` through the existing public-repo confirm flow; the active preset name appears in the detail panel's track meta when `workPlan.showNextUpPreset` is on (default true). Requires CLI ≥ `2026.06.14` for the write; degrades gracefully on older CLIs (no indicator, write unavailable). v0.9.2 hardens the detail-panel issue links: they now carry a real GitHub `href` (instead of `href="#"`) so clicking an issue number opens GitHub even if the webview script is blocked/stale/errored — previously such a click silently scrolled the panel to the top. Also adds a `font-src` CSP directive (the panel was emitting a blocked-`data:`-font console warning). v0.9.1 is a hotfix: v0.9.0 set its minimum-CLI gate one day ahead of the release (`2026.06.15` vs the `2026.06.14` CLI it shipped beside), so every updated user got a false "CLI version may be incompatible" warning — v0.9.1 corrects the gate to `2026.06.14` and adds a guard test so the gate can never again exceed the repo's own CLI `VERSION`. v0.9.0 adds GitHub-native **blocked-by / blocking** surfacing (#257, read-only): a same-repo blocked-by edge in the focused dependency graph, and an expandable ⛓ dependency disclosure on detail-panel issue rows showing ⊘ blocked-by / ⇒ blocking chips. Requires CLI ≥ `2026.06.14`. v0.8.0 adds **per-issue in-progress badge + toggle** (#271): a `work-plan:in-progress` label appears as a live badge on tracked issues in the detail panel, with a toggle action to mark or clear it; the viewer also detects in-progress automatically from a hot `feat/-`/`fix/-` branch (no label needed). Also fixes the v0.7.0 regressions carried into v0.7.1: the **Close-on-GitHub** button in the detail panel renders correctly, and the **open-plan webview** button in the Plans view no longer errors when the plan file path isn't resolvable. Requires CLI ≥ `2026.06.14`. v0.7.1 was an accessibility + polish follow-up to v0.7.0: a **dark-mode contrast pass** — the tree's status/verdict icons moved off the muted `charts.*` tints to theme-tuned, list-semantic tokens that meet WCAG non-text contrast, and the faint detail-panel action icons (Move / Close) are legible at rest — plus two small features: a **per-track open/closed progress bar** in the detail card with a `closed/total` count in the tree (#220), and an **activity-bar badge** showing blocked-track (else total-open) count (#215). Also fixes two v0.7.0 regressions: **Fetch Open Issues** now excludes already-tracked issues (a tracked issue no longer shows under Untracked), and the detail-panel **Close-on-GitHub** button renders correctly. v0.7.0 was a large feature batch that made the Plans view *act*, not just report, and hardened the GitHub path. **Plan frontmatter writes** (all confirm-gated, frontmatter-only): **Confirm Verdict** pins a human verdict to silence a false lie-gap; **Acknowledge & Save to Doc** persists a durable, shared ack; **Stamp Baseline — Watch for Drift** records the verdict and flags a once-shipped plan that silently **regressed**; plus a read-only **off-tree manifest** flag. **Track ↔ plan link**: a track's detail panel shows its linked plan's execution badge, one click to open. **GitHub-aware**: a **fast-fail "Not signed in to GitHub" banner** with a Sign-in path (no more silently-empty tree), **Fetch Open Issues** for trackless repos, and **Close Issue on GitHub** (the viewer's gated issue-close). **Push to Shared Tier** promotes a private track to the repo's shared plan branch. Requires CLI ≥ `2026.06.13`. v0.6.3 made the Tracks/Plans split self-explaining from the other side: a repo that has **tracks but no registered local clone** (no `repos:` entry) now shows in the **Plans** view as a greyed **"not registered"** row instead of being silently absent — click it to **Add Repo** (prefilled with the slug) and it becomes a scannable repo. Pairs with v0.6.2, which fixed the inverse — a **registered repo with no tracks** (e.g. a just-added `agent-armor`) showed in Plans but was missing from **Tracks**. The Tracks view renders from the lens-filtered export, and the lens filter was silently dropping the configured-repos list — so the empty-repo seeding had nothing to seed, under every lens including "All". Empty registered repos appear in Tracks again (right-click → **New Track** to start). v0.6.1 polishes the new Plans view: the **Plans** section is **collapsed by default** (Tracks stays the hot path), **"Scan All Plans"** is now a `$(telescope)` icon (no longer a magnifying-glass that read as Search) **and the empty-state itself is clickable** to run it, and the title bar is trimmed (Show-Acknowledged moved to the `…` overflow). v0.6.0 was the feature release. A new **Plans view** — a read-only second tree that surfaces plan/spec docs and their `plan-status` health, making **stalled** (a `partial` plan whose declared manifest files have gone cold — "started executing, drifted off") and **lie-gap** (scored shipped but its own phase checkboxes aren't ticked) loud across repos, with a cross-repo **"Scan All"** stalled roll-up, lazy per-repo scanning, acknowledge/dismiss, a `workPlan.stallDays` threshold setting, and click-to-open. **Registered repos are now first-class**: a configured repo appears in the sidebar even with no tracks (right-click → **New Track** to start), with **Add Repo / Remove Repo / Clear Local Path** management (clear blocking modals on the destructive actions) and honest add-repo feedback. Also new: **Open Track File** (open a track's underlying `.md`), and **pick-from-a-list** for **Move**, **Set Next-Up**, and **Add Issue to Track** (no more retyping issue numbers — pick from the known list, with filtering). Requires CLI ≥ `2026.06.13` (the Plans view + registered-repo listing need its export/plan-status fields). v0.5.1 was a small fix to the **Daily Brief** title-bar button: a clearer `$(checklist)` icon (the previous hamburger glyph read as a generic menu) and a re-entrancy guard so repeat-clicks no longer stack concurrent brief runs. v0.5.0 was a daily-driver + discoverability release: a new **Search Issues** command (title-bar `$(search)` + palette) finds issues by title across every track and the Untracked bucket with `%wildcard%` substitution (`%depends%` contains, `fix%` starts-with, `%audit` ends-with), case-insensitive, opening matches in a dedicated **Issue Search** tab grouped by repo (open-first) with click-to-open-on-GitHub and reveal-in-tree; the **Daily Brief / Re-orient / Wrap Up Session (Handoff)** verbatim-relay verbs are now runnable from the title bar and track menus; the **active lens + sort** are surfaced inline under the Tracks view title (e.g. `milestone: v2.0.0 · blocked-first`); and milestone entries in the **Select View** filter now sort numeric-aware (`v0.5.0` before `v0.10.0`). v0.4.2 fixed the **visibility × tier badge** rendering — the codicon tokens (`$(globe)`/`$(lock)`/`$(cloud)`) leaked as literal text in the tree because `TreeItem.description` is plain text and never resolves `$(icon)` syntax; the badge now uses Unicode glyphs (🌐 / 🔒 / ☁️, ⚠️ for the exposed state) so it renders as intended. v0.4.1 added the per-track **visibility × tier badge** (🔒 private / 🌐 public repo, ☁ shared tier) on every tree item — flagging the one **exposed** state where a plan committed to a *public* repo's shared tier is world-visible (pairs with the CLI's new `plan-branch` canonical-plan-branch workflow). v0.4.0 was a broad UX + accessibility pass: a **de-noised command palette** (category-namespaced commands) with clearer names (**Sync Issue States from GitHub**, **Check Label Drift**, **Add Issue to Track**), a **frequency-grouped track menu** with confirmation modals on the destructive actions, **editor-theme-adaptive** graph + detail panel (light/dark/high-contrast), a **per-milestone filter** in the detail panel, progress feedback on every write, and an accessibility sweep (distinct status-icon shapes, keyboard-operable disclosures and chips, table semantics, graph alt text). Earlier v0.3.x added the Local History command, Rename Track, milestone bands, Move Issue from Track, and cross-track dependency chips. ## Development notes diff --git a/vscode/package.json b/vscode/package.json index 9a23ad0..16c50ed 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "name": "work-plan-viewer", "displayName": "Work Plan", "publisher": "stylusnexus", - "version": "0.13.0", + "version": "0.14.0", "description": "Browse and manage GitHub issues as tracks — dependency graph, per-track detail, and read/write (slot, move, reconcile, close) in the sidebar.", "license": "MIT", "icon": "media/icon.png", @@ -295,6 +295,42 @@ "title": "Local History (private notes)…", "category": "Work Plan" }, + { + "command": "workPlan.suggestTracks", + "title": "Suggest Tracks for Untracked Issues…", + "category": "Work Plan", + "icon": "$(sparkle)" + }, + { + "command": "workPlan.suggestTracksOffline", + "title": "Suggest Tracks for Untracked Issues (offline heuristic)…", + "category": "Work Plan", + "icon": "$(sparkle)" + }, + { + "command": "workPlan.acceptSuggestion", + "title": "Accept Suggestion (slot into track)…", + "category": "Work Plan", + "icon": "$(check)" + }, + { + "command": "workPlan.dismissSuggestion", + "title": "Dismiss Suggestion", + "category": "Work Plan", + "icon": "$(close)" + }, + { + "command": "workPlan.batchAcceptSuggestions", + "title": "Accept All Suggestions…", + "category": "Work Plan", + "icon": "$(check-all)" + }, + { + "command": "workPlan.dismissAllSuggestions", + "title": "Dismiss All Suggestions", + "category": "Work Plan", + "icon": "$(close-all)" + }, { "command": "workPlan.dailyBrief", "title": "Daily Brief", @@ -502,6 +538,51 @@ "when": "view == workPlan.tree && viewItem == workPlanUntrackedGroup", "group": "1_workPlanWrite@1" }, + { + "command": "workPlan.suggestTracks", + "when": "view == workPlan.tree && viewItem == workPlanUntrackedGroup", + "group": "1_workPlanWrite@2" + }, + { + "command": "workPlan.suggestTracksOffline", + "when": "view == workPlan.tree && viewItem == workPlanUntrackedGroup", + "group": "1_workPlanWrite@3" + }, + { + "command": "workPlan.suggestTracks", + "when": "view == workPlan.tree && viewItem == workPlanRepo", + "group": "0_workPlanRepo@3" + }, + { + "command": "workPlan.suggestTracksOffline", + "when": "view == workPlan.tree && viewItem == workPlanRepo", + "group": "0_workPlanRepo@4" + }, + { + "command": "workPlan.batchAcceptSuggestions", + "when": "view == workPlan.tree && viewItem == workPlanSuggestedGroup", + "group": "1_workPlanWrite@1" + }, + { + "command": "workPlan.dismissAllSuggestions", + "when": "view == workPlan.tree && viewItem == workPlanSuggestedGroup", + "group": "1_workPlanWrite@2" + }, + { + "command": "workPlan.acceptSuggestion", + "when": "view == workPlan.tree && viewItem == workPlanSuggestedIssue", + "group": "1_workPlanWrite@1" + }, + { + "command": "workPlan.dismissSuggestion", + "when": "view == workPlan.tree && viewItem == workPlanSuggestedIssue", + "group": "1_workPlanWrite@2" + }, + { + "command": "workPlan.dismissSuggestion", + "when": "view == workPlan.tree && viewItem == workPlanNeedsReviewIssue", + "group": "1_workPlanWrite@1" + }, { "command": "workPlan.plans.acknowledge", "when": "view == workPlan.plans && viewItem =~ /workPlanPlan-(stalled|dead)/", @@ -603,6 +684,22 @@ { "command": "workPlan.clearRepoLocal", "when": "false" + }, + { + "command": "workPlan.acceptSuggestion", + "when": "false" + }, + { + "command": "workPlan.dismissSuggestion", + "when": "false" + }, + { + "command": "workPlan.batchAcceptSuggestions", + "when": "false" + }, + { + "command": "workPlan.dismissAllSuggestions", + "when": "false" } ] }, @@ -692,6 +789,18 @@ "type": "boolean", "default": true, "description": "Auto-update the Plans view when a scanned repo's git activity changes (e.g. committing a plan's declared files clears its 'stalled' state) without a manual Refresh. Watches each expanded repo's .git refs; falls back to manual refresh when off." + }, + "workPlan.autoSlotSuggestions": { + "type": "boolean", + "default": false, + "description": "Show AI track suggestions for untracked issues under the Untracked bucket. Run 'Suggest Tracks for Untracked Issues' to scan, ask Claude with the relayed prompt, and a 'Suggested' / 'Needs review' sub-bucket appears once answers are written. Off by default." + }, + "workPlan.autoSlotConfidenceThreshold": { + "type": "number", + "default": 0.7, + "minimum": 0, + "maximum": 1, + "description": "Confidence cutoff for a clear-margin suggestion to land in the one-click 'Suggested' bucket (vs 'Needs review'). 0–1; higher means fewer one-click accepts. Default 0.7." } } } diff --git a/vscode/src/cli.ts b/vscode/src/cli.ts index f3b9ead..f64141d 100644 --- a/vscode/src/cli.ts +++ b/vscode/src/cli.ts @@ -497,6 +497,69 @@ export async function whichRepo(run: CliRunner, cwd: string): Promise` (#241). The + * viewer captures `batch_id` to correlate the answers a Claude session writes + * back, and `answers_path` (used verbatim — never recomputed) is where those + * answers must land. `prompt` is relayed to the output channel. + */ +export interface AutoTriageScan { + batch_id: string; + repo: string; + folder: string | null; + untracked: { number: number; title: string }[]; + tracks: { slug: string; name: string; milestone: string | null; priority: string | null; scope: string }[]; + prompt: string; + /** Absolute path the agent should write the answers JSON to (per-repo). */ + answers_path: string; +} + +/** + * Runs `auto-triage --json --repo=` and returns the parsed scan + * (#241). The CLI prints progress to stderr so stdout is one clean JSON object. + * A "full coverage" repo (no untracked issues) prints a human line instead of + * JSON — that parses as failure, surfaced as a soft CliError the caller can + * detect and message as "nothing to triage". Throws CliError on non-zero exit + * or unparseable output. + */ +export async function autoTriageScan( + run: CliRunner, + folderKey: string, + opts: { heuristic?: boolean } = {}, +): Promise { + const args = ["auto-triage", "--json", `--repo=${folderKey}`]; + // --heuristic (#373): the CLI writes the v2 answers file itself (no LLM), so + // suggestions appear with no Claude session — lower-trust, but offline. + if (opts.heuristic) args.push("--heuristic"); + const result = await run(args); + if (result.code !== 0) { + throw new CliError({ + message: `work-plan auto-triage failed (exit ${result.code}): ${result.stderr.trim()}`, + args, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }); + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + throw new CliError({ + message: `could not parse auto-triage JSON: ${result.stdout.slice(0, 200)}`, + args, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }); + } + return parsed as AutoTriageScan; +} + // --------------------------------------------------------------------------- // notes-vcs — opt-in local history for the private notes_root tier (#103/#224) // --------------------------------------------------------------------------- diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 513caea..9759463 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,18 +1,22 @@ import * as vscode from "vscode"; +import * as fs from "node:fs"; import { exportJson, listRepoOpenIssues, makeSpawnRunner, checkVersion, checkAuth, CliError, isAlreadyExistsError, notesVcsStatus, notesVcsRun, notesVcsUndo, suggestNextUp, + autoTriageScan, } from "./cli.ts"; import type { NotesVcsStatus, AuthState } from "./cli.ts"; import { pickAutoFocusSlug } from "./autofocus.ts"; import { WorkPlanTreeProvider } from "./tree.ts"; import { PlansProvider } from "./plansTree.ts"; import { ackKey, unregisteredTrackRepos, LEGEND } from "./planModel.ts"; -import type { Lens, TrackNode, UntrackedIssueNode, UntrackedGroupNode, RepoNode } from "./tree.ts"; +import type { Lens, TrackNode, UntrackedIssueNode, UntrackedGroupNode, RepoNode, SuggestedIssueNode, SuggestedGroupNode } from "./tree.ts"; import type { Track, Issue } from "./model.ts"; import { trackedIssueNumbers, collectMilestones } from "./model.ts"; import { badgeCounts } from "./treeModel.ts"; +import { readSuggestions } from "./suggestions.ts"; +import { issuesFingerprint } from "./fingerprint.ts"; import { buildIssuePickItems } from "./issuePick.ts"; import { WorkPlanPanel } from "./webview/panel.ts"; import { availableLenses, describeView } from "./webview/lenses.ts"; @@ -1610,6 +1614,390 @@ export function activate(context: vscode.ExtensionContext): void { }), ); + // ------------------------------------------------------------------------- + // Auto-slot suggestions (#241): offer to slot untracked issues into tracks. + // + // Flow: Suggest Tracks runs `auto-triage --json` → stores {batchId, answersPath} + // for the repo + relays the prompt → a Claude session writes answers to + // answersPath → an fs.watch fires → we re-read + bucket (suggestions.ts) and + // store on the provider → the tree shows Suggested / Needs review sub-buckets. + // Accept computes a CAS fingerprint (#241) of the target track's current issues + // and slots with --expect, branching on the staleness/rebase outcome. + // ------------------------------------------------------------------------- + + // Per-repo answers-file paths (the CLI-emitted absolute path, used verbatim) + // and their live fs.watchers, so a repo re-scanned mid-session swaps watchers + // cleanly and all watchers tear down on deactivation. + const autoSlotAnswersPath = new Map(); + const autoSlotWatchers = new Map(); + // Per-repo debounce timers — editors write the answers file in several syncs, + // so coalesce a burst of change events into one re-read (~300ms). + const autoSlotDebounce = new Map>(); + + const autoSlotThreshold = (): number => { + const raw = vscode.workspace + .getConfiguration("workPlan") + .get("autoSlotConfidenceThreshold", 0.7); + // Clamp to [0,1] so a hand-edited setting can't invert the buckets. + return Math.min(1, Math.max(0, raw)); + }; + + // Dismissed suggestions (#241): per-repo, per-issue, persisted in workspaceState + // so a dismissed issue stays dropped to plain untracked across reloads. Keyed + // `autoSlot.dismissed..`. + const dismissKey = (repo: string, issueNumber: number): string => + `autoSlot.dismissed.${repo}.${issueNumber}`; + const isDismissed = (repo: string, issueNumber: number): boolean => + context.workspaceState.get(dismissKey(repo, issueNumber), false); + + // Re-read a repo's answers file, bucket it, and push onto the provider. Safe to + // call when the file doesn't exist yet (cold) — readSuggestions tolerates it. + const readAndStoreSuggestions = (repo: string): void => { + const path = autoSlotAnswersPath.get(repo); + const batchId = provider.getBatchId(repo); + if (!path || !batchId) return; + const buckets = readSuggestions( + path, + batchId, + autoSlotThreshold(), + (issueNumber) => isDismissed(repo, issueNumber), + ); + if (buckets.batchMismatch) { + // A stale answers file from a prior scan — surface it once rather than + // silently applying nothing. + vscode.window.showWarningMessage( + `Work Plan: the suggestions file for ${repo} is from a different scan — re-run Suggest Tracks.`, + ); + } + provider.setSuggestions(repo, buckets); + }; + + // Arm (or re-arm) an fs.watch on a repo's answers file. fs.watch fires on the + // file's directory entry; we debounce and re-read. The cache dir is created by + // the CLI before it prints answers_path, so the parent exists. + const watchAnswers = (repo: string, path: string): void => { + autoSlotWatchers.get(repo)?.close(); + autoSlotAnswersPath.set(repo, path); + try { + const watcher = fs.watch(path, { persistent: false }, () => { + const prior = autoSlotDebounce.get(repo); + if (prior) clearTimeout(prior); + autoSlotDebounce.set( + repo, + setTimeout(() => { + autoSlotDebounce.delete(repo); + readAndStoreSuggestions(repo); + }, 300), + ); + }); + watcher.on("error", () => { /* file vanished/renamed — ignore */ }); + autoSlotWatchers.set(repo, watcher); + } catch { + // The file may not exist yet (Claude hasn't written it). fs.watch on a + // missing path throws; the cold-open read below still picks it up once it + // lands on the next Suggest Tracks / reload. Best-effort — never fatal. + } + // Cold read: pick up an answers file already on disk from a prior session. + readAndStoreSuggestions(repo); + }; + + // Tear down every answers-file watcher + pending debounce on deactivation. + context.subscriptions.push({ + dispose: () => { + for (const w of autoSlotWatchers.values()) w.close(); + autoSlotWatchers.clear(); + for (const t of autoSlotDebounce.values()) clearTimeout(t); + autoSlotDebounce.clear(); + }, + }); + + // Re-bucket all repos when the threshold flips (it shifts suggested↔needsReview). + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("workPlan.autoSlotConfidenceThreshold")) { + for (const repo of autoSlotAnswersPath.keys()) readAndStoreSuggestions(repo); + } + }), + ); + + // Pick the repo for a palette invocation of Suggest Tracks: the configured repos + // (with a folder key — the --repo arg) drawn from the raw export. + const pickAutoSlotRepo = async (): Promise<{ repo: string; folder: string } | undefined> => { + const raw = provider.rawExport; + const choices = (raw?.repos ?? []) + .filter((r): r is typeof r & { repo: string; folder: string } => + typeof r.repo === "string" && r.repo !== "" && typeof r.folder === "string" && r.folder !== "") + .map(r => ({ label: r.repo, repo: r.repo, folder: r.folder })); + if (choices.length === 0) { + vscode.window.showInformationMessage("Work Plan: no configured repos to triage — Add Repo first."); + return undefined; + } + if (choices.length === 1) return { repo: choices[0].repo, folder: choices[0].folder }; + const pick = await vscode.window.showQuickPick(choices, { + placeHolder: "Suggest tracks for which repo?", + }); + return pick ? { repo: pick.repo, folder: pick.folder } : undefined; + }; + + // Shared scan driver for both Suggest Tracks variants. heuristic=false runs the + // LLM path (relays a prompt for a Claude session to answer); heuristic=true runs + // the offline scorer (#373) — the CLI writes the answers file itself, so + // suggestions appear immediately via the watcher's cold read, no session needed. + const runSuggestScan = async ( + node: { repo?: string; folder?: string | null } | undefined, + heuristic: boolean, + ): Promise => { + try { + let repo = typeof node?.repo === "string" ? node.repo : undefined; + let folder = typeof node?.folder === "string" ? node.folder : undefined; + if (repo && !folder) { + folder = provider.rawExport?.repos?.find(r => r.repo === repo)?.folder ?? undefined; + } + if (!repo || !folder) { + const picked = await pickAutoSlotRepo(); + if (!picked) return; + repo = picked.repo; + folder = picked.folder; + } + + const scan = await withWriteProgress( + `Work Plan: scanning ${repo} for untracked issues…`, + () => autoTriageScan(runner, folder!, { heuristic }), + ); + + if (scan.untracked.length === 0) { + vscode.window.showInformationMessage( + `Work Plan: ${repo} has no untracked issues — full coverage.`, + ); + return; + } + + // Record the batch + arm the watcher on the CLI-emitted answers path (used + // verbatim). The watcher's cold read picks up the heuristic answers the CLI + // just wrote; for the LLM path it fires when the Claude session writes them. + provider.setBatchId(repo, scan.batch_id); + watchAnswers(repo, scan.answers_path); + + if (heuristic) { + vscode.window.showInformationMessage( + `Work Plan: ${scan.untracked.length} untracked issue(s) in ${repo} — offline heuristic ` + + "suggestions are under Untracked (lower-trust; review before accepting).", + ); + return; + } + + // LLM path: relay the prompt for a Claude session to answer. + outputChannel.clear(); + outputChannel.appendLine( + `Ask Claude to produce suggestions and save to ${scan.answers_path}`, + ); + outputChannel.appendLine(""); + outputChannel.append(scan.prompt); + outputChannel.show(true); + + vscode.window.showInformationMessage( + `Work Plan: scanned ${scan.untracked.length} untracked issue(s) in ${repo}. ` + + "Ask Claude with the prompt in the Work Plan output channel; suggestions appear under Untracked.", + ); + } catch (err: unknown) { + const msg = err instanceof CliError + ? `Work Plan: ${err.message}` + : `Work Plan: suggest-tracks failed — ${String(err)}`; + vscode.window.showErrorMessage(msg); + } + }; + + context.subscriptions.push( + vscode.commands.registerCommand( + "workPlan.suggestTracks", + (node?: { repo?: string; folder?: string | null }) => runSuggestScan(node, false), + ), + vscode.commands.registerCommand( + "workPlan.suggestTracksOffline", + (node?: { repo?: string; folder?: string | null }) => runSuggestScan(node, true), + ), + ); + + // Build the candidate-track QuickPick for accept: the suggested track pre-listed + // first, then a separator + the rest (same same-repo-first logic as slotUntracked). + type AcceptTrackItem = vscode.QuickPickItem & { track?: string }; + const buildAcceptTrackItems = (repo: string, suggestedTrack: string): AcceptTrackItem[] => { + const exp = provider.rawExport ?? provider.currentExport; + const all = exp?.tracks ?? []; + const sameRepo = all.filter(t => t.repo === repo).map(t => t.name); + const candidates = sameRepo.length > 0 ? sameRepo : all.map(t => t.name); + const others = candidates.filter(t => t !== suggestedTrack); + const items: AcceptTrackItem[] = []; + items.push({ label: suggestedTrack, description: "suggested", track: suggestedTrack }); + if (others.length > 0) { + items.push({ label: "Other tracks", kind: vscode.QuickPickItemKind.Separator }); + for (const t of others) items.push({ label: t, track: t }); + } + return items; + }; + + // Compute the CAS fingerprint (#241) of a target track's CURRENT issue list, as + // the viewer last saw it, so --expect can detect an on-disk change. Returns + // undefined when the track isn't in the export (then we slot unguarded). + const expectFor = (trackName: string): string | undefined => { + const exp = provider.rawExport ?? provider.currentExport; + const t = exp?.tracks.find(tr => tr.name === trackName); + if (!t) return undefined; + return issuesFingerprint(t.issues.map(i => i.number)); + }; + + // workPlan.acceptSuggestion — slot one suggested issue, picking/confirming the + // track. Branches on the CAS outcome (stale/needsRebase). + context.subscriptions.push( + vscode.commands.registerCommand( + "workPlan.acceptSuggestion", + async (node?: SuggestedIssueNode) => { + if (!node || node.kind !== "suggestedIssue") return; + try { + const issue = node.issue.number; + const pick = await vscode.window.showQuickPick( + buildAcceptTrackItems(node.repo, node.suggestedTrack), + { + placeHolder: `Slot #${issue} into a track`, + ...({ detail: `${Math.round(node.confidence * 100)}% · ${node.rationale}` } as object), + }, + ); + if (!pick || !pick.track) return; + const track = pick.track; + + const outcome = await withWriteProgress( + `Work Plan: adding #${issue} to ${track}…`, + () => executeWrite( + runner, + { kind: "slot", track, issue, expect: expectFor(track) }, + confirmPublicWrite, + ), + ); + + if (outcome.status === "written") { + await refreshAfterWrite(); + readAndStoreSuggestions(node.repo); // drop the just-accepted suggestion + vscode.window.showInformationMessage(`Work Plan: added #${issue} to ${track}`); + } else if (outcome.status === "cancelled") { + vscode.window.showInformationMessage("Work Plan: kept private — no change written."); + } else if (outcome.status === "stale") { + vscode.window.showInformationMessage( + `Work Plan: ${track} changed since the suggestion — re-scan and try again.`, + ); + await refreshAfterWrite(); + readAndStoreSuggestions(node.repo); + } else { + // needsRebase + vscode.window.showWarningMessage( + `Work Plan: ${track}'s shared plan branch diverged — pull/resolve, then retry.`, + ); + } + } catch (err: unknown) { + const msg = err instanceof CliError + ? `Work Plan: ${err.message}` + : `Work Plan: accept-suggestion failed — ${String(err)}`; + vscode.window.showErrorMessage(msg); + } + }, + ), + // workPlan.dismissSuggestion — drop a suggestion (suggested OR needs-review) + // back to plain untracked. Persists the dismiss key; no toast. + vscode.commands.registerCommand( + "workPlan.dismissSuggestion", + async (node?: SuggestedIssueNode) => { + if (!node || node.kind !== "suggestedIssue") return; + await context.workspaceState.update(dismissKey(node.repo, node.issue.number), true); + readAndStoreSuggestions(node.repo); + }, + ), + // workPlan.batchAcceptSuggestions — accept multiple suggested issues at once, + // grouped by their suggested track, each batch slotted with its own --expect. + vscode.commands.registerCommand( + "workPlan.batchAcceptSuggestions", + async (node?: SuggestedGroupNode) => { + if (!node || node.kind !== "suggestedGroup") return; + try { + type Item = vscode.QuickPickItem & { issueNumber: number; track: string }; + const items: Item[] = node.suggestions.map(s => ({ + label: `#${s.issueNumber} → ${s.suggestedTrack}`, + description: s.rationale, + issueNumber: s.issueNumber, + track: s.suggestedTrack, + picked: true, + })); + const picks = await vscode.window.showQuickPick(items, { + canPickMany: true, + placeHolder: `Accept suggestions for ${node.repo} (each goes to its suggested track)`, + }); + if (!picks || picks.length === 0) return; + + // Group the chosen issues by their suggested track. + const byTrack = new Map(); + for (const p of picks) { + const list = byTrack.get(p.track) ?? []; + list.push(p.issueNumber); + byTrack.set(p.track, list); + } + + let accepted = 0; + let staleTracks = 0; + let rebaseTracks = 0; + for (const [track, issues] of byTrack) { + const outcome = await withWriteProgress( + `Work Plan: adding ${issues.length} issue(s) to ${track}…`, + () => executeWrite( + runner, + { kind: "batchSlot", track, issues, expect: expectFor(track) }, + confirmPublicWrite, + ), + ); + if (outcome.status === "written") { + accepted += issues.length; + } else if (outcome.status === "stale") { + staleTracks++; + } else if (outcome.status === "needsRebase") { + rebaseTracks++; + } + // "cancelled" → user kept private for that track; skip silently. + } + + if (accepted > 0) await refreshAfterWrite(); + readAndStoreSuggestions(node.repo); + + const parts: string[] = []; + if (accepted > 0) parts.push(`added ${accepted} issue(s)`); + if (staleTracks > 0) parts.push(`${staleTracks} track(s) changed — re-scan`); + if (rebaseTracks > 0) parts.push(`${rebaseTracks} track(s) need a pull/rebase`); + vscode.window.showInformationMessage( + `Work Plan: ${parts.length ? parts.join("; ") + "." : "no changes written."}`, + ); + } catch (err: unknown) { + const msg = err instanceof CliError + ? `Work Plan: ${err.message}` + : `Work Plan: batch-accept failed — ${String(err)}`; + vscode.window.showErrorMessage(msg); + } + }, + ), + // workPlan.dismissAllSuggestions — dismiss every suggested issue in a group + // after a non-modal confirm. + vscode.commands.registerCommand( + "workPlan.dismissAllSuggestions", + async (node?: SuggestedGroupNode) => { + if (!node || node.kind !== "suggestedGroup") return; + const ok = await vscode.window.showWarningMessage( + `Dismiss all ${node.suggestions.length} suggestion(s) for ${node.repo}? They drop back to plain untracked.`, + "Dismiss all", + ); + if (ok !== "Dismiss all") return; + for (const s of node.suggestions) { + await context.workspaceState.update(dismissKey(node.repo, s.issueNumber), true); + } + readAndStoreSuggestions(node.repo); + }, + ), + ); + // ------------------------------------------------------------------------- // workPlan.close — close a track with a state (context menu + palette) // ------------------------------------------------------------------------- diff --git a/vscode/src/fingerprint.test.ts b/vscode/src/fingerprint.test.ts new file mode 100644 index 0000000..4fa0a6b --- /dev/null +++ b/vscode/src/fingerprint.test.ts @@ -0,0 +1,42 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { issuesFingerprint } from "./fingerprint.ts"; + +// Known vectors computed against the CLI's Python implementation: +// sha256(json.dumps(sorted(ints), separators=(",",":")).encode()).hexdigest()[:16] +// These MUST stay byte-identical to the CLI or --expect silently mismatches and +// every slot would read as {stale}. +describe("issuesFingerprint — byte-match to the CLI's Python fingerprint (#241)", () => { + test("matches the Python vector for [10,20]", () => { + assert.equal(issuesFingerprint([10, 20]), "28c5e638bdf2b3cd"); + }); + + test("order-independent: [20,10] hashes the same as [10,20]", () => { + assert.equal(issuesFingerprint([20, 10]), issuesFingerprint([10, 20])); + assert.equal(issuesFingerprint([20, 10]), "28c5e638bdf2b3cd"); + }); + + test("numeric sort, not lexicographic: [2,10] collapses with [10,2]", () => { + // Lexicographic sort would order [10,2] as ["10","2"] → wrong hash; numeric + // sort gives [2,10] both ways. + assert.equal(issuesFingerprint([2, 10]), issuesFingerprint([10, 2])); + }); + + test("empty list matches the Python vector", () => { + assert.equal(issuesFingerprint([]), "4f53cda18c2baa0c"); + }); + + test("three-element list matches the Python vector", () => { + assert.equal(issuesFingerprint([1, 2, 3]), "a615eeaee21de517"); + }); + + test("does not mutate its input", () => { + const input = [20, 10, 30]; + issuesFingerprint(input); + assert.deepEqual(input, [20, 10, 30]); + }); + + test("returns a 16-char hex string", () => { + assert.match(issuesFingerprint([1, 2, 3]), /^[0-9a-f]{16}$/); + }); +}); diff --git a/vscode/src/fingerprint.ts b/vscode/src/fingerprint.ts new file mode 100644 index 0000000..f99e4c5 --- /dev/null +++ b/vscode/src/fingerprint.ts @@ -0,0 +1,21 @@ +import { createHash } from "node:crypto"; + +/** + * The compare-and-swap fingerprint of a track's issue list (#241). MUST byte-match + * the CLI's `lib/membership_guard.issues_fingerprint`, which in Python is: + * + * sha256(json.dumps(sorted(issue_ints), separators=(",",":")).encode()).hexdigest()[:16] + * + * `JSON.stringify` of a sorted number array yields `"[1,2,3]"` — no spaces — + * exactly matching Python's compact `separators=(",",":")` output, so the two + * sides hash identical bytes. The sort is numeric (not the default lexicographic + * `Array.prototype.sort`) so `[20,10]` and `[10,20]` collapse to the same value. + * + * The viewer passes the result as `--expect=`: if the on-disk list changed + * since the suggestion was offered, the CLI aborts with `{stale}` and the caller + * re-offers on fresh state instead of clobbering. + */ +export function issuesFingerprint(issues: number[]): string { + const sorted = [...issues].sort((a, b) => a - b); + return createHash("sha256").update(JSON.stringify(sorted)).digest("hex").slice(0, 16); +} diff --git a/vscode/src/suggestions.test.ts b/vscode/src/suggestions.test.ts new file mode 100644 index 0000000..04643be --- /dev/null +++ b/vscode/src/suggestions.test.ts @@ -0,0 +1,167 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { parseSuggestions } from "./suggestions.ts"; + +const BATCH = "abc123"; +const noneDismissed = (): boolean => false; + +/** Builds a v2 answers JSON string with the given suggestions + batch_id. */ +function answers(suggestions: unknown[], batchId: string = BATCH): string { + return JSON.stringify({ version: 2, batch_id: batchId, suggestions }); +} + +describe("parseSuggestions — v2 answers parsing + bucketing (#241)", () => { + test("clear-margin, above-threshold suggest → Suggested bucket", () => { + const json = answers([ + { issue: 4501, verdict: "suggest", track: "auth-flow", confidence: 0.82, margin: "clear", rationale: "matches auth scope" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 1); + assert.equal(out.needsReview.length, 0); + assert.equal(out.suggested[0].issueNumber, 4501); + assert.equal(out.suggested[0].suggestedTrack, "auth-flow"); + assert.equal(out.suggested[0].rationale, "matches auth scope"); + }); + + test("narrow margin → Needs review even with high confidence", () => { + const json = answers([ + { issue: 4502, verdict: "suggest", track: "x", confidence: 0.95, margin: "narrow", rationale: "close call" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 0); + assert.equal(out.needsReview.length, 1); + assert.equal(out.needsReview[0].margin, "narrow"); + }); + + test("clear margin but below threshold → Needs review", () => { + const json = answers([ + { issue: 4503, verdict: "suggest", track: "x", confidence: 0.5, margin: "clear", rationale: "weak" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 0); + assert.equal(out.needsReview.length, 1); + }); + + test("confidence exactly at threshold → Suggested (>= is inclusive)", () => { + const json = answers([ + { issue: 4504, verdict: "suggest", track: "x", confidence: 0.7, margin: "clear", rationale: "" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 1); + }); + + test("abstain verdict → excluded from both buckets", () => { + const json = answers([ + { issue: 4507, verdict: "abstain", rationale: "no good fit" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 0); + assert.equal(out.needsReview.length, 0); + }); + + test("dismissed issue → excluded from both buckets", () => { + const json = answers([ + { issue: 4501, verdict: "suggest", track: "auth-flow", confidence: 0.9, margin: "clear", rationale: "x" }, + { issue: 4502, verdict: "suggest", track: "auth-flow", confidence: 0.9, margin: "clear", rationale: "y" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, (n) => n === 4501); + assert.equal(out.suggested.length, 1); + assert.equal(out.suggested[0].issueNumber, 4502); + }); + + test("batch_id mismatch → empty buckets + batchMismatch true", () => { + const json = answers([ + { issue: 4501, verdict: "suggest", track: "x", confidence: 0.9, margin: "clear", rationale: "" }, + ], "stale-batch"); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 0); + assert.equal(out.needsReview.length, 0); + assert.equal(out.batchMismatch, true); + }); + + test("missing batch_id → treated as mismatch", () => { + const json = JSON.stringify({ version: 2, suggestions: [ + { issue: 1, verdict: "suggest", track: "x", confidence: 0.9, margin: "clear", rationale: "" }, + ] }); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.batchMismatch, true); + }); + + test("unparseable JSON → empty, no mismatch flag", () => { + const out = parseSuggestions("{not json", BATCH, 0.7, noneDismissed); + assert.deepEqual(out, { suggested: [], needsReview: [], batchMismatch: false }); + }); + + test("missing margin defaults to clear", () => { + const json = answers([ + { issue: 1, verdict: "suggest", track: "x", confidence: 0.9, rationale: "" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 1); + assert.equal(out.suggested[0].margin, "clear"); + }); + + test("missing confidence defaults to 0 → Needs review", () => { + const json = answers([ + { issue: 1, verdict: "suggest", track: "x", margin: "clear", rationale: "" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.needsReview.length, 1); + }); + + test("suggest with no track → dropped (unslottable)", () => { + const json = answers([ + { issue: 1, verdict: "suggest", confidence: 0.9, margin: "clear", rationale: "" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 0); + assert.equal(out.needsReview.length, 0); + }); + + test("runner_up is carried when present", () => { + const json = answers([ + { issue: 1, verdict: "suggest", track: "x", runner_up: "y", confidence: 0.9, margin: "clear", rationale: "" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested[0].runnerUp, "y"); + }); + + test("non-integer issue → dropped", () => { + const json = answers([ + { issue: "nope", verdict: "suggest", track: "x", confidence: 0.9, margin: "clear", rationale: "" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.suggested.length, 0); + }); + + test("mixed batch: suggested + needsReview + abstain together", () => { + const json = answers([ + { issue: 1, verdict: "suggest", track: "a", confidence: 0.9, margin: "clear", rationale: "strong" }, + { issue: 2, verdict: "suggest", track: "b", confidence: 0.4, margin: "clear", rationale: "weak" }, + { issue: 3, verdict: "suggest", track: "c", confidence: 0.9, margin: "narrow", rationale: "close" }, + { issue: 4, verdict: "abstain", rationale: "none" }, + ]); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.deepEqual(out.suggested.map(s => s.issueNumber), [1]); + assert.deepEqual(out.needsReview.map(s => s.issueNumber).sort(), [2, 3]); + }); +}); + +describe("parseSuggestions — source threading (#373)", () => { + test("heuristic source is surfaced for the viewer to flag lower-trust", () => { + const json = JSON.stringify({ + version: 2, source: "heuristic", batch_id: BATCH, + suggestions: [{ issue: 1, verdict: "suggest", track: "t", confidence: 0.9, margin: "clear", rationale: "milestone v0.4" }], + }); + const out = parseSuggestions(json, BATCH, 0.7, noneDismissed); + assert.equal(out.source, "heuristic"); + assert.equal(out.suggested.length, 1); + }); + + test("absent source → undefined (LLM path)", () => { + const out = parseSuggestions(answers([ + { issue: 1, verdict: "suggest", track: "t", confidence: 0.9, margin: "clear", rationale: "x" }, + ]), BATCH, 0.7, noneDismissed); + assert.equal(out.source, undefined); + }); +}); diff --git a/vscode/src/suggestions.ts b/vscode/src/suggestions.ts new file mode 100644 index 0000000..3a706c3 --- /dev/null +++ b/vscode/src/suggestions.ts @@ -0,0 +1,156 @@ +import * as fs from "node:fs"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * One accepted suggestion entry, derived from a v2 answers file (#241). The + * answers file is written by a Claude session (NOT the extension) after the user + * runs Suggest Tracks; this model reads it back, validates it, and buckets it. + */ +export interface SuggestionEntry { + issueNumber: number; + suggestedTrack: string; + /** The model's second-choice track, if it offered one. */ + runnerUp?: string; + /** 0–1; surfaced in the tooltip, never as the leading label (per the UX spec). */ + confidence: number; + /** "clear" → one-click accept eligible; "narrow" → forced into Needs review. */ + margin: "clear" | "narrow"; + rationale: string; +} + +/** + * The split result: `suggested` are one-click-accept eligible (verdict "suggest", + * margin "clear", confidence ≥ threshold); `needsReview` are lower-confidence or + * narrow-margin suggestions that need a human eye before slotting. Abstains and + * dismissed issues never appear in either bucket. `batchMismatch` is true when the + * answers file's batch_id didn't match the current scan — the caller may warn and + * both buckets are empty. + */ +export interface SuggestionBuckets { + suggested: SuggestionEntry[]; + needsReview: SuggestionEntry[]; + batchMismatch: boolean; + /** "heuristic" when the offline scorer produced these (#373) — the viewer + * flags them lower-trust; absent/undefined for the LLM path. */ + source?: string; +} + +const EMPTY: SuggestionBuckets = { suggested: [], needsReview: [], batchMismatch: false }; + +// --------------------------------------------------------------------------- +// Parsing + bucketing +// --------------------------------------------------------------------------- + +/** + * Reads a v2 answers file from `path`, validates it against `expectedBatchId`, + * and splits its non-abstain, non-dismissed suggestions into the suggested / + * needs-review buckets. + * + * Tolerant by design: a missing file, an unreadable file, or unparseable JSON + * all return EMPTY (no throw) — the answers file is written asynchronously by a + * separate Claude session, so "not there yet" is the normal cold state, not an + * error. A batch_id mismatch returns empty buckets with `batchMismatch: true` so + * a stale answers file from a prior scan is never silently applied. + * + * Pure aside from the file read: `isDismissed(issueNumber)` lets the caller + * exclude issues the user has dismissed (workspaceState), and `threshold` is the + * suggested-vs-needsReview confidence cutoff. + */ +export function readSuggestions( + path: string, + expectedBatchId: string, + threshold: number, + isDismissed: (issueNumber: number) => boolean, +): SuggestionBuckets { + let raw: string; + try { + raw = fs.readFileSync(path, "utf8"); + } catch { + return EMPTY; // not written yet / unreadable — normal cold state + } + return parseSuggestions(raw, expectedBatchId, threshold, isDismissed); +} + +/** + * The pure core of `readSuggestions` — parses already-read file contents. Split + * out so tests can exercise the parse/bucket/threshold/dismiss/batch-id logic + * without touching the filesystem. + */ +export function parseSuggestions( + contents: string, + expectedBatchId: string, + threshold: number, + isDismissed: (issueNumber: number) => boolean, +): SuggestionBuckets { + let blob: unknown; + try { + blob = JSON.parse(contents); + } catch { + return EMPTY; + } + if (blob === null || typeof blob !== "object") { + return EMPTY; + } + const obj = blob as Record; + + // Validate the batch_id: a stale answers file from a prior scan must never be + // applied to the current untracked set. An answers file with no batch_id is + // treated as a mismatch (we can't correlate it). + if (typeof obj.batch_id !== "string" || obj.batch_id !== expectedBatchId) { + return { suggested: [], needsReview: [], batchMismatch: true }; + } + + const rawSuggestions = Array.isArray(obj.suggestions) ? obj.suggestions : []; + const source = typeof obj.source === "string" ? obj.source : undefined; + + const suggested: SuggestionEntry[] = []; + const needsReview: SuggestionEntry[] = []; + + for (const item of rawSuggestions) { + if (item === null || typeof item !== "object") continue; + const s = item as Record; + + const issueNumber = typeof s.issue === "number" ? s.issue : NaN; + if (!Number.isInteger(issueNumber)) continue; + + // verdict "abstain" → not shown at all (stays in plain untracked). + const verdict = typeof s.verdict === "string" ? s.verdict : ""; + if (verdict !== "suggest") continue; + + // A suggestion with no track can't be slotted — drop it. + const suggestedTrack = typeof s.track === "string" ? s.track : ""; + if (suggestedTrack === "") continue; + + // Dismissed issues drop back to plain untracked. + if (isDismissed(issueNumber)) continue; + + const confidence = typeof s.confidence === "number" ? s.confidence : 0; + const margin: "clear" | "narrow" = s.margin === "narrow" ? "narrow" : "clear"; + const runnerUp = typeof s.runner_up === "string" && s.runner_up !== "" ? s.runner_up : undefined; + const rationale = typeof s.rationale === "string" ? s.rationale : ""; + + const entry: SuggestionEntry = { + issueNumber, + suggestedTrack, + ...(runnerUp ? { runnerUp } : {}), + confidence, + margin, + rationale, + }; + + // THREE-tier bucketing (#241): one-click "Suggested" only when the model is + // confident (clear margin) AND meets the threshold; everything else + // (narrow margin, or below-threshold confidence) goes to "Needs review", + // which has NO one-click accept. + if (margin === "clear" && confidence >= threshold) { + suggested.push(entry); + } else { + needsReview.push(entry); + } + } + + return { suggested, needsReview, batchMismatch: false, ...(source ? { source } : {}) }; +} diff --git a/vscode/src/tree.ts b/vscode/src/tree.ts index 4b4142b..24ce540 100644 --- a/vscode/src/tree.ts +++ b/vscode/src/tree.ts @@ -1,7 +1,8 @@ import * as vscode from "vscode"; import type { Export, Issue } from "./model.ts"; -import { buildTree, mergeFetchedUntracked, shouldExpandRepos, sortTracks, repoDescription, visibilityTierBadge } from "./treeModel.ts"; -import type { RepoNode, TrackNode, UntrackedGroupNode, UntrackedIssueNode, EmptyRepoNode, FetchUntrackedNode, TierDupWarningNode, StatusCategory, TrackSort } from "./treeModel.ts"; +import { buildTree, mergeFetchedUntracked, shouldExpandRepos, sortTracks, repoDescription, visibilityTierBadge, suggestedIssueNode } from "./treeModel.ts"; +import type { RepoNode, TrackNode, UntrackedGroupNode, UntrackedIssueNode, EmptyRepoNode, FetchUntrackedNode, TierDupWarningNode, SuggestedGroupNode, SuggestedIssueNode, NeedsReviewGroupNode, StatusCategory, TrackSort } from "./treeModel.ts"; +import type { SuggestionBuckets } from "./suggestions.ts"; import { applyLens } from "./webview/lenses.ts"; import type { Lens } from "./webview/lenses.ts"; import { lensShouldApply } from "./autofocus.ts"; @@ -10,10 +11,20 @@ import type { AuthState } from "./cli.ts"; import { SingleFlight } from "./singleFlight.ts"; // Re-export the node types so extension.ts only needs to import from tree.ts. -export type { RepoNode, TrackNode, UntrackedGroupNode, UntrackedIssueNode, EmptyRepoNode, FetchUntrackedNode, TierDupWarningNode }; +export type { RepoNode, TrackNode, UntrackedGroupNode, UntrackedIssueNode, EmptyRepoNode, FetchUntrackedNode, TierDupWarningNode, SuggestedGroupNode, SuggestedIssueNode, NeedsReviewGroupNode }; /** Every node kind the Tracks tree can render. */ -type TreeNode = RepoNode | TrackNode | UntrackedGroupNode | UntrackedIssueNode | EmptyRepoNode | FetchUntrackedNode | TierDupWarningNode; +type TreeNode = + | RepoNode + | TrackNode + | UntrackedGroupNode + | UntrackedIssueNode + | EmptyRepoNode + | FetchUntrackedNode + | TierDupWarningNode + | SuggestedGroupNode + | SuggestedIssueNode + | NeedsReviewGroupNode; // Re-export Lens so extension.ts only needs to import from tree.ts. export type { Lens }; // Re-export TrackSort so extension.ts only needs to import from tree.ts. @@ -71,6 +82,12 @@ export class WorkPlanTreeProvider // node's `untracked` on every (re)build. Survives lens/sort re-renders and a // full refresh (a snapshot until the user re-fetches), cleared on nothing. private readonly _fetchedUntracked = new Map(); + // Auto-slot suggestions (#241), keyed by github slug: the suggested/needsReview + // buckets parsed from each repo's per-repo answers file, plus the batch_id of + // the scan they belong to (so a stale answers file from a prior scan is + // ignored). Populated by setSuggestions when an fs.watch fires or on cold-open. + private readonly _suggestionsByRepo = new Map(); + private readonly _batchIdByRepo = new Map(); // Last GitHub-auth probe result (#auth). null before the first probe. Drives // the `workPlanGitHubAuthed` context key + lets activation show its one-time // toast off the same probe the tree already ran (no second `gh` call). @@ -136,6 +153,36 @@ export class WorkPlanTreeProvider this._onDidChangeTreeData.fire(); } + /** + * Records the batch_id of the active auto-slot scan for `repo` (#241), so a + * later answers-file read can validate the file belongs to this scan. Called by + * the Suggest Tracks command right after the scan emits its batch. + */ + setBatchId(repo: string, batchId: string): void { + this._batchIdByRepo.set(repo, batchId); + } + + /** The active scan's batch_id for `repo`, or undefined if none scanned yet. */ + getBatchId(repo: string): string | undefined { + return this._batchIdByRepo.get(repo); + } + + /** + * Stores freshly-parsed auto-slot suggestions for `repo` (#241) and re-renders + * so the Suggested / Needs review buckets appear (or clear). Works off the + * cached filtered export — no CLI re-fetch. Empty buckets are stored too, so a + * watch event that empties the file removes the buckets. + */ + setSuggestions(repo: string, buckets: SuggestionBuckets): void { + this._suggestionsByRepo.set(repo, buckets); + this._onDidChangeTreeData.fire(); + } + + /** The parsed suggestions for `repo`, or undefined when none have been read. */ + getSuggestions(repo: string): SuggestionBuckets | undefined { + return this._suggestionsByRepo.get(repo); + } + /** * Changes the sort mode and re-renders the tree from the cached filtered * export. Does not re-fetch from the CLI. Sort is applied to `roots` (tree) @@ -289,14 +336,53 @@ export class WorkPlanTreeProvider return [...tierDupWarn, ...element.tracks, ...untrackedGroup]; } if (element.kind === "untrackedGroup") { - return element.issues.map( - (issue): UntrackedIssueNode => ({ kind: "untrackedIssue", repo: element.repo, issue }) - ); + // Auto-slot suggestion sub-buckets (#241) nest as the FIRST children of the + // Untracked group, ahead of the plain untracked issues, when the feature is + // on and a Claude session has written matching answers for this repo's scan. + const children: TreeNode[] = []; + // Issue numbers surfaced in a suggestion sub-bucket are removed from the + // plain list below, so a suggested issue shows in ONE place, not two. + const bucketed = new Set(); + if (this._autoSlotEnabled()) { + const buckets = this._suggestionsByRepo.get(element.repo); + if (buckets) { + if (buckets.suggested.length > 0) { + children.push({ kind: "suggestedGroup", repo: element.repo, suggestions: buckets.suggested }); + for (const s of buckets.suggested) bucketed.add(s.issueNumber); + } + if (buckets.needsReview.length > 0) { + children.push({ kind: "needsReviewGroup", repo: element.repo, suggestions: buckets.needsReview }); + for (const s of buckets.needsReview) bucketed.add(s.issueNumber); + } + } + } + return [ + ...children, + ...element.issues + .filter(issue => !bucketed.has(issue.number)) + .map((issue): UntrackedIssueNode => ({ kind: "untrackedIssue", repo: element.repo, issue })), + ]; + } + if (element.kind === "suggestedGroup") { + const untracked = this.roots.find(r => r.repo === element.repo)?.untracked ?? []; + return element.suggestions.map(s => suggestedIssueNode(element.repo, s, untracked, "suggested")); } - // TrackNode or UntrackedIssueNode — leaves; no children. + if (element.kind === "needsReviewGroup") { + const untracked = this.roots.find(r => r.repo === element.repo)?.untracked ?? []; + return element.suggestions.map(s => suggestedIssueNode(element.repo, s, untracked, "needsReview")); + } + // TrackNode, UntrackedIssueNode, or SuggestedIssueNode — leaves; no children. return []; } + /** Whether the auto-slot Suggested/NeedsReview buckets should render (#241). + * Off by default — the feature is opt-in via workPlan.autoSlotSuggestions. */ + private _autoSlotEnabled(): boolean { + return vscode.workspace + .getConfiguration("workPlan") + .get("autoSlotSuggestions", false); + } + /** * Parent lookup — required for `TreeView.reveal()` (used by Reveal-in-tree * from the search results panel, #272). Only the track→repo and @@ -321,6 +407,19 @@ export class WorkPlanTreeProvider if (element.kind === "tierDupWarning") { return this.roots.find(r => r.repo === element.repo); } + // suggestedGroup / needsReviewGroup / suggestedIssue → the repo's Untracked + // group (the suggestion sub-buckets and their issues all nest under it). + if ( + element.kind === "suggestedGroup" || + element.kind === "needsReviewGroup" || + element.kind === "suggestedIssue" + ) { + const repoNode = this.roots.find(r => r.repo === element.repo); + if (repoNode) { + return { kind: "untrackedGroup", repo: repoNode.repo, issues: repoNode.untracked }; + } + return undefined; + } // untrackedIssue → its group const repoNode = this.roots.find(r => r.repo === element.repo); if (repoNode && repoNode.untracked.length > 0) { @@ -364,6 +463,15 @@ export class WorkPlanTreeProvider if (node.kind === "tierDupWarning") { return this._tierDupWarningTreeItem(node); } + if (node.kind === "suggestedGroup") { + return this._suggestedGroupTreeItem(node); + } + if (node.kind === "needsReviewGroup") { + return this._needsReviewGroupTreeItem(node); + } + if (node.kind === "suggestedIssue") { + return this._suggestedIssueTreeItem(node); + } return this._trackTreeItem(node); } @@ -543,4 +651,74 @@ export class WorkPlanTreeProvider }; return item; } + + // --- Auto-slot suggestion nodes (#241) --- + + private _suggestedGroupTreeItem(node: SuggestedGroupNode): vscode.TreeItem { + const item = new vscode.TreeItem( + "Suggested", + vscode.TreeItemCollapsibleState.Expanded, + ); + const heuristic = this._suggestionsByRepo.get(node.repo)?.source === "heuristic"; + item.description = heuristic ? `${node.suggestions.length} · heuristic` : `${node.suggestions.length}`; + item.iconPath = new vscode.ThemeIcon("sparkle"); + item.contextValue = "workPlanSuggestedGroup"; + item.tooltip = + `${node.suggestions.length} issue(s) with a confident track suggestion — ` + + "accept individually, or Accept All from the right-click menu." + + (heuristic ? "\n\nOffline heuristic matches (no AI) — lower-trust; review before accepting." : ""); + return item; + } + + private _needsReviewGroupTreeItem(node: NeedsReviewGroupNode): vscode.TreeItem { + const item = new vscode.TreeItem( + "Needs review", + vscode.TreeItemCollapsibleState.Collapsed, + ); + item.description = `${node.suggestions.length}`; + item.iconPath = new vscode.ThemeIcon("sparkle"); + item.contextValue = "workPlanNeedsReviewGroup"; + item.tooltip = + `${node.suggestions.length} lower-confidence or close-call suggestion(s) — ` + + "no one-click accept; click an issue to open it and decide."; + return item; + } + + private _suggestedIssueTreeItem(node: SuggestedIssueNode): vscode.TreeItem { + const item = new vscode.TreeItem( + `#${node.issue.number} ${node.issue.title}`, + vscode.TreeItemCollapsibleState.None, + ); + // Lead with the rationale + target track, NOT the percentage (the spec keeps + // the number out of the at-a-glance label; it lives in the tooltip only). + const rationale = node.rationale.trim(); + item.description = rationale + ? `→ ${node.suggestedTrack} · ${rationale}` + : `→ ${node.suggestedTrack}`; + item.iconPath = new vscode.ThemeIcon("lightbulb"); + item.contextValue = + node.tier === "suggested" ? "workPlanSuggestedIssue" : "workPlanNeedsReviewIssue"; + + const pct = Math.round(node.confidence * 100); + const tip = new vscode.MarkdownString(undefined, true); + tip.appendMarkdown(`**#${node.issue.number}** ${node.issue.title}\n\n`); + tip.appendMarkdown(`$(lightbulb) Suggested track: **${node.suggestedTrack}**\n\n`); + if (node.runnerUp) { + tip.appendMarkdown(`Runner-up: ${node.runnerUp}\n\n`); + } + tip.appendMarkdown( + `Confidence: ${pct}% · margin: ${node.margin === "clear" ? "clear" : "narrow (needs review)"}\n\n`, + ); + if (rationale) tip.appendMarkdown(`_${rationale}_`); + item.tooltip = tip; + + // A "Suggested" (one-click) issue opens the accept QuickPick on click; a + // "Needs review" issue opens the issue itself (no one-click accept). + item.command = + node.tier === "suggested" + ? { command: "workPlan.acceptSuggestion", title: "Accept suggestion", arguments: [node] } + : { command: "workPlan.openIssue", title: "Open Issue", arguments: [{ repo: node.repo, number: node.issue.number }] }; + + return item; + } } diff --git a/vscode/src/treeModel.test.ts b/vscode/src/treeModel.test.ts index b67602f..63634ea 100644 --- a/vscode/src/treeModel.test.ts +++ b/vscode/src/treeModel.test.ts @@ -10,8 +10,10 @@ import { sortTracks, repoDescription, visibilityTierBadge, + suggestedIssueNode, } from "./treeModel.ts"; import type { RepoNode, TrackNode } from "./treeModel.ts"; +import type { SuggestionEntry } from "./suggestions.ts"; import type { Export, Track, Issue } from "./model.ts"; // --------------------------------------------------------------------------- @@ -828,3 +830,44 @@ describe("buildTree — TrackNode.closed (#220)", () => { assert.equal(ph.closed, 8); }); }); + +// --------------------------------------------------------------------------- +// suggestedIssueNode (#241) +// --------------------------------------------------------------------------- + +describe("suggestedIssueNode", () => { + const entry: SuggestionEntry = { + issueNumber: 4501, + suggestedTrack: "auth-flow", + runnerUp: "billing", + confidence: 0.82, + margin: "clear", + rationale: "matches the auth scope", + }; + + test("resolves the full Issue from the untracked list", () => { + const issue = makeIssue({ number: 4501, title: "Add OAuth refresh" }); + const node = suggestedIssueNode("org/repo", entry, [issue], "suggested"); + assert.equal(node.kind, "suggestedIssue"); + assert.strictEqual(node.issue, issue); + assert.equal(node.suggestedTrack, "auth-flow"); + assert.equal(node.runnerUp, "billing"); + assert.equal(node.confidence, 0.82); + assert.equal(node.margin, "clear"); + assert.equal(node.tier, "suggested"); + }); + + test("synthesizes a placeholder Issue when the number isn't in untracked", () => { + const node = suggestedIssueNode("org/repo", entry, [], "needsReview"); + assert.equal(node.issue.number, 4501); + assert.equal(node.issue.title, "#4501"); + assert.equal(node.issue.state, "open"); + assert.equal(node.tier, "needsReview"); + }); + + test("omits runnerUp when the entry has none", () => { + const noRunner: SuggestionEntry = { ...entry, runnerUp: undefined }; + const node = suggestedIssueNode("org/repo", noRunner, [], "suggested"); + assert.equal(node.runnerUp, undefined); + }); +}); diff --git a/vscode/src/treeModel.ts b/vscode/src/treeModel.ts index f44de5d..26d2ab5 100644 --- a/vscode/src/treeModel.ts +++ b/vscode/src/treeModel.ts @@ -1,4 +1,5 @@ import type { Export, Issue, Track, TierDuplicate } from "./model.ts"; +import type { SuggestionEntry } from "./suggestions.ts"; // --------------------------------------------------------------------------- // Node types @@ -18,6 +19,40 @@ export interface UntrackedIssueNode { issue: Issue; } +/** + * Auto-slot suggestion buckets (#241), nested as the FIRST children of a repo's + * Untracked group when `workPlan.autoSlotSuggestions` is on and a Claude session + * has written answers for the current scan batch: + * - `suggestedGroup`/`suggestedIssue`: high-confidence, clear-margin matches — + * one-click accept (the issue's click opens the accept QuickPick). + * - `needsReviewGroup`/`needsReviewIssue`: narrow-margin or below-threshold + * matches — NO one-click accept (the issue's click opens the issue itself). + * Abstains never reach the tree (they stay plain untracked). + */ +export interface SuggestedGroupNode { + kind: "suggestedGroup"; + repo: string; + suggestions: SuggestionEntry[]; +} + +export interface SuggestedIssueNode { + kind: "suggestedIssue"; + repo: string; + issue: Issue; + suggestedTrack: string; + runnerUp?: string; + confidence: number; + rationale: string; + margin: "clear" | "narrow"; + tier: "suggested" | "needsReview"; +} + +export interface NeedsReviewGroupNode { + kind: "needsReviewGroup"; + repo: string; + suggestions: SuggestionEntry[]; +} + /** * A read-only advisory under a repo (#361): N tracks exist in both the shared * and private tier. Surfaces the otherwise-invisible "exists in both" condition @@ -344,6 +379,49 @@ export function mergeFetchedUntracked( ); } +// --------------------------------------------------------------------------- +// Auto-slot suggestion nodes (#241) +// --------------------------------------------------------------------------- + +/** + * Builds the SuggestedIssueNode for a single suggestion entry, resolving the + * full Issue (for the title) from the repo's untracked list. When the issue is + * no longer in the untracked list (e.g. it got tracked between scan and render), + * a minimal placeholder Issue is synthesized so the node still renders coherently + * — the next refresh drops it. Pure. + */ +export function suggestedIssueNode( + repo: string, + entry: SuggestionEntry, + untracked: Issue[], + tier: "suggested" | "needsReview", +): SuggestedIssueNode { + const issue = + untracked.find(i => i.number === entry.issueNumber) ?? + ({ + number: entry.issueNumber, + title: `#${entry.issueNumber}`, + state: "open", + assignee: "—", + milestone: null, + in_progress: false, + in_progress_label: false, + blocked_by: [], + blocking: [], + } as Issue); + return { + kind: "suggestedIssue", + repo, + issue, + suggestedTrack: entry.suggestedTrack, + ...(entry.runnerUp ? { runnerUp: entry.runnerUp } : {}), + confidence: entry.confidence, + rationale: entry.rationale, + margin: entry.margin, + tier, + }; +} + // --------------------------------------------------------------------------- // Track sort // --------------------------------------------------------------------------- diff --git a/vscode/src/webview/html.test.ts b/vscode/src/webview/html.test.ts index d5db444..fa535ae 100644 --- a/vscode/src/webview/html.test.ts +++ b/vscode/src/webview/html.test.ts @@ -188,6 +188,59 @@ describe("buildHtml — graphDef embedding", () => { }); }); +// --------------------------------------------------------------------------- +// Inline-script syntax (regression guard) +// --------------------------------------------------------------------------- + +describe("buildHtml — inline scripts are valid JavaScript", () => { + // Extract the body of every inline