@stylusnexus/work-plan 2026.6.15 → 2026.6.18-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +14 -12
  2. package/VERSION +1 -1
  3. package/package.json +1 -5
  4. package/skills/work-plan/SKILL.md +2 -0
  5. package/skills/work-plan/commands/auto_triage.py +201 -37
  6. package/skills/work-plan/commands/batch_slot.py +58 -33
  7. package/skills/work-plan/commands/brief.py +29 -3
  8. package/skills/work-plan/commands/dedupe_tiers.py +104 -0
  9. package/skills/work-plan/commands/export.py +29 -4
  10. package/skills/work-plan/commands/handoff.py +90 -26
  11. package/skills/work-plan/commands/hygiene.py +24 -9
  12. package/skills/work-plan/commands/set_next_up.py +64 -8
  13. package/skills/work-plan/commands/slot.py +53 -24
  14. package/skills/work-plan/commands/which_repo.py +52 -0
  15. package/skills/work-plan/lib/cwd_repo.py +133 -0
  16. package/skills/work-plan/lib/drift.py +6 -1
  17. package/skills/work-plan/lib/export_model.py +27 -4
  18. package/skills/work-plan/lib/heuristic_triage.py +134 -0
  19. package/skills/work-plan/lib/membership_guard.py +152 -0
  20. package/skills/work-plan/lib/plan_worktree.py +37 -0
  21. package/skills/work-plan/lib/tracks.py +64 -2
  22. package/skills/work-plan/tests/test_auto_triage.py +137 -0
  23. package/skills/work-plan/tests/test_batch_slot.py +19 -2
  24. package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
  25. package/skills/work-plan/tests/test_cwd_repo.py +164 -0
  26. package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
  27. package/skills/work-plan/tests/test_drift.py +32 -0
  28. package/skills/work-plan/tests/test_export.py +63 -0
  29. package/skills/work-plan/tests/test_export_command.py +56 -0
  30. package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
  31. package/skills/work-plan/tests/test_heuristic_triage.py +114 -0
  32. package/skills/work-plan/tests/test_membership_guard.py +116 -0
  33. package/skills/work-plan/tests/test_register_which_repo.py +22 -0
  34. package/skills/work-plan/tests/test_set_next_up.py +95 -0
  35. package/skills/work-plan/tests/test_shared_rebase_guard.py +154 -0
  36. package/skills/work-plan/tests/test_slot.py +94 -2
  37. package/skills/work-plan/tests/test_slot_move.py +10 -1
  38. package/skills/work-plan/work_plan.py +17 -7
  39. package/scripts/npm-check-deps.js +0 -44
package/README.md CHANGED
@@ -48,11 +48,11 @@ The five essentials you'll use 80% of the time are:
48
48
 
49
49
  | Command | When |
50
50
  |---|---|
51
- | `/work-plan brief` | Morning. Multi-track snapshot — what's on your plate across every active track. Add `--repo=<key>` to scope to one project. |
51
+ | `/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=<key>` to scope to a specific project. |
52
52
  | `/work-plan handoff <track>` | 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. |
53
53
  | `/work-plan orient <track>` | Switching context. ~15-line paste-block of priority / last session / next pick / git state — drop into a fresh Claude Code terminal. |
54
54
  | `/work-plan reconcile <track> \| --all \| --repo=<key> [--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=<key>` scopes the sweep to one repo. |
55
- | `/work-plan hygiene [--repo=<key>]` | **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=<key>` scopes steps and ② to one repo; step is skipped in scoped mode. |
55
+ | `/work-plan hygiene [--repo=<key>]` | **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=<key>` scopes steps ①–③ to one repo; step is skipped in scoped mode. |
56
56
  | `/work-plan in-progress <n> [--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=<key\|slug>` to disambiguate. `brief`/`orient`/the VS Code viewer also detect in-progress automatically from a hot `feat/<n>-`/`fix/<n>-` branch. |
57
57
 
58
58
  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:
@@ -104,12 +104,12 @@ flowchart TB
104
104
 
105
105
  - **Morning** → `brief` shows multi-track plate, then `orient <track>` produces a ~15-line paste-block to drop into a fresh agent session.
106
106
  - **End of work block** → `handoff <track>` captures what you touched. Three ways to set `next_up` for tomorrow:
107
- - `handoff <track> --auto-next` — algorithmic (no LLM): top-3 by priority then most-recently-updated, blockers excluded. Interactive `[Y/n/edit]` prompt — accept, edit, or skip.
107
+ - `handoff <track> --auto-next` — algorithmic (no LLM): top-3 by priority then most-recently-updated, blockers excluded. Interactive `[Y/n/edit]` prompt — accept, edit, or skip. (`--suggest-next` is the read-only, non-interactive sibling: it prints the same suggestion as JSON and writes nothing — the feed the VS Code **Suggest Next-Up (auto)** picker confirms, since the TTY prompt can't run under the extension.)
108
108
  - `handoff <track> --set-next 4167,4148` — explicit numbers when you know exactly which issues are next.
109
109
  - 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.
110
110
  - 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.
111
- - **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 <track> --preset=<name>`, or set `next_up_default: <name>` 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 <track> --order=aging,priority,dependency`. Clear a track's override with `--clear`.
112
- - **Weekly** → `hygiene` runs `refresh-md --all` + `reconcile --all` + `duplicates` in sequence to keep status icons, GitHub labels, and dedup state honest.
111
+ - **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 <track> --preset=<name>`, or set `next_up_default: <name>` 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 <track> --order=aging,priority,dependency`. Clear a track's override with `--clear`. Toggle auto-derivation itself with `--auto=on|off` (no hand-editing frontmatter required).
112
+ - **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.
113
113
 
114
114
  > **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 <track>` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly.
115
115
 
@@ -280,7 +280,7 @@ A skill has two distinct contracts: (1) the underlying **CLI** that does the wor
280
280
 
281
281
  | Tool | Install command | Then invoke as |
282
282
  |---|---|---|
283
- | **npm (standalone CLI / any editor)** | `npm install -g @stylusnexus/work-plan` (requires `python3` + `yq` + `gh` already on PATH — the postinstall warns if any are missing). | `work-plan <subcommand>` |
283
+ | **npm (standalone CLI / any editor)** | `npm install -g @stylusnexus/work-plan` (requires `python3` + `yq` + `gh` already on PATH — the CLI warns on first run if any are missing). | `work-plan <subcommand>` |
284
284
  | **Claude Code** | **Plugin (recommended):** `/plugin marketplace add stylusnexus/agent-plugins` → `/plugin install work-plan@stylus-nexus`. Or script: `./install.sh` / `.\install.ps1` | Plugin: `/work-plan:brief` … `/work-plan:run <sub>`. Script: bare `/work-plan <subcommand>` |
285
285
  | **Codex** | **Plugin:** `codex plugin marketplace add stylusnexus/agent-plugins` → `codex plugin add work-plan@stylus-nexus`. Or script: `./install.sh --target=$HOME/.agents` | Plugin: `@work-plan` / `/skills`. Script: direct CLI |
286
286
  | **Cursor** | Skip installer. Clone repo + copy `shims/cursor/work-plan.cursorrules` into your project's `.cursorrules` (or merge it in) | `python3 <toolkit>/skills/work-plan/work_plan.py <sub>` — alias `wp` recommended |
@@ -301,7 +301,7 @@ To install for **both** Claude Code AND Codex, run the installer twice with diff
301
301
 
302
302
  ### VS Code extension
303
303
 
304
- The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks, with per-track open/closed counts and an **activity-bar badge** for blocked/open status), a Mermaid dependency graph (with focus toggle and repo-scoped full map), per-track detail with an **open/closed progress bar** and a one-click **Plan** link, the Untracked bucket, cross-track dependency chips, per-issue move/close buttons, **keyword issue search** (`%wildcard%` substitution, results in a dedicated tab), the daily-driver **Brief / Re-orient / Handoff** commands, an inline **active lens + sort** indicator under the view title, a **Plans view** with confirm-gated frontmatter writes (verdict / acknowledge / drift-baseline) and a fast-fail GitHub-auth banner, and full read/write (slot/close/edit/move/new-track/push-track/…) with a public-repo confirm modal.
304
+ The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks, with per-track open/closed counts and an **activity-bar badge** for blocked/open status), a Mermaid dependency graph (with focus toggle and repo-scoped full map) that draws **GitHub-native blocked-by edges**, per-track detail with an **open/closed progress bar**, a one-click **Plan** link, a **per-issue in-progress badge + toggle**, expandable **blocked-by / blocking dependency chips**, the Untracked bucket, cross-track dependency chips, per-issue move/close buttons, **keyword issue search** (`%wildcard%` substitution, results in a dedicated tab), the daily-driver **Brief / Re-orient / Handoff** commands, **next-up controls** — a **Set Next-Up** button and a **Set Next-Up Order…** preset picker (`flow` / `priority-driven` / `backlog`) with the active preset shown inline, an inline **active lens + sort** indicator under the view title, a **Plans view** with confirm-gated frontmatter writes (verdict / acknowledge / drift-baseline) and a fast-fail GitHub-auth banner, and full read/write (slot/close/edit/move/new-track/push-track/…) with a public-repo confirm modal.
305
305
 
306
306
  ![Work Plan VS Code extension — sidebar and dependency graph](https://raw.githubusercontent.com/stylusnexus/work-plan-toolkit/main/vscode/media/screenshots/dependency-graph.png)
307
307
 
@@ -510,27 +510,29 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
510
510
 
511
511
  | Subcommand | What it does |
512
512
  |---|---|
513
- | `brief [--repo=<key>]` | Multi-track snapshot of all active tracks across configured repos. `--repo=<key>` filters to one project (matches the folder name under `notes_root` or the `org/repo` GitHub slug; archived-reopen callouts are also scoped). |
513
+ | `brief [--repo=<key> \| --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=<key>` 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`. |
514
+ | `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. |
514
515
  | `handoff <track> [--auto-next \| --set-next 1,2,3]` | Wrap up a work block. Writes a `### Session — <ts>` 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`. |
515
516
  | `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. |
516
- | `slot <issue-num> [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=<token>` (public-repo gate, see below). |
517
+ | `slot <issue-num> [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=<token>` (public-repo gate, see below), `--expect=<fp>` (compare-and-swap: the fingerprint of the track's issue list as the caller last saw it — if the on-disk list changed since, the write aborts with a `{stale}` JSON signal instead of clobbering; used by the viewer's assisted-slot flow, #241). The write always re-reads the file and merges onto fresh disk; for a shared-tier track on a `plan_branch` it fetch+rebases the worktree first and aborts with `{needs_rebase}` on an un-rebasable divergence. |
517
518
  | `close <track> [--state=shipped\|parked\|abandoned] [--note=<text>]` | Mark track shipped, parked, or abandoned. Moves to `archive/<state>/` for shipped/abandoned. Pass `--state=` (and an optional `--note=`) to run without prompts. |
518
519
  | `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | 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=<key>` scopes the sweep to one repo. |
519
- | `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `duplicates`. With `--repo=<key>`, steps 1 and 2 scope to that repo and the global `duplicates` step is skipped. |
520
+ | `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `dedupe-tiers` (report-only) + `duplicates`. With `--repo=<key>`, steps 1–3 scope to that repo and the global `duplicates` step is skipped. |
521
+ | `dedupe-tiers [--repo=<key>] [--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=<key>` scopes to one repo. |
520
522
  | `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. |
521
523
  | `init <path> [--priority=P0..P3] [--milestone=<m>]` | Add frontmatter to a brand-new track .md file (the file must already exist). Pass `--priority=`/`--milestone=` to skip the prompts. |
522
524
  | `init-repo <key> --github=<slug> [--local=<path>] [--update [--clear-local]]` | Bootstrap a new repo: create `<notes_root>/<key>/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. |
523
525
  | `remove-repo <key>` | Unregister a repo: delete its block from your config. **Config-only** — the notes folder, any tracks, and the local clone are left untouched (a notes folder or tracks that referenced it are now orphaned and can be removed by hand). Completes the add/update/remove trio with `init-repo`. |
524
526
  | `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
525
527
  | `rename-track <old-slug \| old@repo> <new-slug> [--repo=<key>] [--fix-refs] [--commit]` | Rename an active track's slug: moves its `.md` file and updates the frontmatter `track` field + `last_touched`. Validates `<new-slug>` like `new-track` and rejects a name already taken in the same repo/tier. For shared tracks, `--commit` stages + commits the move (otherwise it prints a "commit to share" hint). `--fix-refs` rewrites sibling tracks' `depends_on` that reference the old slug; without it they're just warned about. Archived tracks are immutable. Public-repo gated. |
526
- | `set-next-up <track> (--preset=<name> \| --order=a,b,c \| --clear) [--repo=<key>] [--confirm=<token>]` | Configure the ranking preset used when `next_up_auto: true` computes a suggestion. `--preset` sets a named preset: `flow` (default — milestone → dependency → priority → recency), `priority-driven` (priority first, ignores milestone bias, for backlogs with no milestones), `backlog` (oldest issues first — surfaces stalled work), or `custom` (requires `--order`). `--order=a,b,c` sets a custom comma-separated criterion list (`milestone`, `dependency`, `priority`, `recency`, `aging`). `--clear` reverts to the global `next_up_default` config or the default `flow`. Writes `next_up_order` into the track's frontmatter; does NOT touch the `next_up` issue-list. Global default: add `next_up_default: <preset>` to `~/.claude/work-plan/config.yml`. Public-repo gated. |
528
+ | `set-next-up <track> (--preset=<name> \| --order=a,b,c \| --clear \| --auto=on\|off) [--repo=<key>] [--confirm=<token>]` | Configure the ranking preset and/or auto-derivation flag for a track's next_up list. `--preset` sets a named preset: `flow` (default — milestone → dependency → priority → recency), `priority-driven` (priority first, ignores milestone bias, for backlogs with no milestones), `backlog` (oldest issues first — surfaces stalled work), or `custom` (requires `--order`). `--order=a,b,c` sets a custom comma-separated criterion list (`milestone`, `dependency`, `priority`, `recency`, `aging`). `--clear` reverts to the global `next_up_default` config or the default `flow`. `--auto=on` activates `next_up_auto` so brief/orient/export auto-derive the next-up list live from the ranking preset (ignoring the curated list); `--auto=off` clears it to revert to the curated list. `--auto` can be used standalone or combined with `--preset`/`--order`/`--clear`. Writes `next_up_order` and/or `next_up_auto` into the track's frontmatter; does NOT touch the `next_up` issue-list. Global default: add `next_up_default: <preset>` to `~/.claude/work-plan/config.yml`. Public-repo gated. |
527
529
  | `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
528
530
  | `notes-vcs <init\|enable\|disable\|status\|undo> [<sha>] [--no-enable] [--json]` | Opt-in **local** version control for the private `notes_root` tier — history/undo for tracks on your machine, never pushed. `init` git-inits `notes_root` as a personal repo (baseline commit of existing tracks) and turns on auto-commit (`--no-enable` skips that). For safety it **refuses** a `notes_root` that already has a git remote or is a repo work-plan didn't create. When on, every track-mutating command (`slot`/`group`/`handoff`/`close`/`set`/…) commits **only the files it changed** (pre-existing uncommitted edits are left alone) as an undoable commit. The shared tier is unaffected — it's versioned by its own repo. `status` shows whether `notes_root` is a repo, whether auto-commit is on, and the last commit (`--json` for the machine shape the VS Code viewer polls). `undo [<sha>]` reverts a commit (default HEAD) — reverses the last edit. |
529
531
  | `push-track <track\|track@repo> [--repo=<key>] [--no-push] [--confirm=<token>]` | **Promote a private track to the shared tier and publish it** (#306). Moves the track's `.md` from `notes_root` 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`. Tier is derived from location, so this is a file move, not a frontmatter edit. Requires a local clone + a configured `plan_branch` (else hints `plan-branch init`). Pushing to a **public** repo makes the track world-visible → confirm-token gated. |
530
532
  | `plan-branch <init\|status\|push> <repo> [--branch=<name>] [--confirm=<token>] [--dry-run] [--json]` | Set up and share a repo's canonical **shared-tier** plan branch. The `.work-plan/` tier is pinned to ONE per-repo `plan_branch`, read/written through a dedicated git worktree, so planning never diverges across code branches or pollutes PR / deploy diffs. `init` creates that branch + a `.work-plan/` skeleton (default an **orphan** `work-plan/plan` — zero shared history with code, like `gh-pages`; override with `--branch`) and records `plan_branch` in config — or **connects** to a teammate's already-published branch if one exists. `init` is **local only** (no push). `status` reports whether the branch exists, is published to origin, and how many commits are unpushed (`--json` for the machine shape). `push` shares it: on a **public** repo it prints a confirm heads-up + token and exits (re-run with `--confirm=<token>`); `--dry-run` previews the commits that would push. Requires a repo registered via `init-repo` with a local clone path. |
531
533
  | `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
532
534
  | `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
533
- | `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
535
+ | `auto-triage [--repo=<key>] [--apply] [--json] [--heuristic] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`); the scan stamps a `batch_id` and writes a per-repo cache file. `--json` emits the batch (+ prompt + answers path) as one JSON object on stdout for the VS Code viewer's Suggested bucket (#241) instead of the human prompt. `--heuristic` (#373) skips the LLM: it scores each issue against the candidate tracks with local signals (milestone match, track-label overlap, title/scope keyword overlap) and writes the v2 answers file itself (`source: "heuristic"`, abstain-first) — so suggestions work with no Claude session (lower-trust, offline). `--apply` accepts the **v2** abstain-first answers shape (`{version,batch_id,suggestions:[{issue,verdict,track,confidence,margin,rationale}]}` — only clear-margin `suggest` verdicts are slotted; abstains/narrow stay untracked) as well as the legacy v1 `[{track,issues}]`. Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
534
536
  | `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
535
537
  | `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews the label drift (ADDs/MOVEs/FLAGs) without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
536
538
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.15+d52d670
1
+ 2026.06.18+d4980a1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.15",
3
+ "version": "2026.6.18-1",
4
4
  "description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
5
5
  "bin": {
6
6
  "work-plan": "bin/work-plan"
@@ -8,14 +8,10 @@
8
8
  "files": [
9
9
  "bin/",
10
10
  "skills/work-plan/",
11
- "scripts/npm-check-deps.js",
12
11
  "VERSION",
13
12
  "LICENSE",
14
13
  "README.md"
15
14
  ],
16
- "scripts": {
17
- "postinstall": "node scripts/npm-check-deps.js"
18
- },
19
15
  "engines": {
20
16
  "node": ">=18"
21
17
  },
@@ -75,6 +75,8 @@ All three follow the same pattern:
75
75
 
76
76
  Show the proposed labels/clusters/assignments BEFORE applying. The user may want to override.
77
77
 
78
+ **`auto-triage` answers — write atomically and copy the `batch_id`.** The VS Code viewer watches the answers file and reads suggestions live (#241), so a half-written file would be read mid-write. Write to a `.tmp` sibling and then rename it onto the final `auto_triage.<repo>.answers.json` path the CLI printed (the Write tool's create-then-replace is fine; never append). Use the **v2** answers shape — `{"version": 2, "batch_id": "<copy from the scan output>", "suggestions": [...]}` — and copy the `batch_id` the scan printed so the viewer can tell fresh suggestions from a stale older scan. Prefer `"verdict": "abstain"` for issues with no clear home: most untracked issues genuinely have none, and a wrong suggestion a human rubber-stamps is worse than silence.
79
+
78
80
  **Which one to use:**
79
81
  - `group` — issues need to be *clustered into new track files* (run once per milestone or after a re-org)
80
82
  - `auto-triage` — untracked issues need to be *assigned to existing tracks* (run after `coverage` shows a gap)
@@ -9,14 +9,30 @@ Two-step (same pattern as `group`):
9
9
  Use --repo=<key> to scope to one configured repo. When the config has a
10
10
  single repo, --repo is inferred automatically.
11
11
 
12
- Answers JSON format (written to cache/auto_triage.answers.json):
13
- [
14
- {"track": "auth-flow", "issues": [4501, 4502]},
15
- {"track": "tabletop-sessions", "issues": [4503]}
16
- ]
17
- Issues omitted from every list are left untracked (no error).
12
+ Answers JSON two accepted shapes (the reader sniffs which one):
13
+
14
+ v1 (legacy, still accepted):
15
+ [
16
+ {"track": "auth-flow", "issues": [4501, 4502]},
17
+ {"track": "tabletop-sessions", "issues": [4503]}
18
+ ]
19
+
20
+ v2 (preferred — abstain-first, per-issue, carries confidence/rationale the
21
+ VS Code viewer renders; #241):
22
+ {"version": 2, "batch_id": "<from the batch file>", "suggestions": [
23
+ {"issue": 4501, "verdict": "suggest", "track": "auth-flow",
24
+ "runner_up": "tabletop-sessions", "confidence": 0.82, "margin": "clear",
25
+ "rationale": "shares milestone v0.4.0 and label area/auth"},
26
+ {"issue": 4507, "verdict": "abstain", "rationale": "no track covers billing"}
27
+ ]}
28
+
29
+ In v2 `--apply` slots only verdict=="suggest" assignments whose margin is "clear"
30
+ (narrow-margin / abstained issues stay untracked — the safe default). Issues
31
+ omitted entirely are left untracked (no error).
18
32
  """
33
+ import hashlib
19
34
  import json
35
+ import os
20
36
  import subprocess
21
37
  import sys
22
38
  from datetime import datetime
@@ -29,36 +45,62 @@ from lib.tracks import discover_tracks
29
45
  from lib.github_state import fetch_open_issues
30
46
 
31
47
 
32
- def _batch_path() -> Path:
33
- return cache_dir() / "auto_triage.json"
48
+ def _repo_slug(repo) -> str:
49
+ """Filesystem-safe slug for a repo's per-repo cache files (#241): two repos
50
+ never collide on the single fixed cache path (a multi-repo clobber race)."""
51
+ return (repo or "").replace("/", "_")
34
52
 
35
53
 
36
- def _answers_path() -> Path:
37
- return cache_dir() / "auto_triage.answers.json"
54
+ def _batch_path(repo=None) -> Path:
55
+ name = f"auto_triage.{_repo_slug(repo)}.json" if repo else "auto_triage.json"
56
+ return cache_dir() / name
38
57
 
39
58
 
40
- PROMPT_TEMPLATE = """\
41
- You have a list of EXISTING tracks and a list of UNTRACKED open issues.
42
- Assign each issue to the most appropriate existing track.
59
+ def _answers_path(repo=None) -> Path:
60
+ name = f"auto_triage.{_repo_slug(repo)}.answers.json" if repo else "auto_triage.answers.json"
61
+ return cache_dir() / name
43
62
 
44
- Return JSON — an array of assignment objects:
45
- [
46
- {"track": "<exact-track-slug>", "issues": [<issue-numbers>]},
47
- ...
48
- ]
63
+
64
+ def _make_batch_id(repo: str) -> str:
65
+ """A short id correlating an answers file to the batch that produced it. The
66
+ viewer checks it so a stale answers file from an older scan isn't rendered as
67
+ current (mtime alone can't tell — answers are always newer than the batch)."""
68
+ stamp = datetime.now().strftime("%Y%m%dT%H%M%S")
69
+ return hashlib.sha256(f"{repo}:{stamp}".encode("utf-8")).hexdigest()[:12]
70
+
71
+
72
+ PROMPT_TEMPLATE = """\
73
+ For EACH untracked issue below, decide whether one of the EXISTING tracks is a
74
+ clearly correct home — and if not, ABSTAIN. Most untracked issues will NOT have
75
+ a clear home; that is normal and correct. Only suggest a track when the issue is
76
+ unmistakably about that track's scope.
77
+
78
+ Return JSON in this exact shape:
79
+ {"version": 2, "batch_id": "<copy the batch_id printed below>", "suggestions": [
80
+ {"issue": <num>, "verdict": "suggest", "track": "<exact-track-slug>",
81
+ "runner_up": "<second-best slug or null>", "confidence": <0.0-1.0>,
82
+ "margin": "clear" | "narrow",
83
+ "rationale": "<the concrete shared signal: a label, milestone, or scope keyword>"},
84
+ {"issue": <num>, "verdict": "abstain", "rationale": "<why no track fits>"}
85
+ ]}
49
86
 
50
87
  Rules:
51
88
  - Use ONLY the track slugs listed under "Existing tracks" below.
52
- - An issue can appear in AT MOST ONE track assignment.
53
- - Omit issues that genuinely don't fit any existing track (they stay untracked).
89
+ - Name your top choice AND your runner-up. If you cannot clearly distinguish
90
+ them, set "margin": "narrow" that means neither is clearly right.
91
+ - "rationale" must cite a CONCRETE shared signal (a label, a milestone, a scope
92
+ keyword). "Generally related" is not a valid reason — abstain instead.
93
+ - When in doubt, ABSTAIN (verdict "abstain", no track). A wrong suggestion a
94
+ human rubber-stamps is worse than no suggestion.
54
95
  - Do NOT invent new tracks — that's /work-plan group's job.
55
- - Do NOT include empty assignments (issues: []).
56
96
 
57
97
  """
58
98
 
59
99
 
60
100
  def run(args: list[str]) -> int:
61
101
  apply_mode = "--apply" in args
102
+ heuristic = "--heuristic" in args
103
+ json_mode = "--json" in args
62
104
  repo_arg = next((a for a in args if a.startswith("--repo=")), None)
63
105
 
64
106
  limit = 100
@@ -77,7 +119,13 @@ def run(args: list[str]) -> int:
77
119
  return 1
78
120
 
79
121
  if apply_mode:
80
- return _apply(cfg)
122
+ # Resolve repo for per-repo cache files; no --repo falls back to the
123
+ # legacy fixed filenames (back-compat with the pre-#241 terminal flow).
124
+ apply_repo = None
125
+ if repo_arg:
126
+ folder = repo_arg.split("=", 1)[1]
127
+ apply_repo = cfg.get("repos", {}).get(folder, {}).get("github")
128
+ return _apply(cfg, apply_repo)
81
129
 
82
130
  # -----------------------------------------------------------------------
83
131
  # Step 1: fetch untracked issues + print AI prompt
@@ -106,7 +154,14 @@ def run(args: list[str]) -> int:
106
154
  and t.meta.get("status") in ("active", "in-progress", "blocked")
107
155
  ]
108
156
  if not active_tracks:
109
- print(f"No active tracks found for {repo}. Run /work-plan group first.")
157
+ # --json callers (the VS Code viewer) parse stdout as JSON, so this
158
+ # informational early-exit must stay machine-readable rather than a bare
159
+ # human line (which crashed Suggest Tracks with a JSON parse error).
160
+ if json_mode:
161
+ print(json.dumps({"repo": repo, "folder": folder,
162
+ "tracks": [], "untracked": [], "note": "no_active_tracks"}))
163
+ else:
164
+ print(f"No active tracks found for {repo}. Run /work-plan group first.")
110
165
  return 0
111
166
 
112
167
  # Build per-repo set of already-tracked issue numbers
@@ -115,36 +170,82 @@ def run(args: list[str]) -> int:
115
170
  if t.repo == repo and t.has_frontmatter:
116
171
  tracked_nums.update(t.meta.get("github", {}).get("issues") or [])
117
172
 
118
- print(f"Fetching open issues from {repo}...")
173
+ # Progress goes to stderr so --json keeps stdout a single clean JSON object.
174
+ print(f"Fetching open issues from {repo}...", file=sys.stderr)
119
175
  open_issues = fetch_open_issues(repo, limit=500)
120
176
  untracked = [i for i in open_issues if i.get("number") not in tracked_nums]
121
177
 
122
178
  if not untracked:
123
- print(f"No untracked issues found for {repo} — full coverage!")
179
+ if json_mode:
180
+ print(json.dumps({"repo": repo, "folder": folder,
181
+ "tracks": [], "untracked": [], "note": "full_coverage"}))
182
+ else:
183
+ print(f"No untracked issues found for {repo} — full coverage!")
124
184
  return 0
125
185
 
126
- batch_path = _batch_path()
127
- batch_path.write_text(json.dumps({
186
+ batch_id = _make_batch_id(repo)
187
+ batch_obj = {
188
+ "batch_id": batch_id,
128
189
  "repo": repo,
129
190
  "folder": folder,
130
191
  "untracked": untracked,
131
192
  "tracks": [{"slug": t.meta.get("track", t.name), "name": t.name,
132
193
  "milestone": t.meta.get("milestone_alignment"),
133
- "priority": t.meta.get("launch_priority")}
194
+ "priority": t.meta.get("launch_priority"),
195
+ # Scope/description grounds the match on what the track is
196
+ # FOR, not just its slug string (#241, ai-engineer review).
197
+ "scope": t.meta.get("scope") or t.meta.get("description") or ""}
134
198
  for t in active_tracks],
135
- }, indent=2))
199
+ }
200
+ batch_path = _batch_path(repo)
201
+ batch_path.write_text(json.dumps(batch_obj, indent=2))
202
+
203
+ # --heuristic (#373): compute suggestions deterministically (no LLM) and
204
+ # write the v2 answers file ourselves, so the Suggested bucket works with no
205
+ # Claude session. Same schema the LLM path produces, stamped
206
+ # source:"heuristic" so the viewer can flag it lower-trust. Atomic
207
+ # (.tmp + os.replace) since the viewer watches this path live.
208
+ if heuristic:
209
+ from lib.heuristic_triage import score_suggestions
210
+ suggestions = score_suggestions(
211
+ untracked,
212
+ [{"slug": t.meta.get("track", t.name), "name": t.name,
213
+ "milestone": t.meta.get("milestone_alignment"),
214
+ "scope": t.meta.get("scope") or t.meta.get("description") or "",
215
+ "labels": (t.meta.get("github", {}) or {}).get("labels") or []}
216
+ for t in active_tracks],
217
+ )
218
+ answers_path = _answers_path(repo)
219
+ tmp = answers_path.with_suffix(answers_path.suffix + ".tmp")
220
+ tmp.write_text(json.dumps(
221
+ {"version": 2, "source": "heuristic", "batch_id": batch_id,
222
+ "suggestions": suggestions}, indent=2))
223
+ os.replace(tmp, answers_path)
224
+
225
+ # --json: emit the batch (+ prompt + answers path) as one machine-readable
226
+ # object for the VS Code viewer, which captures batch_id to correlate the
227
+ # answers a Claude session writes back (#241). No human prose on stdout.
228
+ if "--json" in args:
229
+ print(json.dumps({**batch_obj,
230
+ "prompt": PROMPT_TEMPLATE,
231
+ "answers_path": str(_answers_path(repo))}))
232
+ return 0
136
233
 
137
234
  print(f"Found {len(untracked)} untracked issues ({len(active_tracks)} active tracks).")
138
235
  print()
139
236
  print("=" * 60)
140
237
  print(PROMPT_TEMPLATE)
141
238
 
239
+ print(f"batch_id: {batch_id} (copy into the answers JSON)")
240
+ print()
142
241
  print("Existing tracks:")
143
242
  for t in active_tracks:
144
243
  slug = t.meta.get("track", t.name)
145
244
  milestone = t.meta.get("milestone_alignment", "—")
146
245
  priority = t.meta.get("launch_priority", "—")
147
- print(f" {slug} [{priority}, {milestone}]")
246
+ scope = t.meta.get("scope") or t.meta.get("description") or ""
247
+ scope_txt = f" — {scope}" if scope else ""
248
+ print(f" {slug} [{priority}, {milestone}]{scope_txt}")
148
249
 
149
250
  print()
150
251
  print("Untracked issues to assign:")
@@ -162,16 +263,75 @@ def run(args: list[str]) -> int:
162
263
 
163
264
  print("=" * 60)
164
265
  print()
165
- print(f"After the agent returns assignment JSON, save it to:")
166
- print(f" {_answers_path()}")
266
+ if heuristic:
267
+ print(f"Heuristic suggestions written to:")
268
+ print(f" {_answers_path(repo)}")
269
+ print("They appear in the VS Code Suggested bucket, or apply from the terminal:")
270
+ print(f" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply --repo={folder}")
271
+ return 0
272
+ print(f"After the agent returns assignment JSON, save it (atomically — write")
273
+ print(f"a .tmp then rename) to:")
274
+ print(f" {_answers_path(repo)}")
167
275
  print("Then run:")
168
- print(" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply")
276
+ print(f" python3 ~/.claude/skills/work-plan/work_plan.py auto-triage --apply --repo={folder}")
169
277
  return 0
170
278
 
171
279
 
172
- def _apply(cfg: dict) -> int:
173
- answers_path = _answers_path()
174
- batch_path = _batch_path()
280
+ def _normalize_answers(answers, batch_id=None):
281
+ """Collapse either answers shape into v1 assignment objects [{track, issues}].
282
+
283
+ - v2 (dict with "suggestions"): keep only verdict=="suggest" whose margin is
284
+ not "narrow" (abstains and narrow-margin items stay untracked — the safe
285
+ default), group by track. confidence/rationale are for the viewer; the
286
+ write ignores them.
287
+ - v1 (list): passed through.
288
+
289
+ The file is model-authored/untrusted, so every field is hardened: issue
290
+ numbers int-coerced, malformed entries skipped, unknown shapes ignored.
291
+ Returns (assignments, batch_mismatch: bool).
292
+ """
293
+ mismatch = False
294
+ if isinstance(answers, dict) and "suggestions" in answers:
295
+ if batch_id and answers.get("batch_id") and answers["batch_id"] != batch_id:
296
+ mismatch = True
297
+ by_track: dict = {}
298
+ for s in answers.get("suggestions") or []:
299
+ if not isinstance(s, dict):
300
+ continue
301
+ if s.get("verdict") != "suggest":
302
+ continue
303
+ if s.get("margin") == "narrow":
304
+ continue
305
+ slug = (s.get("track") or "").strip()
306
+ if not slug:
307
+ continue
308
+ try:
309
+ num = int(s.get("issue"))
310
+ except (TypeError, ValueError):
311
+ continue
312
+ by_track.setdefault(slug, []).append(num)
313
+ return ([{"track": k, "issues": v} for k, v in by_track.items()], mismatch)
314
+
315
+ # v1 legacy list.
316
+ out = []
317
+ for a in answers if isinstance(answers, list) else []:
318
+ if not isinstance(a, dict):
319
+ continue
320
+ slug = (a.get("track") or "").strip()
321
+ nums = []
322
+ for n in a.get("issues") or []:
323
+ try:
324
+ nums.append(int(n))
325
+ except (TypeError, ValueError):
326
+ continue
327
+ if slug and nums:
328
+ out.append({"track": slug, "issues": nums})
329
+ return (out, mismatch)
330
+
331
+
332
+ def _apply(cfg: dict, repo: str = None) -> int:
333
+ answers_path = _answers_path(repo)
334
+ batch_path = _batch_path(repo)
175
335
  if not answers_path.exists():
176
336
  print(f"ERROR: {answers_path} not found. Run without --apply first.")
177
337
  return 1
@@ -186,7 +346,11 @@ def _apply(cfg: dict) -> int:
186
346
  print(f"ERROR: batch folder '{folder}' not in config.yml repos.")
187
347
  return 1
188
348
 
189
- answers = json.loads(answers_path.read_text())
349
+ raw_answers = json.loads(answers_path.read_text())
350
+ answers, batch_mismatch = _normalize_answers(raw_answers, batch.get("batch_id"))
351
+ if batch_mismatch:
352
+ print("⚠ answers batch_id does not match the current batch — these "
353
+ "suggestions may be from an older scan. Re-run without --apply to refresh.")
190
354
 
191
355
  tracks = discover_tracks(cfg)
192
356
  tracks_by_slug = {}
@@ -1,10 +1,11 @@
1
1
  """batch-slot subcommand — slot multiple issues into a track at once."""
2
2
  import json
3
3
  import subprocess
4
+ import sys
4
5
 
5
6
  from lib.config import load_config, ConfigError
6
7
  from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
7
- from lib.frontmatter import write_file
8
+ from lib.membership_guard import guarded_membership_write, shared_rebase_guard
8
9
  from lib.write_guard import needs_confirm, make_token, valid_token
9
10
  from lib.prompts import parse_flags
10
11
 
@@ -25,7 +26,7 @@ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
25
26
 
26
27
  def run(args: list[str]) -> int:
27
28
  flags, positional = parse_flags(
28
- args, {"--confirm", "--move", "--no-move", "--repo"}
29
+ args, {"--confirm", "--move", "--no-move", "--repo", "--expect"}
29
30
  )
30
31
 
31
32
  if len(positional) < 2:
@@ -99,8 +100,22 @@ def run(args: list[str]) -> int:
99
100
  )
100
101
  return 0
101
102
 
103
+ # Shared-tier rebase (#241): pull + rebase the plan_branch worktree onto
104
+ # origin before writing; an un-rebasable divergence aborts cleanly.
105
+ ok, reason = shared_rebase_guard(target, cfg)
106
+ if not ok:
107
+ print(json.dumps({"needs_rebase": True, "reason": reason, "track": target.name}))
108
+ return 0
109
+
102
110
  do_move = "--move" in flags
103
111
 
112
+ # --expect=<fp> opts into the compare-and-swap staleness guard (#241). When
113
+ # present (the assisted/viewer path) advisory notes go to stderr so stdout
114
+ # stays pure for the {stale} abort signal the caller parses.
115
+ expect = flags.get("--expect")
116
+ expect = expect if isinstance(expect, str) else None
117
+ notes = sys.stderr if expect is not None else sys.stdout
118
+
104
119
  # Collect source tracks that need issue removal (consolidated per source).
105
120
  source_removals: dict[str, tuple] = {} # source_name -> (source_track, set[issue_num])
106
121
 
@@ -113,24 +128,29 @@ def run(args: list[str]) -> int:
113
128
  skipped.append(issue_num)
114
129
  continue
115
130
 
116
- # Milestone mismatch check (non-blocking warning).
117
- proc = subprocess.run(
118
- ["gh", "issue", "view", str(issue_num),
119
- "--repo", target.repo, "--json", "milestone"],
120
- capture_output=True, text=True,
121
- )
122
- if proc.returncode == 0:
123
- info = json.loads(proc.stdout)
124
- m = info.get("milestone", {})
125
- if (
126
- m and m.get("title")
127
- and m["title"] != target.meta.get("milestone_alignment")
128
- ):
129
- print(
130
- f"⚠ #{issue_num} is on milestone '{m['title']}', "
131
- f"track '{target.name}' aligned to"
132
- f" '{target.meta.get('milestone_alignment')}'."
133
- )
131
+ # Milestone mismatch check (non-blocking warning). Never let gh being
132
+ # absent/odd crash the command — it's advisory and sits before the write.
133
+ try:
134
+ proc = subprocess.run(
135
+ ["gh", "issue", "view", str(issue_num),
136
+ "--repo", target.repo, "--json", "milestone"],
137
+ capture_output=True, text=True,
138
+ )
139
+ if proc.returncode == 0:
140
+ info = json.loads(proc.stdout)
141
+ m = info.get("milestone", {})
142
+ if (
143
+ m and m.get("title")
144
+ and m["title"] != target.meta.get("milestone_alignment")
145
+ ):
146
+ print(
147
+ f"⚠ #{issue_num} is on milestone '{m['title']}', "
148
+ f"track '{target.name}' aligned to"
149
+ f" '{target.meta.get('milestone_alignment')}'.",
150
+ file=notes,
151
+ )
152
+ except (OSError, json.JSONDecodeError):
153
+ pass
134
154
 
135
155
  # Prior-owner detection.
136
156
  sources = _find_prior_owners(
@@ -149,7 +169,8 @@ def run(args: list[str]) -> int:
149
169
  names = ", ".join(f"'{t.name}'" for t in sources)
150
170
  print(
151
171
  f"ℹ #{issue_num} still listed in {names}"
152
- f" — re-run with --move to relocate."
172
+ f" — re-run with --move to relocate.",
173
+ file=notes,
153
174
  )
154
175
 
155
176
  if not slotted:
@@ -160,21 +181,25 @@ def run(args: list[str]) -> int:
160
181
  )
161
182
  return 0
162
183
 
163
- # Write source tracks (consolidated removals).
184
+ # Write source tracks (consolidated removals), each re-read + merged onto
185
+ # fresh disk so a concurrent edit to a source track isn't clobbered.
164
186
  if do_move:
165
187
  for src_name, (src, removals) in source_removals.items():
166
- src_issues = [
167
- n for n in (src.meta.get("github", {}).get("issues") or [])
168
- if n not in removals
169
- ]
170
- src.meta.setdefault("github", {})["issues"] = src_issues
171
- write_file(src.path, src.meta, src.body)
188
+ guarded_membership_write(src.path, remove_nums=removals)
172
189
  removed_str = ", ".join(f"#{n}" for n in sorted(removals))
173
- print(f" ✓ Removed {removed_str} from '{src_name}'.")
174
-
175
- # Write target track once.
176
- target.meta.setdefault("github", {})["issues"] = sorted(issues)
177
- write_file(target.path, target.meta, target.body)
190
+ print(f" ✓ Removed {removed_str} from '{src_name}'.", file=notes)
191
+
192
+ # Write target track once. Carries `expect`: on a detected concurrent change
193
+ # to the membership list it aborts with {stale} instead of clobbering.
194
+ result = guarded_membership_write(target.path, add_nums=slotted, expect=expect)
195
+ if result.get("stale"):
196
+ print(json.dumps({
197
+ "stale": True,
198
+ "reason": result["reason"],
199
+ "current": result["current"],
200
+ "track": target.name,
201
+ }))
202
+ return 0
178
203
 
179
204
  slotted_str = ", ".join(f"#{n}" for n in slotted)
180
205
  print(f"✓ Slotted {slotted_str} into '{target.name}'.")