@stylusnexus/work-plan 2026.6.14 → 2026.6.15-2
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 +3 -1
- 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 +210 -0
- package/skills/work-plan/lib/export_model.py +25 -2
- package/skills/work-plan/lib/git_state.py +53 -14
- package/skills/work-plan/lib/next_up.py +152 -29
- package/skills/work-plan/tests/test_export.py +116 -0
- package/skills/work-plan/tests/test_git_state.py +47 -12
- package/skills/work-plan/tests/test_next_up.py +281 -8
- package/skills/work-plan/tests/test_set_next_up.py +296 -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`. Toggle auto-derivation itself with `--auto=on|off` (no hand-editing frontmatter required).
|
|
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.
|
|
@@ -300,7 +301,7 @@ To install for **both** Claude Code AND Codex, run the installer twice with diff
|
|
|
300
301
|
|
|
301
302
|
### VS Code extension
|
|
302
303
|
|
|
303
|
-
The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks, with per-track open/closed counts and an **activity-bar badge** for blocked/open status), a Mermaid dependency graph (with focus toggle and repo-scoped full map)
|
|
304
|
+
The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks, with per-track open/closed counts and an **activity-bar badge** for blocked/open status), a Mermaid dependency graph (with focus toggle and repo-scoped full map) that draws **GitHub-native blocked-by edges**, per-track detail with an **open/closed progress bar**, a one-click **Plan** link, a **per-issue in-progress badge + toggle**, expandable **blocked-by / blocking dependency chips**, the Untracked bucket, cross-track dependency chips, per-issue move/close buttons, **keyword issue search** (`%wildcard%` substitution, results in a dedicated tab), the daily-driver **Brief / Re-orient / Handoff** commands, **next-up controls** — a **Set Next-Up** button and a **Set Next-Up Order…** preset picker (`flow` / `priority-driven` / `backlog`) with the active preset shown inline, an inline **active lens + sort** indicator under the view title, a **Plans view** with confirm-gated frontmatter writes (verdict / acknowledge / drift-baseline) and a fast-fail GitHub-auth banner, and full read/write (slot/close/edit/move/new-track/push-track/…) with a public-repo confirm modal.
|
|
304
305
|
|
|
305
306
|

|
|
306
307
|
|
|
@@ -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 \| --auto=on\|off) [--repo=<key>] [--confirm=<token>]` | Configure the ranking preset and/or auto-derivation flag for a track's next_up list. `--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`. `--auto=on` activates `next_up_auto` so brief/orient/export auto-derive the next-up list live from the ranking preset (ignoring the curated list); `--auto=off` clears it to revert to the curated list. `--auto` can be used standalone or combined with `--preset`/`--order`/`--clear`. Writes `next_up_order` and/or `next_up_auto` 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+c80ece1
|
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-2",
|
|
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,210 @@
|
|
|
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>]
|
|
5
|
+
(--preset=<name> | --order=a,b,c | --clear | --auto=on|off)
|
|
6
|
+
[--confirm=<token>]
|
|
7
|
+
|
|
8
|
+
Writes `next_up_order` and/or `next_up_auto` into the track's frontmatter.
|
|
9
|
+
Does NOT touch the `next_up` issue-list key.
|
|
10
|
+
|
|
11
|
+
--preset=<name> Set one of the named ranking presets (flow, priority-driven,
|
|
12
|
+
backlog) or 'custom' (which requires --order).
|
|
13
|
+
--order=a,b,c Set a custom comma-separated criterion list.
|
|
14
|
+
--clear Remove the next_up_order key (reverts to global/default).
|
|
15
|
+
--auto=on|off Toggle the next_up_auto flag. When on, brief/orient/export
|
|
16
|
+
auto-derive the next-up list via the ranking preset (#326).
|
|
17
|
+
Can be used standalone or combined with --preset/--order/--clear.
|
|
18
|
+
|
|
19
|
+
Public-repo gated: without --confirm it prints {needs_confirm, reason, token}
|
|
20
|
+
and makes no change. The VS Code extension surfaces that as a modal then
|
|
21
|
+
re-invokes with --confirm=<token>.
|
|
22
|
+
"""
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from lib.config import load_config, ConfigError
|
|
26
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
27
|
+
from lib.frontmatter import write_file
|
|
28
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
29
|
+
from lib.prompts import parse_flags
|
|
30
|
+
from lib.next_up import CRITERIA, PRESETS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(args: list[str]) -> int:
|
|
34
|
+
flags, positional = parse_flags(
|
|
35
|
+
args, {"--confirm", "--repo", "--clear", "--preset", "--order", "--auto"}
|
|
36
|
+
)
|
|
37
|
+
if not positional:
|
|
38
|
+
print(
|
|
39
|
+
"usage: work_plan.py set-next-up <track> "
|
|
40
|
+
"(--preset=<name> | --order=a,b,c | --clear | --auto=on|off) "
|
|
41
|
+
"[--repo=<key>] [--confirm=<token>]"
|
|
42
|
+
)
|
|
43
|
+
return 2
|
|
44
|
+
|
|
45
|
+
track_arg = positional[0]
|
|
46
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
47
|
+
name = name_from_arg
|
|
48
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
49
|
+
repo_qualifier = repo_from_arg or repo_flag
|
|
50
|
+
|
|
51
|
+
clear = bool(flags.get("--clear"))
|
|
52
|
+
preset_flag = flags.get("--preset") if flags.get("--preset") is not True else None
|
|
53
|
+
order_flag = flags.get("--order") if flags.get("--order") is not True else None
|
|
54
|
+
auto_raw = flags.get("--auto") if flags.get("--auto") is not True else None
|
|
55
|
+
|
|
56
|
+
# Parse and validate --auto value
|
|
57
|
+
auto_value = None # None means not specified
|
|
58
|
+
if auto_raw is not None:
|
|
59
|
+
auto_lower = auto_raw.lower() if isinstance(auto_raw, str) else ""
|
|
60
|
+
if auto_lower == "on":
|
|
61
|
+
auto_value = True
|
|
62
|
+
elif auto_lower == "off":
|
|
63
|
+
auto_value = False
|
|
64
|
+
else:
|
|
65
|
+
print(
|
|
66
|
+
f"ERROR: --auto must be 'on' or 'off', got {auto_raw!r}",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
return 2
|
|
70
|
+
|
|
71
|
+
# Must have at least one of --preset, --order, --clear, or --auto
|
|
72
|
+
if not clear and preset_flag is None and order_flag is None and auto_value is None:
|
|
73
|
+
print(
|
|
74
|
+
"ERROR: specify --preset=<name>, --order=a,b,c, --clear, or --auto=on|off",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
return 2
|
|
78
|
+
|
|
79
|
+
# Validate preset name
|
|
80
|
+
if preset_flag is not None:
|
|
81
|
+
valid_presets = set(PRESETS.keys()) | {"custom"}
|
|
82
|
+
if preset_flag not in valid_presets:
|
|
83
|
+
print(
|
|
84
|
+
f"ERROR: unknown preset {preset_flag!r} "
|
|
85
|
+
f"(allowed: {sorted(valid_presets)})",
|
|
86
|
+
file=sys.stderr,
|
|
87
|
+
)
|
|
88
|
+
return 2
|
|
89
|
+
# 'custom' requires --order
|
|
90
|
+
if preset_flag == "custom" and order_flag is None:
|
|
91
|
+
print(
|
|
92
|
+
"ERROR: --preset=custom requires --order=<criteria>",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
return 2
|
|
96
|
+
|
|
97
|
+
# Validate order criteria
|
|
98
|
+
order_list = None
|
|
99
|
+
if order_flag is not None:
|
|
100
|
+
raw_criteria = [c.strip() for c in order_flag.split(",") if c.strip()]
|
|
101
|
+
invalid = [c for c in raw_criteria if c not in CRITERIA]
|
|
102
|
+
if invalid:
|
|
103
|
+
print(
|
|
104
|
+
f"ERROR: unknown criteria {invalid!r} "
|
|
105
|
+
f"(allowed: {list(CRITERIA)})",
|
|
106
|
+
file=sys.stderr,
|
|
107
|
+
)
|
|
108
|
+
return 2
|
|
109
|
+
if not raw_criteria:
|
|
110
|
+
print("ERROR: --order requires at least one criterion", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
order_list = raw_criteria
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
cfg = load_config()
|
|
116
|
+
except ConfigError as e:
|
|
117
|
+
print(f"ERROR: {e}")
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
track = find_track_by_name(name, discover_tracks(cfg), repo=repo_qualifier)
|
|
122
|
+
except AmbiguousTrackError as e:
|
|
123
|
+
print(str(e))
|
|
124
|
+
return 1
|
|
125
|
+
if not track:
|
|
126
|
+
print(f"No track matching {name!r}.")
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
# Public-repo confirm gate
|
|
130
|
+
confirm = flags.get("--confirm")
|
|
131
|
+
if (
|
|
132
|
+
track.repo
|
|
133
|
+
and needs_confirm(track.repo, cfg)
|
|
134
|
+
and not (isinstance(confirm, str) and valid_token(confirm, track.repo, track.name))
|
|
135
|
+
):
|
|
136
|
+
print(
|
|
137
|
+
json.dumps(
|
|
138
|
+
{
|
|
139
|
+
"needs_confirm": True,
|
|
140
|
+
"reason": (
|
|
141
|
+
f"{track.repo} is PUBLIC (or visibility unknown); "
|
|
142
|
+
"edit will be written there."
|
|
143
|
+
),
|
|
144
|
+
"token": make_token(track.repo, track.name),
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return 0
|
|
149
|
+
|
|
150
|
+
if clear:
|
|
151
|
+
track.meta.pop("next_up_order", None)
|
|
152
|
+
if auto_value is not None:
|
|
153
|
+
_apply_auto(track, auto_value)
|
|
154
|
+
write_file(track.path, track.meta, track.body)
|
|
155
|
+
print(f"✓ cleared next_up_order on {track.name}")
|
|
156
|
+
if auto_value is not None:
|
|
157
|
+
_print_auto_result(track, auto_value)
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
# If --auto is the only flag (no preset/order/clear), write just the auto flag.
|
|
161
|
+
if preset_flag is None and order_list is None and auto_value is not None:
|
|
162
|
+
_apply_auto(track, auto_value)
|
|
163
|
+
write_file(track.path, track.meta, track.body)
|
|
164
|
+
_print_auto_result(track, auto_value)
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
# Build the next_up_order mapping
|
|
168
|
+
if preset_flag == "custom" or (preset_flag is None and order_list is not None):
|
|
169
|
+
# Custom order (either explicit --preset=custom or bare --order)
|
|
170
|
+
nuo = {"preset": "custom", "order": order_list}
|
|
171
|
+
else:
|
|
172
|
+
# Named preset. A named preset supplies its own criterion order, so a
|
|
173
|
+
# co-supplied --order has no effect — warn (advisory, don't reject) so
|
|
174
|
+
# the user isn't surprised it was dropped.
|
|
175
|
+
nuo = {"preset": preset_flag}
|
|
176
|
+
if order_list is not None:
|
|
177
|
+
print(
|
|
178
|
+
f"WARN: --order is ignored when a named preset "
|
|
179
|
+
f"(--preset={preset_flag}) is given; use --preset=custom "
|
|
180
|
+
"to supply your own order.",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
track.meta["next_up_order"] = nuo
|
|
185
|
+
if auto_value is not None:
|
|
186
|
+
_apply_auto(track, auto_value)
|
|
187
|
+
write_file(track.path, track.meta, track.body)
|
|
188
|
+
|
|
189
|
+
if preset_flag and preset_flag != "custom":
|
|
190
|
+
print(f"✓ set next_up_order preset={preset_flag!r} on {track.name}")
|
|
191
|
+
elif order_list is not None:
|
|
192
|
+
print(f"✓ set next_up_order custom order={order_list!r} on {track.name}")
|
|
193
|
+
if auto_value is not None:
|
|
194
|
+
_print_auto_result(track, auto_value)
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _apply_auto(track: "SimpleNamespace", auto_value: bool) -> None:
|
|
199
|
+
"""Mutate track.meta to set or remove next_up_auto."""
|
|
200
|
+
if auto_value:
|
|
201
|
+
track.meta["next_up_auto"] = True
|
|
202
|
+
else:
|
|
203
|
+
track.meta.pop("next_up_auto", None)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _print_auto_result(track: "SimpleNamespace", auto_value: bool) -> None:
|
|
207
|
+
if auto_value:
|
|
208
|
+
print(f"✓ set next_up_auto=true on {track.name}")
|
|
209
|
+
else:
|
|
210
|
+
print(f"✓ cleared next_up_auto on {track.name}")
|
|
@@ -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, suggest_next_up
|
|
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": []}
|
|
@@ -108,8 +110,22 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
108
110
|
issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
109
111
|
opened = sum(1 for i in issues if i["state"] == "open")
|
|
110
112
|
closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
|
|
111
|
-
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
112
113
|
track_path = getattr(t, "path", None)
|
|
114
|
+
next_up_preset_name, next_up_order = resolve_next_up_order(t.meta, next_up_default)
|
|
115
|
+
# When `next_up_auto: true`, derive the next-up list live from the track's
|
|
116
|
+
# open issues using the resolved ranking preset — same as brief/orient —
|
|
117
|
+
# instead of reading the curated `next_up` frontmatter list. This is what
|
|
118
|
+
# surfaces the ranking in the viewer (which only reads this export). #326.
|
|
119
|
+
if t.meta.get("next_up_auto") and raw:
|
|
120
|
+
in_progress_set = {i["number"] for i in raw if issue_in_progress(i, hot)}
|
|
121
|
+
next_up = suggest_next_up(
|
|
122
|
+
raw, t.meta.get("blockers") or [],
|
|
123
|
+
track_milestone=milestone_alignment or None,
|
|
124
|
+
in_progress_nums=in_progress_set,
|
|
125
|
+
order=next_up_order,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
113
129
|
out["tracks"].append({
|
|
114
130
|
"name": t.name,
|
|
115
131
|
"repo": t.repo,
|
|
@@ -135,6 +151,13 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
135
151
|
# null when the track declares no `plan:`. `{rel, resolved:false}` when
|
|
136
152
|
# the link can't be resolved (no local clone / file absent).
|
|
137
153
|
"plan": plan_by_track.get(t.name),
|
|
154
|
+
# Effective next_up ranking preset for this track (#326 Phase 2).
|
|
155
|
+
"next_up_preset": next_up_preset_name,
|
|
156
|
+
# True when the track has `next_up_auto: true` set in its frontmatter,
|
|
157
|
+
# meaning the next-up list is auto-derived from the ranking preset (#326).
|
|
158
|
+
# Reflects the SETTING, not whether derivation actually ran (so the
|
|
159
|
+
# viewer toggle shows On even when a track has zero open issues).
|
|
160
|
+
"next_up_auto": bool(t.meta.get("next_up_auto")),
|
|
138
161
|
})
|
|
139
162
|
out["untracked"] = [
|
|
140
163
|
{"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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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]:
|