@stylusnexus/work-plan 2026.6.21-1 → 2026.6.22

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
@@ -115,6 +115,8 @@ flowchart TB
115
115
 
116
116
  > **`brief` and `orient` annotate blocked issues with `⊘ blocked by #N`** — read-only, surfaced from GitHub's native dependency edges, nothing is written back. Cross-repo blockers show as `owner/repo#N`; same-repo duplicates of manually-declared blockers are deduplicated.
117
117
 
118
+ > **A manual `blockers:` entry can be a free-text note, not just an issue number.** Normally a blocker is an issue number (`- 5550` or `- "#5550"`), which the VS Code dependency graph draws as a `blocks` edge. But it can also be prose (e.g. `- "gated on the cost go/no-go verdict"`) for when the blocker isn't a single issue. Free-text blockers still mark the track blocked and render as prose in `brief`/`orient` and the detail panel — but they don't draw a graph edge, so prefer an issue number when there is one.
119
+
118
120
  > **GitHub access is read-only by default, with three explicit, opt-in write actions.** Issue *data* always comes from read-only `gh` calls (`gh issue list`, `gh issue view`), and every routine write (frontmatter, status table, session log) goes to your local markdown files only. The three GitHub-*mutating* actions are all opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening); `close-issue` (#305) **closes** an issue via `gh issue close` — for the common case where a PR merged to `dev` left its issue OPEN (GitHub auto-closes only from the default branch), with the VS Code viewer firing a mandatory "Close on GitHub? — cannot be undone" modal on every close; and `in-progress` (#271) **adds or removes** the `work-plan:in-progress` label on an issue, public-repo gated via the confirm-token flow. Nothing else touches GitHub state.
119
121
 
120
122
  ## Shared tracks
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.21+1d0cb70
1
+ 2026.06.22+f62c4cc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.21-1",
3
+ "version": "2026.6.22",
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"
@@ -0,0 +1,40 @@
1
+ """Track blocker normalization — the Python sibling of the viewer's blockerIssue().
2
+
3
+ A `blockers:` frontmatter entry is usually an issue number, but it may also be a
4
+ free-text note (e.g. "gated on the cost go/no-go verdict, needs #5548 telemetry").
5
+ Consumers that render a blocker with a leading `#` must funnel it through
6
+ `blocker_display` first, or a prose blocker prints as `#<sentence>`; the next-up
7
+ gate must funnel it through `blocker_issue`, or a string-form ref ("5550") never
8
+ matches the integer issue number it should exclude.
9
+ """
10
+
11
+ import re
12
+
13
+ _ISSUE_REF = re.compile(r"^\s*#?(\d+)\s*$")
14
+
15
+
16
+ def blocker_issue(value):
17
+ """The issue number a blocker refers to, or None for free-text.
18
+
19
+ Mirrors the viewer's `blockerIssue`: a bare int, "5550", or "#5550" resolves
20
+ to the number; anything else (prose, even when it embeds a `#5550`) is
21
+ free-text. A leading-zero string like "007" is treated as free-text rather
22
+ than silently coerced to 7. `bool` is rejected (it is an int subclass).
23
+ """
24
+ if isinstance(value, bool):
25
+ return None
26
+ if isinstance(value, int):
27
+ return value
28
+ if isinstance(value, str):
29
+ m = _ISSUE_REF.match(value)
30
+ if m is not None:
31
+ digits = m.group(1)
32
+ if str(int(digits)) == digits: # reject leading-zero "007"
33
+ return int(digits)
34
+ return None
35
+
36
+
37
+ def blocker_display(value):
38
+ """How to show a blocker: `#N` for an issue ref, the prose itself otherwise."""
39
+ n = blocker_issue(value)
40
+ return f"#{n}" if n is not None else str(value)
@@ -40,6 +40,7 @@ from __future__ import annotations
40
40
  from datetime import datetime
41
41
  from typing import Iterable, Optional
42
42
 
43
+ from lib.blockers import blocker_issue
43
44
  from lib.github_state import extract_priority, short_milestone
44
45
 
45
46
  PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
@@ -151,7 +152,10 @@ def suggest_next_up(
151
152
  List of issue numbers, highest-ranked first. Empty if nothing
152
153
  qualifies (e.g., everything closed or blocked).
153
154
  """
154
- blockers = set(blocker_nums or [])
155
+ # Normalize blockers to issue numbers: a string-form ref ("5550"/"#5550")
156
+ # must still exclude issue 5550, and a free-text blocker resolves to None
157
+ # (dropped) so it harmlessly gates nothing.
158
+ blockers = {n for n in (blocker_issue(b) for b in (blocker_nums or [])) if n is not None}
155
159
  in_progress = set(in_progress_nums or [])
156
160
  # Resolve order: None → default preset; unknown names in list → skipped.
157
161
  effective_order = order if order is not None else PRESETS[DEFAULT_PRESET]
@@ -1,5 +1,7 @@
1
1
  """Compose terminal output strings."""
2
2
 
3
+ from lib.blockers import blocker_display
4
+
3
5
 
4
6
  def time_aware_framing(gap_seconds: int, current_hour: int, handoff_today: bool = True) -> str:
5
7
  """Adapt framing to gap-since-last-activity + hour."""
@@ -66,7 +68,9 @@ def render_track_row(t: dict) -> str:
66
68
  if t["blockers"]:
67
69
  for b in t["blockers"]:
68
70
  reason = b.get("reason", "manually flagged")
69
- lines.append(f" Blocker: #{b['number']} {reason}")
71
+ # blocker_display: `#N` for an issue ref, the prose verbatim for a
72
+ # free-text blocker (a raw `#{number}` would print `#<sentence>`).
73
+ lines.append(f" Blocker: {blocker_display(b['number'])} — {reason}")
70
74
  else:
71
75
  lines.append(" Blockers: none")
72
76
 
@@ -0,0 +1,47 @@
1
+ """Tests for lib.blockers — blocker_issue / blocker_display normalization."""
2
+
3
+ import unittest
4
+
5
+ from lib.blockers import blocker_issue, blocker_display
6
+
7
+
8
+ class TestBlockerIssue(unittest.TestCase):
9
+ def test_bare_int_is_its_own_ref(self):
10
+ self.assertEqual(blocker_issue(5550), 5550)
11
+
12
+ def test_pure_id_strings_resolve(self):
13
+ self.assertEqual(blocker_issue("5550"), 5550)
14
+ self.assertEqual(blocker_issue("#5550"), 5550)
15
+ self.assertEqual(blocker_issue(" #5550 "), 5550)
16
+
17
+ def test_prose_is_free_text_even_with_embedded_ref(self):
18
+ # Must NOT extract 5550 — it's an active next_up item being described.
19
+ self.assertIsNone(
20
+ blocker_issue("#5550 selective routing is gated on the verdict, needs #5548")
21
+ )
22
+ self.assertIsNone(blocker_issue("waiting on design review"))
23
+
24
+ def test_leading_zero_is_free_text(self):
25
+ # "007" must not silently become issue 7.
26
+ self.assertIsNone(blocker_issue("007"))
27
+
28
+ def test_bool_and_empty_are_free_text(self):
29
+ self.assertIsNone(blocker_issue(True))
30
+ self.assertIsNone(blocker_issue(""))
31
+ self.assertIsNone(blocker_issue("#"))
32
+
33
+
34
+ class TestBlockerDisplay(unittest.TestCase):
35
+ def test_issue_ref_gets_hash_prefix(self):
36
+ self.assertEqual(blocker_display(5550), "#5550")
37
+ self.assertEqual(blocker_display("#5550"), "#5550")
38
+ self.assertEqual(blocker_display("5550"), "#5550")
39
+
40
+ def test_free_text_shown_verbatim_without_hash(self):
41
+ prose = "gated on the cost go/no-go verdict"
42
+ self.assertEqual(blocker_display(prose), prose)
43
+ self.assertNotIn("#", blocker_display(prose))
44
+
45
+
46
+ if __name__ == "__main__":
47
+ unittest.main()