@stylusnexus/work-plan 2026.6.15 → 2026.6.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -10
- package/VERSION +1 -1
- package/package.json +1 -5
- 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 +29 -4
- package/skills/work-plan/commands/handoff.py +90 -26
- package/skills/work-plan/commands/hygiene.py +24 -9
- package/skills/work-plan/commands/set_next_up.py +64 -8
- 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 +27 -4
- 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.py +63 -0
- package/skills/work-plan/tests/test_export_command.py +56 -0
- package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
- package/skills/work-plan/tests/test_register_which_repo.py +22 -0
- package/skills/work-plan/tests/test_set_next_up.py +95 -0
- package/skills/work-plan/work_plan.py +15 -5
- package/scripts/npm-check-deps.js +0 -44
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
"""set-next-up subcommand — guarded edit of a track's next_up ranking preset.
|
|
2
2
|
|
|
3
3
|
Usage:
|
|
4
|
-
work_plan.py set-next-up <track> [--repo=<key>]
|
|
4
|
+
work_plan.py set-next-up <track> [--repo=<key>]
|
|
5
|
+
(--preset=<name> | --order=a,b,c | --clear | --auto=on|off)
|
|
6
|
+
[--confirm=<token>]
|
|
5
7
|
|
|
6
|
-
Writes `next_up_order` into the track's frontmatter.
|
|
7
|
-
`next_up` issue-list key.
|
|
8
|
+
Writes `next_up_order` and/or `next_up_auto` into the track's frontmatter.
|
|
9
|
+
Does NOT touch the `next_up` issue-list key.
|
|
10
|
+
|
|
11
|
+
--preset=<name> Set one of the named ranking presets (flow, priority-driven,
|
|
12
|
+
backlog) or 'custom' (which requires --order).
|
|
13
|
+
--order=a,b,c Set a custom comma-separated criterion list.
|
|
14
|
+
--clear Remove the next_up_order key (reverts to global/default).
|
|
15
|
+
--auto=on|off Toggle the next_up_auto flag. When on, brief/orient/export
|
|
16
|
+
auto-derive the next-up list via the ranking preset (#326).
|
|
17
|
+
Can be used standalone or combined with --preset/--order/--clear.
|
|
8
18
|
|
|
9
19
|
Public-repo gated: without --confirm it prints {needs_confirm, reason, token}
|
|
10
20
|
and makes no change. The VS Code extension surfaces that as a modal then
|
|
@@ -22,12 +32,12 @@ from lib.next_up import CRITERIA, PRESETS
|
|
|
22
32
|
|
|
23
33
|
def run(args: list[str]) -> int:
|
|
24
34
|
flags, positional = parse_flags(
|
|
25
|
-
args, {"--confirm", "--repo", "--clear", "--preset", "--order"}
|
|
35
|
+
args, {"--confirm", "--repo", "--clear", "--preset", "--order", "--auto"}
|
|
26
36
|
)
|
|
27
37
|
if not positional:
|
|
28
38
|
print(
|
|
29
39
|
"usage: work_plan.py set-next-up <track> "
|
|
30
|
-
"(--preset=<name> | --order=a,b,c | --clear) "
|
|
40
|
+
"(--preset=<name> | --order=a,b,c | --clear | --auto=on|off) "
|
|
31
41
|
"[--repo=<key>] [--confirm=<token>]"
|
|
32
42
|
)
|
|
33
43
|
return 2
|
|
@@ -41,11 +51,27 @@ def run(args: list[str]) -> int:
|
|
|
41
51
|
clear = bool(flags.get("--clear"))
|
|
42
52
|
preset_flag = flags.get("--preset") if flags.get("--preset") is not True else None
|
|
43
53
|
order_flag = flags.get("--order") if flags.get("--order") is not True else None
|
|
54
|
+
auto_raw = flags.get("--auto") if flags.get("--auto") is not True else None
|
|
55
|
+
|
|
56
|
+
# Parse and validate --auto value
|
|
57
|
+
auto_value = None # None means not specified
|
|
58
|
+
if auto_raw is not None:
|
|
59
|
+
auto_lower = auto_raw.lower() if isinstance(auto_raw, str) else ""
|
|
60
|
+
if auto_lower == "on":
|
|
61
|
+
auto_value = True
|
|
62
|
+
elif auto_lower == "off":
|
|
63
|
+
auto_value = False
|
|
64
|
+
else:
|
|
65
|
+
print(
|
|
66
|
+
f"ERROR: --auto must be 'on' or 'off', got {auto_raw!r}",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
return 2
|
|
44
70
|
|
|
45
|
-
# Must have at least one of --preset, --order, or --
|
|
46
|
-
if not clear and preset_flag is None and order_flag is None:
|
|
71
|
+
# Must have at least one of --preset, --order, --clear, or --auto
|
|
72
|
+
if not clear and preset_flag is None and order_flag is None and auto_value is None:
|
|
47
73
|
print(
|
|
48
|
-
"ERROR: specify --preset=<name>, --order=a,b,c, or --
|
|
74
|
+
"ERROR: specify --preset=<name>, --order=a,b,c, --clear, or --auto=on|off",
|
|
49
75
|
file=sys.stderr,
|
|
50
76
|
)
|
|
51
77
|
return 2
|
|
@@ -123,8 +149,19 @@ def run(args: list[str]) -> int:
|
|
|
123
149
|
|
|
124
150
|
if clear:
|
|
125
151
|
track.meta.pop("next_up_order", None)
|
|
152
|
+
if auto_value is not None:
|
|
153
|
+
_apply_auto(track, auto_value)
|
|
126
154
|
write_file(track.path, track.meta, track.body)
|
|
127
155
|
print(f"✓ cleared next_up_order on {track.name}")
|
|
156
|
+
if auto_value is not None:
|
|
157
|
+
_print_auto_result(track, auto_value)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
# If --auto is the only flag (no preset/order/clear), write just the auto flag.
|
|
161
|
+
if preset_flag is None and order_list is None and auto_value is not None:
|
|
162
|
+
_apply_auto(track, auto_value)
|
|
163
|
+
write_file(track.path, track.meta, track.body)
|
|
164
|
+
_print_auto_result(track, auto_value)
|
|
128
165
|
return 0
|
|
129
166
|
|
|
130
167
|
# Build the next_up_order mapping
|
|
@@ -145,10 +182,29 @@ def run(args: list[str]) -> int:
|
|
|
145
182
|
)
|
|
146
183
|
|
|
147
184
|
track.meta["next_up_order"] = nuo
|
|
185
|
+
if auto_value is not None:
|
|
186
|
+
_apply_auto(track, auto_value)
|
|
148
187
|
write_file(track.path, track.meta, track.body)
|
|
149
188
|
|
|
150
189
|
if preset_flag and preset_flag != "custom":
|
|
151
190
|
print(f"✓ set next_up_order preset={preset_flag!r} on {track.name}")
|
|
152
191
|
elif order_list is not None:
|
|
153
192
|
print(f"✓ set next_up_order custom order={order_list!r} on {track.name}")
|
|
193
|
+
if auto_value is not None:
|
|
194
|
+
_print_auto_result(track, auto_value)
|
|
154
195
|
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_auto(track: "SimpleNamespace", auto_value: bool) -> None:
|
|
199
|
+
"""Mutate track.meta to set or remove next_up_auto."""
|
|
200
|
+
if auto_value:
|
|
201
|
+
track.meta["next_up_auto"] = True
|
|
202
|
+
else:
|
|
203
|
+
track.meta.pop("next_up_auto", None)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _print_auto_result(track: "SimpleNamespace", auto_value: bool) -> None:
|
|
207
|
+
if auto_value:
|
|
208
|
+
print(f"✓ set next_up_auto=true on {track.name}")
|
|
209
|
+
else:
|
|
210
|
+
print(f"✓ cleared next_up_auto on {track.name}")
|
|
@@ -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:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Build the versioned viewer export structure from tracks + fetched issues."""
|
|
2
2
|
from lib.github_state import format_assignees, short_milestone
|
|
3
|
-
from lib.next_up import resolve_next_up_order
|
|
3
|
+
from lib.next_up import resolve_next_up_order, suggest_next_up
|
|
4
4
|
|
|
5
5
|
SCHEMA = 1
|
|
6
6
|
|
|
@@ -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": []}
|
|
@@ -110,9 +110,22 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
110
110
|
issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
111
111
|
opened = sum(1 for i in issues if i["state"] == "open")
|
|
112
112
|
closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
|
|
113
|
-
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
114
113
|
track_path = getattr(t, "path", None)
|
|
115
|
-
next_up_preset_name,
|
|
114
|
+
next_up_preset_name, next_up_order = resolve_next_up_order(t.meta, next_up_default)
|
|
115
|
+
# When `next_up_auto: true`, derive the next-up list live from the track's
|
|
116
|
+
# open issues using the resolved ranking preset — same as brief/orient —
|
|
117
|
+
# instead of reading the curated `next_up` frontmatter list. This is what
|
|
118
|
+
# surfaces the ranking in the viewer (which only reads this export). #326.
|
|
119
|
+
if t.meta.get("next_up_auto") and raw:
|
|
120
|
+
in_progress_set = {i["number"] for i in raw if issue_in_progress(i, hot)}
|
|
121
|
+
next_up = suggest_next_up(
|
|
122
|
+
raw, t.meta.get("blockers") or [],
|
|
123
|
+
track_milestone=milestone_alignment or None,
|
|
124
|
+
in_progress_nums=in_progress_set,
|
|
125
|
+
order=next_up_order,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
116
129
|
out["tracks"].append({
|
|
117
130
|
"name": t.name,
|
|
118
131
|
"repo": t.repo,
|
|
@@ -140,6 +153,11 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
140
153
|
"plan": plan_by_track.get(t.name),
|
|
141
154
|
# Effective next_up ranking preset for this track (#326 Phase 2).
|
|
142
155
|
"next_up_preset": next_up_preset_name,
|
|
156
|
+
# True when the track has `next_up_auto: true` set in its frontmatter,
|
|
157
|
+
# meaning the next-up list is auto-derived from the ranking preset (#326).
|
|
158
|
+
# Reflects the SETTING, not whether derivation actually ran (so the
|
|
159
|
+
# viewer toggle shows On even when a track has zero open issues).
|
|
160
|
+
"next_up_auto": bool(t.meta.get("next_up_auto")),
|
|
143
161
|
})
|
|
144
162
|
out["untracked"] = [
|
|
145
163
|
{"repo": repo, "issues": [normalize_issue(r) for r in rows]}
|
|
@@ -151,4 +169,9 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
151
169
|
# the starting point for adding fresh tracks. Each entry:
|
|
152
170
|
# {folder, repo(slug), local, has_local, visibility}.
|
|
153
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 [])
|
|
154
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()
|