@stylusnexus/work-plan 2026.6.15-3 → 2026.6.15-4
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
|
@@ -104,7 +104,7 @@ flowchart TB
|
|
|
104
104
|
|
|
105
105
|
- **Morning** → `brief` shows multi-track plate, then `orient <track>` produces a ~15-line paste-block to drop into a fresh agent session.
|
|
106
106
|
- **End of work block** → `handoff <track>` captures what you touched. Three ways to set `next_up` for tomorrow:
|
|
107
|
-
- `handoff <track> --auto-next` — algorithmic (no LLM): top-3 by priority then most-recently-updated, blockers excluded. Interactive `[Y/n/edit]` prompt — accept, edit, or skip.
|
|
107
|
+
- `handoff <track> --auto-next` — algorithmic (no LLM): top-3 by priority then most-recently-updated, blockers excluded. Interactive `[Y/n/edit]` prompt — accept, edit, or skip. (`--suggest-next` is the read-only, non-interactive sibling: it prints the same suggestion as JSON and writes nothing — the feed the VS Code **Suggest Next-Up (auto)** picker confirms, since the TTY prompt can't run under the extension.)
|
|
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.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.15+
|
|
1
|
+
2026.06.15+488753b
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.15-
|
|
3
|
+
"version": "2026.6.15-4",
|
|
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"
|
|
@@ -9,6 +9,7 @@ Use --interactive (or -i) for the legacy blank-prompt mode where you fill in
|
|
|
9
9
|
each section by hand.
|
|
10
10
|
"""
|
|
11
11
|
import fnmatch
|
|
12
|
+
import json
|
|
12
13
|
import subprocess
|
|
13
14
|
from datetime import datetime, timedelta
|
|
14
15
|
|
|
@@ -30,9 +31,10 @@ from lib.prompts import prompt_lines, parse_flags, prompt_input
|
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
def run(args: list[str]) -> int:
|
|
33
|
-
flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--repo"})
|
|
34
|
+
flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--suggest-next", "--repo"})
|
|
34
35
|
interactive = flags.get("--interactive", False) or flags.get("-i", False)
|
|
35
36
|
auto_next = flags.get("--auto-next", False)
|
|
37
|
+
suggest_next = flags.get("--suggest-next", False)
|
|
36
38
|
|
|
37
39
|
# Support both --set-next=4167,4148 (parse_flags handles via key=value) and
|
|
38
40
|
# --set-next 4167,4148 (space-separated). For the space form, parse_flags
|
|
@@ -80,6 +82,12 @@ def run(args: list[str]) -> int:
|
|
|
80
82
|
if not track:
|
|
81
83
|
return 1
|
|
82
84
|
|
|
85
|
+
# --suggest-next: read-only. Compute the algorithmic next_up suggestion and
|
|
86
|
+
# emit it as JSON for a non-CLI caller (the VS Code native picker, #274) to
|
|
87
|
+
# render + confirm. No prompt, no write — the caller writes via --set-next.
|
|
88
|
+
if suggest_next:
|
|
89
|
+
return _suggest_next_json(track, cfg)
|
|
90
|
+
|
|
83
91
|
# Apply --set-next first if present, so derived/interactive output reflects it.
|
|
84
92
|
if isinstance(set_next_raw, str):
|
|
85
93
|
rc = _apply_set_next(track, set_next_raw, cfg)
|
|
@@ -129,6 +137,81 @@ def _apply_set_next(track, raw: str, cfg: dict) -> int:
|
|
|
129
137
|
return 0
|
|
130
138
|
|
|
131
139
|
|
|
140
|
+
def _compute_auto_next(track, cfg: dict):
|
|
141
|
+
"""Compute the sibling-filtered next_up suggestion (read-only — no prompt,
|
|
142
|
+
no write). Shared by the interactive --auto-next flow and the --suggest-next
|
|
143
|
+
JSON output (#274).
|
|
144
|
+
|
|
145
|
+
Returns (suggestion, by_num, skipped, had_raw):
|
|
146
|
+
- suggestion: list[int] — ordered issue numbers to queue
|
|
147
|
+
- by_num: {int: issue} — fetched issues keyed by number (for decoration)
|
|
148
|
+
- skipped: list[(int, str)] — (issue, sibling_track) dropped as already-claimed
|
|
149
|
+
- had_raw: bool — whether the algorithm produced ANY candidate before the
|
|
150
|
+
sibling filter (distinguishes "no open non-blocker issues" from
|
|
151
|
+
"all candidates claimed by siblings"). Caller guarantees track.repo and a
|
|
152
|
+
non-empty issue list.
|
|
153
|
+
"""
|
|
154
|
+
issue_nums = track.meta.get("github", {}).get("issues") or []
|
|
155
|
+
issues = fetch_issues(track.repo, issue_nums)
|
|
156
|
+
blocker_nums = track.meta.get("blockers") or []
|
|
157
|
+
track_milestone = track.meta.get("milestone_alignment") or None
|
|
158
|
+
hot_nums = hot_issue_numbers(track.local_path) if track.local_path else set()
|
|
159
|
+
in_progress_set = {i["number"] for i in issues if issue_in_progress(i, hot_nums)}
|
|
160
|
+
_, order = resolve_next_up_order(track.meta, cfg.get("next_up_default"))
|
|
161
|
+
raw_suggestion = suggest_next_up(
|
|
162
|
+
issues, blocker_nums,
|
|
163
|
+
track_milestone=track_milestone,
|
|
164
|
+
in_progress_nums=in_progress_set,
|
|
165
|
+
order=order,
|
|
166
|
+
)
|
|
167
|
+
claimed = _sibling_claimed_next_up_map(track, cfg)
|
|
168
|
+
suggestion, skipped = [], []
|
|
169
|
+
for num in raw_suggestion:
|
|
170
|
+
if num in claimed:
|
|
171
|
+
skipped.append((num, claimed[num]))
|
|
172
|
+
else:
|
|
173
|
+
suggestion.append(num)
|
|
174
|
+
by_num = {i["number"]: i for i in issues}
|
|
175
|
+
return suggestion, by_num, skipped, bool(raw_suggestion)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _suggest_next_json(track, cfg: dict) -> int:
|
|
179
|
+
"""Emit the algorithmic next_up suggestion as JSON for a non-CLI caller (#274).
|
|
180
|
+
|
|
181
|
+
Read-only: never prompts or writes. Soft errors (no repo / no issues) are
|
|
182
|
+
reported in the payload with an empty `suggested`, so the caller always gets
|
|
183
|
+
parseable JSON and exit 0; hard errors (track resolution) are handled upstream
|
|
184
|
+
in run() before this is reached.
|
|
185
|
+
"""
|
|
186
|
+
out = {
|
|
187
|
+
"track": track.name,
|
|
188
|
+
"repo": track.repo,
|
|
189
|
+
"current": track.meta.get("next_up") or [],
|
|
190
|
+
"suggested": [],
|
|
191
|
+
"skipped": [],
|
|
192
|
+
}
|
|
193
|
+
if not track.repo:
|
|
194
|
+
out["error"] = f"track '{track.name}' has no github.repo"
|
|
195
|
+
print(json.dumps(out))
|
|
196
|
+
return 0
|
|
197
|
+
if not (track.meta.get("github", {}).get("issues") or []):
|
|
198
|
+
out["error"] = f"no issues attached to '{track.name}'"
|
|
199
|
+
print(json.dumps(out))
|
|
200
|
+
return 0
|
|
201
|
+
suggestion, by_num, skipped, _ = _compute_auto_next(track, cfg)
|
|
202
|
+
for num in suggestion:
|
|
203
|
+
i = by_num.get(num, {})
|
|
204
|
+
out["suggested"].append({
|
|
205
|
+
"number": num,
|
|
206
|
+
"title": i.get("title", ""),
|
|
207
|
+
"priority": extract_priority(i.get("labels", [])),
|
|
208
|
+
"milestone": short_milestone(i.get("milestone")) or "",
|
|
209
|
+
})
|
|
210
|
+
out["skipped"] = [{"number": n, "claimed_by": s} for n, s in skipped]
|
|
211
|
+
print(json.dumps(out))
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
132
215
|
def _apply_auto_next(track, cfg: dict) -> int:
|
|
133
216
|
"""Suggest a next_up list from open issues; prompt user to apply/edit/skip.
|
|
134
217
|
|
|
@@ -150,38 +233,19 @@ def _apply_auto_next(track, cfg: dict) -> int:
|
|
|
150
233
|
print(f"No issues attached to {track.name}; nothing to suggest.")
|
|
151
234
|
return 0
|
|
152
235
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
)
|
|
165
|
-
if not raw_suggestion:
|
|
236
|
+
suggestion, by_num, skipped, had_raw = _compute_auto_next(track, cfg)
|
|
237
|
+
# Print one line per sibling-claimed skip so the user knows what was dropped.
|
|
238
|
+
for num, sib in skipped:
|
|
239
|
+
print(f"↷ skipped #{num} (already next_up on '{sib}')")
|
|
240
|
+
|
|
241
|
+
if not had_raw:
|
|
166
242
|
print(f"No open, non-blocker issues for {track.name}; next_up unchanged.")
|
|
167
243
|
return 0
|
|
168
|
-
|
|
169
|
-
# Filter sibling-claimed issues out of the suggestion. Print one line
|
|
170
|
-
# per skip so the user knows what was dropped and why.
|
|
171
|
-
claimed = _sibling_claimed_next_up_map(track, cfg)
|
|
172
|
-
suggestion = []
|
|
173
|
-
for num in raw_suggestion:
|
|
174
|
-
if num in claimed:
|
|
175
|
-
print(f"↷ skipped #{num} (already next_up on '{claimed[num]}')")
|
|
176
|
-
else:
|
|
177
|
-
suggestion.append(num)
|
|
178
|
-
|
|
179
244
|
if not suggestion:
|
|
180
245
|
print(f"All suggested issues are already next_up on sibling tracks; next_up unchanged.")
|
|
181
246
|
return 0
|
|
182
247
|
|
|
183
248
|
# Decorate with title + priority + milestone for the preview.
|
|
184
|
-
by_num = {i["number"]: i for i in issues}
|
|
185
249
|
print(f"\nSuggested next_up for {track.name}:")
|
|
186
250
|
for num in suggestion:
|
|
187
251
|
i = by_num.get(num, {})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Tests for `handoff --suggest-next` — the read-only JSON suggestion mode (#274).
|
|
2
|
+
|
|
3
|
+
The VS Code native auto-next picker needs the algorithmic next_up suggestion
|
|
4
|
+
WITHOUT a write or a TTY prompt (the CLI's prompt helpers no-op under VS Code's
|
|
5
|
+
non-TTY stdin). `--suggest-next` computes the same sibling-filtered suggestion as
|
|
6
|
+
`--auto-next` and prints it as JSON; the extension renders + confirms it and
|
|
7
|
+
writes back via `--set-next`. These tests assert: valid JSON shape, sibling-claim
|
|
8
|
+
skips surface in `skipped` (not written), no frontmatter is mutated, and the
|
|
9
|
+
soft-error payloads (no repo / no issues) stay parseable with exit 0.
|
|
10
|
+
"""
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import unittest
|
|
16
|
+
from contextlib import redirect_stdout
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from unittest import mock
|
|
19
|
+
|
|
20
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
22
|
+
|
|
23
|
+
from commands import handoff
|
|
24
|
+
from lib.frontmatter import parse_file, write_file
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_track(dir_path: Path, slug: str, *, repo: str, status: str = "active",
|
|
28
|
+
next_up=None, issues=None) -> Path:
|
|
29
|
+
meta = {
|
|
30
|
+
"track": slug,
|
|
31
|
+
"status": status,
|
|
32
|
+
"launch_priority": "P1",
|
|
33
|
+
"github": {"repo": repo,
|
|
34
|
+
"issues": list([100, 200] if issues is None else issues),
|
|
35
|
+
"branches": []},
|
|
36
|
+
"next_up": list(next_up or []),
|
|
37
|
+
}
|
|
38
|
+
path = dir_path / f"{slug}.md"
|
|
39
|
+
write_file(path, meta, f"\n# {slug}\n\nBody.\n")
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _open_issue(num: int, *, priority: str = "P1", milestone=None) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"number": num,
|
|
46
|
+
"title": f"issue-{num}",
|
|
47
|
+
"state": "OPEN",
|
|
48
|
+
"labels": [{"name": f"priority/{priority}"}],
|
|
49
|
+
"updatedAt": "2026-04-30T00:00:00Z",
|
|
50
|
+
"milestone": milestone,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SuggestNextJsonTest(unittest.TestCase):
|
|
55
|
+
def setUp(self):
|
|
56
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
57
|
+
self.notes_root = Path(self.tmp.name) / "notes_root"
|
|
58
|
+
self.repo_dir = self.notes_root / "demo"
|
|
59
|
+
self.repo_dir.mkdir(parents=True)
|
|
60
|
+
self.cfg = {
|
|
61
|
+
"notes_root": str(self.notes_root),
|
|
62
|
+
"repos": {"demo": {"github": "stylusnexus/Demo"}},
|
|
63
|
+
}
|
|
64
|
+
self._patches = [
|
|
65
|
+
mock.patch("commands.handoff.load_config", return_value=self.cfg),
|
|
66
|
+
mock.patch("commands.handoff.has_uncommitted", return_value=False),
|
|
67
|
+
]
|
|
68
|
+
for p in self._patches:
|
|
69
|
+
p.start()
|
|
70
|
+
|
|
71
|
+
def tearDown(self):
|
|
72
|
+
for p in self._patches:
|
|
73
|
+
p.stop()
|
|
74
|
+
self.tmp.cleanup()
|
|
75
|
+
|
|
76
|
+
def _run(self, track_name, *, issues_response):
|
|
77
|
+
buf = io.StringIO()
|
|
78
|
+
with mock.patch("commands.handoff.fetch_issues", return_value=issues_response), \
|
|
79
|
+
redirect_stdout(buf):
|
|
80
|
+
rc = handoff.run([track_name, "--suggest-next"])
|
|
81
|
+
return rc, buf.getvalue()
|
|
82
|
+
|
|
83
|
+
def test_emits_valid_json_with_decorated_suggestions(self):
|
|
84
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
85
|
+
issues=[100, 200])
|
|
86
|
+
rc, out = self._run("track-a",
|
|
87
|
+
issues_response=[_open_issue(100, milestone={"title": "v1.0.0"}),
|
|
88
|
+
_open_issue(200)])
|
|
89
|
+
self.assertEqual(rc, 0)
|
|
90
|
+
payload = json.loads(out)
|
|
91
|
+
self.assertEqual(payload["track"], "track-a")
|
|
92
|
+
self.assertEqual(payload["repo"], "stylusnexus/Demo")
|
|
93
|
+
nums = [s["number"] for s in payload["suggested"]]
|
|
94
|
+
self.assertEqual(sorted(nums), [100, 200])
|
|
95
|
+
first = next(s for s in payload["suggested"] if s["number"] == 100)
|
|
96
|
+
self.assertEqual(first["title"], "issue-100")
|
|
97
|
+
self.assertEqual(first["priority"], "P1")
|
|
98
|
+
self.assertEqual(first["milestone"], "v1.0.0")
|
|
99
|
+
|
|
100
|
+
def test_read_only_does_not_write_next_up(self):
|
|
101
|
+
target = _make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo",
|
|
102
|
+
issues=[100, 200], next_up=[7])
|
|
103
|
+
rc, out = self._run("track-a",
|
|
104
|
+
issues_response=[_open_issue(100), _open_issue(200)])
|
|
105
|
+
self.assertEqual(rc, 0)
|
|
106
|
+
meta, _ = parse_file(target)
|
|
107
|
+
self.assertEqual(meta["next_up"], [7]) # untouched by a read-only suggest
|
|
108
|
+
|
|
109
|
+
def test_sibling_claimed_issue_lands_in_skipped_not_suggested(self):
|
|
110
|
+
_make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo", issues=[100, 200])
|
|
111
|
+
_make_track(self.repo_dir, "track-b", repo="stylusnexus/Demo", next_up=[100])
|
|
112
|
+
rc, out = self._run("track-a",
|
|
113
|
+
issues_response=[_open_issue(100), _open_issue(200)])
|
|
114
|
+
payload = json.loads(out)
|
|
115
|
+
self.assertEqual([s["number"] for s in payload["suggested"]], [200])
|
|
116
|
+
self.assertEqual(payload["skipped"], [{"number": 100, "claimed_by": "track-b"}])
|
|
117
|
+
|
|
118
|
+
def test_no_issues_attached_is_soft_error_with_empty_suggested(self):
|
|
119
|
+
_make_track(self.repo_dir, "track-a", repo="stylusnexus/Demo", issues=[])
|
|
120
|
+
buf = io.StringIO()
|
|
121
|
+
with redirect_stdout(buf):
|
|
122
|
+
rc = handoff.run(["track-a", "--suggest-next"])
|
|
123
|
+
self.assertEqual(rc, 0)
|
|
124
|
+
payload = json.loads(buf.getvalue())
|
|
125
|
+
self.assertEqual(payload["suggested"], [])
|
|
126
|
+
self.assertIn("error", payload)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
unittest.main()
|