diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6800108..57b3ad4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,3 +109,53 @@ jobs: - name: Run bin/work-plan launcher test (Linux/macOS) if: runner.os != 'Windows' run: python3 tests/test_bin_wrapper.py + + # Installer smoke test on Linux (#5). The unittest matrix exercises the CLI but + # never runs install.sh or its --target override, and it downloads the yq binary + # directly rather than going through the README one-liners. This job mechanizes + # the installer-centric acceptance criteria from #5: install.sh --help, a real + # install into a scratch target, the --target override, and work_plan.py --help. + # What stays genuinely manual is the per-distro package-manager one-liners + # (apt/pacman/dnf) and non-Ubuntu distros — GitHub-hosted Ubuntu can't prove those. + smoke-install: + name: install.sh smoke (ubuntu) + needs: lint-py39 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install mikefarah/yq + run: | + sudo curl -sSL -o /usr/local/bin/yq \ + "https://github.com/mikefarah/yq/releases/download/v4.53.2/yq_linux_amd64" + sudo chmod +x /usr/local/bin/yq + yq --version + + - name: install.sh --help renders + run: | + out=$(./install.sh --help) + echo "$out" + echo "$out" | grep -q "Usage: ./install.sh" + + - name: install.sh into a scratch target + run: | + target="${RUNNER_TEMP}/claude" + mkdir -p "${target}" + ./install.sh --target="${target}" + test -f "${target}/skills/work-plan/work_plan.py" + test -f "${target}/skills/work-plan/.installed-from" + test -f "${target}/commands/work-plan.md" + + - name: install.sh --target override (Codex layout) + run: | + target="${RUNNER_TEMP}/agents" + mkdir -p "${target}" + ./install.sh --target="${target}" + test -f "${target}/skills/work-plan/work_plan.py" + + - name: work_plan.py --help executes + run: python3 skills/work-plan/work_plan.py --help diff --git a/.work-plan/cli-viewer-cross.md b/.work-plan/cli-viewer-cross.md index 07ffc40..424d875 100644 --- a/.work-plan/cli-viewer-cross.md +++ b/.work-plan/cli-viewer-cross.md @@ -9,12 +9,65 @@ github: - 271 - 280 - 285 + - 324 + - 328 + - 329 + - 330 + - 349 branches: [] depends_on: [] -last_touched: 2026-06-13T09:00 -last_handoff: 2026-06-13T09:00 -next_up: [] +last_touched: 2026-06-15T21:09 +last_handoff: 2026-06-15T21:09 +next_up: + - 330 + - 329 + - 328 blockers: [] tier: shared --- # cli-viewer-cross + +## Session log + +### Session — 2026-06-15 21:09 + +- Touched: chore(cli-viewer-cross): slot #330 + #349 into the track (8738ff5) +- Touched: feat(vscode): graph zoom/pan/fit + SVG/PNG export (#216) (#353) (211d8aa) +- Touched: feat(vscode): graph zoom/pan/fit + SVG/PNG export (#216) (9b7dbc7) +- Touched: chore(npm): drop redundant postinstall script for npm 12 default-deny compatibility (#344) (8dec257) +- Touched: chore(npm): drop redundant postinstall script for npm 12 default-deny (6f74481) +- Touched: fix(vscode): real GitHub href on issue links — clicking scrolled instead of opening (#324 follow-up) (e06c4f1) +- Touched: Merge pull request #323 from stylusnexus/dev (6579bf7) +- Touched: Merge pull request #322 from stylusnexus/fix/271-hot-branch-perf (5e55e68) +- Touched: fix(git_state): batch hot-branch detection — was O(branches) git calls per track (#271) (79164e8) +- Touched: Merge pull request #319 from stylusnexus/dev (ef58902) +- Touched: feat: issue-level in-progress (#271) (#317) (89843cf) +- Touched: docs(vscode): correct v0.8.0 CLI requirement to 2026.06.14 (#271 review) (3857898) +- Touched: fix(in-progress): toggle reflects label state, not the union signal (#271 review) (dab0ee8) +- Touched: fix(export): key issues_by_track by (repo,name) so same-named cross-repo tracks don't bleed (#271 review) (11932ef) +- Touched: fix(in-progress): reject --repo that doesn't track the issue (#271 review) (d290042) +- Touched: docs: document in-progress command + correct GitHub-mutation inventory (#271) (e8e1673) +- Touched: chore(vscode): require CLI 2026.06.14 + bump extension to 0.8.0 (#271) (8f377e7) +- Touched: fix(vscode): extract + test webview message guard so dropped messages can't recur (#305, #285) (c5d58ec) +- Touched: feat(vscode): detail-webview in-progress toggle via postMessage (#271) (423cdf5) +- Touched: feat(work_plan): register in-progress subcommand (#271) (149da7e) +- Touched: feat(in-progress): repo-qualified command behind confirm gate (#271) (4ca9f48) +- Touched: feat(github_state): set_issue_in_progress label writer (#271) (bf34ca6) +- Touched: feat(vscode): in-progress badge on tracked issue rows (#271) (9fe9f85) +- Touched: feat(orient): mark in-progress on next pick + behind-it rows (#271) (117564a) +- Touched: test(list-open-issues): expect in_progress field on shared issue surface (#271) (10d0a95) +- Touched: feat(brief): mark in-progress issues in the next-up list (#271) (78f8712) +- Touched: feat(export): compute per-track branch heat for in_progress (#271) (c602ddd) +- Touched: feat(export_model): thread per-issue in_progress keyed by (repo,name) (#271) (f8262a5) +- Touched: feat(github_state): fetch labels in lean GQL set for in-progress signal (#271) (b6281ea) +- Touched: feat(in_progress): union-merge issue in-progress signal (#271) (1ab0c52) +- Touched: feat(git_state): map hot feat/fix branches to issue numbers (#271) (a37825a) +- Touched: feat: declared track↔plan link + viewer navigation (#285) (#302) (20e085d) +- Next: (open) + +### Session — 2026-06-15 21:09 + +- Touched: (no git activity attributed; 5 open from GitHub) +- Next: #330 design: track cleanup/deletion flow + clear deletion confirmation in VS Code +- Next: #329 feat: Mark a track for cleanup/deletion — CLI + VS Code menu +- Next: #328 feat: Archive a track — CLI command + VS Code track menu diff --git a/README.md b/README.md index edad271..a8cc708 100644 --- a/README.md +++ b/README.md @@ -48,11 +48,11 @@ The five essentials you'll use 80% of the time are: | Command | When | |---|---| -| `/work-plan brief` | Morning. Multi-track snapshot — what's on your plate across every active track. Add `--repo=` to scope to one project. | +| `/work-plan brief` | Morning. Multi-track snapshot — what's on your plate across every active track. Run from inside a configured repo's checkout and it **auto-scopes to that repo** (one-line banner; `--repo=all` shows everything). Add `--repo=` to scope to a specific project. | | `/work-plan handoff ` | End of a work block. Captures what you touched. Use `--auto-next` for an algorithmic priority-sorted `next_up` (no LLM), `--set-next 1,2,3` for explicit numbers, or pair with Claude in chat for a curated pick. | | `/work-plan orient ` | Switching context. ~15-line paste-block of priority / last session / next pick / git state — drop into a fresh Claude Code terminal. | | `/work-plan reconcile \| --all \| --repo= [--draft] [--yes]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. In an `--all`/`--repo` sweep it also moves issues relabeled from one track to another in the same repo. `--draft` previews proposed ADDs/MOVEs/FLAGs; `--yes` applies without prompting. `--repo=` scopes the sweep to one repo. | -| `/work-plan hygiene [--repo=]` | **Weekly all-in-one cleanup.** Runs three steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `duplicates` (flag likely-duplicate issues). `--repo=` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode. | +| `/work-plan hygiene [--repo=]` | **Weekly all-in-one cleanup.** Runs four steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `dedupe-tiers` (report shared/private duplicate tracks, no deletes), ④ `duplicates` (flag likely-duplicate issues). `--repo=` scopes steps ①–③ to one repo; step ④ is skipped in scoped mode. | | `/work-plan in-progress [--clear]` | Starting or stopping active work on an issue. Adds (or removes with `--clear`) the `work-plan:in-progress` label on GitHub. Repo-resolved from the issue number, or pass `--repo=` to disambiguate. `brief`/`orient`/the VS Code viewer also detect in-progress automatically from a hot `feat/-`/`fix/-` branch. | A dozen more subcommands cover slotting new issues into tracks, closing tracks (shipped/abandoned/parked), and one-time priority-label backfill. Three capabilities worth calling out explicitly: @@ -109,7 +109,7 @@ flowchart TB - Free-form via Claude in your agent session, which can review project memory and write a curated list back. The two `--*-next` flags are the no-LLM paths. - For tracks where you don't want to bother curating at all, set `next_up_auto: true` in the track's frontmatter — `brief` will then derive the list live each invocation, ignoring whatever's stored. - **Ranking presets** — when `next_up_auto: true` is on, the default ranking is `flow` (milestone → dependency → priority → recency). Override per-track with `set-next-up --preset=`, or set `next_up_default: ` in your config for a global fallback. Named presets: `flow` (the default), `priority-driven` (priority first, no milestone bias — good for backlogs with no milestones), `backlog` (oldest issues first — surfaces stalled work). Custom criterion order: `set-next-up --order=aging,priority,dependency`. Clear a track's override with `--clear`. Toggle auto-derivation itself with `--auto=on|off` (no hand-editing frontmatter required). -- **Weekly** → `hygiene` runs `refresh-md --all` + `reconcile --all` + `duplicates` in sequence to keep status icons, GitHub labels, and dedup state honest. +- **Weekly** → `hygiene` runs `refresh-md --all` + `reconcile --all` + `dedupe-tiers` (report-only) + `duplicates` in sequence to keep status icons, GitHub labels, tier dedup, and issue-dedup state honest. > **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md ` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly. @@ -510,13 +510,15 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h | Subcommand | What it does | |---|---| -| `brief [--repo=]` | Multi-track snapshot of all active tracks across configured repos. `--repo=` filters to one project (matches the folder name under `notes_root` or the `org/repo` GitHub slug; archived-reopen callouts are also scoped). | +| `brief [--repo= \| --repo=all]` | Multi-track snapshot of all active tracks across configured repos. When `--repo` is omitted and you're inside a configured repo's checkout, `brief` **auto-scopes to that repo** (resolved by clone path, then git remote) and prints a one-line banner; `--repo=all` forces the full cross-repo view. `--repo=` filters to one project explicitly (matches the folder name under `notes_root` or the `org/repo` GitHub slug). In all cases the archived-reopen callouts are scoped to the same repo. Disable cwd auto-scope with `brief_auto_scope: false` in `config.yml`. | +| `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). | | `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` + `duplicates`. With `--repo=`, steps 1 and 2 scope to that repo and the global `duplicates` step is skipped. | +| `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. | +| `dedupe-tiers [--repo=] [--apply]` | Remove private track copies that a shared twin in a repo's `.work-plan/` supersedes (#359). When a track is promoted to the shared tier, its private original under `notes_root` is sometimes left behind (bulk/manual promotion, or a failed unlink during `push-track`) — `discover_tracks` then warns `exists in both shared and private` on every run with no cleanup path. This removes the safe orphans and **refuses** any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is `issue_refs(private) ⊆ issue_refs(shared)`). Covers active and archived tiers. Default is a **dry-run report**; `--apply` deletes (auto-committed to `notes_root`, so undoable via `notes-vcs undo`). `--repo=` scopes to one repo. | | `list [--all] [--sort=recent\|priority]` | List active tracks (or all including parked/archived). `--sort=recent` orders by `last_touched` (most recent first); `--sort=priority` orders by `launch_priority` (P0→P3) with recency as tiebreaker. Default keeps discovery order. | | `init [--priority=P0..P3] [--milestone=]` | Add frontmatter to a brand-new track .md file (the file must already exist). Pass `--priority=`/`--milestone=` to skip the prompts. | | `init-repo --github= [--local=] [--update [--clear-local]]` | Bootstrap a new repo: create `//archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required for an add; `--local` is optional. `--update` on an existing key changes its local/github; `--update --clear-local` forgets the saved local path (keeps github + other fields). `--clear-local` and `--local` are mutually exclusive. | diff --git a/skills/work-plan/commands/brief.py b/skills/work-plan/commands/brief.py index f20294a..a189176 100644 --- a/skills/work-plan/commands/brief.py +++ b/skills/work-plan/commands/brief.py @@ -1,8 +1,10 @@ """brief subcommand — fully featured.""" +import os from datetime import datetime from pathlib import Path from lib.config import load_config, ConfigError +from lib.cwd_repo import resolve_repo_for_dir from lib.tracks import ( discover_tracks, discover_archived_tracks, filter_tracks_by_repo, priority_rank, recency_sort_key, @@ -24,9 +26,9 @@ def run(args: list[str]) -> int: flags, _ = parse_flags(args, {"--repo"}) - repo_key = flags.get("--repo") - if repo_key is True: - print("usage: work_plan.py brief [--repo=]") + repo_arg = flags.get("--repo") + if repo_arg is True: + print("usage: work_plan.py brief [--repo= | --repo=all]") return 2 try: @@ -36,15 +38,39 @@ def run(args: list[str]) -> int: return 1 tracks = discover_tracks(cfg) + + # Resolve the effective scope key ONCE, then thread it through both the track + # filter and the archived-reopen pass so the two can never diverge (#358): + # explicit --repo= → scope to it (unchanged behavior, no banner) + # explicit --repo=all → force the full view (no scope, no auto-detect) + # --repo omitted → auto-detect from cwd (unless opted out); banner on hit + auto_scoped = False + if isinstance(repo_arg, str): + repo_key = None if repo_arg.lower() == "all" else repo_arg + else: + repo_key = None + if cfg.get("brief_auto_scope", True): + match = resolve_repo_for_dir(cfg, os.getcwd()) + # Only auto-scope when the detected repo actually has tracks — a + # convenience must never hide tracks you'd otherwise see. + if match and filter_tracks_by_repo(tracks, match["key"]): + repo_key = match["key"] + auto_scoped = True + if repo_key: scoped = filter_tracks_by_repo(tracks, repo_key) if not scoped: + # Reachable only via an explicit --repo (the auto path above already + # guaranteed a non-empty match). print(f"No tracks found for repo '{repo_key}'.") available = sorted((cfg.get("repos") or {}).keys()) if available: print(f"Configured repo keys: {', '.join(available)}") return 0 tracks = scoped + if auto_scoped: + print(f"Scoped to repo '{repo_key}' (cwd). Use --repo=all to see everything.") + print() active = [t for t in tracks if t.has_frontmatter and t.meta.get("status") in ("active", "in-progress", "blocked")] diff --git a/skills/work-plan/commands/dedupe_tiers.py b/skills/work-plan/commands/dedupe_tiers.py new file mode 100644 index 0000000..b4383f3 --- /dev/null +++ b/skills/work-plan/commands/dedupe_tiers.py @@ -0,0 +1,104 @@ +"""dedupe-tiers — remove private track copies shadowed by a shared twin (#359). + +When a track is promoted to the shared tier (a repo's `.work-plan/`), the private +original under `notes_root` is normally moved out by `push-track`. But bulk or +manual promotion — or a failed unlink mid-promote — leaves the private copy +behind. `discover_tracks` then resolves the collision ("using shared") but warns +on EVERY invocation, with no built-in way to clean up. + +This verb removes the orphaned private copies that their shared twin supersedes, +and REFUSES to touch any whose private copy still references issue numbers the +shared one lacks — so no tracked work is ever silently dropped. The no-data-loss +invariant is `issue_refs(private) ⊆ issue_refs(shared)`. + +Default is a dry-run report. Pass `--apply` to delete the safe orphans; the +deletion lands in notes_root and the dispatcher's auto-commit makes it undoable. + +Usage: + work_plan.py dedupe-tiers [--repo=] [--apply] +""" +import sys +from pathlib import Path + +from lib.config import load_config, ConfigError +from lib.tracks import find_tier_duplicates, issue_refs +from lib.prompts import parse_flags + +KNOWN = {"--repo", "--apply"} + + +def run(args: list) -> int: + flags, _ = parse_flags(args, KNOWN) + repo_key = flags.get("--repo") + if repo_key is True: + print("usage: work_plan.py dedupe-tiers [--repo=] [--apply]", + file=sys.stderr) + return 2 + apply = bool(flags.get("--apply")) + + try: + cfg = load_config() + except ConfigError as e: + print(f"ERROR: {e}", file=sys.stderr) + return 1 + + pairs = find_tier_duplicates(cfg) + if repo_key: + k = repo_key.lower() + pairs = [(s, p) for (s, p) in pairs + if (s.folder and s.folder.lower() == k) + or (s.repo and s.repo.lower() == k)] + + if not pairs: + scope = f" for repo '{repo_key}'" if repo_key else "" + print(f"No shared/private duplicate tracks found{scope}. Nothing to dedupe.") + return 0 + + safe: list = [] # (shared, private) — private issue refs ⊆ shared + diverged: list = [] # (shared, private, extra_refs) — private has unique refs + for s, p in pairs: + extra = issue_refs(p) - issue_refs(s) + if extra: + diverged.append((s, p, extra)) + else: + safe.append((s, p)) + + print(f"Found {len(pairs)} shared/private duplicate track(s):") + print(f" {len(safe)} safe to remove (private issue refs ⊆ shared)") + print(f" {len(diverged)} diverged — kept for manual review") + print() + + for s, p in safe: + print(f" ✓ {p.name} (repo {s.repo or s.folder}) — private superseded by shared") + print(f" private: {p.path}") + for s, p, extra in diverged: + refs = ", ".join(f"#{n}" for n in sorted(extra)) + print(f" ⚠ {p.name} (repo {s.repo or s.folder}) — private has issue refs " + f"not in shared: {refs}") + print(f" KEPT: {p.path} — reconcile by hand") + + if not apply: + print() + if safe: + print(f"Dry run. Re-run with --apply to remove {len(safe)} private orphan(s).") + else: + print("Dry run. Nothing safe to remove automatically.") + return 0 + + if not safe: + print() + print("Nothing removed — every duplicate diverged and needs manual review.") + return 0 + + removed = 0 + for s, p in safe: + try: + Path(p.path).unlink() + removed += 1 + except OSError as e: + print(f"WARN: could not remove {p.path}: {e}", file=sys.stderr) + + print() + print(f"Removed {removed} private orphan(s). " + f"{len(diverged)} diverged track(s) left for manual review.") + return 0 diff --git a/skills/work-plan/commands/export.py b/skills/work-plan/commands/export.py index a6db9f6..be385f7 100644 --- a/skills/work-plan/commands/export.py +++ b/skills/work-plan/commands/export.py @@ -2,7 +2,7 @@ import json from datetime import datetime, date from lib.config import load_config, ConfigError, resolve_local_path_for_folder -from lib.tracks import discover_tracks +from lib.tracks import discover_tracks, find_tier_duplicates, issue_refs from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility from lib.git_state import hot_issue_numbers from lib.export_model import build_export @@ -148,6 +148,22 @@ def run(args: list[str]) -> int: if nums: hot_by_track[(t.repo, t.name)] = nums + # Shared/private tier duplicates (#361): the viewer is otherwise blind to + # them — discover_tracks drops the private copy with a stderr-only WARN. We + # surface them as a read-only health signal; `safe` mirrors dedupe-tiers' + # no-data-loss invariant (private issue refs ⊆ shared), so the viewer can + # tell auto-removable orphans from diverged ones needing manual review. + tier_duplicates = [] + for shared_t, private_t in find_tier_duplicates(cfg): + tier_duplicates.append({ + "repo": shared_t.repo, + "folder": shared_t.folder, + "name": shared_t.name, + "shared_path": str(shared_t.path), + "private_path": str(private_t.path), + "safe": issue_refs(private_t) <= issue_refs(shared_t), + }) + next_up_default = cfg.get("next_up_default") now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") print(json.dumps( @@ -156,7 +172,8 @@ def run(args: list[str]) -> int: config_repos=config_repos, plan_by_track=plan_by_track, hot_by_track=hot_by_track, - next_up_default=next_up_default), + next_up_default=next_up_default, + tier_duplicates=tier_duplicates), indent=2, )) return 0 diff --git a/skills/work-plan/commands/hygiene.py b/skills/work-plan/commands/hygiene.py index 2ff8e91..ed2bf19 100644 --- a/skills/work-plan/commands/hygiene.py +++ b/skills/work-plan/commands/hygiene.py @@ -3,11 +3,15 @@ Runs in sequence: 1. refresh-md --all --yes (drift in body status tables) 2. reconcile --all (sync track/ labels ↔ frontmatter) - 3. duplicates (find consolidation candidates) + 3. dedupe-tiers (report shared/private duplicate tracks) + 4. duplicates (find consolidation candidates) One command for the standard weekly maintenance pass. -Pass --repo= to scope steps 1 and 2 to a single repo. Step 3 (duplicates) +Step 3 (dedupe-tiers) is report-only here — it never deletes during hygiene. +Run `/work-plan dedupe-tiers --apply` directly to remove the safe orphans. + +Pass --repo= to scope steps 1 and 2 to a single repo. Step 4 (duplicates) is per-repo, so: - when --repo is set, it's scoped to that repo; - when --repo is absent and config has exactly one repo, it runs against @@ -18,7 +22,7 @@ Pass --timeout=N to set the gh subprocess timeout for the duplicates step (default 30s). """ -from commands import refresh_md, reconcile, duplicates +from commands import refresh_md, reconcile, duplicates, dedupe_tiers from lib.config import load_config, ConfigError from lib.prompts import parse_flags import time @@ -62,7 +66,7 @@ def run(args: list[str]) -> int: t0 = time.time() print("=" * 60) - print(f"WEEKLY HYGIENE — step 1 of 3: refresh-md{scope_label}") + print(f"WEEKLY HYGIENE — step 1 of 4: refresh-md{scope_label}") print("=" * 60) refresh_args = [f"--repo={repo_key}"] if repo_key else ["--all"] if yes: @@ -70,12 +74,12 @@ def run(args: list[str]) -> int: rc = refresh_md.run(refresh_args) if rc != 0: print(f"\n⚠ refresh-md exited with code {rc}; continuing.") - print(f" (step 1/3 done in {time.time() - t0:.1f}s)") + print(f" (step 1/4 done in {time.time() - t0:.1f}s)") t1 = time.time() print() print("=" * 60) - print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}") + print(f"WEEKLY HYGIENE — step 2 of 4: reconcile{scope_label}") print("=" * 60) reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"] if yes: @@ -83,7 +87,18 @@ def run(args: list[str]) -> int: rc = reconcile.run(reconcile_args) if rc != 0: print(f"\n⚠ reconcile exited with code {rc}; continuing.") - print(f" (step 2/3 done in {time.time() - t1:.1f}s)") + print(f" (step 2/4 done in {time.time() - t1:.1f}s)") + + t_dt = time.time() + print() + print("=" * 60) + print(f"WEEKLY HYGIENE — step 3 of 4: dedupe-tiers{scope_label} (report-only)") + print("=" * 60) + dedupe_args = [f"--repo={repo_key}"] if repo_key else [] + rc = dedupe_tiers.run(dedupe_args) + if rc != 0: + print(f"\n⚠ dedupe-tiers exited with code {rc}; continuing.") + print(f" (step 3/4 done in {time.time() - t_dt:.1f}s)") if skip_dups: print() @@ -93,7 +108,7 @@ def run(args: list[str]) -> int: t2 = time.time() print() print("=" * 60) - print("WEEKLY HYGIENE — step 3 of 3: duplicates") + print("WEEKLY HYGIENE — step 4 of 4: duplicates") print("=" * 60) try: @@ -122,7 +137,7 @@ def run(args: list[str]) -> int: rc = duplicates.run(dupes_args) if rc != 0: print(f"\n⚠ duplicates exited with code {rc}.") - print(f" (step 3/3 done in {time.time() - t2:.1f}s)") + print(f" (step 4/4 done in {time.time() - t2:.1f}s)") print() print(f"✓ Weekly hygiene complete ({time.time() - t0:.1f}s total). Review the duplicate candidates above and " diff --git a/skills/work-plan/commands/which_repo.py b/skills/work-plan/commands/which_repo.py new file mode 100644 index 0000000..a48e825 --- /dev/null +++ b/skills/work-plan/commands/which_repo.py @@ -0,0 +1,52 @@ +"""which-repo — resolve the current directory to a configured repo. + +Prints the matched repo's config key + GitHub slug, or reports no match. The +VS Code viewer spawns this with cwd set to the workspace folder to auto-focus its +repo lens (#357); `brief` calls the underlying resolver directly for cwd +auto-scope (#358). Read-only — never mutates anything. + +Exit codes (human form): 0 on a match, 1 on no match — so a shell caller can +gate on it. The `--json` form always exits 0 and prints a `{"key": ...}` payload +(key is null on no match) for the viewer to parse. +""" +import json +import os + +from lib.config import load_config, ConfigError +from lib.cwd_repo import resolve_repo_for_dir +from lib.prompts import parse_flags + + +def run(args: list) -> int: + flags, _ = parse_flags(args, {"--json"}) + want_json = bool(flags.get("--json")) + + try: + cfg = load_config() + except ConfigError as e: + if want_json: + print(json.dumps({"key": None})) + return 0 + print(f"ERROR: {e}") + return 1 + + match = resolve_repo_for_dir(cfg, os.getcwd()) + + if want_json: + if match: + print(json.dumps({ + "key": match["key"], + "github": match.get("github"), + "matched_by": match["matched_by"], + })) + else: + print(json.dumps({"key": None})) + return 0 + + if match: + how = "local clone path" if match["matched_by"] == "local" else "git remote" + print(f"Resolved to repo '{match['key']}' (matched by {how}).") + return 0 + + print("No configured repo matches the current directory.") + return 1 diff --git a/skills/work-plan/lib/cwd_repo.py b/skills/work-plan/lib/cwd_repo.py new file mode 100644 index 0000000..04c6607 --- /dev/null +++ b/skills/work-plan/lib/cwd_repo.py @@ -0,0 +1,133 @@ +"""Resolve a directory to a configured repo (config key + GitHub slug). + +Shared substrate for two sibling features: `brief` cwd auto-scope (#358) and the +VS Code viewer auto-focus (#357). Both need the same "which configured repo is +this directory?" answer, so it lives here once — exposed to the viewer through +the `which-repo` command and called directly by `brief`. + +Resolution order: the local clone path is the primary signal (it's the most +explicit thing the user configured); the git `origin` remote is the fallback for +repos registered with `local: null`. If both resolve but to different keys (which +shouldn't happen in practice), the local-path key wins. + +Read-only and never raises — every git call goes through the bounded `_git` +wrapper, which returns None on failure, and a no-match returns None so callers +fall back to their current all-repos behavior unchanged. +""" +import re +from pathlib import Path +from typing import Optional + +from lib.git_state import _git + + +def _normalize_remote_url(url: str) -> Optional[str]: + """Normalize a git remote URL to a lowercased `org/repo` slug, or None. + + Handles the forms git emits for GitHub remotes: + git@github.com:org/repo.git -> org/repo (scp-like) + ssh://git@github.com/org/repo.git -> org/repo + https://github.com/org/repo.git -> org/repo + https://github.com/org/repo -> org/repo + + A trailing `.git`, surrounding whitespace, and trailing slashes are stripped. + Returns None for anything it can't parse into a host-path. + """ + if not url: + return None + u = url.strip() + + # scp-like syntax: [user@]host:path (no scheme, single colon before path) + m = re.match(r"^[\w.+-]+@[\w.-]+:(.+)$", u) + if m: + path = m.group(1) + else: + # url syntax: scheme://[user@]host[:port]/path + m = re.match(r"^[\w.+-]+://(?:[^/@]+@)?[^/]+/(.+)$", u) + if not m: + return None + path = m.group(1) + + path = path.strip().strip("/") + if path.endswith(".git"): + path = path[:-len(".git")] + path = path.strip("/") + return path.lower() or None + + +def _toplevel(start_dir) -> Optional[Path]: + """The git work-tree root containing `start_dir`, resolved absolute, or None. + + Uses `rev-parse --show-toplevel` (not the raw dir) so resolution works from + any nested subdirectory and a configured `local` that merely *contains* the + cwd can't false-match. + """ + proc = _git(start_dir, "rev-parse", "--show-toplevel") + if proc is None or proc.returncode != 0: + return None + out = proc.stdout.strip() + if not out: + return None + try: + return Path(out).resolve() + except OSError: + return None + + +def _origin_slug(start_dir) -> Optional[str]: + """The `org/repo` slug of `start_dir`'s `origin` remote, or None.""" + proc = _git(start_dir, "remote", "get-url", "origin") + if proc is None or proc.returncode != 0: + return None + return _normalize_remote_url(proc.stdout.strip()) + + +def resolve_repo_for_dir(cfg: dict, start_dir) -> Optional[dict]: + """Resolve `start_dir` to a single configured repo. + + Returns `{"key", "github", "matched_by"}` (matched_by is "local" or + "remote") when exactly one repo matches, else None. None covers: no repos + configured, dir isn't a git repo, no match, or an ambiguous (>1) match — + callers treat all of these as "don't auto-scope." + """ + repos = cfg.get("repos") or {} + if not repos: + return None + + # --- local clone path (primary) --- + top = _toplevel(start_dir) + if top is not None: + local_keys = [] + for key, entry in repos.items(): + local_raw = (entry or {}).get("local") + if not local_raw: + continue + try: + cfg_root = Path(local_raw).expanduser().resolve() + except OSError: + continue + if cfg_root == top: + local_keys.append(key) + if len(local_keys) == 1: + key = local_keys[0] + return {"key": key, + "github": (repos[key] or {}).get("github"), + "matched_by": "local"} + if len(local_keys) > 1: + # Ambiguous local config — refuse to guess. + return None + + # --- git origin remote (fallback) --- + slug = _origin_slug(start_dir) + if slug: + remote_keys = [ + key for key, entry in repos.items() + if entry and entry.get("github") and entry["github"].lower() == slug + ] + if len(remote_keys) == 1: + key = remote_keys[0] + return {"key": key, + "github": repos[key].get("github"), + "matched_by": "remote"} + + return None diff --git a/skills/work-plan/lib/drift.py b/skills/work-plan/lib/drift.py index 456e5b8..9c4d42b 100644 --- a/skills/work-plan/lib/drift.py +++ b/skills/work-plan/lib/drift.py @@ -23,8 +23,13 @@ def detect_drift(body: str, github_issues: list[dict]) -> list[dict]: continue gh_state = state_by_num[num] looks_closed = any(k in body_status for k in ("✅", "shipped", "merged", "closed")) - looks_open = "🔲" in body_status or "open" in body_status + # Asymmetric by design: CLOSED is terminal, so a closed issue whose + # row doesn't explicitly read closed (open marker, ambiguous, or empty) + # is drift. OPEN is not terminal — an open issue legitimately sits in + # many states (in-progress, blocked, todo), so only an explicit closed + # marker contradicts it. A broad open-side check (`not looks_open`) + # would false-positive every in-progress row, which is why we don't. if gh_state == "CLOSED" and not looks_closed: drift.append({"issue": num, "body_status": body_status, "github_state": "CLOSED"}) elif gh_state == "OPEN" and looks_closed: diff --git a/skills/work-plan/lib/export_model.py b/skills/work-plan/lib/export_model.py index 436bdb2..e7675d6 100644 --- a/skills/work-plan/lib/export_model.py +++ b/skills/work-plan/lib/export_model.py @@ -86,7 +86,7 @@ def normalize_issue(i: dict, in_progress: bool = False, def build_export(tracks, issues_by_track, visibility, now: str, untracked_by_repo=None, config_repos=None, plan_by_track=None, hot_by_track=None, - next_up_default=None) -> dict: + next_up_default=None, tier_duplicates=None) -> dict: plan_by_track = plan_by_track or {} hot_by_track = hot_by_track or {} out = {"schema": SCHEMA, "generated_at": now, "tracks": []} @@ -169,4 +169,9 @@ def build_export(tracks, issues_by_track, visibility, now: str, # the starting point for adding fresh tracks. Each entry: # {folder, repo(slug), local, has_local, visibility}. out["repos"] = list(config_repos or []) + # Shared/private tier duplicates (#361, additive): tracks that exist in both + # a repo's shared .work-plan/ and the private notes_root tier. Read-only + # health signal for the viewer; resolved with the `dedupe-tiers` CLI verb. + # Each entry: {repo, folder, name, shared_path, private_path, safe}. + out["tier_duplicates"] = list(tier_duplicates or []) return out diff --git a/skills/work-plan/lib/tracks.py b/skills/work-plan/lib/tracks.py index 163b8a5..0915b95 100644 --- a/skills/work-plan/lib/tracks.py +++ b/skills/work-plan/lib/tracks.py @@ -1,4 +1,5 @@ """Discover tracks under notes_root and shared .work-plan/ dirs.""" +import re import sys from dataclasses import dataclass, field from pathlib import Path @@ -74,9 +75,11 @@ def discover_tracks(cfg: dict) -> list[Track]: for t in private: key = (t.repo, t.name) if key in shared_keys: + hint = f" --repo={t.folder}" if t.folder else "" print( f"WARN: track {t.name!r} (repo={t.repo!r}) exists in both shared" - f" ({shared_keys[key].path}) and private ({t.path}); using shared.", + f" ({shared_keys[key].path}) and private ({t.path}); using shared." + f" → resolve with `/work-plan dedupe-tiers{hint}`.", file=sys.stderr, ) else: @@ -85,6 +88,63 @@ def discover_tracks(cfg: dict) -> list[Track]: return merged +def issue_refs(track: "Track") -> set: + """All GitHub issue numbers a track references: the union of its frontmatter + `github.issues` list and every `#NNNN` token in its body. Used by + dedupe-tiers as the no-data-loss invariant — a private copy is only safe to + drop when its issue refs are a subset of the shared twin's. + """ + refs: set = set() + fm_issues = (track.meta.get("github") or {}).get("issues") or [] + for n in fm_issues: + try: + refs.add(int(n)) + except (TypeError, ValueError): + continue + for m in re.finditer(r"#(\d+)", track.body or ""): + refs.add(int(m.group(1))) + return refs + + +def find_tier_duplicates(cfg: dict) -> list: + """Return (shared, private) Track pairs that collide on (repo, name) across + BOTH the active and archived tiers. Unlike discover_tracks, this does NOT + print a warning and does NOT drop the private side — it hands both copies to + the caller (dedupe-tiers) so the orphan can be inspected and removed. + """ + pairs: list = [] + + # A duplicate requires a PRIVATE copy, which lives under notes_root. With no + # notes_root configured there can be no private tier and thus no duplicates — + # return early (also keeps the helper safe to call with a bare cfg). + if not cfg.get("notes_root"): + return pairs + + shared_active = {(t.repo, t.name): t + for t in _discover_shared_tracks(cfg, include_archive=False)} + for t in _discover_private_tracks(cfg, include_archive=False): + s = shared_active.get((t.repo, t.name)) + if s is not None: + pairs.append((s, t)) + + shared_arch = {(t.repo, t.name): t + for t in _discover_shared_tracks(cfg, include_archive=True, + archive_only=True)} + notes_root = Path(cfg["notes_root"]).expanduser() + if notes_root.exists(): + for md_path in sorted(notes_root.rglob("*.md")): + if "archive" not in md_path.parts: + continue + if md_path.name.startswith((".", "_", "-")): + continue + t = _build_track(md_path, notes_root, cfg) + s = shared_arch.get((t.repo, t.name)) + if s is not None: + pairs.append((s, t)) + + return pairs + + def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]: """Filter tracks by repo. Matches the config-key folder name OR the `org/repo` GitHub slug, so users can pass either. Case-insensitive.""" @@ -176,10 +236,12 @@ def discover_archived_tracks(cfg: dict) -> list[Track]: for t in private_archived: key = (t.repo, t.name) if key in shared_keys: + hint = f" --repo={t.folder}" if t.folder else "" print( f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in" f" both shared ({shared_keys[key].path}) and private" - f" ({t.path}); using shared.", + f" ({t.path}); using shared." + f" → resolve with `/work-plan dedupe-tiers{hint}`.", file=sys.stderr, ) else: diff --git a/skills/work-plan/tests/test_brief_autoscope.py b/skills/work-plan/tests/test_brief_autoscope.py new file mode 100644 index 0000000..f790871 --- /dev/null +++ b/skills/work-plan/tests/test_brief_autoscope.py @@ -0,0 +1,126 @@ +"""brief cwd auto-scope (#358 Phase 2). + +Exercises brief.run()'s scope resolution with the resolver, config loader, +track discovery, and the archived-reopen pass all mocked — so no network/git. +Tracks are inactive (status 'shipped') so the render loop stays trivial; we +assert behavior through the banner text and the `repo_key` threaded into +`_surface_archived_reopens` (which is exactly the value used to scope both the +track list and the archived callouts). +""" +import io +import sys +import types +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT)) + +from commands import brief + + +def _track(folder, repo): + return types.SimpleNamespace( + has_frontmatter=True, + meta={"status": "shipped"}, # inactive → no per-track render + needs_init=False, + needs_filing=False, + folder=folder, + repo=repo, + path=Path(f"/notes/{folder}/{folder}.md"), + ) + + +CFG = {"repos": { + "work-plan-toolkit": {"local": "/code/wpt", "github": "stylusnexus/work-plan-toolkit"}, + "defect-scan": {"local": "/code/ds", "github": "stylusnexus/defect-scan"}, +}} + +TRACKS = [ + _track("work-plan-toolkit", "stylusnexus/work-plan-toolkit"), + _track("defect-scan", "stylusnexus/defect-scan"), +] + +BANNER = "Scoped to repo" + + +def _run(args, cfg=None, resolve_return=mock.DEFAULT): + """Run brief.run(args) with collaborators mocked. Returns (stdout, archived_mock, resolve_mock).""" + cfg = CFG if cfg is None else cfg + buf = io.StringIO() + with mock.patch.object(brief, "load_config", return_value=cfg), \ + mock.patch.object(brief, "discover_tracks", return_value=list(TRACKS)), \ + mock.patch.object(brief, "_surface_archived_reopens") as archived, \ + mock.patch.object(brief, "resolve_repo_for_dir") as resolve: + if resolve_return is not mock.DEFAULT: + resolve.return_value = resolve_return + with redirect_stdout(buf): + brief.run(args) + return buf.getvalue(), archived, resolve + + +def _archived_repo_key(archived): + self_call = archived.call_args + return self_call.kwargs.get("repo_key") + + +class BriefAutoScopeTest(unittest.TestCase): + # --- auto-detect on (default) ------------------------------------------- + + def test_autoscope_prints_banner_and_scopes(self): + out, archived, _ = _run( + [], resolve_return={"key": "work-plan-toolkit", + "github": "stylusnexus/work-plan-toolkit", + "matched_by": "local"}) + self.assertIn(BANNER, out) + self.assertIn("work-plan-toolkit", out) + # archived-reopen pass scoped to the same detected key + self.assertEqual(_archived_repo_key(archived), "work-plan-toolkit") + + def test_archived_reopens_scoped_to_detected_repo(self): + _, archived, _ = _run( + [], resolve_return={"key": "defect-scan", + "github": "stylusnexus/defect-scan", + "matched_by": "local"}) + self.assertEqual(_archived_repo_key(archived), "defect-scan") + + def test_banner_printed_exactly_once(self): + out, _, _ = _run( + [], resolve_return={"key": "work-plan-toolkit", + "github": "stylusnexus/work-plan-toolkit", + "matched_by": "local"}) + self.assertEqual(out.count(BANNER), 1) + + def test_no_match_shows_all_no_banner(self): + out, archived, _ = _run([], resolve_return=None) + self.assertNotIn(BANNER, out) + self.assertIsNone(_archived_repo_key(archived)) + + # --- explicit --repo / escape hatch ------------------------------------- + + def test_repo_all_shows_everything_no_banner_no_autodetect(self): + out, archived, resolve = _run(["--repo=all"]) + self.assertNotIn(BANNER, out) + self.assertIsNone(_archived_repo_key(archived)) + resolve.assert_not_called() # --repo=all must short-circuit auto-detect + + def test_explicit_repo_scopes_without_banner_or_autodetect(self): + out, archived, resolve = _run(["--repo=defect-scan"]) + self.assertNotIn(BANNER, out) + self.assertEqual(_archived_repo_key(archived), "defect-scan") + resolve.assert_not_called() + + # --- opt-out ------------------------------------------------------------- + + def test_optout_disables_autoscope(self): + cfg = {"repos": CFG["repos"], "brief_auto_scope": False} + out, archived, resolve = _run([], cfg=cfg) + self.assertNotIn(BANNER, out) + self.assertIsNone(_archived_repo_key(archived)) + resolve.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/tests/test_cwd_repo.py b/skills/work-plan/tests/test_cwd_repo.py new file mode 100644 index 0000000..1182a69 --- /dev/null +++ b/skills/work-plan/tests/test_cwd_repo.py @@ -0,0 +1,164 @@ +"""cwd → configured-repo resolution (#358/#357 Phase 1). + +All git calls are mocked, so these run offline. The resolver shells `git` via +`lib.git_state._git`, imported into `lib.cwd_repo`'s namespace — so we patch +`lib.cwd_repo._git`. +""" +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT)) + +from lib import cwd_repo +from lib.cwd_repo import resolve_repo_for_dir, _normalize_remote_url + + +def _proc(stdout="", returncode=0): + """A stand-in for subprocess.CompletedProcess as `_git` returns it.""" + return types.SimpleNamespace(stdout=stdout, returncode=returncode) + + +def _fake_git(toplevel=None, origin=None): + """Build a `_git` replacement keyed on the git subcommand. + + `toplevel` / `origin` are raw stdout strings (or None → non-zero exit, i.e. + git failed / not a repo / no remote). + """ + def _g(repo_path, *args, **kwargs): + if args[:2] == ("rev-parse", "--show-toplevel"): + return _proc(toplevel, 0) if toplevel is not None else _proc("", 128) + if args[:2] == ("remote", "get-url"): + return _proc(origin, 0) if origin is not None else _proc("", 2) + return _proc("", 0) + return _g + + +# Absolute, non-symlinked paths so .resolve() is a no-op-equal on both sides. +CFG = { + "repos": { + "work-plan-toolkit": { + "local": "/code/work-plan-toolkit", + "github": "stylusnexus/work-plan-toolkit", + }, + "defect-scan": { + "local": "/code/defect-scan", + "github": "stylusnexus/defect-scan", + }, + # A repo with no local clone — only a remote can match it. + "remote-only": { + "local": None, + "github": "stylusnexus/remote-only", + }, + } +} + + +class NormalizeRemoteUrlTest(unittest.TestCase): + def test_scp_form(self): + self.assertEqual( + _normalize_remote_url("git@github.com:Org/Repo.git"), "org/repo") + + def test_https_with_git_suffix(self): + self.assertEqual( + _normalize_remote_url("https://github.com/org/repo.git"), "org/repo") + + def test_https_without_suffix(self): + self.assertEqual( + _normalize_remote_url("https://github.com/org/repo"), "org/repo") + + def test_ssh_url_form(self): + self.assertEqual( + _normalize_remote_url("ssh://git@github.com/org/repo.git"), "org/repo") + + def test_all_forms_land_on_same_slug(self): + forms = [ + "git@github.com:org/repo.git", + "https://github.com/org/repo.git", + "https://github.com/org/repo", + "ssh://git@github.com/org/repo.git", + ] + slugs = {_normalize_remote_url(f) for f in forms} + self.assertEqual(slugs, {"org/repo"}) + + def test_garbage_returns_none(self): + self.assertIsNone(_normalize_remote_url("")) + self.assertIsNone(_normalize_remote_url("not-a-url")) + + +class ResolveRepoForDirTest(unittest.TestCase): + def test_local_match_at_clone_root(self): + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel="/code/work-plan-toolkit")): + got = resolve_repo_for_dir(CFG, "/code/work-plan-toolkit") + self.assertEqual(got, { + "key": "work-plan-toolkit", + "github": "stylusnexus/work-plan-toolkit", + "matched_by": "local", + }) + + def test_local_match_from_nested_subdir(self): + # cwd is deep inside the clone; toplevel still resolves to the root. + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel="/code/work-plan-toolkit")): + got = resolve_repo_for_dir( + CFG, "/code/work-plan-toolkit/skills/work-plan/lib") + self.assertIsNotNone(got) + self.assertEqual(got["key"], "work-plan-toolkit") + self.assertEqual(got["matched_by"], "local") + + def test_remote_match_when_local_is_null(self): + # Not a configured local path, but origin matches the remote-only repo. + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel="/somewhere/else", + origin="git@github.com:stylusnexus/remote-only.git")): + got = resolve_repo_for_dir(CFG, "/somewhere/else") + self.assertEqual(got, { + "key": "remote-only", + "github": "stylusnexus/remote-only", + "matched_by": "remote", + }) + + def test_local_wins_over_remote_when_they_disagree(self): + # toplevel == defect-scan's clone, but origin points at work-plan-toolkit. + # The local-path key must win. + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel="/code/defect-scan", + origin="git@github.com:stylusnexus/work-plan-toolkit.git")): + got = resolve_repo_for_dir(CFG, "/code/defect-scan") + self.assertEqual(got["key"], "defect-scan") + self.assertEqual(got["matched_by"], "local") + + def test_no_match_inside_unconfigured_repo(self): + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel="/code/unknown", + origin="git@github.com:someone/unknown.git")): + self.assertIsNone(resolve_repo_for_dir(CFG, "/code/unknown")) + + def test_no_match_when_not_a_git_repo(self): + # git rev-parse fails AND no remote — resolver returns None, no raise. + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel=None, origin=None)): + self.assertIsNone(resolve_repo_for_dir(CFG, "/tmp/plain-dir")) + + def test_none_when_two_repos_share_a_local_path(self): + # Pathological config: two keys point at the same clone. Refuse to guess. + cfg = {"repos": { + "a": {"local": "/code/dup", "github": "org/a"}, + "b": {"local": "/code/dup", "github": "org/b"}, + }} + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel="/code/dup")): + self.assertIsNone(resolve_repo_for_dir(cfg, "/code/dup")) + + def test_none_when_no_repos_configured(self): + with mock.patch.object(cwd_repo, "_git", + _fake_git(toplevel="/code/x")): + self.assertIsNone(resolve_repo_for_dir({"repos": {}}, "/code/x")) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/tests/test_dedupe_tiers.py b/skills/work-plan/tests/test_dedupe_tiers.py new file mode 100644 index 0000000..f84e8b8 --- /dev/null +++ b/skills/work-plan/tests/test_dedupe_tiers.py @@ -0,0 +1,147 @@ +"""Tests for the dedupe-tiers command and its tracks.py helpers (#359).""" +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT)) + +from commands import dedupe_tiers +from lib import tracks + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _track(*, name, repo="org/repo", folder="repo", issues=None, body="", path=None, + tier="private"): + meta = {"track": name, "github": {"repo": repo}} + if issues is not None: + meta["github"]["issues"] = issues + return SimpleNamespace( + name=name, + path=Path(path) if path else Path(f"/tmp/notes/{folder}/{name}.md"), + repo=repo, + folder=folder, + meta=meta, + body=body, + tier=tier, + ) + + +def _drive(args, pairs, *, notes_root="/tmp/notes"): + cfg = {"notes_root": notes_root, "repos": {"repo": {"github": "org/repo"}}} + buf = io.StringIO() + with patch("commands.dedupe_tiers.load_config", return_value=cfg), \ + patch("commands.dedupe_tiers.find_tier_duplicates", return_value=pairs), \ + redirect_stdout(buf): + rc = dedupe_tiers.run(args) + return rc, buf.getvalue() + + +# --------------------------------------------------------------------------- +# issue_refs +# --------------------------------------------------------------------------- + +class TestIssueRefs(unittest.TestCase): + def test_unions_frontmatter_and_body(self): + t = _track(name="a", issues=[10, 20], body="see #20 and #30 here") + self.assertEqual(tracks.issue_refs(t), {10, 20, 30}) + + def test_empty_when_no_refs(self): + t = _track(name="a", issues=None, body="no refs at all") + self.assertEqual(tracks.issue_refs(t), set()) + + def test_ignores_non_int_frontmatter(self): + t = _track(name="a", issues=[1, "oops", None], body="") + self.assertEqual(tracks.issue_refs(t), {1}) + + +# --------------------------------------------------------------------------- +# find_tier_duplicates pairing (helpers patched) +# --------------------------------------------------------------------------- + +class TestFindTierDuplicates(unittest.TestCase): + def test_pairs_only_colliding_active_tracks(self): + shared = [_track(name="dup", tier="shared"), _track(name="only-shared", tier="shared")] + private = [_track(name="dup"), _track(name="only-private")] + cfg = {"notes_root": "/tmp/does-not-exist-xyz", "repos": {}} + with patch.object(tracks, "_discover_shared_tracks") as ds, \ + patch.object(tracks, "_discover_private_tracks", return_value=private): + # active call returns `shared`; archive-only call returns [] + ds.side_effect = lambda cfg, include_archive=False, archive_only=False: ( + [] if archive_only else shared) + pairs = tracks.find_tier_duplicates(cfg) + names = [(s.name, p.name) for (s, p) in pairs] + self.assertEqual(names, [("dup", "dup")]) + + +# --------------------------------------------------------------------------- +# command: report / apply / safety +# --------------------------------------------------------------------------- + +class TestDedupeCommand(unittest.TestCase): + def test_no_pairs_reports_nothing(self): + rc, out = _drive([], []) + self.assertEqual(rc, 0) + self.assertIn("Nothing to dedupe", out) + + def test_dry_run_removes_nothing(self): + with tempfile.TemporaryDirectory() as d: + pf = Path(d) / "dup.md" + pf.write_text("# dup\n") + shared = _track(name="dup", issues=[1, 2], tier="shared") + private = _track(name="dup", issues=[1], path=str(pf)) + rc, out = _drive([], [(shared, private)]) + self.assertTrue(pf.exists(), "dry run must not delete") + self.assertEqual(rc, 0) + self.assertIn("Dry run", out) + self.assertIn("--apply", out) + + def test_apply_removes_subset_orphan(self): + with tempfile.TemporaryDirectory() as d: + pf = Path(d) / "dup.md" + pf.write_text("# dup\n") + shared = _track(name="dup", issues=[1, 2, 3], tier="shared") + private = _track(name="dup", issues=[1, 3], path=str(pf)) + rc, out = _drive(["--apply"], [(shared, private)]) + self.assertEqual(rc, 0) + self.assertFalse(pf.exists(), "subset orphan must be removed on --apply") + self.assertIn("Removed 1 private orphan", out) + + def test_apply_keeps_diverged_orphan(self): + with tempfile.TemporaryDirectory() as d: + pf = Path(d) / "dup.md" + pf.write_text("# dup\n") + # private references #99 which the shared twin lacks → must be kept + shared = _track(name="dup", issues=[1, 2], tier="shared") + private = _track(name="dup", issues=[1], body="leftover #99", path=str(pf)) + rc, out = _drive(["--apply"], [(shared, private)]) + self.assertEqual(rc, 0) + self.assertTrue(pf.exists(), "diverged orphan must NOT be removed") + self.assertIn("#99", out) + self.assertIn("manual review", out) + + def test_repo_filter_scopes_pairs(self): + a_shared = _track(name="a", repo="org/a", folder="a", issues=[1], tier="shared") + a_priv = _track(name="a", repo="org/a", folder="a", issues=[1]) + b_shared = _track(name="b", repo="org/b", folder="b", issues=[1], tier="shared") + b_priv = _track(name="b", repo="org/b", folder="b", issues=[1]) + rc, out = _drive(["--repo=a"], [(a_shared, a_priv), (b_shared, b_priv)]) + self.assertEqual(rc, 0) + self.assertIn("a (repo org/a)", out) + self.assertNotIn("org/b", out) + + def test_repo_flag_without_value_is_usage_error(self): + rc, _ = _drive(["--repo"], []) + self.assertEqual(rc, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/tests/test_drift.py b/skills/work-plan/tests/test_drift.py index d117faa..68293be 100644 --- a/skills/work-plan/tests/test_drift.py +++ b/skills/work-plan/tests/test_drift.py @@ -33,6 +33,38 @@ def test_drift_when_open_in_md_closed_in_github(self): def test_no_table_returns_empty(self): self.assertEqual(detect_drift("# No table\n", [{"number": 1, "state": "CLOSED"}]), []) + # --- OPEN-side + ambiguous-body cases: pin the *intentional asymmetry* ---- + # CLOSED is terminal → broad check (anything not-closed drifts). OPEN is not + # terminal → narrow check (only an explicit closed marker drifts). These + # guard against someone "restoring symmetry" with a `not looks_open` open-side + # check, which would false-positive every in-progress row. + + def _body(self, status: str) -> str: + return ("| # | Title | Status |\n" + "|---|---|---|\n" + f"| #1 | foo | {status} |\n") + + def test_drift_when_closed_in_md_open_in_github(self): + # OPEN-side condition (was untested): body says shipped, GitHub reopened it. + drift = detect_drift(self._body("✅ Shipped"), [{"number": 1, "state": "OPEN"}]) + self.assertEqual(len(drift), 1) + self.assertEqual(drift[0]["github_state"], "OPEN") + + def test_no_drift_when_open_in_md_open_in_github(self): + self.assertEqual(detect_drift(self._body("🔲 Open"), [{"number": 1, "state": "OPEN"}]), []) + + def test_open_with_ambiguous_status_is_NOT_drift(self): + # The deliberate narrow OPEN-side: an in-progress row must not be flagged. + self.assertEqual( + detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "OPEN"}]), []) + + def test_closed_with_ambiguous_status_IS_drift(self): + # The deliberate broad CLOSED-side: a closed issue whose row doesn't read + # closed (here: ambiguous) is drift. + drift = detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "CLOSED"}]) + self.assertEqual(len(drift), 1) + self.assertEqual(drift[0]["github_state"], "CLOSED") + if __name__ == "__main__": unittest.main() diff --git a/skills/work-plan/tests/test_export_command.py b/skills/work-plan/tests/test_export_command.py index d8520a7..eae8886 100644 --- a/skills/work-plan/tests/test_export_command.py +++ b/skills/work-plan/tests/test_export_command.py @@ -151,6 +151,49 @@ def test_track_without_issues_gets_empty_issues(self): self.assertEqual(rc, 0) self.assertEqual(out["tracks"][0]["issues"], []) + # --- tier_duplicates (#361) ------------------------------------------- + + def _dup_track(self, *, issues, body="", path): + return SimpleNamespace( + repo=_SHARED_REPO, folder="myrepo", name="dup", path=Path(path), + meta={"github": {"repo": _SHARED_REPO, "issues": issues}}, body=body, + ) + + def test_tier_duplicates_empty_when_none(self): + """With no notes_root in cfg (mock returns {}), the field is an empty + list — present but quiet, so the viewer can rely on its shape.""" + tracks = [_track("alpha", _SHARED_REPO, [1])] + rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP) + self.assertEqual(rc, 0) + self.assertEqual(out["tier_duplicates"], []) + + def test_tier_duplicate_subset_is_safe(self): + shared = self._dup_track(issues=[1, 2, 3], path="/repo/.work-plan/dup.md") + private = self._dup_track(issues=[1, 3], path="/notes/myrepo/dup.md") + with patch("commands.export.find_tier_duplicates", + return_value=[(shared, private)]): + rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP) + self.assertEqual(rc, 0) + td = out["tier_duplicates"] + self.assertEqual(len(td), 1) + self.assertEqual(td[0]["name"], "dup") + self.assertEqual(td[0]["repo"], _SHARED_REPO) + self.assertEqual(td[0]["folder"], "myrepo") + self.assertTrue(td[0]["safe"]) + self.assertEqual(td[0]["shared_path"], str(Path("/repo/.work-plan/dup.md"))) + self.assertEqual(td[0]["private_path"], str(Path("/notes/myrepo/dup.md"))) + + def test_tier_duplicate_diverged_is_unsafe(self): + # private references #99, which the shared twin lacks → not safe to remove + shared = self._dup_track(issues=[1, 2], path="/repo/.work-plan/dup.md") + private = self._dup_track(issues=[1], body="leftover #99", + path="/notes/myrepo/dup.md") + with patch("commands.export.find_tier_duplicates", + return_value=[(shared, private)]): + rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP) + self.assertEqual(rc, 0) + self.assertFalse(out["tier_duplicates"][0]["safe"]) + def test_visibility_included_in_output(self): tracks = [_track("alpha", _SHARED_REPO, [1])] rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP, vis={_SHARED_REPO: "PUBLIC"}) diff --git a/skills/work-plan/tests/test_register_which_repo.py b/skills/work-plan/tests/test_register_which_repo.py new file mode 100644 index 0000000..5ab25d5 --- /dev/null +++ b/skills/work-plan/tests/test_register_which_repo.py @@ -0,0 +1,22 @@ +"""which-repo is dispatchable + documented (#358/#357 Phase 1).""" +import sys +import unittest +from pathlib import Path + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT)) + +import work_plan + + +class RegisterWhichRepoTest(unittest.TestCase): + def test_in_subcommands(self): + self.assertEqual(work_plan.SUBCOMMANDS["which-repo"], "commands.which_repo") + + def test_in_descriptions(self): + names = {row[0] for row in work_plan.DESCRIPTIONS} + self.assertIn("which-repo", names) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/work-plan/work_plan.py b/skills/work-plan/work_plan.py index b2151d6..043c2e1 100755 --- a/skills/work-plan/work_plan.py +++ b/skills/work-plan/work_plan.py @@ -47,6 +47,7 @@ def _load_version() -> str: "duplicates": "commands.duplicates", "coverage": "commands.coverage", "canonicalize": "commands.canonicalize", + "dedupe-tiers": "commands.dedupe_tiers", "hygiene": "commands.hygiene", "--hygiene": "commands.hygiene", # flag-style alias "plan-status": "commands.plan_status", @@ -57,6 +58,7 @@ def _load_version() -> str: "close-issue": "commands.close_issue", "in-progress": "commands.in_progress", "export": "commands.export", + "which-repo": "commands.which_repo", "auth-status": "commands.auth_status", "list-open-issues": "commands.list_open_issues", "set": "commands.set_field", @@ -147,8 +149,12 @@ def _load_version() -> str: "Insert a canonical master issue table at the top of a track. The table has a Milestone column and is ordered active-milestone-first (the track's milestone_alignment milestone, then other milestones grouped with a blank divider row, then no-milestone last) so near-term work sits above someday work (#101). Refresh-md then targets ONLY this table, re-deriving it (so the order self-heals) and leaving narrative tables alone. Use --repo= or track@repo to disambiguate; with --all, --repo= scopes to one repo.", "ONE-TIME for hand-written tracks with multiple narrative tables, OR after restructuring a track.", "/work-plan canonicalize ux-redesign"), + ("dedupe-tiers", "[--repo=] [--apply]", + "Remove private track copies that a shared twin in a repo's .work-plan/ supersedes (#359). When a track is promoted to the shared tier, the private original under notes_root is sometimes left behind (bulk/manual promotion, or a failed unlink) — discover_tracks then warns 'exists in both shared and private' on every run with no cleanup path. This removes the safe orphans and REFUSES any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is issue_refs(private) ⊆ issue_refs(shared)). Covers active and archived tiers. Default is a dry-run report; --apply deletes (auto-committed to notes_root, so undoable).", + "When `exists in both shared and private` warnings appear, or after a bulk promote that left private originals behind.", + "/work-plan dedupe-tiers --repo=critforge --apply"), ("hygiene", "[--yes] [--no-duplicates] [--repo=] [--timeout=N]", - "Weekly cleanup wrapper: refresh-md + reconcile + duplicates. With --repo=, steps 1 and 2 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).", + "Weekly cleanup wrapper: refresh-md + reconcile + dedupe-tiers (report-only) + duplicates. With --repo=, steps 1–3 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).", "WEEKLY — runs all three hygiene commands in sequence so you don't have to remember each. Use --repo= to clean up one project without touching the others.", "/work-plan hygiene --repo=myproject"), ("export", "--json", @@ -219,6 +225,10 @@ def _load_version() -> str: "Promote a PRIVATE track (local-only, in notes_root) to the repo's SHARED tier and publish it (#306). Moves the track's `.md` into the repo's `.work-plan/` (on its `plan_branch`, via a worktree), removes the private copy so it isn't duplicated, commits to the plan branch, and pushes — unless `--no-push` (keeps it local). The tier is derived from location, so this is a file move, not a frontmatter edit. Requires the repo to have a local clone + a `plan_branch` (else hints `plan-branch init`). Pushing to a PUBLIC repo makes the track world-visible, so the push is confirm-token gated (prints `needs_confirm` + token; re-run with `--confirm=`).", "When a private track is ready to share with teammates — promote it to the shared plan branch in one step instead of hand-moving the file.", "/work-plan push-track my-feature --repo=myproject"), + ("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. Underlies `brief` cwd auto-scope and the VS Code viewer's repo auto-focus.", + "Rarely run by hand — it's the shared resolver the viewer and `brief` call. Useful to confirm which repo a checkout maps to.", + "/work-plan which-repo --json"), ] diff --git a/vscode/README.md b/vscode/README.md index 5d71f36..d93d41e 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -9,12 +9,13 @@ The human face of the [`work-plan`](https://github.com/stylusnexus/work-plan-too - A **sidebar tree** (repos → tracks) showing the live state of every tracked GitHub repo — status dot, open count + a **closed/total** count (#220), blocked/next hints, a ⚠ badge on public repos, and a per-track **visibility × tier** badge (🔒 private / 🌐 public repo, ☁ shared tier) that flags the one **exposed** state — a plan committed to a *public* repo's shared tier is world-visible. The shared tier can be pinned to a dedicated **canonical plan branch** (set up with the CLI's `plan-branch` command) so planning lives off your code branches and out of PR/deploy diffs; the viewer reads it transparently from any checkout. The Work Plan **activity-bar icon carries a badge** (#215) — blocked-track count, falling back to total open — for at-a-glance status without expanding the view. - A **Mermaid dependency graph** webview + **per-track detail** panel (issue table — capped at 50 rows with a collapsible overflow — blockers, **depends-on chips**, ordered next-up, a **Plan** affordance, and a thin **open/closed progress bar** #220) — with a focus toggle that zooms in on the selected track, and a full map scoped to the track's repo. The graph has **zoom / pan / fit-to-width** controls (scroll-wheel + drag, or the header buttons) and **Export as SVG / PNG** (#216), so a dense map stays navigable. - 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. +- **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. +- 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: -- **Edit fields** (status / priority / milestone / blockers / next-up / **cross-track dependencies**), **Set next-up**, **Slot** an issue, **Move Issue from Track** (source-first: pick a destination track in the same repo), **Close** a track (shipped / parked / abandoned), **Refresh** a track body, **Reconcile** (draft preview), **Run hygiene**, **New track**, and **Push to Shared Tier** (promote a private track to the repo's shared plan branch — confirm modal names the repo and warns when it's public/world-visible; #306). +- **Edit fields** (status / priority / milestone / blockers / next-up / **cross-track dependencies**), **Set next-up**, **Slot** an issue, **Move Issue from Track** (source-first: pick a destination track in the same repo), **Close** a track (shipped / parked / abandoned), **Refresh** a track body, **Reconcile** (draft preview + one-click apply), **Run hygiene**, **New track**, and **Push to Shared Tier** (promote a private track to the repo's shared plan branch — confirm modal names the repo and warns when it's public/world-visible; #306). - **Public-repo confirm modal.** Before any write into a repo that's public (or whose visibility `gh` can't determine), the extension surfaces the CLI's heads-up as a **"Write anyway / Keep private"** dialog and re-invokes with a confirm token — the leak guard, moved from a terminal prompt to a GUI. Private repos write straight through with no friction. - **Close an issue on GitHub (#305).** Right-click an **Untracked** issue → **Close Issue on GitHub…**, or use the **⊗** action on a tracked issue row in the detail panel. Pick a reason (Completed / Not planned), optionally add a closing comment, and confirm a mandatory **"Close on GitHub? — cannot be undone"** modal (fires on *every* close, public or private). For the common case where a PR merged to `dev` left its issue open. - **Mark / clear in-progress on GitHub (#271).** Use the in-progress toggle on a tracked issue row in the detail panel to add or remove the `work-plan:in-progress` label. Public repos go through the confirm-token modal. The viewer also derives in-progress automatically from a hot `feat/-`/`fix/-` branch — the label is for issues with no hot branch yet. These two actions are the only GitHub writes the extension makes; everything else is read-only on GitHub. @@ -95,7 +96,7 @@ The menu is grouped, with a separator between each group: **open the track file* | **Suggest Next-Up (auto)…** | The native equivalent of the CLI's `handoff --auto-next` (#274). Reads an **algorithmic** priority-sorted suggestion (open, non-blocker issues, sibling-claimed ones dropped) via the read-only `handoff --suggest-next`, shows it pre-checked in a multi-select QuickPick (order = priority order), and on confirm writes via the same audited `handoff --set-next` path (public-repo confirm modal, session log). Uncheck any candidate to drop it. No CLI TTY prompt. | | *— separator —* | | | **Sync Issue States from GitHub** | Pull live GitHub state into the track's status table. **Run this after closing or merging issues** — it re-fetches each issue's open/closed state and rewrites the status cells, refreshing the dependency graph and next-up display. Equivalent to `work-plan refresh-md --yes`. | -| **Check Label Drift (preview)** | Read-only draft of where the track's frontmatter membership disagrees with GitHub labels (no writes). Equivalent to `work-plan reconcile ` in draft mode. | +| **Check Label Drift (preview)** | Draft of where the track's frontmatter membership disagrees with GitHub labels. After showing the preview it offers an **Apply reconcile** action that writes the ADDs/MOVEs (`work-plan reconcile --yes`) without a terminal trip — local frontmatter only, MOVEs into public tracks are self-skipped, and a public-repo write trips the leak-guard modal (#221). Skip the action to keep it preview-only. | | *— separator —* | | | **Close Track** | Mark it shipped / parked / abandoned (with an optional wrap-up note); shipped & abandoned get archived. **Abandon** asks for confirmation first (it's the destructive close). | | **Rename Track** | Rename the track's slug — moves its file and updates the frontmatter. Enter a new lowercase slug, then confirm; a public-repo write is additionally gated by the leak-guard modal. | @@ -157,6 +158,7 @@ Before any write into a **public** (or unknown-visibility) repo, a **"Write anyw |---------|---------|-------------| | `workPlan.cliPath` | `"work-plan"` | Path to the `work-plan` CLI launcher. Read at activation — reload the window after changing it. | | `workPlan.expandReposByDefault` | `false` | Expand all repo groups on load (a single-repo workspace always expands). | +| `workPlan.autoFocusRepo` | `true` | When the open workspace folder is a configured repo, default the Tracks 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; switch back to **All tracks** any time. Off → always show every repo. | | `workPlan.autoRefreshInterval` | `0` (off) | Re-poll the CLI silently in the background. Options: 0 (off), 30 s, 60 s, 5 min, 15 min. Useful when teammates are pushing shared-track changes and you want the tree to stay current without manual refreshes. | ## Build & run @@ -187,7 +189,7 @@ The webview loads **`dist/mermaid.min.js`** — the **UMD bundle** from Mermaid ## Status -**Published — v0.12.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.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.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. ## Development notes diff --git a/vscode/package.json b/vscode/package.json index 9cf47f1..9a23ad0 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "name": "work-plan-viewer", "displayName": "Work Plan", "publisher": "stylusnexus", - "version": "0.12.0", + "version": "0.13.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", @@ -637,6 +637,11 @@ "default": false, "description": "Expand all repo groups in the Tracks view on load. Off by default (repos start collapsed); a single-repo workspace always expands." }, + "workPlan.autoFocusRepo": { + "type": "boolean", + "default": true, + "description": "When the open workspace folder is a configured repo, default the Tracks view's lens to that repo (so you don't read another repo's issues by accident). Resolved by clone path, then git remote. A manual lens choice always wins; switch back to 'All tracks' any time. Turn off to always show every repo." + }, "workPlan.autoRefreshInterval": { "type": "number", "default": 0, diff --git a/vscode/src/autofocus.test.ts b/vscode/src/autofocus.test.ts new file mode 100644 index 0000000..cd454b5 --- /dev/null +++ b/vscode/src/autofocus.test.ts @@ -0,0 +1,138 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import type { CliResult, CliRunner, CliRunOpts } from "./cli.ts"; +import { whichRepo } from "./cli.ts"; +import { lensShouldApply, pickAutoFocusSlug } from "./autofocus.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A runner that returns a canned which-repo payload per cwd, recording every + * call's args + cwd. An unmapped cwd yields `{"key": null}` (no match). */ +function cwdRunner( + byCwd: Record>, +): { run: CliRunner; calls: { args: string[]; cwd?: string }[] } { + const calls: { args: string[]; cwd?: string }[] = []; + const run: CliRunner = (args: string[], opts?: CliRunOpts) => { + calls.push({ args, cwd: opts?.cwd }); + const blob = byCwd[opts?.cwd ?? ""]; + const stdout = blob === undefined ? JSON.stringify({ key: null }) : JSON.stringify(blob); + return Promise.resolve({ code: 0, stdout, stderr: "" } as CliResult); + }; + return { run, calls }; +} + +function fixedRunner(result: CliResult): CliRunner { + return (_args: string[], _opts?: CliRunOpts) => Promise.resolve(result); +} + +const WPT = { key: "work-plan-toolkit", github: "stylusnexus/work-plan-toolkit" }; + +// --------------------------------------------------------------------------- +// lensShouldApply — the override decision +// --------------------------------------------------------------------------- + +describe("lensShouldApply", () => { + test("auto applies over a prior auto", () => { + assert.equal(lensShouldApply("auto", "auto"), true); + }); + test("user always applies (over auto)", () => { + assert.equal(lensShouldApply("auto", "user"), true); + }); + test("user applies over a prior user", () => { + assert.equal(lensShouldApply("user", "user"), true); + }); + test("auto does NOT override a user choice", () => { + assert.equal(lensShouldApply("user", "auto"), false); + }); +}); + +// --------------------------------------------------------------------------- +// whichRepo — parsing + cwd plumbing + never-throw +// --------------------------------------------------------------------------- + +describe("whichRepo", () => { + test("parses {key, github} on a match", async () => { + const { run } = cwdRunner({ "/code/wpt": WPT }); + assert.deepEqual(await whichRepo(run, "/code/wpt"), { + key: "work-plan-toolkit", + github: "stylusnexus/work-plan-toolkit", + }); + }); + + test("runs `which-repo --json` with cwd set to the given dir", async () => { + const { run, calls } = cwdRunner({ "/code/wpt": WPT }); + await whichRepo(run, "/code/wpt"); + assert.deepEqual(calls[0].args, ["which-repo", "--json"]); + assert.equal(calls[0].cwd, "/code/wpt"); + }); + + test("key:null → null (no match)", async () => { + const { run } = cwdRunner({}); + assert.equal(await whichRepo(run, "/tmp/elsewhere"), null); + }); + + test("github:null is preserved (slug-less repo)", async () => { + const { run } = cwdRunner({ "/code/x": { key: "x", github: null } }); + assert.deepEqual(await whichRepo(run, "/code/x"), { key: "x", github: null }); + }); + + test("unparseable stdout → null, no throw", async () => { + const run = fixedRunner({ code: 0, stdout: "not json{{", stderr: "" }); + assert.equal(await whichRepo(run, "/code/x"), null); + }); +}); + +// --------------------------------------------------------------------------- +// pickAutoFocusSlug — multi-folder selection +// --------------------------------------------------------------------------- + +describe("pickAutoFocusSlug", () => { + test("returns the github slug of the matched folder", async () => { + const { run } = cwdRunner({ "/code/wpt": WPT }); + assert.equal( + await pickAutoFocusSlug(run, ["/code/wpt"]), + "stylusnexus/work-plan-toolkit", + ); + }); + + test("multi-root: first slug-yielding folder wins", async () => { + const { run } = cwdRunner({ + "/code/wpt": WPT, + "/code/ds": { key: "defect-scan", github: "stylusnexus/defect-scan" }, + }); + // wpt is listed first → its slug is chosen even though ds also matches. + assert.equal( + await pickAutoFocusSlug(run, ["/code/wpt", "/code/ds"]), + "stylusnexus/work-plan-toolkit", + ); + }); + + test("skips a github-null match and takes the next folder's slug", async () => { + const { run } = cwdRunner({ + "/code/slugless": { key: "slugless", github: null }, + "/code/ds": { key: "defect-scan", github: "stylusnexus/defect-scan" }, + }); + assert.equal( + await pickAutoFocusSlug(run, ["/code/slugless", "/code/ds"]), + "stylusnexus/defect-scan", + ); + }); + + test("no folder matches → null", async () => { + const { run } = cwdRunner({}); + assert.equal(await pickAutoFocusSlug(run, ["/tmp/a", "/tmp/b"]), null); + }); + + test("all matches are slug-less → null", async () => { + const { run } = cwdRunner({ "/code/x": { key: "x", github: null } }); + assert.equal(await pickAutoFocusSlug(run, ["/code/x"]), null); + }); + + test("probes each folder with its own cwd", async () => { + const { run, calls } = cwdRunner({}); + await pickAutoFocusSlug(run, ["/a", "/b"]); + assert.deepEqual(calls.map(c => c.cwd), ["/a", "/b"]); + }); +}); diff --git a/vscode/src/autofocus.ts b/vscode/src/autofocus.ts new file mode 100644 index 0000000..f0650f6 --- /dev/null +++ b/vscode/src/autofocus.ts @@ -0,0 +1,34 @@ +// Repo auto-focus decision logic (#357) — pure, no vscode, so it's unit-tested. +// The tree provider and extension activation are thin glue over these. +import type { CliRunner } from "./cli.ts"; +import { whichRepo } from "./cli.ts"; + +/** Who set the active lens. A user choice is sticky; an auto choice is not. */ +export type LensSource = "auto" | "user"; + +/** + * Whether an incoming lens should replace the current one, given who set the + * current lens. The only blocked case is an *auto* attempt over a lens the + * *user* explicitly chose — auto-focus must never fight a human. Everything + * else applies (user always wins; auto applies over a prior auto). + */ +export function lensShouldApply(current: LensSource, incoming: LensSource): boolean { + return !(incoming === "auto" && current === "user"); +} + +/** + * Resolve the GitHub slug to auto-focus from a list of workspace folder paths: + * the first folder that maps to a configured repo *with* a github slug wins. + * Returns null when no folder matches (or every match is slug-less). Folders are + * probed in order, so a multi-root workspace focuses its first known repo. + */ +export async function pickAutoFocusSlug( + run: CliRunner, + folderPaths: string[], +): Promise { + for (const path of folderPaths) { + const resolved = await whichRepo(run, path); + if (resolved && resolved.github) return resolved.github; + } + return null; +} diff --git a/vscode/src/cli.ts b/vscode/src/cli.ts index e379f0d..f3b9ead 100644 --- a/vscode/src/cli.ts +++ b/vscode/src/cli.ts @@ -8,8 +8,15 @@ import type { Export, Issue, IssueDep, PlanStatus } from "./model.ts"; /** Raw result from a single CLI invocation. Never throws; the caller decides. */ export type CliResult = { code: number; stdout: string; stderr: string }; -/** Injectable runner — real spawn in production, fake in tests. */ -export type CliRunner = (args: string[]) => Promise; +/** Per-invocation options. `cwd` targets a specific directory — used by the + * repo auto-focus probe (#357), which runs `which-repo` from each workspace + * folder. Omitted → the CLI runs in the extension host's default cwd. */ +export type CliRunOpts = { cwd?: string }; + +/** Injectable runner — real spawn in production, fake in tests. A fake that + * ignores `opts` (i.e. `(args) => …`) stays assignable, so existing callers and + * test doubles need no change. */ +export type CliRunner = (args: string[], opts?: CliRunOpts) => Promise; // --------------------------------------------------------------------------- // Error type @@ -64,9 +71,11 @@ export function isAlreadyExistsError(err: unknown): boolean { * Rejects only on spawn failure (e.g. ENOENT); non-zero exit resolves normally. */ export function makeSpawnRunner(cliPath: string): CliRunner { - return (args: string[]): Promise => { + return (args: string[], opts?: CliRunOpts): Promise => { return new Promise((resolve, reject) => { - const child = spawn(cliPath, args, { shell: false }); + // cwd: undefined keeps spawn's default (the extension host's cwd), so + // callers that omit opts are unaffected. + const child = spawn(cliPath, args, { shell: false, cwd: opts?.cwd }); let stdout = ""; let stderr = ""; // A non-ENOENT spawn error (EPERM, signal abort) can emit both "error" and @@ -463,6 +472,31 @@ export async function checkAuth(run: CliRunner): Promise { } } +// --------------------------------------------------------------------------- +// which-repo — resolve a directory to a configured repo (#357/#358) +// --------------------------------------------------------------------------- + +/** Parsed `which-repo --json` result. `github` may be null for a repo with no + * configured slug — the viewer can't focus those (the repo lens keys on the + * slug), so they're treated as "no usable match." */ +export type ResolvedRepo = { key: string; github: string | null }; + +/** + * Runs `which-repo --json` with `cwd` set to the given directory and returns the + * resolved repo, or null on no match / non-repo / parse failure. Never throws — + * auto-focus must degrade silently, never break activation. + */ +export async function whichRepo(run: CliRunner, cwd: string): Promise { + try { + const result = await run(["which-repo", "--json"], { cwd }); + const blob = JSON.parse(result.stdout) as Partial<{ key: string | null; github: string | null }>; + if (!blob || blob.key == null) return null; + return { key: blob.key, github: blob.github ?? null }; + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // 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 05edd30..513caea 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -5,6 +5,7 @@ import { notesVcsStatus, notesVcsRun, notesVcsUndo, suggestNextUp, } 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"; @@ -74,6 +75,39 @@ export function activate(context: vscode.ExtensionContext): void { }; context.subscriptions.push(provider.onDidChangeTreeData(syncBadge)); + // ------------------------------------------------------------------------- + // Repo auto-focus (#357): default the tree's lens to the repo of the open + // workspace folder, so you're not reading another repo's issues by accident. + // Probes each folder via `which-repo` (cwd-scoped) and focuses the first that + // resolves to a configured repo with a github slug. setLens(..., "auto") is a + // no-op once the user has picked a lens, so this never fights a manual choice. + // ------------------------------------------------------------------------- + + const autoFocusRepo = async (): Promise => { + const enabled = vscode.workspace + .getConfiguration("workPlan") + .get("autoFocusRepo", true); + if (!enabled) return; + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return; + try { + const slug = await pickAutoFocusSlug(runner, folders.map(f => f.uri.fsPath)); + if (slug) provider.setLens({ kind: "repo", repo: slug }, "auto"); + } catch { + // Auto-focus is a convenience — never surface its failure. + } + }; + + // Re-arm + re-resolve when the workspace folders change (folder opened/closed). + // resetLensSource() clears any prior user override so a deliberate folder switch + // can focus again; a plain refresh() never resets it. + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(() => { + provider.resetLensSource(); + void autoFocusRepo(); + }), + ); + // ------------------------------------------------------------------------- // refreshAndRerender — shared helper: reload CLI data + re-render panel. // Defined here so workPlan.refresh and all write commands share one copy. @@ -1235,31 +1269,47 @@ export function activate(context: vscode.ExtensionContext): void { const track = await resolveTrackName(node); if (!track) return; - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Work Plan: reconciling ${track} (draft)…`, - cancellable: false, - }, - async () => { - const outcome: WriteOutcome = await executeWrite( - runner, - { kind: "reconcileDraft", track }, - confirmPublicWrite, - ); + const outcome: WriteOutcome = await withWriteProgress( + `Work Plan: reconciling ${track} (draft)…`, + () => executeWrite(runner, { kind: "reconcileDraft", track }, confirmPublicWrite), + ); - if (outcome.status === "written") { - outputChannel.clear(); - outputChannel.append(outcome.stdout); - outputChannel.show(true); - vscode.window.showInformationMessage( - "Work Plan: label-drift preview (draft) — see the Work Plan output channel.", - ); - } else { - vscode.window.showInformationMessage("Work Plan: kept private — no change written."); - } - }, + if (outcome.status !== "written") { + vscode.window.showInformationMessage("Work Plan: kept private — no change written."); + return; + } + + outputChannel.clear(); + outputChannel.append(outcome.stdout); + outputChannel.show(true); + + // Offer a one-click apply of the drift the draft just showed (#221) + // instead of forcing a trip to the terminal. The message resolves only + // once the draft progress has cleared, so the spinner doesn't linger + // while we wait for the user's choice. The apply re-runs the analysis + // non-interactively (`--yes`) and self-skips MOVEs into PUBLIC tracks. + const action = await vscode.window.showInformationMessage( + "Work Plan: label-drift preview (draft) — see the Work Plan output channel.", + "Apply reconcile", + ); + if (action !== "Apply reconcile") return; + + const applied: WriteOutcome = await withWriteProgress( + `Work Plan: applying reconcile to ${track}…`, + () => executeWrite(runner, { kind: "reconcileApply", track }, confirmPublicWrite), ); + + if (applied.status === "written") { + outputChannel.clear(); + outputChannel.append(applied.stdout); + outputChannel.show(true); + await refreshAfterWrite(); + vscode.window.showInformationMessage( + `Work Plan: reconcile applied to ${track} — see the Work Plan output channel.`, + ); + } else { + vscode.window.showInformationMessage("Work Plan: kept private — no change written."); + } } catch (err: unknown) { const msg = err instanceof CliError ? `Work Plan: ${err.message}` @@ -2875,6 +2925,9 @@ export function activate(context: vscode.ExtensionContext): void { // the Tracks welcome banner) — no second `gh` call. provider.refresh().then(() => { maybeShowAuthToast(provider.lastAuth); + // Auto-focus AFTER the first load so the repo lens filters against populated + // data immediately (setLens works off the cached export). + void autoFocusRepo(); }, (err: unknown) => { if (err instanceof CliError) { vscode.window.showErrorMessage( diff --git a/vscode/src/model.ts b/vscode/src/model.ts index ae0b796..ac5a9c2 100644 --- a/vscode/src/model.ts +++ b/vscode/src/model.ts @@ -124,6 +124,30 @@ export interface ConfigRepo { visibility: "PUBLIC" | "PRIVATE" | null; } +/** + * A track that exists in BOTH a repo's shared `.work-plan/` tier and the private + * `notes_root` tier (#361) — a private copy left behind after promotion. The CLI + * already resolves the collision ("using shared") but the private orphan keeps + * warning to stderr, which the viewer never sees; this surfaces it as a + * read-only health signal. Resolved with the `dedupe-tiers` CLI verb. + */ +export interface TierDuplicate { + /** GitHub slug "org/repo". */ + repo: string | null; + /** Config repo key (the key under `repos:`), for the `--repo=` hint. */ + folder: string | null; + /** Track name (filename stem, shared on both tiers). */ + name: string; + /** Absolute path to the shared copy (the one that wins). */ + shared_path: string; + /** Absolute path to the private orphan (the one dedupe-tiers would remove). */ + private_path: string; + /** true when the private copy's issue refs are a subset of the shared copy's — + * i.e. dedupe-tiers can remove it with no data loss. false = diverged, needs + * manual review. */ + safe: boolean; +} + /** Root shape emitted by `work-plan export --json`. */ export interface Export { schema: number; @@ -133,6 +157,8 @@ export interface Export { untracked?: { repo: string; issues: Issue[] }[]; /** Every configured repo, regardless of track membership (#288, additive). */ repos?: ConfigRepo[]; + /** Tracks present in both the shared and private tier (#361, additive). */ + tier_duplicates?: TierDuplicate[]; } /** A plan/spec doc with its plan-status verdict (#164). */ diff --git a/vscode/src/tree.ts b/vscode/src/tree.ts index c6e0180..4b4142b 100644 --- a/vscode/src/tree.ts +++ b/vscode/src/tree.ts @@ -1,17 +1,19 @@ 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, StatusCategory, TrackSort } from "./treeModel.ts"; +import type { RepoNode, TrackNode, UntrackedGroupNode, UntrackedIssueNode, EmptyRepoNode, FetchUntrackedNode, TierDupWarningNode, StatusCategory, TrackSort } from "./treeModel.ts"; import { applyLens } from "./webview/lenses.ts"; import type { Lens } from "./webview/lenses.ts"; +import { lensShouldApply } from "./autofocus.ts"; +import type { LensSource } from "./autofocus.ts"; 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 }; +export type { RepoNode, TrackNode, UntrackedGroupNode, UntrackedIssueNode, EmptyRepoNode, FetchUntrackedNode, TierDupWarningNode }; /** Every node kind the Tracks tree can render. */ -type TreeNode = RepoNode | TrackNode | UntrackedGroupNode | UntrackedIssueNode | EmptyRepoNode | FetchUntrackedNode; +type TreeNode = RepoNode | TrackNode | UntrackedGroupNode | UntrackedIssueNode | EmptyRepoNode | FetchUntrackedNode | TierDupWarningNode; // 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. @@ -59,6 +61,9 @@ export class WorkPlanTreeProvider private _filteredCache: Export | null = null; private roots: RepoNode[] = []; private _activeLens: Lens = { kind: "all" }; + // Who set _activeLens (#357). Auto-focus uses this to never override a lens the + // user picked. Starts "auto" so the first activation auto-focus can apply. + private _lensSource: LensSource = "auto"; private _activeSort: TrackSort = "default"; // On-demand open-issue fetches for trackless repos (#303), keyed by github // slug. `export` doesn't emit untracked for repos with no tracks, so the user @@ -144,10 +149,15 @@ export class WorkPlanTreeProvider } /** - * Applies a new lens and re-renders the tree. - * Does not re-fetch from the CLI — works off the cached export. + * Applies a new lens and re-renders the tree (works off the cached export — no + * CLI re-fetch). `source` records who chose it (#357): user choices are sticky, + * so an `"auto"` call is a no-op once the user has set a lens. Defaults to + * `"user"` so every existing call site (the QuickPick, the milestone filter, the + * "All tracks" reset) marks its lens user-chosen without passing the arg. */ - setLens(lens: Lens): void { + setLens(lens: Lens, source: LensSource = "user"): void { + if (!lensShouldApply(this._lensSource, source)) return; + this._lensSource = source; this._activeLens = lens; this._filteredCache = this.cache ? applyLens(this.cache, lens) : null; this.roots = this._applySortToRepos( @@ -159,6 +169,16 @@ export class WorkPlanTreeProvider this._onDidChangeTreeData.fire(); } + /** + * Re-arms auto-focus by clearing a prior user override (#357). Called when the + * workspace folders change, so opening a different folder can auto-focus again. + * State-only — no re-render. A plain refresh() deliberately does NOT call this, + * so a background poll can never clobber the lens the user chose. + */ + resetLensSource(): void { + this._lensSource = "auto"; + } + /** * Fetches fresh data from the CLI and fires a tree refresh. * Concurrent calls coalesce onto the in-flight run (with a trailing run so @@ -232,12 +252,27 @@ export class WorkPlanTreeProvider ? [{ kind: "untrackedGroup", repo: element.repo, issues: element.untracked }] : []; + // Read-only tier-duplicate advisory (#361), pinned at the top of the repo + // so the "exists in both tiers" condition is visible at a glance instead + // of buried in unread stderr. No command — cleanup stays in the CLI. + const tierDupWarn: TierDupWarningNode[] = + element.tierDuplicates.length > 0 + ? [{ + kind: "tierDupWarning", + repo: element.repo, + folder: element.folder, + count: element.tierDuplicates.length, + safeCount: element.tierDuplicates.filter(d => d.safe).length, + }] + : []; + // A configured-but-empty repo (#288): the dimmed "add a track" affordance, // plus — for a real repo (has a slug to query) — an on-demand fetch of its // open issues (#303), since `export` doesn't emit untracked for a trackless // repo. After a fetch, the Untracked bucket renders alongside. if (element.tracks.length === 0) { const children: TreeNode[] = [ + ...tierDupWarn, { kind: "emptyRepo", repo: element.repo, folder: element.folder }, ...untrackedGroup, ]; @@ -251,7 +286,7 @@ export class WorkPlanTreeProvider } return children; } - return [...element.tracks, ...untrackedGroup]; + return [...tierDupWarn, ...element.tracks, ...untrackedGroup]; } if (element.kind === "untrackedGroup") { return element.issues.map( @@ -283,6 +318,9 @@ export class WorkPlanTreeProvider if (element.kind === "fetchUntracked") { return this.roots.find(r => r.repo === element.repo); } + if (element.kind === "tierDupWarning") { + return this.roots.find(r => r.repo === element.repo); + } // untrackedIssue → its group const repoNode = this.roots.find(r => r.repo === element.repo); if (repoNode && repoNode.untracked.length > 0) { @@ -323,9 +361,40 @@ export class WorkPlanTreeProvider if (node.kind === "untrackedIssue") { return this._untrackedIssueTreeItem(node); } + if (node.kind === "tierDupWarning") { + return this._tierDupWarningTreeItem(node); + } return this._trackTreeItem(node); } + private _tierDupWarningTreeItem(node: TierDupWarningNode): vscode.TreeItem { + const item = new vscode.TreeItem( + `${node.count} track${node.count === 1 ? "" : "s"} duplicated across tiers`, + vscode.TreeItemCollapsibleState.None, + ); + const cmd = node.folder ? `dedupe-tiers --repo=${node.folder}` : "dedupe-tiers"; + item.description = cmd; + item.iconPath = new vscode.ThemeIcon( + "warning", + new vscode.ThemeColor("list.warningForeground"), + ); + item.contextValue = "workPlanTierDupWarning"; + const diverged = node.count - node.safeCount; + const md = new vscode.MarkdownString(undefined, true); + md.appendMarkdown( + `$(warning) **${node.count} track${node.count === 1 ? "" : "s"} ` + + `exist in both the shared and private tier.**\n\n` + + `Private copies left behind after a track was promoted to the shared ` + + `\`.work-plan/\` tier. The CLI uses the shared copy; the private orphan is ignored.\n\n` + + `- ${node.safeCount} safe to remove automatically (private issues ⊆ shared)\n` + + `- ${diverged} diverged — need manual review\n\n` + + `Resolve from a terminal:\n\n\`/work-plan ${cmd}\`\n\n` + + `(dry-run report; add \`--apply\` to remove the safe ones)`, + ); + item.tooltip = md; + return item; + } + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- @@ -420,6 +489,7 @@ export class WorkPlanTreeProvider tier: "private", tracks: [], untracked: [], + tierDuplicates: [], folder: node.folder, hasLocal: false, } diff --git a/vscode/src/treeModel.test.ts b/vscode/src/treeModel.test.ts index e2f45b9..b67602f 100644 --- a/vscode/src/treeModel.test.ts +++ b/vscode/src/treeModel.test.ts @@ -394,6 +394,38 @@ describe("buildTree", () => { assert.deepEqual(tree[0].untracked[0], issue); }); + // --- tier_duplicates (#361) --- + + test("MOCKUP_EXPORT (no tier_duplicates key) → all repo nodes have tierDuplicates:[]", () => { + const tree = buildTree(MOCKUP_EXPORT); + for (const node of tree) { + assert.deepEqual(node.tierDuplicates, []); + } + }); + + test("tier_duplicates: matched repo gets its entries; unmatched repo gets []", () => { + const dup = { + repo: "your-org/myproject", folder: "myproject", name: "auth-flow", + shared_path: "/p/myproject/.work-plan/auth-flow.md", + private_path: "/notes/myproject/auth-flow.md", safe: true, + }; + const exp: Export = { + schema: 1, + generated_at: "2026-06-15T00:00:00Z", + tracks: [ + makeTrack({ name: "t1", repo: "your-org/myproject" }), + makeTrack({ name: "t2", repo: "stylusnexus/work-plan-toolkit" }), + ], + tier_duplicates: [dup], + }; + const tree = buildTree(exp); + const myproject = tree.find(n => n.repo === "your-org/myproject")!; + const wpt = tree.find(n => n.repo === "stylusnexus/work-plan-toolkit")!; + assert.equal(myproject.tierDuplicates.length, 1); + assert.strictEqual(myproject.tierDuplicates[0], dup); + assert.deepEqual(wpt.tierDuplicates, []); + }); + // --- configured repos (#288): seeded even with zero tracks --- test("a config repo with no tracks produces a repo node with empty tracks", () => { diff --git a/vscode/src/treeModel.ts b/vscode/src/treeModel.ts index 669c722..f44de5d 100644 --- a/vscode/src/treeModel.ts +++ b/vscode/src/treeModel.ts @@ -1,4 +1,4 @@ -import type { Export, Issue, Track } from "./model.ts"; +import type { Export, Issue, Track, TierDuplicate } from "./model.ts"; // --------------------------------------------------------------------------- // Node types @@ -18,6 +18,21 @@ export interface UntrackedIssueNode { issue: Issue; } +/** + * 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 + * and names the `dedupe-tiers` verb. Has NO command — the destructive cleanup + * stays in the CLI; this only makes the condition visible. + */ +export interface TierDupWarningNode { + kind: "tierDupWarning"; + repo: string; + folder: string | null; + count: number; + /** How many of `count` are safe to auto-remove (private issues ⊆ shared). */ + safeCount: number; +} + export interface TrackNode { kind: "track"; name: string; @@ -49,6 +64,12 @@ export interface RepoNode { * untracked issues or when the CLI did not emit the field (older versions). */ untracked: Issue[]; + /** + * Tracks present in both this repo's shared and private tier (#361). + * Populated from `Export.tier_duplicates`; `[]` when none or when the CLI + * predates the field. Drives the read-only `tierDupWarning` advisory node. + */ + tierDuplicates: TierDuplicate[]; /** * Config repo key (the key under `repos:` in config.yml) for a configured * repo (#288), or null for a track-only repo not present in `Export.repos` @@ -225,6 +246,7 @@ export function buildTree(exp: Export): RepoNode[] { tier: "private", tracks: [], untracked: [], + tierDuplicates: [], folder: cr.folder, hasLocal: cr.has_local, }); @@ -241,6 +263,7 @@ export function buildTree(exp: Export): RepoNode[] { tier: track.tier ?? "private", tracks: [], untracked: [], + tierDuplicates: [], folder: null, hasLocal: false, }); @@ -277,6 +300,8 @@ export function buildTree(exp: Export): RepoNode[] { for (const node of repoMap.values()) { if (node.repo !== "(no repo)") { node.untracked = exp.untracked?.find(u => u.repo === node.repo)?.issues ?? []; + node.tierDuplicates = + exp.tier_duplicates?.filter(d => d.repo === node.repo) ?? []; } } diff --git a/vscode/src/write.test.ts b/vscode/src/write.test.ts index c94c649..4083a84 100644 --- a/vscode/src/write.test.ts +++ b/vscode/src/write.test.ts @@ -123,6 +123,11 @@ describe("actionToArgs", () => { assert.deepEqual(actionToArgs(action), ["reconcile", "--draft", "--", "platform-health"]); }); + test("reconcileApply → ['reconcile', '--yes', '--', track]", () => { + const action: WriteAction = { kind: "reconcileApply", track: "platform-health" }; + assert.deepEqual(actionToArgs(action), ["reconcile", "--yes", "--", "platform-health"]); + }); + test("hygiene → ['hygiene', '--yes'] (no positionals, no separator)", () => { const action: WriteAction = { kind: "hygiene" }; assert.deepEqual(actionToArgs(action), ["hygiene", "--yes"]); diff --git a/vscode/src/write.ts b/vscode/src/write.ts index 5695a14..10af46c 100644 --- a/vscode/src/write.ts +++ b/vscode/src/write.ts @@ -11,6 +11,11 @@ export type WriteAction = | { kind: "setNext"; track: string; issues: number[] } | { kind: "refresh"; track: string } | { kind: "reconcileDraft"; track: string } + // Non-draft reconcile (#221) — applies the label-drift ADDs/MOVEs the draft + // previewed. `--yes` runs it non-interactively; writes are local frontmatter + // only (the read-only-GitHub contract holds), and the CLI self-skips MOVEs into + // PUBLIC destination tracks. Routed through executeWrite for a uniform path. + | { kind: "reconcileApply"; track: string } | { kind: "hygiene" } | { kind: "slot"; track: string; issue: number } | { kind: "batchSlot"; track: string; issues: number[] } @@ -92,6 +97,7 @@ export type WriteOutcome = * setNext → ["handoff", "--set-next=", "--", track] (equals form) * refresh → ["refresh-md", "--yes", "--", track] * reconcileDraft → ["reconcile", "--draft", "--", track] + * reconcileApply → ["reconcile", "--yes", "--", track] * hygiene → ["hygiene", "--yes"] * slot → ["slot", "--no-move", "--", issue, track] * batchSlot → ["batch-slot", "--no-move", "--", ...issues, track] @@ -139,6 +145,9 @@ export function actionToArgs(action: WriteAction): string[] { case "reconcileDraft": return ["reconcile", "--draft", "--", action.track]; + case "reconcileApply": + return ["reconcile", "--yes", "--", action.track]; + case "hygiene": return ["hygiene", "--yes"];