@stylusnexus/work-plan 2026.6.14-1 → 2026.6.15
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 +2 -0
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +10 -3
- package/skills/work-plan/commands/export.py +3 -1
- package/skills/work-plan/commands/handoff.py +12 -3
- package/skills/work-plan/commands/set_next_up.py +154 -0
- package/skills/work-plan/lib/export_model.py +6 -1
- package/skills/work-plan/lib/next_up.py +152 -29
- package/skills/work-plan/tests/test_export.py +53 -0
- package/skills/work-plan/tests/test_next_up.py +281 -8
- package/skills/work-plan/tests/test_set_next_up.py +201 -0
- package/skills/work-plan/work_plan.py +5 -0
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ flowchart TB
|
|
|
108
108
|
- `handoff <track> --set-next 4167,4148` — explicit numbers when you know exactly which issues are next.
|
|
109
109
|
- Free-form via Claude in your agent session, which can review project memory and write a curated list back. The two `--*-next` flags are the no-LLM paths.
|
|
110
110
|
- For tracks where you don't want to bother curating at all, set `next_up_auto: true` in the track's frontmatter — `brief` will then derive the list live each invocation, ignoring whatever's stored.
|
|
111
|
+
- **Ranking presets** — when `next_up_auto: true` is on, the default ranking is `flow` (milestone → dependency → priority → recency). Override per-track with `set-next-up <track> --preset=<name>`, or set `next_up_default: <name>` in your config for a global fallback. Named presets: `flow` (the default), `priority-driven` (priority first, no milestone bias — good for backlogs with no milestones), `backlog` (oldest issues first — surfaces stalled work). Custom criterion order: `set-next-up <track> --order=aging,priority,dependency`. Clear a track's override with `--clear`.
|
|
111
112
|
- **Weekly** → `hygiene` runs `refresh-md --all` + `reconcile --all` + `duplicates` in sequence to keep status icons, GitHub labels, and dedup state honest.
|
|
112
113
|
|
|
113
114
|
> **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md <track>` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly.
|
|
@@ -522,6 +523,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
522
523
|
| `remove-repo <key>` | Unregister a repo: delete its block from your config. **Config-only** — the notes folder, any tracks, and the local clone are left untouched (a notes folder or tracks that referenced it are now orphaned and can be removed by hand). Completes the add/update/remove trio with `init-repo`. |
|
|
523
524
|
| `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
|
|
524
525
|
| `rename-track <old-slug \| old@repo> <new-slug> [--repo=<key>] [--fix-refs] [--commit]` | Rename an active track's slug: moves its `.md` file and updates the frontmatter `track` field + `last_touched`. Validates `<new-slug>` like `new-track` and rejects a name already taken in the same repo/tier. For shared tracks, `--commit` stages + commits the move (otherwise it prints a "commit to share" hint). `--fix-refs` rewrites sibling tracks' `depends_on` that reference the old slug; without it they're just warned about. Archived tracks are immutable. Public-repo gated. |
|
|
526
|
+
| `set-next-up <track> (--preset=<name> \| --order=a,b,c \| --clear) [--repo=<key>] [--confirm=<token>]` | Configure the ranking preset used when `next_up_auto: true` computes a suggestion. `--preset` sets a named preset: `flow` (default — milestone → dependency → priority → recency), `priority-driven` (priority first, ignores milestone bias, for backlogs with no milestones), `backlog` (oldest issues first — surfaces stalled work), or `custom` (requires `--order`). `--order=a,b,c` sets a custom comma-separated criterion list (`milestone`, `dependency`, `priority`, `recency`, `aging`). `--clear` reverts to the global `next_up_default` config or the default `flow`. Writes `next_up_order` into the track's frontmatter; does NOT touch the `next_up` issue-list. Global default: add `next_up_default: <preset>` to `~/.claude/work-plan/config.yml`. Public-repo gated. |
|
|
525
527
|
| `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
|
|
526
528
|
| `notes-vcs <init\|enable\|disable\|status\|undo> [<sha>] [--no-enable] [--json]` | Opt-in **local** version control for the private `notes_root` tier — history/undo for tracks on your machine, never pushed. `init` git-inits `notes_root` as a personal repo (baseline commit of existing tracks) and turns on auto-commit (`--no-enable` skips that). For safety it **refuses** a `notes_root` that already has a git remote or is a repo work-plan didn't create. When on, every track-mutating command (`slot`/`group`/`handoff`/`close`/`set`/…) commits **only the files it changed** (pre-existing uncommitted edits are left alone) as an undoable commit. The shared tier is unaffected — it's versioned by its own repo. `status` shows whether `notes_root` is a repo, whether auto-commit is on, and the last commit (`--json` for the machine shape the VS Code viewer polls). `undo [<sha>]` reverts a commit (default HEAD) — reverses the last edit. |
|
|
527
529
|
| `push-track <track\|track@repo> [--repo=<key>] [--no-push] [--confirm=<token>]` | **Promote a private track to the shared tier and publish it** (#306). Moves the track's `.md` from `notes_root` into the repo's `.work-plan/` (on its `plan_branch`, via a worktree), removes the private copy so it isn't duplicated, commits to the plan branch, and pushes — unless `--no-push`. Tier is derived from location, so this is a file move, not a frontmatter edit. Requires a local clone + a configured `plan_branch` (else hints `plan-branch init`). Pushing to a **public** repo makes the track world-visible → confirm-token gated. |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.15+d52d670
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.15",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -17,7 +17,7 @@ from lib.git_state import (
|
|
|
17
17
|
from lib.in_progress import issue_in_progress
|
|
18
18
|
from lib.closure import compute_signals, is_closure_ready
|
|
19
19
|
from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
|
|
20
|
-
from lib.next_up import suggest_next_up
|
|
20
|
+
from lib.next_up import suggest_next_up, resolve_next_up_order
|
|
21
21
|
from lib.drift import detect_drift
|
|
22
22
|
from lib.render import time_aware_framing, render_track_row, render_archived_reopen
|
|
23
23
|
|
|
@@ -121,15 +121,22 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
|
|
|
121
121
|
# for display purposes — useful for tracks where you don't want to
|
|
122
122
|
# hand-curate but still want a sensible "what's next" surfaced.
|
|
123
123
|
track_milestone = meta.get("milestone_alignment") or None
|
|
124
|
+
hot_nums = hot_issue_numbers(local) if local else set()
|
|
124
125
|
if meta.get("next_up_auto") and issues:
|
|
125
126
|
blocker_nums = meta.get("blockers") or []
|
|
126
|
-
|
|
127
|
+
in_progress_set = {i["number"] for i in issues if issue_in_progress(i, hot_nums)}
|
|
128
|
+
_, order = resolve_next_up_order(meta, cfg.get("next_up_default"))
|
|
129
|
+
next_up_nums = suggest_next_up(
|
|
130
|
+
issues, blocker_nums,
|
|
131
|
+
track_milestone=track_milestone,
|
|
132
|
+
in_progress_nums=in_progress_set,
|
|
133
|
+
order=order,
|
|
134
|
+
)
|
|
127
135
|
else:
|
|
128
136
|
next_up_nums = stored_next_up
|
|
129
137
|
|
|
130
138
|
next_up_items = []
|
|
131
139
|
next_up_closed_count = 0
|
|
132
|
-
hot_nums = hot_issue_numbers(local) if local else set()
|
|
133
140
|
for num in next_up_nums:
|
|
134
141
|
i = issues_by_num.get(num)
|
|
135
142
|
if not i:
|
|
@@ -140,13 +140,15 @@ def run(args: list[str]) -> int:
|
|
|
140
140
|
if nums:
|
|
141
141
|
hot_by_track[(t.repo, t.name)] = nums
|
|
142
142
|
|
|
143
|
+
next_up_default = cfg.get("next_up_default")
|
|
143
144
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
144
145
|
print(json.dumps(
|
|
145
146
|
build_export(tracks, issues_by_track, visibility, now,
|
|
146
147
|
untracked_by_repo=untracked_by_repo,
|
|
147
148
|
config_repos=config_repos,
|
|
148
149
|
plan_by_track=plan_by_track,
|
|
149
|
-
hot_by_track=hot_by_track
|
|
150
|
+
hot_by_track=hot_by_track,
|
|
151
|
+
next_up_default=next_up_default),
|
|
150
152
|
indent=2,
|
|
151
153
|
))
|
|
152
154
|
return 0
|
|
@@ -19,12 +19,13 @@ from lib.session_log import append_session_log, SESSION_LOG_HEADER
|
|
|
19
19
|
from lib.git_state import (
|
|
20
20
|
has_uncommitted, current_branch, parse_iso_timestamp,
|
|
21
21
|
gap_seconds_to_label, uncommitted_file_count, commits_ahead,
|
|
22
|
-
is_safe_ref, GIT_TIMEOUT,
|
|
22
|
+
is_safe_ref, GIT_TIMEOUT, hot_issue_numbers,
|
|
23
23
|
)
|
|
24
24
|
from lib.github_state import fetch_issues, state_to_status_label, extract_priority, short_milestone
|
|
25
|
+
from lib.in_progress import issue_in_progress
|
|
25
26
|
from lib.status_table import update_row_status, sync_missing_rows, find_canonical_status_tables, ISSUE_NUM_RE
|
|
26
27
|
from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
|
|
27
|
-
from lib.next_up import suggest_next_up
|
|
28
|
+
from lib.next_up import suggest_next_up, resolve_next_up_order
|
|
28
29
|
from lib.prompts import prompt_lines, parse_flags, prompt_input
|
|
29
30
|
|
|
30
31
|
|
|
@@ -152,7 +153,15 @@ def _apply_auto_next(track, cfg: dict) -> int:
|
|
|
152
153
|
issues = fetch_issues(track.repo, issue_nums)
|
|
153
154
|
blocker_nums = track.meta.get("blockers") or []
|
|
154
155
|
track_milestone = track.meta.get("milestone_alignment") or None
|
|
155
|
-
|
|
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
|
+
)
|
|
156
165
|
if not raw_suggestion:
|
|
157
166
|
print(f"No open, non-blocker issues for {track.name}; next_up unchanged.")
|
|
158
167
|
return 0
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""set-next-up subcommand — guarded edit of a track's next_up ranking preset.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
work_plan.py set-next-up <track> [--repo=<key>] (--preset=<name> | --order=a,b,c | --clear) [--confirm=<token>]
|
|
5
|
+
|
|
6
|
+
Writes `next_up_order` into the track's frontmatter. Does NOT touch the
|
|
7
|
+
`next_up` issue-list key.
|
|
8
|
+
|
|
9
|
+
Public-repo gated: without --confirm it prints {needs_confirm, reason, token}
|
|
10
|
+
and makes no change. The VS Code extension surfaces that as a modal then
|
|
11
|
+
re-invokes with --confirm=<token>.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from lib.config import load_config, ConfigError
|
|
16
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
17
|
+
from lib.frontmatter import write_file
|
|
18
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
19
|
+
from lib.prompts import parse_flags
|
|
20
|
+
from lib.next_up import CRITERIA, PRESETS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(args: list[str]) -> int:
|
|
24
|
+
flags, positional = parse_flags(
|
|
25
|
+
args, {"--confirm", "--repo", "--clear", "--preset", "--order"}
|
|
26
|
+
)
|
|
27
|
+
if not positional:
|
|
28
|
+
print(
|
|
29
|
+
"usage: work_plan.py set-next-up <track> "
|
|
30
|
+
"(--preset=<name> | --order=a,b,c | --clear) "
|
|
31
|
+
"[--repo=<key>] [--confirm=<token>]"
|
|
32
|
+
)
|
|
33
|
+
return 2
|
|
34
|
+
|
|
35
|
+
track_arg = positional[0]
|
|
36
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
37
|
+
name = name_from_arg
|
|
38
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
39
|
+
repo_qualifier = repo_from_arg or repo_flag
|
|
40
|
+
|
|
41
|
+
clear = bool(flags.get("--clear"))
|
|
42
|
+
preset_flag = flags.get("--preset") if flags.get("--preset") is not True else None
|
|
43
|
+
order_flag = flags.get("--order") if flags.get("--order") is not True else None
|
|
44
|
+
|
|
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:
|
|
47
|
+
print(
|
|
48
|
+
"ERROR: specify --preset=<name>, --order=a,b,c, or --clear",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
return 2
|
|
52
|
+
|
|
53
|
+
# Validate preset name
|
|
54
|
+
if preset_flag is not None:
|
|
55
|
+
valid_presets = set(PRESETS.keys()) | {"custom"}
|
|
56
|
+
if preset_flag not in valid_presets:
|
|
57
|
+
print(
|
|
58
|
+
f"ERROR: unknown preset {preset_flag!r} "
|
|
59
|
+
f"(allowed: {sorted(valid_presets)})",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
return 2
|
|
63
|
+
# 'custom' requires --order
|
|
64
|
+
if preset_flag == "custom" and order_flag is None:
|
|
65
|
+
print(
|
|
66
|
+
"ERROR: --preset=custom requires --order=<criteria>",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
return 2
|
|
70
|
+
|
|
71
|
+
# Validate order criteria
|
|
72
|
+
order_list = None
|
|
73
|
+
if order_flag is not None:
|
|
74
|
+
raw_criteria = [c.strip() for c in order_flag.split(",") if c.strip()]
|
|
75
|
+
invalid = [c for c in raw_criteria if c not in CRITERIA]
|
|
76
|
+
if invalid:
|
|
77
|
+
print(
|
|
78
|
+
f"ERROR: unknown criteria {invalid!r} "
|
|
79
|
+
f"(allowed: {list(CRITERIA)})",
|
|
80
|
+
file=sys.stderr,
|
|
81
|
+
)
|
|
82
|
+
return 2
|
|
83
|
+
if not raw_criteria:
|
|
84
|
+
print("ERROR: --order requires at least one criterion", file=sys.stderr)
|
|
85
|
+
return 2
|
|
86
|
+
order_list = raw_criteria
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
cfg = load_config()
|
|
90
|
+
except ConfigError as e:
|
|
91
|
+
print(f"ERROR: {e}")
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
track = find_track_by_name(name, discover_tracks(cfg), repo=repo_qualifier)
|
|
96
|
+
except AmbiguousTrackError as e:
|
|
97
|
+
print(str(e))
|
|
98
|
+
return 1
|
|
99
|
+
if not track:
|
|
100
|
+
print(f"No track matching {name!r}.")
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
# Public-repo confirm gate
|
|
104
|
+
confirm = flags.get("--confirm")
|
|
105
|
+
if (
|
|
106
|
+
track.repo
|
|
107
|
+
and needs_confirm(track.repo, cfg)
|
|
108
|
+
and not (isinstance(confirm, str) and valid_token(confirm, track.repo, track.name))
|
|
109
|
+
):
|
|
110
|
+
print(
|
|
111
|
+
json.dumps(
|
|
112
|
+
{
|
|
113
|
+
"needs_confirm": True,
|
|
114
|
+
"reason": (
|
|
115
|
+
f"{track.repo} is PUBLIC (or visibility unknown); "
|
|
116
|
+
"edit will be written there."
|
|
117
|
+
),
|
|
118
|
+
"token": make_token(track.repo, track.name),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
if clear:
|
|
125
|
+
track.meta.pop("next_up_order", None)
|
|
126
|
+
write_file(track.path, track.meta, track.body)
|
|
127
|
+
print(f"✓ cleared next_up_order on {track.name}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
# Build the next_up_order mapping
|
|
131
|
+
if preset_flag == "custom" or (preset_flag is None and order_list is not None):
|
|
132
|
+
# Custom order (either explicit --preset=custom or bare --order)
|
|
133
|
+
nuo = {"preset": "custom", "order": order_list}
|
|
134
|
+
else:
|
|
135
|
+
# Named preset. A named preset supplies its own criterion order, so a
|
|
136
|
+
# co-supplied --order has no effect — warn (advisory, don't reject) so
|
|
137
|
+
# the user isn't surprised it was dropped.
|
|
138
|
+
nuo = {"preset": preset_flag}
|
|
139
|
+
if order_list is not None:
|
|
140
|
+
print(
|
|
141
|
+
f"WARN: --order is ignored when a named preset "
|
|
142
|
+
f"(--preset={preset_flag}) is given; use --preset=custom "
|
|
143
|
+
"to supply your own order.",
|
|
144
|
+
file=sys.stderr,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
track.meta["next_up_order"] = nuo
|
|
148
|
+
write_file(track.path, track.meta, track.body)
|
|
149
|
+
|
|
150
|
+
if preset_flag and preset_flag != "custom":
|
|
151
|
+
print(f"✓ set next_up_order preset={preset_flag!r} on {track.name}")
|
|
152
|
+
elif order_list is not None:
|
|
153
|
+
print(f"✓ set next_up_order custom order={order_list!r} on {track.name}")
|
|
154
|
+
return 0
|
|
@@ -1,5 +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
4
|
|
|
4
5
|
SCHEMA = 1
|
|
5
6
|
|
|
@@ -84,7 +85,8 @@ def normalize_issue(i: dict, in_progress: bool = False,
|
|
|
84
85
|
|
|
85
86
|
def build_export(tracks, issues_by_track, visibility, now: str,
|
|
86
87
|
untracked_by_repo=None, config_repos=None,
|
|
87
|
-
plan_by_track=None, hot_by_track=None
|
|
88
|
+
plan_by_track=None, hot_by_track=None,
|
|
89
|
+
next_up_default=None) -> dict:
|
|
88
90
|
plan_by_track = plan_by_track or {}
|
|
89
91
|
hot_by_track = hot_by_track or {}
|
|
90
92
|
out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
|
|
@@ -110,6 +112,7 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
110
112
|
closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
|
|
111
113
|
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
112
114
|
track_path = getattr(t, "path", None)
|
|
115
|
+
next_up_preset_name, _ = resolve_next_up_order(t.meta, next_up_default)
|
|
113
116
|
out["tracks"].append({
|
|
114
117
|
"name": t.name,
|
|
115
118
|
"repo": t.repo,
|
|
@@ -135,6 +138,8 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
135
138
|
# null when the track declares no `plan:`. `{rel, resolved:false}` when
|
|
136
139
|
# the link can't be resolved (no local clone / file absent).
|
|
137
140
|
"plan": plan_by_track.get(t.name),
|
|
141
|
+
# Effective next_up ranking preset for this track (#326 Phase 2).
|
|
142
|
+
"next_up_preset": next_up_preset_name,
|
|
138
143
|
})
|
|
139
144
|
out["untracked"] = [
|
|
140
145
|
{"repo": repo, "issues": [normalize_issue(r) for r in rows]}
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
"""Compute a suggested `next_up` issue list for a track.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Default sort policy (all filters/keys applied in this order):
|
|
4
|
+
|
|
5
|
+
Eligibility (filter, in order):
|
|
6
|
+
1. Drop non-OPEN issues.
|
|
7
|
+
2. Drop issues whose number is in blocker_nums (manual track blockers).
|
|
8
|
+
3. Drop issues that have a non-empty `blocked_by` list — the dependency
|
|
9
|
+
gate — UNLESS the issue is in-progress (an in-progress issue is never
|
|
10
|
+
gated out; you're already working it).
|
|
11
|
+
|
|
12
|
+
Sort key (lexicographic, ascending = comes first) over survivors:
|
|
13
|
+
1. in-progress: 0 if in-progress else 1 (in-progress floats to top)
|
|
14
|
+
2..N. the preset-configurable MIDDLE dimensions (see below)
|
|
15
|
+
last. issue number: ascending (deterministic final tiebreak)
|
|
16
|
+
|
|
17
|
+
The in-progress prefix and the number tiebreak are ALWAYS-ON. The middle
|
|
18
|
+
dimensions are configurable per track via the `order` param — a list of
|
|
19
|
+
criterion names drawn from CRITERIA (`milestone`, `dependency`, `priority`,
|
|
20
|
+
`recency`, and `aging` — oldest-first, for surfacing stalled work). Named
|
|
21
|
+
bundles live in PRESETS (default `flow`, plus `priority-driven` and
|
|
22
|
+
`backlog`); `resolve_next_up_order` maps a track's frontmatter / global config
|
|
23
|
+
to an effective order. `order=None` falls back to PRESETS["flow"], which
|
|
24
|
+
reproduces the historical fixed policy (milestone → -fan_out → priority →
|
|
25
|
+
-recency).
|
|
7
26
|
|
|
8
27
|
Used by:
|
|
9
28
|
- `commands/handoff.py` — `--auto-next` flag prompts the user to apply
|
|
@@ -19,7 +38,7 @@ they go here.
|
|
|
19
38
|
from __future__ import annotations
|
|
20
39
|
|
|
21
40
|
from datetime import datetime
|
|
22
|
-
from typing import Iterable
|
|
41
|
+
from typing import Iterable, Optional
|
|
23
42
|
|
|
24
43
|
from lib.github_state import extract_priority, short_milestone
|
|
25
44
|
|
|
@@ -32,6 +51,17 @@ MILESTONE_ALIGNED = 0
|
|
|
32
51
|
MILESTONE_OTHER = 1
|
|
33
52
|
MILESTONE_NONE = 2
|
|
34
53
|
|
|
54
|
+
# Available sort criteria and their named preset bundles.
|
|
55
|
+
CRITERIA = ("milestone", "dependency", "priority", "recency", "aging")
|
|
56
|
+
|
|
57
|
+
PRESETS = {
|
|
58
|
+
"flow": ["milestone", "dependency", "priority", "recency"],
|
|
59
|
+
"priority-driven": ["priority", "dependency", "recency"],
|
|
60
|
+
"backlog": ["aging", "priority"],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
DEFAULT_PRESET = "flow"
|
|
64
|
+
|
|
35
65
|
|
|
36
66
|
def _updated_unix(issue: dict) -> float:
|
|
37
67
|
"""Parse the gh-formatted updatedAt field to a unix timestamp.
|
|
@@ -50,49 +80,142 @@ def _updated_unix(issue: dict) -> float:
|
|
|
50
80
|
return 0.0
|
|
51
81
|
|
|
52
82
|
|
|
83
|
+
def _fan_out(issue: dict) -> int:
|
|
84
|
+
"""Return the number of open blocking edges this issue unblocks.
|
|
85
|
+
|
|
86
|
+
A higher value means merging this issue unblocks more downstream work.
|
|
87
|
+
Uses `.get(...) or []` so a missing key is treated as zero fan-out.
|
|
88
|
+
"""
|
|
89
|
+
return len(issue.get("blocking") or [])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _criterion_scalar(criterion: str, issue: dict,
|
|
93
|
+
track_milestone: Optional[str]) -> float:
|
|
94
|
+
"""Return a single ascending sort scalar for one criterion.
|
|
95
|
+
|
|
96
|
+
Lower value = ranks first (since we sort ascending).
|
|
97
|
+
Unknown criterion names return 0.0 (neutral; caller should skip them).
|
|
98
|
+
"""
|
|
99
|
+
if criterion == "milestone":
|
|
100
|
+
ms = short_milestone(issue.get("milestone"))
|
|
101
|
+
if not ms:
|
|
102
|
+
return float(MILESTONE_NONE)
|
|
103
|
+
if track_milestone and ms == track_milestone:
|
|
104
|
+
return float(MILESTONE_ALIGNED)
|
|
105
|
+
return float(MILESTONE_OTHER)
|
|
106
|
+
if criterion == "dependency":
|
|
107
|
+
return float(-_fan_out(issue))
|
|
108
|
+
if criterion == "priority":
|
|
109
|
+
pri = extract_priority(issue.get("labels", []))
|
|
110
|
+
return float(PRIORITY_RANK.get(pri, 3))
|
|
111
|
+
if criterion == "recency":
|
|
112
|
+
return float(-_updated_unix(issue))
|
|
113
|
+
if criterion == "aging":
|
|
114
|
+
return float(_updated_unix(issue)) # ascending = oldest first
|
|
115
|
+
return 0.0 # unknown criterion — neutral
|
|
116
|
+
|
|
117
|
+
|
|
53
118
|
def suggest_next_up(
|
|
54
119
|
issues: list[dict],
|
|
55
|
-
blocker_nums: Iterable[int]
|
|
120
|
+
blocker_nums: Optional[Iterable[int]] = None,
|
|
56
121
|
n: int = DEFAULT_TOP_N,
|
|
57
|
-
track_milestone: str
|
|
122
|
+
track_milestone: Optional[str] = None,
|
|
123
|
+
in_progress_nums: Optional[Iterable[int]] = None,
|
|
124
|
+
order: Optional[list[str]] = None,
|
|
58
125
|
) -> list[int]:
|
|
59
126
|
"""Return up to `n` issue numbers ranked for "what to work on next."
|
|
60
127
|
|
|
61
128
|
Args:
|
|
62
129
|
issues: issue dicts as returned by `gh issue list --json
|
|
63
|
-
number,state,labels,milestone,updatedAt,...`.
|
|
130
|
+
number,state,labels,milestone,updatedAt,blocked_by,blocking,...`.
|
|
64
131
|
blocker_nums: iterable of issue numbers to exclude (a track's
|
|
65
|
-
manually-flagged blockers).
|
|
132
|
+
manually-flagged blockers). These are ALWAYS excluded, even if
|
|
133
|
+
the issue is in-progress.
|
|
66
134
|
n: maximum items to return. Default is DEFAULT_TOP_N.
|
|
67
135
|
track_milestone: optional `milestone_alignment:` value from the
|
|
68
136
|
track's frontmatter (e.g. `"v0.4.0"`). When provided, issues
|
|
69
137
|
on this milestone rank above items on any other milestone,
|
|
70
|
-
which in turn rank above items with no milestone
|
|
71
|
-
|
|
138
|
+
which in turn rank above items with no milestone.
|
|
139
|
+
in_progress_nums: optional set of issue numbers currently in-progress
|
|
140
|
+
(label or hot branch). In-progress issues float to the top of the
|
|
141
|
+
ranked list and are also exempt from the `blocked_by` gate — you
|
|
142
|
+
are already working them, so they must stay visible. When None
|
|
143
|
+
(or empty), no in-progress boost is applied.
|
|
144
|
+
order: optional list of criterion names (from CRITERIA) controlling
|
|
145
|
+
the sort dimensions after the in-progress prefix and before the
|
|
146
|
+
number tiebreak. None defaults to PRESETS[DEFAULT_PRESET] (="flow"),
|
|
147
|
+
producing identical results to Phase 1 behaviour. Unknown criterion
|
|
148
|
+
names in `order` are silently skipped (defensive).
|
|
72
149
|
|
|
73
150
|
Returns:
|
|
74
151
|
List of issue numbers, highest-ranked first. Empty if nothing
|
|
75
152
|
qualifies (e.g., everything closed or blocked).
|
|
76
153
|
"""
|
|
77
154
|
blockers = set(blocker_nums or [])
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
]
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
155
|
+
in_progress = set(in_progress_nums or [])
|
|
156
|
+
# Resolve order: None → default preset; unknown names in list → skipped.
|
|
157
|
+
effective_order = order if order is not None else PRESETS[DEFAULT_PRESET]
|
|
158
|
+
|
|
159
|
+
candidates = []
|
|
160
|
+
for i in issues:
|
|
161
|
+
if str(i.get("state", "")).upper() != "OPEN":
|
|
162
|
+
continue
|
|
163
|
+
num = i.get("number")
|
|
164
|
+
# Manual blocker_nums always excluded — in-progress does NOT override.
|
|
165
|
+
if num in blockers:
|
|
166
|
+
continue
|
|
167
|
+
# Dependency gate: skip if blocked_by is non-empty, UNLESS in-progress.
|
|
168
|
+
if (i.get("blocked_by") or []) and num not in in_progress:
|
|
169
|
+
continue
|
|
170
|
+
candidates.append(i)
|
|
171
|
+
|
|
172
|
+
def sort_key(issue: dict) -> tuple:
|
|
173
|
+
num = issue.get("number")
|
|
174
|
+
in_prog_rank = 0 if num in in_progress else 1
|
|
175
|
+
criterion_scalars = tuple(
|
|
176
|
+
_criterion_scalar(c, issue, track_milestone)
|
|
177
|
+
for c in effective_order
|
|
178
|
+
if c in CRITERIA # skip unknown criteria
|
|
179
|
+
)
|
|
180
|
+
return (in_prog_rank,) + criterion_scalars + (num,)
|
|
96
181
|
|
|
97
182
|
candidates.sort(key=sort_key)
|
|
98
183
|
return [i["number"] for i in candidates[:n]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def resolve_next_up_order(track_meta: dict,
|
|
187
|
+
default_preset: Optional[str] = None) -> tuple:
|
|
188
|
+
"""Return (effective_preset_name, order_list) for a track.
|
|
189
|
+
|
|
190
|
+
Resolution priority:
|
|
191
|
+
1. track frontmatter next_up_order.preset (or next_up_order.order if
|
|
192
|
+
preset=='custom')
|
|
193
|
+
2. global default_preset param
|
|
194
|
+
3. DEFAULT_PRESET ("flow")
|
|
195
|
+
|
|
196
|
+
Unknown preset names fall back to DEFAULT_PRESET.
|
|
197
|
+
'custom' preset uses track_meta['next_up_order']['order'] (validated
|
|
198
|
+
against CRITERIA; invalid/empty list → DEFAULT_PRESET's order).
|
|
199
|
+
|
|
200
|
+
IMPORTANT: reads from 'next_up_order' key (a mapping), NOT 'next_up'
|
|
201
|
+
(the issue-list).
|
|
202
|
+
"""
|
|
203
|
+
nuo = track_meta.get("next_up_order")
|
|
204
|
+
if isinstance(nuo, dict):
|
|
205
|
+
preset = nuo.get("preset")
|
|
206
|
+
if preset == "custom":
|
|
207
|
+
raw_order = nuo.get("order") or []
|
|
208
|
+
# Validate: all entries must be in CRITERIA
|
|
209
|
+
valid = [c for c in raw_order if c in CRITERIA]
|
|
210
|
+
if valid:
|
|
211
|
+
return ("custom", valid)
|
|
212
|
+
# Invalid or empty custom order → fall through to default
|
|
213
|
+
elif preset in PRESETS:
|
|
214
|
+
return (preset, PRESETS[preset])
|
|
215
|
+
# Unknown preset name falls through to global default
|
|
216
|
+
|
|
217
|
+
# Global default
|
|
218
|
+
if default_preset and default_preset in PRESETS:
|
|
219
|
+
return (default_preset, PRESETS[default_preset])
|
|
220
|
+
|
|
221
|
+
return (DEFAULT_PRESET, PRESETS[DEFAULT_PRESET])
|
|
@@ -35,6 +35,9 @@ class BuildExportTest(unittest.TestCase):
|
|
|
35
35
|
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
36
36
|
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
37
37
|
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None, "in_progress": False, "in_progress_label": False, "blocked_by": [], "blocking": []})
|
|
38
|
+
# Phase 2: next_up_preset must be present in every track
|
|
39
|
+
self.assertIn("next_up_preset", t)
|
|
40
|
+
self.assertEqual(t["next_up_preset"], "flow") # default when no next_up_order in meta
|
|
38
41
|
json.dumps(out) # must be serializable
|
|
39
42
|
|
|
40
43
|
def test_path_is_null_when_track_has_no_path(self):
|
|
@@ -459,6 +462,56 @@ class InProgressExportTest(unittest.TestCase):
|
|
|
459
462
|
self.assertFalse(issue["in_progress_label"]) # no label present
|
|
460
463
|
|
|
461
464
|
|
|
465
|
+
class BuildExportNextUpPresetTest(unittest.TestCase):
|
|
466
|
+
"""Tests that build_export emits next_up_preset on each track (#326 Phase 2)."""
|
|
467
|
+
|
|
468
|
+
def _build(self, track_meta_override=None, next_up_default=None):
|
|
469
|
+
from types import SimpleNamespace
|
|
470
|
+
meta = {
|
|
471
|
+
"status": "active",
|
|
472
|
+
"launch_priority": "P2",
|
|
473
|
+
"milestone_alignment": "v1",
|
|
474
|
+
"blockers": [],
|
|
475
|
+
"next_up": [],
|
|
476
|
+
"depends_on": [],
|
|
477
|
+
"github": {"repo": "o/r", "issues": []},
|
|
478
|
+
}
|
|
479
|
+
if track_meta_override:
|
|
480
|
+
meta.update(track_meta_override)
|
|
481
|
+
t = SimpleNamespace(name="alpha", repo="o/r", tier="private",
|
|
482
|
+
path=Path("/tmp/notes/alpha.md"), folder="myrepo",
|
|
483
|
+
meta=meta)
|
|
484
|
+
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"},
|
|
485
|
+
now="2026-06-14T00:00", next_up_default=next_up_default)
|
|
486
|
+
return out["tracks"][0]
|
|
487
|
+
|
|
488
|
+
def test_next_up_preset_field_present(self):
|
|
489
|
+
"""Export emits next_up_preset for each track."""
|
|
490
|
+
track = self._build()
|
|
491
|
+
self.assertIn("next_up_preset", track)
|
|
492
|
+
|
|
493
|
+
def test_next_up_preset_defaults_to_flow(self):
|
|
494
|
+
"""Track with no next_up_order → next_up_preset == 'flow'."""
|
|
495
|
+
track = self._build()
|
|
496
|
+
self.assertEqual(track["next_up_preset"], "flow")
|
|
497
|
+
|
|
498
|
+
def test_next_up_preset_reflects_track_setting(self):
|
|
499
|
+
"""Track with next_up_order: {preset: priority-driven} → next_up_preset == 'priority-driven'."""
|
|
500
|
+
track = self._build({"next_up_order": {"preset": "priority-driven"}})
|
|
501
|
+
self.assertEqual(track["next_up_preset"], "priority-driven")
|
|
502
|
+
|
|
503
|
+
def test_next_up_preset_uses_global_default(self):
|
|
504
|
+
"""Track with no next_up_order + global next_up_default='backlog' → next_up_preset == 'backlog'."""
|
|
505
|
+
track = self._build(next_up_default="backlog")
|
|
506
|
+
self.assertEqual(track["next_up_preset"], "backlog")
|
|
507
|
+
|
|
508
|
+
def test_track_setting_overrides_global_default(self):
|
|
509
|
+
"""Track-level next_up_order overrides the global next_up_default."""
|
|
510
|
+
track = self._build({"next_up_order": {"preset": "backlog"}},
|
|
511
|
+
next_up_default="priority-driven")
|
|
512
|
+
self.assertEqual(track["next_up_preset"], "backlog")
|
|
513
|
+
|
|
514
|
+
|
|
462
515
|
class BlockedByExportTest(unittest.TestCase):
|
|
463
516
|
def _track(self, name, repo):
|
|
464
517
|
from types import SimpleNamespace
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Tests for the next_up suggestion algorithm.
|
|
2
2
|
|
|
3
3
|
Covers the priority + recency sort, blocker exclusion, closed-issue filter,
|
|
4
|
-
top-N capping,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
top-N capping, `updatedAt`-missing fallback, dependency gate (blocked_by),
|
|
5
|
+
in-progress float, fan-out ranking, and the deterministic number tiebreak.
|
|
6
|
+
The algorithm has one home (lib/next_up.py) shared by handoff's --auto-next
|
|
7
|
+
flag and brief's next_up_auto: true frontmatter knob — so a regression here
|
|
8
|
+
would surface in both commands.
|
|
8
9
|
"""
|
|
9
10
|
import sys
|
|
10
11
|
import unittest
|
|
@@ -13,19 +14,33 @@ from pathlib import Path
|
|
|
13
14
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
15
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
16
|
|
|
16
|
-
from lib.next_up import suggest_next_up
|
|
17
|
+
from lib.next_up import suggest_next_up, resolve_next_up_order, PRESETS, DEFAULT_PRESET
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def _issue(num, *, state="OPEN", priority=None, updated="2026-01-01T00:00:00Z",
|
|
20
|
-
title="", milestone=None):
|
|
21
|
-
"""Build a minimal issue dict matching gh's --json output.
|
|
21
|
+
title="", milestone=None, blocked_by=None, blocking=None):
|
|
22
|
+
"""Build a minimal issue dict matching gh's --json output.
|
|
23
|
+
|
|
24
|
+
blocked_by / blocking each accept a list of {number, repo, title} dicts
|
|
25
|
+
(OPEN-filtered, as delivered by github_state after #257).
|
|
26
|
+
"""
|
|
22
27
|
labels = [{"name": f"priority/{priority}"}] if priority else []
|
|
23
28
|
ms_obj = {"title": milestone} if milestone else None
|
|
24
|
-
|
|
29
|
+
issue = {
|
|
25
30
|
"number": num, "state": state, "labels": labels,
|
|
26
31
|
"updatedAt": updated, "title": title or f"issue #{num}",
|
|
27
32
|
"milestone": ms_obj,
|
|
28
33
|
}
|
|
34
|
+
if blocked_by is not None:
|
|
35
|
+
issue["blocked_by"] = blocked_by
|
|
36
|
+
if blocking is not None:
|
|
37
|
+
issue["blocking"] = blocking
|
|
38
|
+
return issue
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dep(num, repo="stylusnexus/demo"):
|
|
42
|
+
"""Build a minimal dependency edge dict {number, repo, title}."""
|
|
43
|
+
return {"number": num, "repo": repo, "title": f"dep #{num}"}
|
|
29
44
|
|
|
30
45
|
|
|
31
46
|
class SuggestNextUpTest(unittest.TestCase):
|
|
@@ -145,5 +160,263 @@ class SuggestNextUpTest(unittest.TestCase):
|
|
|
145
160
|
self.assertEqual(result, [2, 1]) # parsable+newer wins; garbage trails
|
|
146
161
|
|
|
147
162
|
|
|
163
|
+
class InProgressAndDependencyTest(unittest.TestCase):
|
|
164
|
+
"""Phase 1 additions: dependency gate, in-progress float, fan-out, tiebreak."""
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# in-progress float
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def test_in_progress_floats_above_higher_priority(self):
|
|
171
|
+
"""An in-progress P2 should sort above a non-in-progress P0."""
|
|
172
|
+
issues = [
|
|
173
|
+
_issue(1, priority="P0"), # high priority, NOT in-progress
|
|
174
|
+
_issue(2, priority="P2"), # lower priority, IS in-progress
|
|
175
|
+
]
|
|
176
|
+
result = suggest_next_up(issues, [], in_progress_nums={2})
|
|
177
|
+
self.assertEqual(result[0], 2, "in-progress issue must float to top")
|
|
178
|
+
self.assertEqual(result[1], 1)
|
|
179
|
+
|
|
180
|
+
def test_in_progress_floats_above_milestone_aligned(self):
|
|
181
|
+
"""In-progress with no milestone still beats milestone-aligned non-in-progress."""
|
|
182
|
+
issues = [
|
|
183
|
+
_issue(1, priority="P3", milestone="v1.0 — MVP"), # aligned, not in-prog
|
|
184
|
+
_issue(2, priority="P3", milestone=None), # no milestone, in-prog
|
|
185
|
+
]
|
|
186
|
+
result = suggest_next_up(issues, [], track_milestone="v1.0",
|
|
187
|
+
in_progress_nums={2})
|
|
188
|
+
self.assertEqual(result[0], 2)
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# dependency gate
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def test_blocked_issue_excluded_from_candidates(self):
|
|
195
|
+
"""An issue with a non-empty blocked_by list is gated out of the result."""
|
|
196
|
+
issues = [
|
|
197
|
+
_issue(1, priority="P0", blocked_by=[_dep(99)]), # blocked — gated
|
|
198
|
+
_issue(2, priority="P1"), # open, unblocked
|
|
199
|
+
]
|
|
200
|
+
result = suggest_next_up(issues, [])
|
|
201
|
+
self.assertNotIn(1, result, "blocked issue must not appear")
|
|
202
|
+
self.assertIn(2, result)
|
|
203
|
+
|
|
204
|
+
def test_blocked_but_in_progress_stays_in_result(self):
|
|
205
|
+
"""An in-progress issue is never gated out by blocked_by."""
|
|
206
|
+
issues = [
|
|
207
|
+
_issue(1, priority="P0", blocked_by=[_dep(99)]), # blocked but in-progress
|
|
208
|
+
_issue(2, priority="P1"),
|
|
209
|
+
]
|
|
210
|
+
result = suggest_next_up(issues, [], in_progress_nums={1})
|
|
211
|
+
self.assertIn(1, result, "in-progress issue must survive the blocked_by gate")
|
|
212
|
+
|
|
213
|
+
def test_empty_blocked_by_list_is_not_gated(self):
|
|
214
|
+
"""An explicit empty blocked_by list is treated as unblocked."""
|
|
215
|
+
issues = [
|
|
216
|
+
_issue(1, priority="P0", blocked_by=[]),
|
|
217
|
+
_issue(2, priority="P1"),
|
|
218
|
+
]
|
|
219
|
+
result = suggest_next_up(issues, [])
|
|
220
|
+
self.assertIn(1, result, "empty blocked_by should not gate the issue")
|
|
221
|
+
|
|
222
|
+
def test_missing_blocked_by_key_is_not_gated(self):
|
|
223
|
+
"""Issues without a blocked_by key at all pass through (backward-compat)."""
|
|
224
|
+
issues = [
|
|
225
|
+
_issue(1, priority="P0"), # no blocked_by key
|
|
226
|
+
_issue(2, priority="P1"),
|
|
227
|
+
]
|
|
228
|
+
result = suggest_next_up(issues, [])
|
|
229
|
+
self.assertIn(1, result)
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# fan-out ranking
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def test_higher_fanout_ranks_above_higher_priority(self):
|
|
236
|
+
"""Fan-out (unblocking count) outranks priority within same milestone bucket."""
|
|
237
|
+
issues = [
|
|
238
|
+
_issue(1, priority="P0", # high priority, zero fan-out
|
|
239
|
+
blocking=[]),
|
|
240
|
+
_issue(2, priority="P2", # lower priority, high fan-out
|
|
241
|
+
blocking=[_dep(10), _dep(11), _dep(12)]),
|
|
242
|
+
]
|
|
243
|
+
result = suggest_next_up(issues, [])
|
|
244
|
+
self.assertEqual(result[0], 2, "fan-out beats priority")
|
|
245
|
+
self.assertEqual(result[1], 1)
|
|
246
|
+
|
|
247
|
+
def test_milestone_beats_fanout(self):
|
|
248
|
+
"""Milestone alignment beats fan-out: aligned-zero-fanout > off-milestone-high-fanout."""
|
|
249
|
+
issues = [
|
|
250
|
+
_issue(1, priority="P2", milestone="v1.0 — MVP", # aligned, no fan-out
|
|
251
|
+
blocking=[]),
|
|
252
|
+
_issue(2, priority="P2", milestone="v2.0 — Beta", # off-milestone, high fan-out
|
|
253
|
+
blocking=[_dep(10), _dep(11), _dep(12)]),
|
|
254
|
+
]
|
|
255
|
+
result = suggest_next_up(issues, [], track_milestone="v1.0")
|
|
256
|
+
self.assertEqual(result[0], 1, "milestone-aligned must beat high-fanout off-milestone")
|
|
257
|
+
|
|
258
|
+
def test_blocking_field_absent_treated_as_zero_fanout(self):
|
|
259
|
+
"""Missing blocking key → fan-out = 0; does not crash."""
|
|
260
|
+
issues = [
|
|
261
|
+
_issue(1, priority="P1"), # no blocking key
|
|
262
|
+
_issue(2, priority="P1", blocking=[_dep(10)]), # fan-out=1
|
|
263
|
+
]
|
|
264
|
+
result = suggest_next_up(issues, [])
|
|
265
|
+
self.assertEqual(result[0], 2, "fan-out=1 beats fan-out=0")
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
# deterministic tiebreak
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def test_number_tiebreak_when_all_else_equal(self):
|
|
272
|
+
"""When every sort dimension ties, lower issue number wins."""
|
|
273
|
+
issues = [
|
|
274
|
+
_issue(30, priority="P1", updated="2026-01-01T00:00:00Z"),
|
|
275
|
+
_issue(10, priority="P1", updated="2026-01-01T00:00:00Z"),
|
|
276
|
+
_issue(20, priority="P1", updated="2026-01-01T00:00:00Z"),
|
|
277
|
+
]
|
|
278
|
+
result = suggest_next_up(issues, [])
|
|
279
|
+
self.assertEqual(result, [10, 20, 30])
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
# backward-compat
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def test_no_in_progress_nums_does_not_crash(self):
|
|
286
|
+
"""Calling without in_progress_nums must not raise; no in-progress boost applied."""
|
|
287
|
+
issues = [_issue(1, priority="P0"), _issue(2, priority="P1")]
|
|
288
|
+
result = suggest_next_up(issues, []) # old call signature
|
|
289
|
+
self.assertEqual(result, [1, 2])
|
|
290
|
+
|
|
291
|
+
def test_blocked_by_excluded_without_in_progress_nums(self):
|
|
292
|
+
"""Without in_progress_nums, blocked issues are gated out (no in-prog bypass)."""
|
|
293
|
+
issues = [
|
|
294
|
+
_issue(1, priority="P0", blocked_by=[_dep(99)]),
|
|
295
|
+
_issue(2, priority="P1"),
|
|
296
|
+
]
|
|
297
|
+
result = suggest_next_up(issues, [])
|
|
298
|
+
self.assertNotIn(1, result)
|
|
299
|
+
self.assertIn(2, result)
|
|
300
|
+
|
|
301
|
+
def test_manual_blocker_still_excluded(self):
|
|
302
|
+
"""Manual blocker_nums exclusion is unaffected by the new in-progress param."""
|
|
303
|
+
issues = [
|
|
304
|
+
_issue(1, priority="P0"),
|
|
305
|
+
_issue(2, priority="P1"),
|
|
306
|
+
]
|
|
307
|
+
result = suggest_next_up(issues, [1], in_progress_nums={1})
|
|
308
|
+
# in-progress does NOT override manual blocker_nums exclusion
|
|
309
|
+
self.assertNotIn(1, result)
|
|
310
|
+
self.assertIn(2, result)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class PresetAndResolverTest(unittest.TestCase):
|
|
314
|
+
"""Phase 2: preset ordering + resolver tests."""
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------
|
|
317
|
+
# preset ordering
|
|
318
|
+
# ------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def test_flow_preset_equivalent_to_phase1_default(self):
|
|
321
|
+
"""order=None and order=PRESETS['flow'] must produce identical results."""
|
|
322
|
+
issues = [
|
|
323
|
+
_issue(1, priority="P0", milestone="v1.0"),
|
|
324
|
+
_issue(2, priority="P2", updated="2026-04-30T00:00:00Z"),
|
|
325
|
+
_issue(3, priority="P1", milestone="v1.0", blocking=[_dep(5), _dep(6)]),
|
|
326
|
+
]
|
|
327
|
+
result_none = suggest_next_up(issues, [], track_milestone="v1.0")
|
|
328
|
+
result_flow = suggest_next_up(issues, [], track_milestone="v1.0",
|
|
329
|
+
order=PRESETS["flow"])
|
|
330
|
+
self.assertEqual(result_none, result_flow,
|
|
331
|
+
"order=None must be equivalent to order=PRESETS['flow']")
|
|
332
|
+
|
|
333
|
+
def test_priority_driven_ranks_p0_above_off_milestone(self):
|
|
334
|
+
"""priority-driven: a P0 with no milestone beats a P3 on the track milestone."""
|
|
335
|
+
issues = [
|
|
336
|
+
_issue(1, priority="P0", milestone=None),
|
|
337
|
+
_issue(2, priority="P3", milestone="v0.4.0 — MVP"),
|
|
338
|
+
]
|
|
339
|
+
result = suggest_next_up(issues, [], track_milestone="v0.4.0",
|
|
340
|
+
order=PRESETS["priority-driven"])
|
|
341
|
+
# With priority-driven, priority > milestone so P0 beats P3-on-milestone
|
|
342
|
+
self.assertEqual(result[0], 1,
|
|
343
|
+
"P0 with no milestone must beat P3 on track milestone under priority-driven")
|
|
344
|
+
|
|
345
|
+
def test_backlog_puts_oldest_first(self):
|
|
346
|
+
"""backlog preset: oldest issue (smallest timestamp) ranks above newer."""
|
|
347
|
+
issues = [
|
|
348
|
+
_issue(10, priority="P2", updated="2026-06-01T00:00:00Z"), # newer
|
|
349
|
+
_issue(20, priority="P2", updated="2024-01-01T00:00:00Z"), # older
|
|
350
|
+
]
|
|
351
|
+
result = suggest_next_up(issues, [], order=PRESETS["backlog"])
|
|
352
|
+
self.assertEqual(result[0], 20,
|
|
353
|
+
"backlog preset must surface oldest (most stalled) issue first")
|
|
354
|
+
|
|
355
|
+
def test_unknown_criterion_in_order_skipped(self):
|
|
356
|
+
"""An order list with an unknown criterion must not crash; known ones still apply."""
|
|
357
|
+
issues = [
|
|
358
|
+
_issue(1, priority="P0"),
|
|
359
|
+
_issue(2, priority="P1"),
|
|
360
|
+
]
|
|
361
|
+
# Should not raise; the unknown 'bogus' criterion is skipped
|
|
362
|
+
result = suggest_next_up(issues, [], order=["bogus", "priority", "recency"])
|
|
363
|
+
self.assertEqual(result[0], 1, "priority criterion must still sort correctly")
|
|
364
|
+
self.assertNotIn("bogus", result) # just making sure it didn't error on str
|
|
365
|
+
|
|
366
|
+
# ------------------------------------------------------------------
|
|
367
|
+
# resolver
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
def test_resolve_next_up_order_uses_track_frontmatter(self):
|
|
371
|
+
"""Track with next_up_order: {preset: priority-driven} → resolver returns that preset."""
|
|
372
|
+
meta = {"next_up_order": {"preset": "priority-driven"}}
|
|
373
|
+
name, order = resolve_next_up_order(meta)
|
|
374
|
+
self.assertEqual(name, "priority-driven")
|
|
375
|
+
self.assertEqual(order, PRESETS["priority-driven"])
|
|
376
|
+
|
|
377
|
+
def test_resolve_next_up_order_falls_back_to_global_default(self):
|
|
378
|
+
"""No track frontmatter → uses global default param."""
|
|
379
|
+
meta = {}
|
|
380
|
+
name, order = resolve_next_up_order(meta, default_preset="backlog")
|
|
381
|
+
self.assertEqual(name, "backlog")
|
|
382
|
+
self.assertEqual(order, PRESETS["backlog"])
|
|
383
|
+
|
|
384
|
+
def test_resolve_next_up_order_falls_back_to_default_preset(self):
|
|
385
|
+
"""Neither track frontmatter nor global default → returns ('flow', PRESETS['flow'])."""
|
|
386
|
+
meta = {}
|
|
387
|
+
name, order = resolve_next_up_order(meta)
|
|
388
|
+
self.assertEqual(name, DEFAULT_PRESET)
|
|
389
|
+
self.assertEqual(order, PRESETS[DEFAULT_PRESET])
|
|
390
|
+
|
|
391
|
+
def test_resolve_next_up_order_custom_uses_order_list(self):
|
|
392
|
+
"""next_up_order: {preset: custom, order: [priority, recency]} → custom order used."""
|
|
393
|
+
meta = {"next_up_order": {"preset": "custom", "order": ["priority", "recency"]}}
|
|
394
|
+
name, order = resolve_next_up_order(meta)
|
|
395
|
+
self.assertEqual(name, "custom")
|
|
396
|
+
self.assertEqual(order, ["priority", "recency"])
|
|
397
|
+
|
|
398
|
+
def test_resolve_next_up_order_unknown_preset_falls_back_to_flow(self):
|
|
399
|
+
"""Track has next_up_order: {preset: nonexistent} → returns ('flow', PRESETS['flow'])."""
|
|
400
|
+
meta = {"next_up_order": {"preset": "nonexistent"}}
|
|
401
|
+
name, order = resolve_next_up_order(meta)
|
|
402
|
+
self.assertEqual(name, DEFAULT_PRESET)
|
|
403
|
+
self.assertEqual(order, PRESETS[DEFAULT_PRESET])
|
|
404
|
+
|
|
405
|
+
def test_resolve_next_up_order_invalid_custom_order_falls_back(self):
|
|
406
|
+
"""next_up_order: {preset: custom, order: [bogus]} → falls back to flow."""
|
|
407
|
+
meta = {"next_up_order": {"preset": "custom", "order": ["bogus"]}}
|
|
408
|
+
name, order = resolve_next_up_order(meta)
|
|
409
|
+
self.assertEqual(name, DEFAULT_PRESET)
|
|
410
|
+
self.assertEqual(order, PRESETS[DEFAULT_PRESET])
|
|
411
|
+
|
|
412
|
+
def test_resolve_reads_next_up_order_not_next_up(self):
|
|
413
|
+
"""Resolver reads 'next_up_order' key (a mapping), NOT 'next_up' (the issue-list)."""
|
|
414
|
+
# next_up is the issue list — should not affect the resolver
|
|
415
|
+
meta = {"next_up": [101, 102], "next_up_order": {"preset": "backlog"}}
|
|
416
|
+
name, order = resolve_next_up_order(meta)
|
|
417
|
+
self.assertEqual(name, "backlog")
|
|
418
|
+
self.assertEqual(order, PRESETS["backlog"])
|
|
419
|
+
|
|
420
|
+
|
|
148
421
|
if __name__ == "__main__":
|
|
149
422
|
unittest.main()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# tests/test_set_next_up.py
|
|
2
|
+
"""Tests for the set-next-up command.
|
|
3
|
+
|
|
4
|
+
Mirrors test_set_field.py structure. Tests preset setting, custom order,
|
|
5
|
+
clear, public-repo gating, and validation.
|
|
6
|
+
"""
|
|
7
|
+
import io
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from unittest.mock import patch
|
|
14
|
+
|
|
15
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
17
|
+
|
|
18
|
+
from commands import set_next_up
|
|
19
|
+
from lib.write_guard import make_token
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _t(name="ph", repo="o/r", meta=None):
|
|
23
|
+
base_meta = {"status": "active", "github": {"repo": repo}}
|
|
24
|
+
if meta is not None:
|
|
25
|
+
base_meta.update(meta)
|
|
26
|
+
return SimpleNamespace(
|
|
27
|
+
name=name,
|
|
28
|
+
repo=repo,
|
|
29
|
+
path=Path(f"/tmp/{name}.md"),
|
|
30
|
+
has_frontmatter=True,
|
|
31
|
+
meta=base_meta,
|
|
32
|
+
body="# b",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _drive(args, vis="PRIVATE", cfg=None, track=None):
|
|
37
|
+
base_cfg = {"notes_root": "/tmp"}
|
|
38
|
+
if cfg is not None:
|
|
39
|
+
base_cfg.update(cfg)
|
|
40
|
+
t = track if track is not None else _t()
|
|
41
|
+
with patch("commands.set_next_up.load_config", return_value=base_cfg), \
|
|
42
|
+
patch("commands.set_next_up.discover_tracks", return_value=[t]), \
|
|
43
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
44
|
+
patch("commands.set_next_up.write_file") as mw:
|
|
45
|
+
buf = io.StringIO()
|
|
46
|
+
with redirect_stdout(buf):
|
|
47
|
+
rc = set_next_up.run(args)
|
|
48
|
+
return rc, mw, buf.getvalue()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SetNextUpTest(unittest.TestCase):
|
|
52
|
+
|
|
53
|
+
def test_set_preset_private(self):
|
|
54
|
+
"""set-next-up ph --preset=priority-driven on private repo writes next_up_order."""
|
|
55
|
+
rc, mw, out = _drive(["ph", "--preset=priority-driven"])
|
|
56
|
+
self.assertEqual(rc, 0)
|
|
57
|
+
mw.assert_called_once()
|
|
58
|
+
meta = mw.call_args[0][1]
|
|
59
|
+
self.assertEqual(meta["next_up_order"], {"preset": "priority-driven"})
|
|
60
|
+
|
|
61
|
+
def test_set_order_custom(self):
|
|
62
|
+
"""set-next-up ph --order=priority,recency writes next_up_order with preset=custom."""
|
|
63
|
+
rc, mw, out = _drive(["ph", "--order=priority,recency"])
|
|
64
|
+
self.assertEqual(rc, 0)
|
|
65
|
+
mw.assert_called_once()
|
|
66
|
+
meta = mw.call_args[0][1]
|
|
67
|
+
self.assertEqual(meta["next_up_order"], {"preset": "custom", "order": ["priority", "recency"]})
|
|
68
|
+
|
|
69
|
+
def test_clear_removes_key(self):
|
|
70
|
+
"""set-next-up ph --clear with next_up_order in meta removes the key."""
|
|
71
|
+
t = _t(meta={"status": "active", "next_up_order": {"preset": "backlog"}})
|
|
72
|
+
rc, mw, out = _drive(["ph", "--clear"], track=t)
|
|
73
|
+
self.assertEqual(rc, 0)
|
|
74
|
+
mw.assert_called_once()
|
|
75
|
+
meta = mw.call_args[0][1]
|
|
76
|
+
self.assertNotIn("next_up_order", meta)
|
|
77
|
+
|
|
78
|
+
def test_public_blocks_without_confirm(self):
|
|
79
|
+
"""PUBLIC repo without --confirm emits needs_confirm and does not write."""
|
|
80
|
+
rc, mw, out = _drive(["ph", "--preset=priority-driven"], vis="PUBLIC")
|
|
81
|
+
self.assertEqual(rc, 0)
|
|
82
|
+
mw.assert_not_called()
|
|
83
|
+
self.assertIn("needs_confirm", out)
|
|
84
|
+
|
|
85
|
+
def test_public_with_valid_confirm_writes(self):
|
|
86
|
+
"""PUBLIC repo with valid --confirm token proceeds to write."""
|
|
87
|
+
tok = make_token("o/r", "ph")
|
|
88
|
+
rc, mw, out = _drive(["ph", "--preset=priority-driven", f"--confirm={tok}"], vis="PUBLIC")
|
|
89
|
+
self.assertEqual(rc, 0)
|
|
90
|
+
mw.assert_called_once()
|
|
91
|
+
meta = mw.call_args[0][1]
|
|
92
|
+
self.assertEqual(meta["next_up_order"], {"preset": "priority-driven"})
|
|
93
|
+
|
|
94
|
+
def test_rejects_invalid_preset(self):
|
|
95
|
+
"""Unknown preset name → rc=2, no write."""
|
|
96
|
+
rc, mw, out = _drive(["ph", "--preset=nonexistent"])
|
|
97
|
+
self.assertEqual(rc, 2)
|
|
98
|
+
mw.assert_not_called()
|
|
99
|
+
|
|
100
|
+
def test_rejects_invalid_criteria(self):
|
|
101
|
+
"""--order with an invalid criterion (bogus) → rc=2, no write."""
|
|
102
|
+
rc, mw, out = _drive(["ph", "--order=bogus,milestone"])
|
|
103
|
+
self.assertEqual(rc, 2)
|
|
104
|
+
mw.assert_not_called()
|
|
105
|
+
|
|
106
|
+
def test_custom_preset_requires_order(self):
|
|
107
|
+
"""--preset=custom without --order → rc=2, no write."""
|
|
108
|
+
rc, mw, out = _drive(["ph", "--preset=custom"])
|
|
109
|
+
self.assertEqual(rc, 2)
|
|
110
|
+
mw.assert_not_called()
|
|
111
|
+
|
|
112
|
+
def test_requires_preset_or_order_or_clear(self):
|
|
113
|
+
"""No flags at all → rc=2."""
|
|
114
|
+
rc, mw, out = _drive(["ph"])
|
|
115
|
+
self.assertEqual(rc, 2)
|
|
116
|
+
mw.assert_not_called()
|
|
117
|
+
|
|
118
|
+
def test_set_preset_flow(self):
|
|
119
|
+
"""--preset=flow is valid and writes correctly."""
|
|
120
|
+
rc, mw, out = _drive(["ph", "--preset=flow"])
|
|
121
|
+
self.assertEqual(rc, 0)
|
|
122
|
+
mw.assert_called_once()
|
|
123
|
+
meta = mw.call_args[0][1]
|
|
124
|
+
self.assertEqual(meta["next_up_order"], {"preset": "flow"})
|
|
125
|
+
|
|
126
|
+
def test_set_preset_backlog(self):
|
|
127
|
+
"""--preset=backlog is valid and writes correctly."""
|
|
128
|
+
rc, mw, out = _drive(["ph", "--preset=backlog"])
|
|
129
|
+
self.assertEqual(rc, 0)
|
|
130
|
+
mw.assert_called_once()
|
|
131
|
+
meta = mw.call_args[0][1]
|
|
132
|
+
self.assertEqual(meta["next_up_order"], {"preset": "backlog"})
|
|
133
|
+
|
|
134
|
+
def test_custom_with_order_all_criteria(self):
|
|
135
|
+
"""--preset=custom --order=milestone,dependency,priority,recency,aging is valid."""
|
|
136
|
+
rc, mw, out = _drive(["ph", "--preset=custom",
|
|
137
|
+
"--order=milestone,dependency,priority,recency,aging"])
|
|
138
|
+
self.assertEqual(rc, 0)
|
|
139
|
+
mw.assert_called_once()
|
|
140
|
+
meta = mw.call_args[0][1]
|
|
141
|
+
self.assertEqual(meta["next_up_order"]["preset"], "custom")
|
|
142
|
+
self.assertEqual(meta["next_up_order"]["order"],
|
|
143
|
+
["milestone", "dependency", "priority", "recency", "aging"])
|
|
144
|
+
|
|
145
|
+
def test_order_without_preset_sets_custom(self):
|
|
146
|
+
"""--order alone (no --preset) → preset=custom is implied."""
|
|
147
|
+
rc, mw, out = _drive(["ph", "--order=aging,priority"])
|
|
148
|
+
self.assertEqual(rc, 0)
|
|
149
|
+
mw.assert_called_once()
|
|
150
|
+
meta = mw.call_args[0][1]
|
|
151
|
+
self.assertEqual(meta["next_up_order"]["preset"], "custom")
|
|
152
|
+
|
|
153
|
+
def test_clear_on_track_without_key_still_succeeds(self):
|
|
154
|
+
"""--clear on a track that has no next_up_order key still writes ok."""
|
|
155
|
+
# meta has no next_up_order key
|
|
156
|
+
t = _t()
|
|
157
|
+
rc, mw, out = _drive(["ph", "--clear"], track=t)
|
|
158
|
+
self.assertEqual(rc, 0)
|
|
159
|
+
mw.assert_called_once()
|
|
160
|
+
meta = mw.call_args[0][1]
|
|
161
|
+
self.assertNotIn("next_up_order", meta)
|
|
162
|
+
|
|
163
|
+
def test_track_not_found_returns_1(self):
|
|
164
|
+
"""Unrecognized track name → rc=1."""
|
|
165
|
+
rc, mw, out = _drive(["unknown-track", "--preset=flow"])
|
|
166
|
+
self.assertEqual(rc, 1)
|
|
167
|
+
mw.assert_not_called()
|
|
168
|
+
|
|
169
|
+
def test_does_not_touch_next_up_issue_list(self):
|
|
170
|
+
"""set-next-up must not modify the next_up issue-list key."""
|
|
171
|
+
t = _t(meta={"status": "active", "next_up": [101, 102]})
|
|
172
|
+
rc, mw, out = _drive(["ph", "--preset=flow"], track=t)
|
|
173
|
+
self.assertEqual(rc, 0)
|
|
174
|
+
mw.assert_called_once()
|
|
175
|
+
meta = mw.call_args[0][1]
|
|
176
|
+
# next_up issue list must be unchanged
|
|
177
|
+
self.assertEqual(meta.get("next_up"), [101, 102])
|
|
178
|
+
|
|
179
|
+
def test_named_preset_plus_order_warns_and_ignores_order(self):
|
|
180
|
+
"""--preset=<named> + --order: WARN on stderr; named preset wins, order dropped."""
|
|
181
|
+
base_cfg = {"notes_root": "/tmp"}
|
|
182
|
+
t = _t()
|
|
183
|
+
with patch("commands.set_next_up.load_config", return_value=base_cfg), \
|
|
184
|
+
patch("commands.set_next_up.discover_tracks", return_value=[t]), \
|
|
185
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
186
|
+
patch("commands.set_next_up.write_file") as mw:
|
|
187
|
+
out, err = io.StringIO(), io.StringIO()
|
|
188
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
189
|
+
rc = set_next_up.run(["ph", "--preset=priority-driven",
|
|
190
|
+
"--order=aging,priority"])
|
|
191
|
+
self.assertEqual(rc, 0)
|
|
192
|
+
mw.assert_called_once()
|
|
193
|
+
meta = mw.call_args[0][1]
|
|
194
|
+
# Named preset wins — the co-supplied order is NOT stored.
|
|
195
|
+
self.assertEqual(meta["next_up_order"], {"preset": "priority-driven"})
|
|
196
|
+
self.assertIn("WARN", err.getvalue())
|
|
197
|
+
self.assertIn("--order is ignored", err.getvalue())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
unittest.main()
|
|
@@ -60,6 +60,7 @@ SUBCOMMANDS = {
|
|
|
60
60
|
"auth-status": "commands.auth_status",
|
|
61
61
|
"list-open-issues": "commands.list_open_issues",
|
|
62
62
|
"set": "commands.set_field",
|
|
63
|
+
"set-next-up": "commands.set_next_up",
|
|
63
64
|
"new-track": "commands.new_track",
|
|
64
65
|
"rename-track": "commands.rename_track",
|
|
65
66
|
"set-notes-root": "commands.set_notes_root",
|
|
@@ -166,6 +167,10 @@ DESCRIPTIONS = [
|
|
|
166
167
|
"Guarded edit of a track's frontmatter fields (status, launch_priority, milestone_alignment, blockers, next_up). Validates field names + status values; blockers/next_up take comma-separated issue numbers. Setting `next_up` here writes ONLY the frontmatter field — for next_up plus a session-log entry (and a body refresh), use `handoff --set-next` instead. Writes into a PUBLIC repo only with a confirm token: without one it prints {needs_confirm, reason, token} and makes no change (the VS Code viewer surfaces that as a modal, then re-invokes with --confirm=<token>).",
|
|
167
168
|
"Programmatic/GUI edits that have no dedicated verb — e.g. the VS Code extension changing a status or blockers list. On the terminal you'll usually use the named verbs instead.",
|
|
168
169
|
"/work-plan set ux-redesign status=parked"),
|
|
170
|
+
("set-next-up", "<track | track@repo> (--preset=<name> | --order=a,b,c | --clear) [--repo=<key>] [--confirm=<token>]",
|
|
171
|
+
"Configure the ranking preset for a track's auto next_up suggestion. --preset sets one of the named presets (flow, priority-driven, backlog) or 'custom' (which requires --order). --order=a,b,c sets a custom comma-separated criterion list (milestone, dependency, priority, recency, aging). --clear reverts to the global or default preset. Writes next_up_order into the track's frontmatter (does NOT touch the next_up issue list). Public-repo gated: without --confirm it prints {needs_confirm, reason, token} and makes no change.",
|
|
172
|
+
"When you want a track to use a different ranking order than the default (flow). Use priority-driven for pure backlog work with no milestones, backlog to surface oldest stalled issues first.",
|
|
173
|
+
"/work-plan set-next-up my-track --preset=priority-driven"),
|
|
169
174
|
("new-track", "<repo> <slug> [--priority=P0..P3] [--milestone=<m>] [--private] [--confirm=<token>]",
|
|
170
175
|
"Create a brand-new track file under notes_root in one headless call. <repo> is either a configured key (e.g. 'myproject') or a bare org/repo slug (e.g. 'your-org/myproject'). Writes frontmatter with status=active and optional priority/milestone. Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token> to proceed.",
|
|
171
176
|
"When a new feature branch or initiative starts and you want the track file created immediately — especially from a non-terminal caller like the VS Code extension that can't interactively run init.",
|