@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.
@@ -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>] (--preset=<name> | --order=a,b,c | --clear) [--confirm=<token>]
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. Does NOT touch the
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 --clear
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 --clear",
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, _ = resolve_next_up_order(t.meta, next_up_default)
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()