@stylusnexus/work-plan 2026.6.15 → 2026.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,20 +510,22 @@ 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
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). |
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. |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.15+d52d670
1
+ 2026.06.16+ebd6045
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.16",
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
  },
@@ -1,8 +1,10 @@
1
1
  """brief subcommand — fully featured."""
2
+ import os
2
3
  from datetime import datetime
3
4
  from pathlib import Path
4
5
 
5
6
  from lib.config import load_config, ConfigError
7
+ from lib.cwd_repo import resolve_repo_for_dir
6
8
  from lib.tracks import (
7
9
  discover_tracks, discover_archived_tracks, filter_tracks_by_repo,
8
10
  priority_rank, recency_sort_key,
@@ -24,9 +26,9 @@ from lib.render import time_aware_framing, render_track_row, render_archived_reo
24
26
 
25
27
  def run(args: list[str]) -> int:
26
28
  flags, _ = parse_flags(args, {"--repo"})
27
- repo_key = flags.get("--repo")
28
- if repo_key is True:
29
- print("usage: work_plan.py brief [--repo=<key>]")
29
+ repo_arg = flags.get("--repo")
30
+ if repo_arg is True:
31
+ print("usage: work_plan.py brief [--repo=<key> | --repo=all]")
30
32
  return 2
31
33
 
32
34
  try:
@@ -36,15 +38,39 @@ def run(args: list[str]) -> int:
36
38
  return 1
37
39
 
38
40
  tracks = discover_tracks(cfg)
41
+
42
+ # Resolve the effective scope key ONCE, then thread it through both the track
43
+ # filter and the archived-reopen pass so the two can never diverge (#358):
44
+ # explicit --repo=<key> → scope to it (unchanged behavior, no banner)
45
+ # explicit --repo=all → force the full view (no scope, no auto-detect)
46
+ # --repo omitted → auto-detect from cwd (unless opted out); banner on hit
47
+ auto_scoped = False
48
+ if isinstance(repo_arg, str):
49
+ repo_key = None if repo_arg.lower() == "all" else repo_arg
50
+ else:
51
+ repo_key = None
52
+ if cfg.get("brief_auto_scope", True):
53
+ match = resolve_repo_for_dir(cfg, os.getcwd())
54
+ # Only auto-scope when the detected repo actually has tracks — a
55
+ # convenience must never hide tracks you'd otherwise see.
56
+ if match and filter_tracks_by_repo(tracks, match["key"]):
57
+ repo_key = match["key"]
58
+ auto_scoped = True
59
+
39
60
  if repo_key:
40
61
  scoped = filter_tracks_by_repo(tracks, repo_key)
41
62
  if not scoped:
63
+ # Reachable only via an explicit --repo (the auto path above already
64
+ # guaranteed a non-empty match).
42
65
  print(f"No tracks found for repo '{repo_key}'.")
43
66
  available = sorted((cfg.get("repos") or {}).keys())
44
67
  if available:
45
68
  print(f"Configured repo keys: {', '.join(available)}")
46
69
  return 0
47
70
  tracks = scoped
71
+ if auto_scoped:
72
+ print(f"Scoped to repo '{repo_key}' (cwd). Use --repo=all to see everything.")
73
+ print()
48
74
  active = [t for t in tracks if t.has_frontmatter
49
75
  and t.meta.get("status") in ("active", "in-progress", "blocked")]
50
76
 
@@ -0,0 +1,104 @@
1
+ """dedupe-tiers — remove private track copies shadowed by a shared twin (#359).
2
+
3
+ When a track is promoted to the shared tier (a repo's `.work-plan/`), the private
4
+ original under `notes_root` is normally moved out by `push-track`. But bulk or
5
+ manual promotion — or a failed unlink mid-promote — leaves the private copy
6
+ behind. `discover_tracks` then resolves the collision ("using shared") but warns
7
+ on EVERY invocation, with no built-in way to clean up.
8
+
9
+ This verb removes the orphaned private copies that their shared twin supersedes,
10
+ and REFUSES to touch any whose private copy still references issue numbers the
11
+ shared one lacks — so no tracked work is ever silently dropped. The no-data-loss
12
+ invariant is `issue_refs(private) ⊆ issue_refs(shared)`.
13
+
14
+ Default is a dry-run report. Pass `--apply` to delete the safe orphans; the
15
+ deletion lands in notes_root and the dispatcher's auto-commit makes it undoable.
16
+
17
+ Usage:
18
+ work_plan.py dedupe-tiers [--repo=<key>] [--apply]
19
+ """
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from lib.config import load_config, ConfigError
24
+ from lib.tracks import find_tier_duplicates, issue_refs
25
+ from lib.prompts import parse_flags
26
+
27
+ KNOWN = {"--repo", "--apply"}
28
+
29
+
30
+ def run(args: list) -> int:
31
+ flags, _ = parse_flags(args, KNOWN)
32
+ repo_key = flags.get("--repo")
33
+ if repo_key is True:
34
+ print("usage: work_plan.py dedupe-tiers [--repo=<key>] [--apply]",
35
+ file=sys.stderr)
36
+ return 2
37
+ apply = bool(flags.get("--apply"))
38
+
39
+ try:
40
+ cfg = load_config()
41
+ except ConfigError as e:
42
+ print(f"ERROR: {e}", file=sys.stderr)
43
+ return 1
44
+
45
+ pairs = find_tier_duplicates(cfg)
46
+ if repo_key:
47
+ k = repo_key.lower()
48
+ pairs = [(s, p) for (s, p) in pairs
49
+ if (s.folder and s.folder.lower() == k)
50
+ or (s.repo and s.repo.lower() == k)]
51
+
52
+ if not pairs:
53
+ scope = f" for repo '{repo_key}'" if repo_key else ""
54
+ print(f"No shared/private duplicate tracks found{scope}. Nothing to dedupe.")
55
+ return 0
56
+
57
+ safe: list = [] # (shared, private) — private issue refs ⊆ shared
58
+ diverged: list = [] # (shared, private, extra_refs) — private has unique refs
59
+ for s, p in pairs:
60
+ extra = issue_refs(p) - issue_refs(s)
61
+ if extra:
62
+ diverged.append((s, p, extra))
63
+ else:
64
+ safe.append((s, p))
65
+
66
+ print(f"Found {len(pairs)} shared/private duplicate track(s):")
67
+ print(f" {len(safe)} safe to remove (private issue refs ⊆ shared)")
68
+ print(f" {len(diverged)} diverged — kept for manual review")
69
+ print()
70
+
71
+ for s, p in safe:
72
+ print(f" ✓ {p.name} (repo {s.repo or s.folder}) — private superseded by shared")
73
+ print(f" private: {p.path}")
74
+ for s, p, extra in diverged:
75
+ refs = ", ".join(f"#{n}" for n in sorted(extra))
76
+ print(f" ⚠ {p.name} (repo {s.repo or s.folder}) — private has issue refs "
77
+ f"not in shared: {refs}")
78
+ print(f" KEPT: {p.path} — reconcile by hand")
79
+
80
+ if not apply:
81
+ print()
82
+ if safe:
83
+ print(f"Dry run. Re-run with --apply to remove {len(safe)} private orphan(s).")
84
+ else:
85
+ print("Dry run. Nothing safe to remove automatically.")
86
+ return 0
87
+
88
+ if not safe:
89
+ print()
90
+ print("Nothing removed — every duplicate diverged and needs manual review.")
91
+ return 0
92
+
93
+ removed = 0
94
+ for s, p in safe:
95
+ try:
96
+ Path(p.path).unlink()
97
+ removed += 1
98
+ except OSError as e:
99
+ print(f"WARN: could not remove {p.path}: {e}", file=sys.stderr)
100
+
101
+ print()
102
+ print(f"Removed {removed} private orphan(s). "
103
+ f"{len(diverged)} diverged track(s) left for manual review.")
104
+ return 0
@@ -2,7 +2,7 @@
2
2
  import json
3
3
  from datetime import datetime, date
4
4
  from lib.config import load_config, ConfigError, resolve_local_path_for_folder
5
- from lib.tracks import discover_tracks
5
+ from lib.tracks import discover_tracks, find_tier_duplicates, issue_refs
6
6
  from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
7
7
  from lib.git_state import hot_issue_numbers
8
8
  from lib.export_model import build_export
@@ -93,11 +93,19 @@ def run(args: list[str]) -> int:
93
93
  visibility[t.repo] = repo_visibility(t.repo)
94
94
 
95
95
  # Compute untracked: open issues not referenced by any track, per repo.
96
+ # Iterate over every repo that has ANY track — NOT just repos in
97
+ # repo_to_numbers (which only collects tracks whose github.issues is
98
+ # non-empty). A repo whose only track has `issues: []` must still get its
99
+ # open issues surfaced as untracked; otherwise creating an empty track in a
100
+ # previously-trackless repo makes its open issues vanish — neither in the
101
+ # (empty) track nor in untracked, and the viewer's trackless fallback
102
+ # (treeModel.mergeFetchedUntracked) shuts off the moment a track exists (#342).
96
103
  # One `gh issue list` call per repo — bounded by the number of tracked repos
97
104
  # (typically a handful), not by issue count, so a serial loop is fine.
105
+ tracked_repos = {t.repo for t in tracks if t.repo}
98
106
  untracked_by_repo: dict[str, list] = {}
99
- for repo in repo_to_numbers:
100
- tracked = set(repo_to_numbers[repo])
107
+ for repo in tracked_repos:
108
+ tracked = set(repo_to_numbers.get(repo, []))
101
109
  open_rows = fetch_open_issues(repo)
102
110
  untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
103
111
 
@@ -140,6 +148,22 @@ def run(args: list[str]) -> int:
140
148
  if nums:
141
149
  hot_by_track[(t.repo, t.name)] = nums
142
150
 
151
+ # Shared/private tier duplicates (#361): the viewer is otherwise blind to
152
+ # them — discover_tracks drops the private copy with a stderr-only WARN. We
153
+ # surface them as a read-only health signal; `safe` mirrors dedupe-tiers'
154
+ # no-data-loss invariant (private issue refs ⊆ shared), so the viewer can
155
+ # tell auto-removable orphans from diverged ones needing manual review.
156
+ tier_duplicates = []
157
+ for shared_t, private_t in find_tier_duplicates(cfg):
158
+ tier_duplicates.append({
159
+ "repo": shared_t.repo,
160
+ "folder": shared_t.folder,
161
+ "name": shared_t.name,
162
+ "shared_path": str(shared_t.path),
163
+ "private_path": str(private_t.path),
164
+ "safe": issue_refs(private_t) <= issue_refs(shared_t),
165
+ })
166
+
143
167
  next_up_default = cfg.get("next_up_default")
144
168
  now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
145
169
  print(json.dumps(
@@ -148,7 +172,8 @@ def run(args: list[str]) -> int:
148
172
  config_repos=config_repos,
149
173
  plan_by_track=plan_by_track,
150
174
  hot_by_track=hot_by_track,
151
- next_up_default=next_up_default),
175
+ next_up_default=next_up_default,
176
+ tier_duplicates=tier_duplicates),
152
177
  indent=2,
153
178
  ))
154
179
  return 0
@@ -9,6 +9,7 @@ Use --interactive (or -i) for the legacy blank-prompt mode where you fill in
9
9
  each section by hand.
10
10
  """
11
11
  import fnmatch
12
+ import json
12
13
  import subprocess
13
14
  from datetime import datetime, timedelta
14
15
 
@@ -30,9 +31,10 @@ from lib.prompts import prompt_lines, parse_flags, prompt_input
30
31
 
31
32
 
32
33
  def run(args: list[str]) -> int:
33
- flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--repo"})
34
+ flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--suggest-next", "--repo"})
34
35
  interactive = flags.get("--interactive", False) or flags.get("-i", False)
35
36
  auto_next = flags.get("--auto-next", False)
37
+ suggest_next = flags.get("--suggest-next", False)
36
38
 
37
39
  # Support both --set-next=4167,4148 (parse_flags handles via key=value) and
38
40
  # --set-next 4167,4148 (space-separated). For the space form, parse_flags
@@ -80,6 +82,12 @@ def run(args: list[str]) -> int:
80
82
  if not track:
81
83
  return 1
82
84
 
85
+ # --suggest-next: read-only. Compute the algorithmic next_up suggestion and
86
+ # emit it as JSON for a non-CLI caller (the VS Code native picker, #274) to
87
+ # render + confirm. No prompt, no write — the caller writes via --set-next.
88
+ if suggest_next:
89
+ return _suggest_next_json(track, cfg)
90
+
83
91
  # Apply --set-next first if present, so derived/interactive output reflects it.
84
92
  if isinstance(set_next_raw, str):
85
93
  rc = _apply_set_next(track, set_next_raw, cfg)
@@ -129,6 +137,81 @@ def _apply_set_next(track, raw: str, cfg: dict) -> int:
129
137
  return 0
130
138
 
131
139
 
140
+ def _compute_auto_next(track, cfg: dict):
141
+ """Compute the sibling-filtered next_up suggestion (read-only — no prompt,
142
+ no write). Shared by the interactive --auto-next flow and the --suggest-next
143
+ JSON output (#274).
144
+
145
+ Returns (suggestion, by_num, skipped, had_raw):
146
+ - suggestion: list[int] — ordered issue numbers to queue
147
+ - by_num: {int: issue} — fetched issues keyed by number (for decoration)
148
+ - skipped: list[(int, str)] — (issue, sibling_track) dropped as already-claimed
149
+ - had_raw: bool — whether the algorithm produced ANY candidate before the
150
+ sibling filter (distinguishes "no open non-blocker issues" from
151
+ "all candidates claimed by siblings"). Caller guarantees track.repo and a
152
+ non-empty issue list.
153
+ """
154
+ issue_nums = track.meta.get("github", {}).get("issues") or []
155
+ issues = fetch_issues(track.repo, issue_nums)
156
+ blocker_nums = track.meta.get("blockers") or []
157
+ track_milestone = track.meta.get("milestone_alignment") or None
158
+ hot_nums = hot_issue_numbers(track.local_path) if track.local_path else set()
159
+ in_progress_set = {i["number"] for i in issues if issue_in_progress(i, hot_nums)}
160
+ _, order = resolve_next_up_order(track.meta, cfg.get("next_up_default"))
161
+ raw_suggestion = suggest_next_up(
162
+ issues, blocker_nums,
163
+ track_milestone=track_milestone,
164
+ in_progress_nums=in_progress_set,
165
+ order=order,
166
+ )
167
+ claimed = _sibling_claimed_next_up_map(track, cfg)
168
+ suggestion, skipped = [], []
169
+ for num in raw_suggestion:
170
+ if num in claimed:
171
+ skipped.append((num, claimed[num]))
172
+ else:
173
+ suggestion.append(num)
174
+ by_num = {i["number"]: i for i in issues}
175
+ return suggestion, by_num, skipped, bool(raw_suggestion)
176
+
177
+
178
+ def _suggest_next_json(track, cfg: dict) -> int:
179
+ """Emit the algorithmic next_up suggestion as JSON for a non-CLI caller (#274).
180
+
181
+ Read-only: never prompts or writes. Soft errors (no repo / no issues) are
182
+ reported in the payload with an empty `suggested`, so the caller always gets
183
+ parseable JSON and exit 0; hard errors (track resolution) are handled upstream
184
+ in run() before this is reached.
185
+ """
186
+ out = {
187
+ "track": track.name,
188
+ "repo": track.repo,
189
+ "current": track.meta.get("next_up") or [],
190
+ "suggested": [],
191
+ "skipped": [],
192
+ }
193
+ if not track.repo:
194
+ out["error"] = f"track '{track.name}' has no github.repo"
195
+ print(json.dumps(out))
196
+ return 0
197
+ if not (track.meta.get("github", {}).get("issues") or []):
198
+ out["error"] = f"no issues attached to '{track.name}'"
199
+ print(json.dumps(out))
200
+ return 0
201
+ suggestion, by_num, skipped, _ = _compute_auto_next(track, cfg)
202
+ for num in suggestion:
203
+ i = by_num.get(num, {})
204
+ out["suggested"].append({
205
+ "number": num,
206
+ "title": i.get("title", ""),
207
+ "priority": extract_priority(i.get("labels", [])),
208
+ "milestone": short_milestone(i.get("milestone")) or "",
209
+ })
210
+ out["skipped"] = [{"number": n, "claimed_by": s} for n, s in skipped]
211
+ print(json.dumps(out))
212
+ return 0
213
+
214
+
132
215
  def _apply_auto_next(track, cfg: dict) -> int:
133
216
  """Suggest a next_up list from open issues; prompt user to apply/edit/skip.
134
217
 
@@ -150,38 +233,19 @@ def _apply_auto_next(track, cfg: dict) -> int:
150
233
  print(f"No issues attached to {track.name}; nothing to suggest.")
151
234
  return 0
152
235
 
153
- issues = fetch_issues(track.repo, issue_nums)
154
- blocker_nums = track.meta.get("blockers") or []
155
- track_milestone = track.meta.get("milestone_alignment") or None
156
- hot_nums = hot_issue_numbers(track.local_path) if track.local_path else set()
157
- in_progress_set = {i["number"] for i in issues if issue_in_progress(i, hot_nums)}
158
- _, order = resolve_next_up_order(track.meta, cfg.get("next_up_default"))
159
- raw_suggestion = suggest_next_up(
160
- issues, blocker_nums,
161
- track_milestone=track_milestone,
162
- in_progress_nums=in_progress_set,
163
- order=order,
164
- )
165
- if not raw_suggestion:
236
+ suggestion, by_num, skipped, had_raw = _compute_auto_next(track, cfg)
237
+ # Print one line per sibling-claimed skip so the user knows what was dropped.
238
+ for num, sib in skipped:
239
+ print(f"↷ skipped #{num} (already next_up on '{sib}')")
240
+
241
+ if not had_raw:
166
242
  print(f"No open, non-blocker issues for {track.name}; next_up unchanged.")
167
243
  return 0
168
-
169
- # Filter sibling-claimed issues out of the suggestion. Print one line
170
- # per skip so the user knows what was dropped and why.
171
- claimed = _sibling_claimed_next_up_map(track, cfg)
172
- suggestion = []
173
- for num in raw_suggestion:
174
- if num in claimed:
175
- print(f"↷ skipped #{num} (already next_up on '{claimed[num]}')")
176
- else:
177
- suggestion.append(num)
178
-
179
244
  if not suggestion:
180
245
  print(f"All suggested issues are already next_up on sibling tracks; next_up unchanged.")
181
246
  return 0
182
247
 
183
248
  # Decorate with title + priority + milestone for the preview.
184
- by_num = {i["number"]: i for i in issues}
185
249
  print(f"\nSuggested next_up for {track.name}:")
186
250
  for num in suggestion:
187
251
  i = by_num.get(num, {})
@@ -3,11 +3,15 @@
3
3
  Runs in sequence:
4
4
  1. refresh-md --all --yes (drift in body status tables)
5
5
  2. reconcile --all (sync track/<slug> labels ↔ frontmatter)
6
- 3. duplicates (find consolidation candidates)
6
+ 3. dedupe-tiers (report shared/private duplicate tracks)
7
+ 4. duplicates (find consolidation candidates)
7
8
 
8
9
  One command for the standard weekly maintenance pass.
9
10
 
10
- Pass --repo=<key> to scope steps 1 and 2 to a single repo. Step 3 (duplicates)
11
+ Step 3 (dedupe-tiers) is report-only here it never deletes during hygiene.
12
+ Run `/work-plan dedupe-tiers --apply` directly to remove the safe orphans.
13
+
14
+ Pass --repo=<key> to scope steps 1 and 2 to a single repo. Step 4 (duplicates)
11
15
  is per-repo, so:
12
16
  - when --repo is set, it's scoped to that repo;
13
17
  - when --repo is absent and config has exactly one repo, it runs against
@@ -18,7 +22,7 @@ is per-repo, so:
18
22
  Pass --timeout=N to set the gh subprocess timeout for the duplicates step
19
23
  (default 30s).
20
24
  """
21
- from commands import refresh_md, reconcile, duplicates
25
+ from commands import refresh_md, reconcile, duplicates, dedupe_tiers
22
26
  from lib.config import load_config, ConfigError
23
27
  from lib.prompts import parse_flags
24
28
  import time
@@ -62,7 +66,7 @@ def run(args: list[str]) -> int:
62
66
 
63
67
  t0 = time.time()
64
68
  print("=" * 60)
65
- print(f"WEEKLY HYGIENE — step 1 of 3: refresh-md{scope_label}")
69
+ print(f"WEEKLY HYGIENE — step 1 of 4: refresh-md{scope_label}")
66
70
  print("=" * 60)
67
71
  refresh_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
68
72
  if yes:
@@ -70,12 +74,12 @@ def run(args: list[str]) -> int:
70
74
  rc = refresh_md.run(refresh_args)
71
75
  if rc != 0:
72
76
  print(f"\n⚠ refresh-md exited with code {rc}; continuing.")
73
- print(f" (step 1/3 done in {time.time() - t0:.1f}s)")
77
+ print(f" (step 1/4 done in {time.time() - t0:.1f}s)")
74
78
 
75
79
  t1 = time.time()
76
80
  print()
77
81
  print("=" * 60)
78
- print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
82
+ print(f"WEEKLY HYGIENE — step 2 of 4: reconcile{scope_label}")
79
83
  print("=" * 60)
80
84
  reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
81
85
  if yes:
@@ -83,7 +87,18 @@ def run(args: list[str]) -> int:
83
87
  rc = reconcile.run(reconcile_args)
84
88
  if rc != 0:
85
89
  print(f"\n⚠ reconcile exited with code {rc}; continuing.")
86
- print(f" (step 2/3 done in {time.time() - t1:.1f}s)")
90
+ print(f" (step 2/4 done in {time.time() - t1:.1f}s)")
91
+
92
+ t_dt = time.time()
93
+ print()
94
+ print("=" * 60)
95
+ print(f"WEEKLY HYGIENE — step 3 of 4: dedupe-tiers{scope_label} (report-only)")
96
+ print("=" * 60)
97
+ dedupe_args = [f"--repo={repo_key}"] if repo_key else []
98
+ rc = dedupe_tiers.run(dedupe_args)
99
+ if rc != 0:
100
+ print(f"\n⚠ dedupe-tiers exited with code {rc}; continuing.")
101
+ print(f" (step 3/4 done in {time.time() - t_dt:.1f}s)")
87
102
 
88
103
  if skip_dups:
89
104
  print()
@@ -93,7 +108,7 @@ def run(args: list[str]) -> int:
93
108
  t2 = time.time()
94
109
  print()
95
110
  print("=" * 60)
96
- print("WEEKLY HYGIENE — step 3 of 3: duplicates")
111
+ print("WEEKLY HYGIENE — step 4 of 4: duplicates")
97
112
  print("=" * 60)
98
113
 
99
114
  try:
@@ -122,7 +137,7 @@ def run(args: list[str]) -> int:
122
137
  rc = duplicates.run(dupes_args)
123
138
  if rc != 0:
124
139
  print(f"\n⚠ duplicates exited with code {rc}.")
125
- print(f" (step 3/3 done in {time.time() - t2:.1f}s)")
140
+ print(f" (step 4/4 done in {time.time() - t2:.1f}s)")
126
141
 
127
142
  print()
128
143
  print(f"✓ Weekly hygiene complete ({time.time() - t0:.1f}s total). Review the duplicate candidates above and "