@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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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()
|