@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 +7 -5
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +29 -3
- package/skills/work-plan/commands/dedupe_tiers.py +104 -0
- package/skills/work-plan/commands/export.py +19 -2
- package/skills/work-plan/commands/hygiene.py +24 -9
- package/skills/work-plan/commands/which_repo.py +52 -0
- package/skills/work-plan/lib/cwd_repo.py +133 -0
- package/skills/work-plan/lib/drift.py +6 -1
- package/skills/work-plan/lib/export_model.py +6 -1
- package/skills/work-plan/lib/tracks.py +64 -2
- package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
- package/skills/work-plan/tests/test_cwd_repo.py +164 -0
- package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
- package/skills/work-plan/tests/test_drift.py +32 -0
- package/skills/work-plan/tests/test_export_command.py +43 -0
- package/skills/work-plan/tests/test_register_which_repo.py +22 -0
- package/skills/work-plan/work_plan.py +11 -1
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
28
|
-
if
|
|
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.
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
|