@stylusnexus/work-plan 2026.6.15 → 2026.6.18

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