@stylusnexus/work-plan 2026.6.14 → 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 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.14+ef58902
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.14",
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
- next_up_nums = suggest_next_up(issues, blocker_nums, track_milestone=track_milestone)
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
- raw_suggestion = suggest_next_up(issues, blocker_nums, track_milestone=track_milestone)
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) -> dict:
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]}
@@ -139,28 +139,67 @@ def branch_in_progress(branch_name: str, repo_path: Path) -> bool:
139
139
  _BRANCH_ISSUE_RE = re.compile(r"^(?:feat|fix)/(\d+)-")
140
140
 
141
141
 
142
- def hot_issue_numbers(repo_path: Path) -> set:
143
- """Issue numbers with a 'hot' branch in `repo_path`.
142
+ # Per-process memo for hot_issue_numbers, keyed by resolved repo path. A single
143
+ # export/brief/orient run calls hot_issue_numbers once per track, but many tracks
144
+ # share one clone (e.g. ~25 CritForge tracks → one checkout). Live git state can't
145
+ # change mid-run, so caching by resolved path turns an O(tracks) rescan into
146
+ # O(distinct clones). The CLI is one-shot, so the cache dies with the process;
147
+ # tests reset it via _reset_hot_cache(). (#257 follow-up: pre-memo this was
148
+ # ~40s × 25 tracks ≈ 16min for CritForge on every VS Code reload.)
149
+ _HOT_CACHE: dict = {}
150
+
151
+
152
+ def _reset_hot_cache() -> None:
153
+ """Clear the hot_issue_numbers memo (test hook; not used in production)."""
154
+ _HOT_CACHE.clear()
144
155
 
145
- Enumerates local branches with `git branch --format=%(refname:short)` (the
146
- --format is load-bearing: plain `git branch` prefixes lines with ` `/`* `/`+ `,
147
- which would defeat the anchored regex), maps each `feat/<n>-`/`fix/<n>-` name
148
- to <n>, and keeps those whose branch is `branch_in_progress`.
149
156
 
150
- Failure contract: if the enumeration call fails -> empty set. A per-branch heat
151
- check that fails collapses to cold (that branch is simply not added). Never raises.
157
+ def hot_issue_numbers(repo_path: Path) -> set:
158
+ """Issue numbers with a 'hot' (in-progress) feat/<n>-/fix/<n>- branch in `repo_path`.
159
+
160
+ A branch is hot when its tip was committed in the last 24h, OR it is the
161
+ checked-out branch with uncommitted changes. Enumerates every branch and its
162
+ tip commit time in ONE `git for-each-ref` call (the recency signal), then does
163
+ a single current-branch/uncommitted check — so the cost is O(1) git calls, not
164
+ O(branches). (Previously each of the N branches incurred ~4 git subprocesses
165
+ via branch_in_progress; on a clone with hundreds of feat/fix branches that was
166
+ tens of seconds per call.) Result is memoized per resolved path for the process.
167
+
168
+ Failure contract: any git enumeration failure -> empty set (not cached, so a
169
+ later call in the same run can still succeed). Never raises.
152
170
  """
153
171
  if not repo_path or not Path(repo_path).exists():
154
172
  return set()
155
- proc = _git(repo_path, "branch", "--format=%(refname:short)", "--list")
173
+ key = str(Path(repo_path).resolve())
174
+ cached = _HOT_CACHE.get(key)
175
+ if cached is not None:
176
+ return cached
177
+ proc = _git(repo_path, "for-each-ref", "refs/heads",
178
+ "--format=%(refname:short)%09%(committerdate:unix)")
156
179
  if proc is None or proc.returncode != 0:
157
180
  return set()
158
- out = set()
181
+ cutoff = (datetime.now() - timedelta(hours=24)).timestamp()
182
+ hot = set()
183
+ candidates: dict = {} # feat/fix branch name -> issue number
159
184
  for line in proc.stdout.splitlines():
160
- m = _BRANCH_ISSUE_RE.match(line.strip())
161
- if m and branch_in_progress(line.strip(), repo_path):
162
- out.add(int(m.group(1)))
163
- return out
185
+ name, _tab, ts = line.strip().partition("\t")
186
+ m = _BRANCH_ISSUE_RE.match(name)
187
+ if not m:
188
+ continue
189
+ num = int(m.group(1))
190
+ candidates[name] = num
191
+ try:
192
+ if float(ts) >= cutoff:
193
+ hot.add(num)
194
+ except ValueError:
195
+ pass # missing/odd committerdate -> not hot by recency
196
+ # Uncommitted-changes-on-the-checked-out-branch case: 2 git calls total,
197
+ # independent of branch count (mirrors branch_in_progress's first clause).
198
+ cur = current_branch(repo_path)
199
+ if cur in candidates and has_uncommitted(repo_path):
200
+ hot.add(candidates[cur])
201
+ _HOT_CACHE[key] = hot
202
+ return hot
164
203
 
165
204
 
166
205
  def last_commit_date(branch_name: str, repo_path: Path) -> Optional[datetime]:
@@ -1,9 +1,28 @@
1
1
  """Compute a suggested `next_up` issue list for a track.
2
2
 
3
- Sort policy: open issues only, exclude blockers, ranked by priority label
4
- (P0 < P1 < P2 < P3 with missing label defaulting to P3), then by most-
5
- recently-updated within the same priority bucket. Closed issues are
6
- filtered out `next_up` should never propose work that's already done.
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] | None = None,
120
+ blocker_nums: Optional[Iterable[int]] = None,
56
121
  n: int = DEFAULT_TOP_N,
57
- track_milestone: str | None = None,
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 — keeps
71
- post-launch deferrals from polluting a launch-window list.
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
- candidates = [
79
- i for i in issues
80
- if str(i.get("state", "")).upper() == "OPEN"
81
- and i.get("number") not in blockers
82
- ]
83
-
84
- def milestone_rank(issue: dict) -> int:
85
- ms = short_milestone(issue.get("milestone"))
86
- if not ms:
87
- return MILESTONE_NONE
88
- if track_milestone and ms == track_milestone:
89
- return MILESTONE_ALIGNED
90
- return MILESTONE_OTHER
91
-
92
- def sort_key(issue: dict) -> tuple[int, int, float]:
93
- pri = extract_priority(issue.get("labels", []))
94
- # Negate timestamp so newer comes first within a priority bucket.
95
- return (milestone_rank(issue), PRIORITY_RANK.get(pri, 3), -_updated_unix(issue))
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])