@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.
- package/README.md +14 -12
- package/VERSION +1 -1
- package/package.json +1 -5
- package/skills/work-plan/SKILL.md +2 -0
- package/skills/work-plan/commands/auto_triage.py +187 -35
- package/skills/work-plan/commands/batch_slot.py +58 -33
- 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/slot.py +53 -24
- 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/heuristic_triage.py +134 -0
- package/skills/work-plan/lib/membership_guard.py +152 -0
- package/skills/work-plan/lib/plan_worktree.py +37 -0
- package/skills/work-plan/lib/tracks.py +64 -2
- package/skills/work-plan/tests/test_auto_triage.py +103 -0
- package/skills/work-plan/tests/test_batch_slot.py +19 -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_heuristic_triage.py +114 -0
- package/skills/work-plan/tests/test_membership_guard.py +116 -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/tests/test_shared_rebase_guard.py +154 -0
- package/skills/work-plan/tests/test_slot.py +94 -2
- package/skills/work-plan/tests/test_slot_move.py +10 -1
- package/skills/work-plan/work_plan.py +17 -7
- 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
|
-
|
|
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
|
|
@@ -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
|
|
100
|
-
tracked = set(repo_to_numbers[
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
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 "
|
|
@@ -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}")
|