@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.
@@ -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])
@@ -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):
@@ -46,6 +49,69 @@ class BuildExportTest(unittest.TestCase):
46
49
  self.assertIsNone(out["tracks"][0]["path"])
47
50
  json.dumps(out) # null is serializable
48
51
 
52
+ class BuildExportNextUpAutoTest(unittest.TestCase):
53
+ """next_up_auto: true → export derives next_up live via the ranking preset."""
54
+
55
+ def _open(self, n, pri, ms="v1"):
56
+ return {"number": n, "state": "open", "title": f"#{n}",
57
+ "labels": [{"name": f"priority/{pri}"}], "milestone": {"title": ms},
58
+ "updatedAt": "2026-06-10T00:00:00Z", "blocked_by": [], "blocking": []}
59
+
60
+ def _track_auto(self, auto):
61
+ meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
62
+ "blockers": [], "next_up": [7], "depends_on": [],
63
+ "github": {"repo": "o/r", "issues": [1, 2, 3]}}
64
+ if auto:
65
+ meta["next_up_auto"] = True
66
+ return SimpleNamespace(name="t", repo="o/r", tier="private",
67
+ path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
68
+
69
+ def _issues(self):
70
+ # P0 #3, P1 #2, P2 #1 — flow ranks by priority within the same milestone.
71
+ return [self._open(1, "P2"), self._open(2, "P1"), self._open(3, "P0")]
72
+
73
+ def test_auto_derives_and_flags(self):
74
+ out = build_export([self._track_auto(True)],
75
+ {("o/r", "t"): self._issues()},
76
+ {"o/r": "PRIVATE"}, now="t")
77
+ tr = out["tracks"][0]
78
+ self.assertEqual(tr["next_up"], [3, 2, 1]) # P0 → P1 → P2 (ignores stored [7])
79
+ self.assertTrue(tr["next_up_auto"])
80
+
81
+ def test_no_auto_uses_stored_list(self):
82
+ out = build_export([self._track_auto(False)],
83
+ {("o/r", "t"): self._issues()},
84
+ {"o/r": "PRIVATE"}, now="t")
85
+ tr = out["tracks"][0]
86
+ self.assertEqual(tr["next_up"], [7]) # the curated list, unchanged
87
+ self.assertFalse(tr["next_up_auto"])
88
+
89
+ def test_auto_on_with_zero_open_issues_still_exports_flag_true(self):
90
+ """next_up_auto: true + zero fetched issues → next_up_auto=True in export (the
91
+ SETTING, not whether auto-derivation actually ran). Viewer toggle must show
92
+ On even when there are no issues to rank.
93
+
94
+ When no issues are fetched, the auto-derivation branch does not run (there's
95
+ nothing to rank); the export still emits next_up_auto=True so the viewer
96
+ knows the flag is on. The next_up list itself falls through to the curated
97
+ list (which may be non-empty — that's acceptable and a separate concern)."""
98
+ # Use a track with no stored next_up to isolate the flag assertion cleanly.
99
+ meta = {"status": "active", "launch_priority": "P2", "milestone_alignment": "v1",
100
+ "blockers": [], "next_up": [], "depends_on": [],
101
+ "next_up_auto": True,
102
+ "github": {"repo": "o/r", "issues": []}}
103
+ from types import SimpleNamespace
104
+ from pathlib import Path
105
+ t = SimpleNamespace(name="t", repo="o/r", tier="private",
106
+ path=Path("/tmp/notes/t.md"), folder="myrepo", meta=meta)
107
+ out = build_export([t],
108
+ {("o/r", "t"): []}, # zero issues fetched
109
+ {"o/r": "PRIVATE"}, now="t")
110
+ tr = out["tracks"][0]
111
+ self.assertTrue(tr["next_up_auto"]) # flag reflects setting, not derivation
112
+ self.assertEqual(tr["next_up"], []) # no issues → empty list
113
+
114
+
49
115
  class BuildExportNextUpFilterTest(unittest.TestCase):
50
116
  """next_up entries whose issue is closed in the fetched payload are filtered out."""
51
117
 
@@ -459,6 +525,56 @@ class InProgressExportTest(unittest.TestCase):
459
525
  self.assertFalse(issue["in_progress_label"]) # no label present
460
526
 
461
527
 
528
+ class BuildExportNextUpPresetTest(unittest.TestCase):
529
+ """Tests that build_export emits next_up_preset on each track (#326 Phase 2)."""
530
+
531
+ def _build(self, track_meta_override=None, next_up_default=None):
532
+ from types import SimpleNamespace
533
+ meta = {
534
+ "status": "active",
535
+ "launch_priority": "P2",
536
+ "milestone_alignment": "v1",
537
+ "blockers": [],
538
+ "next_up": [],
539
+ "depends_on": [],
540
+ "github": {"repo": "o/r", "issues": []},
541
+ }
542
+ if track_meta_override:
543
+ meta.update(track_meta_override)
544
+ t = SimpleNamespace(name="alpha", repo="o/r", tier="private",
545
+ path=Path("/tmp/notes/alpha.md"), folder="myrepo",
546
+ meta=meta)
547
+ out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"},
548
+ now="2026-06-14T00:00", next_up_default=next_up_default)
549
+ return out["tracks"][0]
550
+
551
+ def test_next_up_preset_field_present(self):
552
+ """Export emits next_up_preset for each track."""
553
+ track = self._build()
554
+ self.assertIn("next_up_preset", track)
555
+
556
+ def test_next_up_preset_defaults_to_flow(self):
557
+ """Track with no next_up_order → next_up_preset == 'flow'."""
558
+ track = self._build()
559
+ self.assertEqual(track["next_up_preset"], "flow")
560
+
561
+ def test_next_up_preset_reflects_track_setting(self):
562
+ """Track with next_up_order: {preset: priority-driven} → next_up_preset == 'priority-driven'."""
563
+ track = self._build({"next_up_order": {"preset": "priority-driven"}})
564
+ self.assertEqual(track["next_up_preset"], "priority-driven")
565
+
566
+ def test_next_up_preset_uses_global_default(self):
567
+ """Track with no next_up_order + global next_up_default='backlog' → next_up_preset == 'backlog'."""
568
+ track = self._build(next_up_default="backlog")
569
+ self.assertEqual(track["next_up_preset"], "backlog")
570
+
571
+ def test_track_setting_overrides_global_default(self):
572
+ """Track-level next_up_order overrides the global next_up_default."""
573
+ track = self._build({"next_up_order": {"preset": "backlog"}},
574
+ next_up_default="priority-driven")
575
+ self.assertEqual(track["next_up_preset"], "backlog")
576
+
577
+
462
578
  class BlockedByExportTest(unittest.TestCase):
463
579
  def _track(self, name, repo):
464
580
  from types import SimpleNamespace
@@ -1,6 +1,7 @@
1
1
  """Tests for git_state pure functions."""
2
2
  import unittest
3
3
  import sys
4
+ from datetime import datetime, timedelta
4
5
  from pathlib import Path
5
6
  from unittest import mock
6
7
 
@@ -49,40 +50,74 @@ class BranchInProgressTest(unittest.TestCase):
49
50
 
50
51
 
51
52
  class HotIssueNumbersTest(unittest.TestCase):
53
+ def setUp(self):
54
+ # hot_issue_numbers memoizes per resolved path; reset between cases so a
55
+ # prior test's "/repo" result doesn't leak into the next.
56
+ from lib import git_state
57
+ git_state._reset_hot_cache()
58
+
59
+ def _ref_line(self, branch, age_hours):
60
+ ts = int((datetime.now() - timedelta(hours=age_hours)).timestamp())
61
+ return f"{branch}\t{ts}"
62
+
63
+ def _enum(self, *lines):
64
+ return mock.Mock(return_value=mock.Mock(returncode=0,
65
+ stdout="\n".join(lines) + "\n"))
66
+
52
67
  def test_returns_empty_when_repo_missing(self):
53
68
  self.assertEqual(hot_issue_numbers(None), set())
54
69
  self.assertEqual(hot_issue_numbers(Path("/nonexistent")), set())
55
70
 
56
- def test_maps_hot_feat_and_fix_branches_to_numbers(self):
57
- listing = "dev\nmain\nfeat/271-foo\nfix/88-bar\nchore/x\nwork-plan/plan\n"
58
- enum = mock.Mock(return_value=mock.Mock(returncode=0, stdout=listing))
71
+ def test_recent_feat_and_fix_branches_are_hot(self):
72
+ # Two recently-committed feat/fix branches + cold/non-matching ones.
73
+ lines = ["dev\t0", "main\t0",
74
+ self._ref_line("feat/271-foo", 1), # 1h ago -> hot
75
+ self._ref_line("fix/88-bar", 5), # 5h ago -> hot
76
+ self._ref_line("feat/9-old", 100), # 100h ago -> cold
77
+ "chore/x\t0", "work-plan/plan\t0"]
59
78
  with mock.patch("lib.git_state.Path.exists", return_value=True), \
60
- mock.patch("lib.git_state._git", enum), \
61
- mock.patch("lib.git_state.branch_in_progress",
62
- side_effect=lambda b, p: b in ("feat/271-foo", "fix/88-bar")):
79
+ mock.patch("lib.git_state._git", self._enum(*lines)), \
80
+ mock.patch("lib.git_state.current_branch", return_value=None):
63
81
  self.assertEqual(hot_issue_numbers(Path("/repo")), {271, 88})
64
82
 
65
83
  def test_no_substring_collision_2710_is_not_271(self):
66
- listing = "feat/2710-y\n"
67
84
  with mock.patch("lib.git_state.Path.exists", return_value=True), \
68
85
  mock.patch("lib.git_state._git",
69
- return_value=mock.Mock(returncode=0, stdout=listing)), \
70
- mock.patch("lib.git_state.branch_in_progress", return_value=True):
86
+ self._enum(self._ref_line("feat/2710-y", 1))), \
87
+ mock.patch("lib.git_state.current_branch", return_value=None):
71
88
  self.assertEqual(hot_issue_numbers(Path("/repo")), {2710})
72
89
 
73
90
  def test_cold_matched_branch_excluded(self):
74
- listing = "feat/271-foo\n"
75
91
  with mock.patch("lib.git_state.Path.exists", return_value=True), \
76
92
  mock.patch("lib.git_state._git",
77
- return_value=mock.Mock(returncode=0, stdout=listing)), \
78
- mock.patch("lib.git_state.branch_in_progress", return_value=False):
93
+ self._enum(self._ref_line("feat/271-foo", 48))), \
94
+ mock.patch("lib.git_state.current_branch", return_value=None):
79
95
  self.assertEqual(hot_issue_numbers(Path("/repo")), set())
80
96
 
97
+ def test_uncommitted_on_current_branch_is_hot_even_if_cold(self):
98
+ # A cold (old-tip) feat branch that's checked out with uncommitted work.
99
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
100
+ mock.patch("lib.git_state._git",
101
+ self._enum(self._ref_line("feat/271-foo", 200))), \
102
+ mock.patch("lib.git_state.current_branch", return_value="feat/271-foo"), \
103
+ mock.patch("lib.git_state.has_uncommitted", return_value=True):
104
+ self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
105
+
81
106
  def test_enumeration_failure_returns_empty(self):
82
107
  with mock.patch("lib.git_state.Path.exists", return_value=True), \
83
108
  mock.patch("lib.git_state._git", return_value=None):
84
109
  self.assertEqual(hot_issue_numbers(Path("/repo")), set())
85
110
 
111
+ def test_memoizes_per_resolved_path(self):
112
+ # Second call with the same path must NOT re-invoke git.
113
+ enum = self._enum(self._ref_line("feat/271-foo", 1))
114
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
115
+ mock.patch("lib.git_state._git", enum), \
116
+ mock.patch("lib.git_state.current_branch", return_value=None):
117
+ self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
118
+ self.assertEqual(hot_issue_numbers(Path("/repo")), {271})
119
+ self.assertEqual(enum.call_count, 1)
120
+
86
121
 
87
122
  if __name__ == "__main__":
88
123
  unittest.main()