@stylusnexus/work-plan 2026.6.15-4 → 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:
@@ -109,7 +109,7 @@ flowchart TB
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
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` + `duplicates` in sequence to keep status icons, GitHub labels, and dedup state honest.
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
 
@@ -510,13 +510,15 @@ 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. |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.15+488753b
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-4",
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"
@@ -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
@@ -148,6 +148,22 @@ def run(args: list[str]) -> int:
148
148
  if nums:
149
149
  hot_by_track[(t.repo, t.name)] = nums
150
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
+
151
167
  next_up_default = cfg.get("next_up_default")
152
168
  now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
153
169
  print(json.dumps(
@@ -156,7 +172,8 @@ def run(args: list[str]) -> int:
156
172
  config_repos=config_repos,
157
173
  plan_by_track=plan_by_track,
158
174
  hot_by_track=hot_by_track,
159
- next_up_default=next_up_default),
175
+ next_up_default=next_up_default,
176
+ tier_duplicates=tier_duplicates),
160
177
  indent=2,
161
178
  ))
162
179
  return 0
@@ -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 "
@@ -0,0 +1,52 @@
1
+ """which-repo — resolve the current directory to a configured repo.
2
+
3
+ Prints the matched repo's config key + GitHub slug, or reports no match. The
4
+ VS Code viewer spawns this with cwd set to the workspace folder to auto-focus its
5
+ repo lens (#357); `brief` calls the underlying resolver directly for cwd
6
+ auto-scope (#358). Read-only — never mutates anything.
7
+
8
+ Exit codes (human form): 0 on a match, 1 on no match — so a shell caller can
9
+ gate on it. The `--json` form always exits 0 and prints a `{"key": ...}` payload
10
+ (key is null on no match) for the viewer to parse.
11
+ """
12
+ import json
13
+ import os
14
+
15
+ from lib.config import load_config, ConfigError
16
+ from lib.cwd_repo import resolve_repo_for_dir
17
+ from lib.prompts import parse_flags
18
+
19
+
20
+ def run(args: list) -> int:
21
+ flags, _ = parse_flags(args, {"--json"})
22
+ want_json = bool(flags.get("--json"))
23
+
24
+ try:
25
+ cfg = load_config()
26
+ except ConfigError as e:
27
+ if want_json:
28
+ print(json.dumps({"key": None}))
29
+ return 0
30
+ print(f"ERROR: {e}")
31
+ return 1
32
+
33
+ match = resolve_repo_for_dir(cfg, os.getcwd())
34
+
35
+ if want_json:
36
+ if match:
37
+ print(json.dumps({
38
+ "key": match["key"],
39
+ "github": match.get("github"),
40
+ "matched_by": match["matched_by"],
41
+ }))
42
+ else:
43
+ print(json.dumps({"key": None}))
44
+ return 0
45
+
46
+ if match:
47
+ how = "local clone path" if match["matched_by"] == "local" else "git remote"
48
+ print(f"Resolved to repo '{match['key']}' (matched by {how}).")
49
+ return 0
50
+
51
+ print("No configured repo matches the current directory.")
52
+ return 1
@@ -0,0 +1,133 @@
1
+ """Resolve a directory to a configured repo (config key + GitHub slug).
2
+
3
+ Shared substrate for two sibling features: `brief` cwd auto-scope (#358) and the
4
+ VS Code viewer auto-focus (#357). Both need the same "which configured repo is
5
+ this directory?" answer, so it lives here once — exposed to the viewer through
6
+ the `which-repo` command and called directly by `brief`.
7
+
8
+ Resolution order: the local clone path is the primary signal (it's the most
9
+ explicit thing the user configured); the git `origin` remote is the fallback for
10
+ repos registered with `local: null`. If both resolve but to different keys (which
11
+ shouldn't happen in practice), the local-path key wins.
12
+
13
+ Read-only and never raises — every git call goes through the bounded `_git`
14
+ wrapper, which returns None on failure, and a no-match returns None so callers
15
+ fall back to their current all-repos behavior unchanged.
16
+ """
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from lib.git_state import _git
22
+
23
+
24
+ def _normalize_remote_url(url: str) -> Optional[str]:
25
+ """Normalize a git remote URL to a lowercased `org/repo` slug, or None.
26
+
27
+ Handles the forms git emits for GitHub remotes:
28
+ git@github.com:org/repo.git -> org/repo (scp-like)
29
+ ssh://git@github.com/org/repo.git -> org/repo
30
+ https://github.com/org/repo.git -> org/repo
31
+ https://github.com/org/repo -> org/repo
32
+
33
+ A trailing `.git`, surrounding whitespace, and trailing slashes are stripped.
34
+ Returns None for anything it can't parse into a host-path.
35
+ """
36
+ if not url:
37
+ return None
38
+ u = url.strip()
39
+
40
+ # scp-like syntax: [user@]host:path (no scheme, single colon before path)
41
+ m = re.match(r"^[\w.+-]+@[\w.-]+:(.+)$", u)
42
+ if m:
43
+ path = m.group(1)
44
+ else:
45
+ # url syntax: scheme://[user@]host[:port]/path
46
+ m = re.match(r"^[\w.+-]+://(?:[^/@]+@)?[^/]+/(.+)$", u)
47
+ if not m:
48
+ return None
49
+ path = m.group(1)
50
+
51
+ path = path.strip().strip("/")
52
+ if path.endswith(".git"):
53
+ path = path[:-len(".git")]
54
+ path = path.strip("/")
55
+ return path.lower() or None
56
+
57
+
58
+ def _toplevel(start_dir) -> Optional[Path]:
59
+ """The git work-tree root containing `start_dir`, resolved absolute, or None.
60
+
61
+ Uses `rev-parse --show-toplevel` (not the raw dir) so resolution works from
62
+ any nested subdirectory and a configured `local` that merely *contains* the
63
+ cwd can't false-match.
64
+ """
65
+ proc = _git(start_dir, "rev-parse", "--show-toplevel")
66
+ if proc is None or proc.returncode != 0:
67
+ return None
68
+ out = proc.stdout.strip()
69
+ if not out:
70
+ return None
71
+ try:
72
+ return Path(out).resolve()
73
+ except OSError:
74
+ return None
75
+
76
+
77
+ def _origin_slug(start_dir) -> Optional[str]:
78
+ """The `org/repo` slug of `start_dir`'s `origin` remote, or None."""
79
+ proc = _git(start_dir, "remote", "get-url", "origin")
80
+ if proc is None or proc.returncode != 0:
81
+ return None
82
+ return _normalize_remote_url(proc.stdout.strip())
83
+
84
+
85
+ def resolve_repo_for_dir(cfg: dict, start_dir) -> Optional[dict]:
86
+ """Resolve `start_dir` to a single configured repo.
87
+
88
+ Returns `{"key", "github", "matched_by"}` (matched_by is "local" or
89
+ "remote") when exactly one repo matches, else None. None covers: no repos
90
+ configured, dir isn't a git repo, no match, or an ambiguous (>1) match —
91
+ callers treat all of these as "don't auto-scope."
92
+ """
93
+ repos = cfg.get("repos") or {}
94
+ if not repos:
95
+ return None
96
+
97
+ # --- local clone path (primary) ---
98
+ top = _toplevel(start_dir)
99
+ if top is not None:
100
+ local_keys = []
101
+ for key, entry in repos.items():
102
+ local_raw = (entry or {}).get("local")
103
+ if not local_raw:
104
+ continue
105
+ try:
106
+ cfg_root = Path(local_raw).expanduser().resolve()
107
+ except OSError:
108
+ continue
109
+ if cfg_root == top:
110
+ local_keys.append(key)
111
+ if len(local_keys) == 1:
112
+ key = local_keys[0]
113
+ return {"key": key,
114
+ "github": (repos[key] or {}).get("github"),
115
+ "matched_by": "local"}
116
+ if len(local_keys) > 1:
117
+ # Ambiguous local config — refuse to guess.
118
+ return None
119
+
120
+ # --- git origin remote (fallback) ---
121
+ slug = _origin_slug(start_dir)
122
+ if slug:
123
+ remote_keys = [
124
+ key for key, entry in repos.items()
125
+ if entry and entry.get("github") and entry["github"].lower() == slug
126
+ ]
127
+ if len(remote_keys) == 1:
128
+ key = remote_keys[0]
129
+ return {"key": key,
130
+ "github": repos[key].get("github"),
131
+ "matched_by": "remote"}
132
+
133
+ return None
@@ -23,8 +23,13 @@ def detect_drift(body: str, github_issues: list[dict]) -> list[dict]:
23
23
  continue
24
24
  gh_state = state_by_num[num]
25
25
  looks_closed = any(k in body_status for k in ("✅", "shipped", "merged", "closed"))
26
- looks_open = "🔲" in body_status or "open" in body_status
27
26
 
27
+ # Asymmetric by design: CLOSED is terminal, so a closed issue whose
28
+ # row doesn't explicitly read closed (open marker, ambiguous, or empty)
29
+ # is drift. OPEN is not terminal — an open issue legitimately sits in
30
+ # many states (in-progress, blocked, todo), so only an explicit closed
31
+ # marker contradicts it. A broad open-side check (`not looks_open`)
32
+ # would false-positive every in-progress row, which is why we don't.
28
33
  if gh_state == "CLOSED" and not looks_closed:
29
34
  drift.append({"issue": num, "body_status": body_status, "github_state": "CLOSED"})
30
35
  elif gh_state == "OPEN" and looks_closed:
@@ -86,7 +86,7 @@ def normalize_issue(i: dict, in_progress: bool = False,
86
86
  def build_export(tracks, issues_by_track, visibility, now: str,
87
87
  untracked_by_repo=None, config_repos=None,
88
88
  plan_by_track=None, hot_by_track=None,
89
- next_up_default=None) -> dict:
89
+ next_up_default=None, tier_duplicates=None) -> dict:
90
90
  plan_by_track = plan_by_track or {}
91
91
  hot_by_track = hot_by_track or {}
92
92
  out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
@@ -169,4 +169,9 @@ def build_export(tracks, issues_by_track, visibility, now: str,
169
169
  # the starting point for adding fresh tracks. Each entry:
170
170
  # {folder, repo(slug), local, has_local, visibility}.
171
171
  out["repos"] = list(config_repos or [])
172
+ # Shared/private tier duplicates (#361, additive): tracks that exist in both
173
+ # a repo's shared .work-plan/ and the private notes_root tier. Read-only
174
+ # health signal for the viewer; resolved with the `dedupe-tiers` CLI verb.
175
+ # Each entry: {repo, folder, name, shared_path, private_path, safe}.
176
+ out["tier_duplicates"] = list(tier_duplicates or [])
172
177
  return out
@@ -1,4 +1,5 @@
1
1
  """Discover tracks under notes_root and shared .work-plan/ dirs."""
2
+ import re
2
3
  import sys
3
4
  from dataclasses import dataclass, field
4
5
  from pathlib import Path
@@ -74,9 +75,11 @@ def discover_tracks(cfg: dict) -> list[Track]:
74
75
  for t in private:
75
76
  key = (t.repo, t.name)
76
77
  if key in shared_keys:
78
+ hint = f" --repo={t.folder}" if t.folder else ""
77
79
  print(
78
80
  f"WARN: track {t.name!r} (repo={t.repo!r}) exists in both shared"
79
- f" ({shared_keys[key].path}) and private ({t.path}); using shared.",
81
+ f" ({shared_keys[key].path}) and private ({t.path}); using shared."
82
+ f" → resolve with `/work-plan dedupe-tiers{hint}`.",
80
83
  file=sys.stderr,
81
84
  )
82
85
  else:
@@ -85,6 +88,63 @@ def discover_tracks(cfg: dict) -> list[Track]:
85
88
  return merged
86
89
 
87
90
 
91
+ def issue_refs(track: "Track") -> set:
92
+ """All GitHub issue numbers a track references: the union of its frontmatter
93
+ `github.issues` list and every `#NNNN` token in its body. Used by
94
+ dedupe-tiers as the no-data-loss invariant — a private copy is only safe to
95
+ drop when its issue refs are a subset of the shared twin's.
96
+ """
97
+ refs: set = set()
98
+ fm_issues = (track.meta.get("github") or {}).get("issues") or []
99
+ for n in fm_issues:
100
+ try:
101
+ refs.add(int(n))
102
+ except (TypeError, ValueError):
103
+ continue
104
+ for m in re.finditer(r"#(\d+)", track.body or ""):
105
+ refs.add(int(m.group(1)))
106
+ return refs
107
+
108
+
109
+ def find_tier_duplicates(cfg: dict) -> list:
110
+ """Return (shared, private) Track pairs that collide on (repo, name) across
111
+ BOTH the active and archived tiers. Unlike discover_tracks, this does NOT
112
+ print a warning and does NOT drop the private side — it hands both copies to
113
+ the caller (dedupe-tiers) so the orphan can be inspected and removed.
114
+ """
115
+ pairs: list = []
116
+
117
+ # A duplicate requires a PRIVATE copy, which lives under notes_root. With no
118
+ # notes_root configured there can be no private tier and thus no duplicates —
119
+ # return early (also keeps the helper safe to call with a bare cfg).
120
+ if not cfg.get("notes_root"):
121
+ return pairs
122
+
123
+ shared_active = {(t.repo, t.name): t
124
+ for t in _discover_shared_tracks(cfg, include_archive=False)}
125
+ for t in _discover_private_tracks(cfg, include_archive=False):
126
+ s = shared_active.get((t.repo, t.name))
127
+ if s is not None:
128
+ pairs.append((s, t))
129
+
130
+ shared_arch = {(t.repo, t.name): t
131
+ for t in _discover_shared_tracks(cfg, include_archive=True,
132
+ archive_only=True)}
133
+ notes_root = Path(cfg["notes_root"]).expanduser()
134
+ if notes_root.exists():
135
+ for md_path in sorted(notes_root.rglob("*.md")):
136
+ if "archive" not in md_path.parts:
137
+ continue
138
+ if md_path.name.startswith((".", "_", "-")):
139
+ continue
140
+ t = _build_track(md_path, notes_root, cfg)
141
+ s = shared_arch.get((t.repo, t.name))
142
+ if s is not None:
143
+ pairs.append((s, t))
144
+
145
+ return pairs
146
+
147
+
88
148
  def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
89
149
  """Filter tracks by repo. Matches the config-key folder name OR the
90
150
  `org/repo` GitHub slug, so users can pass either. Case-insensitive."""
@@ -176,10 +236,12 @@ def discover_archived_tracks(cfg: dict) -> list[Track]:
176
236
  for t in private_archived:
177
237
  key = (t.repo, t.name)
178
238
  if key in shared_keys:
239
+ hint = f" --repo={t.folder}" if t.folder else ""
179
240
  print(
180
241
  f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in"
181
242
  f" both shared ({shared_keys[key].path}) and private"
182
- f" ({t.path}); using shared.",
243
+ f" ({t.path}); using shared."
244
+ f" → resolve with `/work-plan dedupe-tiers{hint}`.",
183
245
  file=sys.stderr,
184
246
  )
185
247
  else:
@@ -0,0 +1,126 @@
1
+ """brief cwd auto-scope (#358 Phase 2).
2
+
3
+ Exercises brief.run()'s scope resolution with the resolver, config loader,
4
+ track discovery, and the archived-reopen pass all mocked — so no network/git.
5
+ Tracks are inactive (status 'shipped') so the render loop stays trivial; we
6
+ assert behavior through the banner text and the `repo_key` threaded into
7
+ `_surface_archived_reopens` (which is exactly the value used to scope both the
8
+ track list and the archived callouts).
9
+ """
10
+ import io
11
+ import sys
12
+ import types
13
+ import unittest
14
+ from contextlib import redirect_stdout
15
+ from pathlib import Path
16
+ from unittest import mock
17
+
18
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
19
+ sys.path.insert(0, str(SKILL_ROOT))
20
+
21
+ from commands import brief
22
+
23
+
24
+ def _track(folder, repo):
25
+ return types.SimpleNamespace(
26
+ has_frontmatter=True,
27
+ meta={"status": "shipped"}, # inactive → no per-track render
28
+ needs_init=False,
29
+ needs_filing=False,
30
+ folder=folder,
31
+ repo=repo,
32
+ path=Path(f"/notes/{folder}/{folder}.md"),
33
+ )
34
+
35
+
36
+ CFG = {"repos": {
37
+ "work-plan-toolkit": {"local": "/code/wpt", "github": "stylusnexus/work-plan-toolkit"},
38
+ "defect-scan": {"local": "/code/ds", "github": "stylusnexus/defect-scan"},
39
+ }}
40
+
41
+ TRACKS = [
42
+ _track("work-plan-toolkit", "stylusnexus/work-plan-toolkit"),
43
+ _track("defect-scan", "stylusnexus/defect-scan"),
44
+ ]
45
+
46
+ BANNER = "Scoped to repo"
47
+
48
+
49
+ def _run(args, cfg=None, resolve_return=mock.DEFAULT):
50
+ """Run brief.run(args) with collaborators mocked. Returns (stdout, archived_mock, resolve_mock)."""
51
+ cfg = CFG if cfg is None else cfg
52
+ buf = io.StringIO()
53
+ with mock.patch.object(brief, "load_config", return_value=cfg), \
54
+ mock.patch.object(brief, "discover_tracks", return_value=list(TRACKS)), \
55
+ mock.patch.object(brief, "_surface_archived_reopens") as archived, \
56
+ mock.patch.object(brief, "resolve_repo_for_dir") as resolve:
57
+ if resolve_return is not mock.DEFAULT:
58
+ resolve.return_value = resolve_return
59
+ with redirect_stdout(buf):
60
+ brief.run(args)
61
+ return buf.getvalue(), archived, resolve
62
+
63
+
64
+ def _archived_repo_key(archived):
65
+ self_call = archived.call_args
66
+ return self_call.kwargs.get("repo_key")
67
+
68
+
69
+ class BriefAutoScopeTest(unittest.TestCase):
70
+ # --- auto-detect on (default) -------------------------------------------
71
+
72
+ def test_autoscope_prints_banner_and_scopes(self):
73
+ out, archived, _ = _run(
74
+ [], resolve_return={"key": "work-plan-toolkit",
75
+ "github": "stylusnexus/work-plan-toolkit",
76
+ "matched_by": "local"})
77
+ self.assertIn(BANNER, out)
78
+ self.assertIn("work-plan-toolkit", out)
79
+ # archived-reopen pass scoped to the same detected key
80
+ self.assertEqual(_archived_repo_key(archived), "work-plan-toolkit")
81
+
82
+ def test_archived_reopens_scoped_to_detected_repo(self):
83
+ _, archived, _ = _run(
84
+ [], resolve_return={"key": "defect-scan",
85
+ "github": "stylusnexus/defect-scan",
86
+ "matched_by": "local"})
87
+ self.assertEqual(_archived_repo_key(archived), "defect-scan")
88
+
89
+ def test_banner_printed_exactly_once(self):
90
+ out, _, _ = _run(
91
+ [], resolve_return={"key": "work-plan-toolkit",
92
+ "github": "stylusnexus/work-plan-toolkit",
93
+ "matched_by": "local"})
94
+ self.assertEqual(out.count(BANNER), 1)
95
+
96
+ def test_no_match_shows_all_no_banner(self):
97
+ out, archived, _ = _run([], resolve_return=None)
98
+ self.assertNotIn(BANNER, out)
99
+ self.assertIsNone(_archived_repo_key(archived))
100
+
101
+ # --- explicit --repo / escape hatch -------------------------------------
102
+
103
+ def test_repo_all_shows_everything_no_banner_no_autodetect(self):
104
+ out, archived, resolve = _run(["--repo=all"])
105
+ self.assertNotIn(BANNER, out)
106
+ self.assertIsNone(_archived_repo_key(archived))
107
+ resolve.assert_not_called() # --repo=all must short-circuit auto-detect
108
+
109
+ def test_explicit_repo_scopes_without_banner_or_autodetect(self):
110
+ out, archived, resolve = _run(["--repo=defect-scan"])
111
+ self.assertNotIn(BANNER, out)
112
+ self.assertEqual(_archived_repo_key(archived), "defect-scan")
113
+ resolve.assert_not_called()
114
+
115
+ # --- opt-out -------------------------------------------------------------
116
+
117
+ def test_optout_disables_autoscope(self):
118
+ cfg = {"repos": CFG["repos"], "brief_auto_scope": False}
119
+ out, archived, resolve = _run([], cfg=cfg)
120
+ self.assertNotIn(BANNER, out)
121
+ self.assertIsNone(_archived_repo_key(archived))
122
+ resolve.assert_not_called()
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()
@@ -0,0 +1,164 @@
1
+ """cwd → configured-repo resolution (#358/#357 Phase 1).
2
+
3
+ All git calls are mocked, so these run offline. The resolver shells `git` via
4
+ `lib.git_state._git`, imported into `lib.cwd_repo`'s namespace — so we patch
5
+ `lib.cwd_repo._git`.
6
+ """
7
+ import sys
8
+ import types
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from lib import cwd_repo
17
+ from lib.cwd_repo import resolve_repo_for_dir, _normalize_remote_url
18
+
19
+
20
+ def _proc(stdout="", returncode=0):
21
+ """A stand-in for subprocess.CompletedProcess as `_git` returns it."""
22
+ return types.SimpleNamespace(stdout=stdout, returncode=returncode)
23
+
24
+
25
+ def _fake_git(toplevel=None, origin=None):
26
+ """Build a `_git` replacement keyed on the git subcommand.
27
+
28
+ `toplevel` / `origin` are raw stdout strings (or None → non-zero exit, i.e.
29
+ git failed / not a repo / no remote).
30
+ """
31
+ def _g(repo_path, *args, **kwargs):
32
+ if args[:2] == ("rev-parse", "--show-toplevel"):
33
+ return _proc(toplevel, 0) if toplevel is not None else _proc("", 128)
34
+ if args[:2] == ("remote", "get-url"):
35
+ return _proc(origin, 0) if origin is not None else _proc("", 2)
36
+ return _proc("", 0)
37
+ return _g
38
+
39
+
40
+ # Absolute, non-symlinked paths so .resolve() is a no-op-equal on both sides.
41
+ CFG = {
42
+ "repos": {
43
+ "work-plan-toolkit": {
44
+ "local": "/code/work-plan-toolkit",
45
+ "github": "stylusnexus/work-plan-toolkit",
46
+ },
47
+ "defect-scan": {
48
+ "local": "/code/defect-scan",
49
+ "github": "stylusnexus/defect-scan",
50
+ },
51
+ # A repo with no local clone — only a remote can match it.
52
+ "remote-only": {
53
+ "local": None,
54
+ "github": "stylusnexus/remote-only",
55
+ },
56
+ }
57
+ }
58
+
59
+
60
+ class NormalizeRemoteUrlTest(unittest.TestCase):
61
+ def test_scp_form(self):
62
+ self.assertEqual(
63
+ _normalize_remote_url("git@github.com:Org/Repo.git"), "org/repo")
64
+
65
+ def test_https_with_git_suffix(self):
66
+ self.assertEqual(
67
+ _normalize_remote_url("https://github.com/org/repo.git"), "org/repo")
68
+
69
+ def test_https_without_suffix(self):
70
+ self.assertEqual(
71
+ _normalize_remote_url("https://github.com/org/repo"), "org/repo")
72
+
73
+ def test_ssh_url_form(self):
74
+ self.assertEqual(
75
+ _normalize_remote_url("ssh://git@github.com/org/repo.git"), "org/repo")
76
+
77
+ def test_all_forms_land_on_same_slug(self):
78
+ forms = [
79
+ "git@github.com:org/repo.git",
80
+ "https://github.com/org/repo.git",
81
+ "https://github.com/org/repo",
82
+ "ssh://git@github.com/org/repo.git",
83
+ ]
84
+ slugs = {_normalize_remote_url(f) for f in forms}
85
+ self.assertEqual(slugs, {"org/repo"})
86
+
87
+ def test_garbage_returns_none(self):
88
+ self.assertIsNone(_normalize_remote_url(""))
89
+ self.assertIsNone(_normalize_remote_url("not-a-url"))
90
+
91
+
92
+ class ResolveRepoForDirTest(unittest.TestCase):
93
+ def test_local_match_at_clone_root(self):
94
+ with mock.patch.object(cwd_repo, "_git",
95
+ _fake_git(toplevel="/code/work-plan-toolkit")):
96
+ got = resolve_repo_for_dir(CFG, "/code/work-plan-toolkit")
97
+ self.assertEqual(got, {
98
+ "key": "work-plan-toolkit",
99
+ "github": "stylusnexus/work-plan-toolkit",
100
+ "matched_by": "local",
101
+ })
102
+
103
+ def test_local_match_from_nested_subdir(self):
104
+ # cwd is deep inside the clone; toplevel still resolves to the root.
105
+ with mock.patch.object(cwd_repo, "_git",
106
+ _fake_git(toplevel="/code/work-plan-toolkit")):
107
+ got = resolve_repo_for_dir(
108
+ CFG, "/code/work-plan-toolkit/skills/work-plan/lib")
109
+ self.assertIsNotNone(got)
110
+ self.assertEqual(got["key"], "work-plan-toolkit")
111
+ self.assertEqual(got["matched_by"], "local")
112
+
113
+ def test_remote_match_when_local_is_null(self):
114
+ # Not a configured local path, but origin matches the remote-only repo.
115
+ with mock.patch.object(cwd_repo, "_git",
116
+ _fake_git(toplevel="/somewhere/else",
117
+ origin="git@github.com:stylusnexus/remote-only.git")):
118
+ got = resolve_repo_for_dir(CFG, "/somewhere/else")
119
+ self.assertEqual(got, {
120
+ "key": "remote-only",
121
+ "github": "stylusnexus/remote-only",
122
+ "matched_by": "remote",
123
+ })
124
+
125
+ def test_local_wins_over_remote_when_they_disagree(self):
126
+ # toplevel == defect-scan's clone, but origin points at work-plan-toolkit.
127
+ # The local-path key must win.
128
+ with mock.patch.object(cwd_repo, "_git",
129
+ _fake_git(toplevel="/code/defect-scan",
130
+ origin="git@github.com:stylusnexus/work-plan-toolkit.git")):
131
+ got = resolve_repo_for_dir(CFG, "/code/defect-scan")
132
+ self.assertEqual(got["key"], "defect-scan")
133
+ self.assertEqual(got["matched_by"], "local")
134
+
135
+ def test_no_match_inside_unconfigured_repo(self):
136
+ with mock.patch.object(cwd_repo, "_git",
137
+ _fake_git(toplevel="/code/unknown",
138
+ origin="git@github.com:someone/unknown.git")):
139
+ self.assertIsNone(resolve_repo_for_dir(CFG, "/code/unknown"))
140
+
141
+ def test_no_match_when_not_a_git_repo(self):
142
+ # git rev-parse fails AND no remote — resolver returns None, no raise.
143
+ with mock.patch.object(cwd_repo, "_git",
144
+ _fake_git(toplevel=None, origin=None)):
145
+ self.assertIsNone(resolve_repo_for_dir(CFG, "/tmp/plain-dir"))
146
+
147
+ def test_none_when_two_repos_share_a_local_path(self):
148
+ # Pathological config: two keys point at the same clone. Refuse to guess.
149
+ cfg = {"repos": {
150
+ "a": {"local": "/code/dup", "github": "org/a"},
151
+ "b": {"local": "/code/dup", "github": "org/b"},
152
+ }}
153
+ with mock.patch.object(cwd_repo, "_git",
154
+ _fake_git(toplevel="/code/dup")):
155
+ self.assertIsNone(resolve_repo_for_dir(cfg, "/code/dup"))
156
+
157
+ def test_none_when_no_repos_configured(self):
158
+ with mock.patch.object(cwd_repo, "_git",
159
+ _fake_git(toplevel="/code/x")):
160
+ self.assertIsNone(resolve_repo_for_dir({"repos": {}}, "/code/x"))
161
+
162
+
163
+ if __name__ == "__main__":
164
+ unittest.main()
@@ -0,0 +1,147 @@
1
+ """Tests for the dedupe-tiers command and its tracks.py helpers (#359)."""
2
+ import io
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from unittest.mock import patch
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import dedupe_tiers
15
+ from lib import tracks
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def _track(*, name, repo="org/repo", folder="repo", issues=None, body="", path=None,
23
+ tier="private"):
24
+ meta = {"track": name, "github": {"repo": repo}}
25
+ if issues is not None:
26
+ meta["github"]["issues"] = issues
27
+ return SimpleNamespace(
28
+ name=name,
29
+ path=Path(path) if path else Path(f"/tmp/notes/{folder}/{name}.md"),
30
+ repo=repo,
31
+ folder=folder,
32
+ meta=meta,
33
+ body=body,
34
+ tier=tier,
35
+ )
36
+
37
+
38
+ def _drive(args, pairs, *, notes_root="/tmp/notes"):
39
+ cfg = {"notes_root": notes_root, "repos": {"repo": {"github": "org/repo"}}}
40
+ buf = io.StringIO()
41
+ with patch("commands.dedupe_tiers.load_config", return_value=cfg), \
42
+ patch("commands.dedupe_tiers.find_tier_duplicates", return_value=pairs), \
43
+ redirect_stdout(buf):
44
+ rc = dedupe_tiers.run(args)
45
+ return rc, buf.getvalue()
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # issue_refs
50
+ # ---------------------------------------------------------------------------
51
+
52
+ class TestIssueRefs(unittest.TestCase):
53
+ def test_unions_frontmatter_and_body(self):
54
+ t = _track(name="a", issues=[10, 20], body="see #20 and #30 here")
55
+ self.assertEqual(tracks.issue_refs(t), {10, 20, 30})
56
+
57
+ def test_empty_when_no_refs(self):
58
+ t = _track(name="a", issues=None, body="no refs at all")
59
+ self.assertEqual(tracks.issue_refs(t), set())
60
+
61
+ def test_ignores_non_int_frontmatter(self):
62
+ t = _track(name="a", issues=[1, "oops", None], body="")
63
+ self.assertEqual(tracks.issue_refs(t), {1})
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # find_tier_duplicates pairing (helpers patched)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class TestFindTierDuplicates(unittest.TestCase):
71
+ def test_pairs_only_colliding_active_tracks(self):
72
+ shared = [_track(name="dup", tier="shared"), _track(name="only-shared", tier="shared")]
73
+ private = [_track(name="dup"), _track(name="only-private")]
74
+ cfg = {"notes_root": "/tmp/does-not-exist-xyz", "repos": {}}
75
+ with patch.object(tracks, "_discover_shared_tracks") as ds, \
76
+ patch.object(tracks, "_discover_private_tracks", return_value=private):
77
+ # active call returns `shared`; archive-only call returns []
78
+ ds.side_effect = lambda cfg, include_archive=False, archive_only=False: (
79
+ [] if archive_only else shared)
80
+ pairs = tracks.find_tier_duplicates(cfg)
81
+ names = [(s.name, p.name) for (s, p) in pairs]
82
+ self.assertEqual(names, [("dup", "dup")])
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # command: report / apply / safety
87
+ # ---------------------------------------------------------------------------
88
+
89
+ class TestDedupeCommand(unittest.TestCase):
90
+ def test_no_pairs_reports_nothing(self):
91
+ rc, out = _drive([], [])
92
+ self.assertEqual(rc, 0)
93
+ self.assertIn("Nothing to dedupe", out)
94
+
95
+ def test_dry_run_removes_nothing(self):
96
+ with tempfile.TemporaryDirectory() as d:
97
+ pf = Path(d) / "dup.md"
98
+ pf.write_text("# dup\n")
99
+ shared = _track(name="dup", issues=[1, 2], tier="shared")
100
+ private = _track(name="dup", issues=[1], path=str(pf))
101
+ rc, out = _drive([], [(shared, private)])
102
+ self.assertTrue(pf.exists(), "dry run must not delete")
103
+ self.assertEqual(rc, 0)
104
+ self.assertIn("Dry run", out)
105
+ self.assertIn("--apply", out)
106
+
107
+ def test_apply_removes_subset_orphan(self):
108
+ with tempfile.TemporaryDirectory() as d:
109
+ pf = Path(d) / "dup.md"
110
+ pf.write_text("# dup\n")
111
+ shared = _track(name="dup", issues=[1, 2, 3], tier="shared")
112
+ private = _track(name="dup", issues=[1, 3], path=str(pf))
113
+ rc, out = _drive(["--apply"], [(shared, private)])
114
+ self.assertEqual(rc, 0)
115
+ self.assertFalse(pf.exists(), "subset orphan must be removed on --apply")
116
+ self.assertIn("Removed 1 private orphan", out)
117
+
118
+ def test_apply_keeps_diverged_orphan(self):
119
+ with tempfile.TemporaryDirectory() as d:
120
+ pf = Path(d) / "dup.md"
121
+ pf.write_text("# dup\n")
122
+ # private references #99 which the shared twin lacks → must be kept
123
+ shared = _track(name="dup", issues=[1, 2], tier="shared")
124
+ private = _track(name="dup", issues=[1], body="leftover #99", path=str(pf))
125
+ rc, out = _drive(["--apply"], [(shared, private)])
126
+ self.assertEqual(rc, 0)
127
+ self.assertTrue(pf.exists(), "diverged orphan must NOT be removed")
128
+ self.assertIn("#99", out)
129
+ self.assertIn("manual review", out)
130
+
131
+ def test_repo_filter_scopes_pairs(self):
132
+ a_shared = _track(name="a", repo="org/a", folder="a", issues=[1], tier="shared")
133
+ a_priv = _track(name="a", repo="org/a", folder="a", issues=[1])
134
+ b_shared = _track(name="b", repo="org/b", folder="b", issues=[1], tier="shared")
135
+ b_priv = _track(name="b", repo="org/b", folder="b", issues=[1])
136
+ rc, out = _drive(["--repo=a"], [(a_shared, a_priv), (b_shared, b_priv)])
137
+ self.assertEqual(rc, 0)
138
+ self.assertIn("a (repo org/a)", out)
139
+ self.assertNotIn("org/b", out)
140
+
141
+ def test_repo_flag_without_value_is_usage_error(self):
142
+ rc, _ = _drive(["--repo"], [])
143
+ self.assertEqual(rc, 2)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()
@@ -33,6 +33,38 @@ class DetectDriftTest(unittest.TestCase):
33
33
  def test_no_table_returns_empty(self):
34
34
  self.assertEqual(detect_drift("# No table\n", [{"number": 1, "state": "CLOSED"}]), [])
35
35
 
36
+ # --- OPEN-side + ambiguous-body cases: pin the *intentional asymmetry* ----
37
+ # CLOSED is terminal → broad check (anything not-closed drifts). OPEN is not
38
+ # terminal → narrow check (only an explicit closed marker drifts). These
39
+ # guard against someone "restoring symmetry" with a `not looks_open` open-side
40
+ # check, which would false-positive every in-progress row.
41
+
42
+ def _body(self, status: str) -> str:
43
+ return ("| # | Title | Status |\n"
44
+ "|---|---|---|\n"
45
+ f"| #1 | foo | {status} |\n")
46
+
47
+ def test_drift_when_closed_in_md_open_in_github(self):
48
+ # OPEN-side condition (was untested): body says shipped, GitHub reopened it.
49
+ drift = detect_drift(self._body("✅ Shipped"), [{"number": 1, "state": "OPEN"}])
50
+ self.assertEqual(len(drift), 1)
51
+ self.assertEqual(drift[0]["github_state"], "OPEN")
52
+
53
+ def test_no_drift_when_open_in_md_open_in_github(self):
54
+ self.assertEqual(detect_drift(self._body("🔲 Open"), [{"number": 1, "state": "OPEN"}]), [])
55
+
56
+ def test_open_with_ambiguous_status_is_NOT_drift(self):
57
+ # The deliberate narrow OPEN-side: an in-progress row must not be flagged.
58
+ self.assertEqual(
59
+ detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "OPEN"}]), [])
60
+
61
+ def test_closed_with_ambiguous_status_IS_drift(self):
62
+ # The deliberate broad CLOSED-side: a closed issue whose row doesn't read
63
+ # closed (here: ambiguous) is drift.
64
+ drift = detect_drift(self._body("🚧 In progress"), [{"number": 1, "state": "CLOSED"}])
65
+ self.assertEqual(len(drift), 1)
66
+ self.assertEqual(drift[0]["github_state"], "CLOSED")
67
+
36
68
 
37
69
  if __name__ == "__main__":
38
70
  unittest.main()
@@ -151,6 +151,49 @@ class ExportRunJsonTest(unittest.TestCase):
151
151
  self.assertEqual(rc, 0)
152
152
  self.assertEqual(out["tracks"][0]["issues"], [])
153
153
 
154
+ # --- tier_duplicates (#361) -------------------------------------------
155
+
156
+ def _dup_track(self, *, issues, body="", path):
157
+ return SimpleNamespace(
158
+ repo=_SHARED_REPO, folder="myrepo", name="dup", path=Path(path),
159
+ meta={"github": {"repo": _SHARED_REPO, "issues": issues}}, body=body,
160
+ )
161
+
162
+ def test_tier_duplicates_empty_when_none(self):
163
+ """With no notes_root in cfg (mock returns {}), the field is an empty
164
+ list — present but quiet, so the viewer can rely on its shape."""
165
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
166
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
167
+ self.assertEqual(rc, 0)
168
+ self.assertEqual(out["tier_duplicates"], [])
169
+
170
+ def test_tier_duplicate_subset_is_safe(self):
171
+ shared = self._dup_track(issues=[1, 2, 3], path="/repo/.work-plan/dup.md")
172
+ private = self._dup_track(issues=[1, 3], path="/notes/myrepo/dup.md")
173
+ with patch("commands.export.find_tier_duplicates",
174
+ return_value=[(shared, private)]):
175
+ rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP)
176
+ self.assertEqual(rc, 0)
177
+ td = out["tier_duplicates"]
178
+ self.assertEqual(len(td), 1)
179
+ self.assertEqual(td[0]["name"], "dup")
180
+ self.assertEqual(td[0]["repo"], _SHARED_REPO)
181
+ self.assertEqual(td[0]["folder"], "myrepo")
182
+ self.assertTrue(td[0]["safe"])
183
+ self.assertEqual(td[0]["shared_path"], str(Path("/repo/.work-plan/dup.md")))
184
+ self.assertEqual(td[0]["private_path"], str(Path("/notes/myrepo/dup.md")))
185
+
186
+ def test_tier_duplicate_diverged_is_unsafe(self):
187
+ # private references #99, which the shared twin lacks → not safe to remove
188
+ shared = self._dup_track(issues=[1, 2], path="/repo/.work-plan/dup.md")
189
+ private = self._dup_track(issues=[1], body="leftover #99",
190
+ path="/notes/myrepo/dup.md")
191
+ with patch("commands.export.find_tier_duplicates",
192
+ return_value=[(shared, private)]):
193
+ rc, out, _ = self._run_with_mocks([_track("a", _SHARED_REPO, [1])], _EXPORT_MAP)
194
+ self.assertEqual(rc, 0)
195
+ self.assertFalse(out["tier_duplicates"][0]["safe"])
196
+
154
197
  def test_visibility_included_in_output(self):
155
198
  tracks = [_track("alpha", _SHARED_REPO, [1])]
156
199
  rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP, vis={_SHARED_REPO: "PUBLIC"})
@@ -0,0 +1,22 @@
1
+ """which-repo is dispatchable + documented (#358/#357 Phase 1)."""
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ import work_plan
10
+
11
+
12
+ class RegisterWhichRepoTest(unittest.TestCase):
13
+ def test_in_subcommands(self):
14
+ self.assertEqual(work_plan.SUBCOMMANDS["which-repo"], "commands.which_repo")
15
+
16
+ def test_in_descriptions(self):
17
+ names = {row[0] for row in work_plan.DESCRIPTIONS}
18
+ self.assertIn("which-repo", names)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ unittest.main()
@@ -47,6 +47,7 @@ SUBCOMMANDS = {
47
47
  "duplicates": "commands.duplicates",
48
48
  "coverage": "commands.coverage",
49
49
  "canonicalize": "commands.canonicalize",
50
+ "dedupe-tiers": "commands.dedupe_tiers",
50
51
  "hygiene": "commands.hygiene",
51
52
  "--hygiene": "commands.hygiene", # flag-style alias
52
53
  "plan-status": "commands.plan_status",
@@ -57,6 +58,7 @@ SUBCOMMANDS = {
57
58
  "close-issue": "commands.close_issue",
58
59
  "in-progress": "commands.in_progress",
59
60
  "export": "commands.export",
61
+ "which-repo": "commands.which_repo",
60
62
  "auth-status": "commands.auth_status",
61
63
  "list-open-issues": "commands.list_open_issues",
62
64
  "set": "commands.set_field",
@@ -147,8 +149,12 @@ DESCRIPTIONS = [
147
149
  "Insert a canonical master issue table at the top of a track. The table has a Milestone column and is ordered active-milestone-first (the track's milestone_alignment milestone, then other milestones grouped with a blank divider row, then no-milestone last) so near-term work sits above someday work (#101). Refresh-md then targets ONLY this table, re-deriving it (so the order self-heals) and leaving narrative tables alone. Use --repo=<key> or track@repo to disambiguate; with --all, --repo=<key> scopes to one repo.",
148
150
  "ONE-TIME for hand-written tracks with multiple narrative tables, OR after restructuring a track.",
149
151
  "/work-plan canonicalize ux-redesign"),
152
+ ("dedupe-tiers", "[--repo=<key>] [--apply]",
153
+ "Remove private track copies that a shared twin in a repo's .work-plan/ supersedes (#359). When a track is promoted to the shared tier, the private original under notes_root is sometimes left behind (bulk/manual promotion, or a failed unlink) — discover_tracks then warns 'exists in both shared and private' on every run with no cleanup path. This removes the safe orphans and REFUSES any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is issue_refs(private) ⊆ issue_refs(shared)). Covers active and archived tiers. Default is a dry-run report; --apply deletes (auto-committed to notes_root, so undoable).",
154
+ "When `exists in both shared and private` warnings appear, or after a bulk promote that left private originals behind.",
155
+ "/work-plan dedupe-tiers --repo=critforge --apply"),
150
156
  ("hygiene", "[--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]",
151
- "Weekly cleanup wrapper: refresh-md + reconcile + duplicates. With --repo=<key>, steps 1 and 2 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).",
157
+ "Weekly cleanup wrapper: refresh-md + reconcile + dedupe-tiers (report-only) + duplicates. With --repo=<key>, steps 1–3 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).",
152
158
  "WEEKLY — runs all three hygiene commands in sequence so you don't have to remember each. Use --repo=<key> to clean up one project without touching the others.",
153
159
  "/work-plan hygiene --repo=myproject"),
154
160
  ("export", "--json",
@@ -219,6 +225,10 @@ DESCRIPTIONS = [
219
225
  "Promote a PRIVATE track (local-only, in notes_root) to the repo's SHARED tier and publish it (#306). Moves the track's `.md` into the repo's `.work-plan/` (on its `plan_branch`, via a worktree), removes the private copy so it isn't duplicated, commits to the plan branch, and pushes — unless `--no-push` (keeps it local). The tier is derived from location, so this is a file move, not a frontmatter edit. Requires the repo to have a local clone + a `plan_branch` (else hints `plan-branch init`). Pushing to a PUBLIC repo makes the track world-visible, so the push is confirm-token gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`).",
220
226
  "When a private track is ready to share with teammates — promote it to the shared plan branch in one step instead of hand-moving the file.",
221
227
  "/work-plan push-track my-feature --repo=myproject"),
228
+ ("which-repo", "[--json]",
229
+ "Resolve the current directory to one configured repo — by local clone path first, then the git `origin` remote. Prints the matched config key + GitHub slug, or reports no match. Read-only. Underlies `brief` cwd auto-scope and the VS Code viewer's repo auto-focus.",
230
+ "Rarely run by hand — it's the shared resolver the viewer and `brief` call. Useful to confirm which repo a checkout maps to.",
231
+ "/work-plan which-repo --json"),
222
232
  ]
223
233
 
224
234