@stylusnexus/work-plan 2026.6.15-2 → 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.
@@ -280,7 +280,7 @@ A skill has two distinct contracts: (1) the underlying **CLI** that does the wor
280
280
 
281
281
  | Tool | Install command | Then invoke as |
282
282
  |---|---|---|
283
- | **npm (standalone CLI / any editor)** | `npm install -g @stylusnexus/work-plan` (requires `python3` + `yq` + `gh` already on PATH — the postinstall warns if any are missing). | `work-plan <subcommand>` |
283
+ | **npm (standalone CLI / any editor)** | `npm install -g @stylusnexus/work-plan` (requires `python3` + `yq` + `gh` already on PATH — the CLI warns on first run if any are missing). | `work-plan <subcommand>` |
284
284
  | **Claude Code** | **Plugin (recommended):** `/plugin marketplace add stylusnexus/agent-plugins` → `/plugin install work-plan@stylus-nexus`. Or script: `./install.sh` / `.\install.ps1` | Plugin: `/work-plan:brief` … `/work-plan:run <sub>`. Script: bare `/work-plan <subcommand>` |
285
285
  | **Codex** | **Plugin:** `codex plugin marketplace add stylusnexus/agent-plugins` → `codex plugin add work-plan@stylus-nexus`. Or script: `./install.sh --target=$HOME/.agents` | Plugin: `@work-plan` / `/skills`. Script: direct CLI |
286
286
  | **Cursor** | Skip installer. Clone repo + copy `shims/cursor/work-plan.cursorrules` into your project's `.cursorrules` (or merge it in) | `python3 <toolkit>/skills/work-plan/work_plan.py <sub>` — alias `wp` recommended |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.15+c80ece1
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-2",
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"
@@ -8,14 +8,10 @@
8
8
  "files": [
9
9
  "bin/",
10
10
  "skills/work-plan/",
11
- "scripts/npm-check-deps.js",
12
11
  "VERSION",
13
12
  "LICENSE",
14
13
  "README.md"
15
14
  ],
16
- "scripts": {
17
- "postinstall": "node scripts/npm-check-deps.js"
18
- },
19
15
  "engines": {
20
16
  "node": ">=18"
21
17
  },
@@ -93,11 +93,19 @@ def run(args: list[str]) -> int:
93
93
  visibility[t.repo] = repo_visibility(t.repo)
94
94
 
95
95
  # Compute untracked: open issues not referenced by any track, per repo.
96
+ # Iterate over every repo that has ANY track — NOT just repos in
97
+ # repo_to_numbers (which only collects tracks whose github.issues is
98
+ # non-empty). A repo whose only track has `issues: []` must still get its
99
+ # open issues surfaced as untracked; otherwise creating an empty track in a
100
+ # previously-trackless repo makes its open issues vanish — neither in the
101
+ # (empty) track nor in untracked, and the viewer's trackless fallback
102
+ # (treeModel.mergeFetchedUntracked) shuts off the moment a track exists (#342).
96
103
  # One `gh issue list` call per repo — bounded by the number of tracked repos
97
104
  # (typically a handful), not by issue count, so a serial loop is fine.
105
+ tracked_repos = {t.repo for t in tracks if t.repo}
98
106
  untracked_by_repo: dict[str, list] = {}
99
- for repo in repo_to_numbers:
100
- tracked = set(repo_to_numbers[repo])
107
+ for repo in tracked_repos:
108
+ tracked = set(repo_to_numbers.get(repo, []))
101
109
  open_rows = fetch_open_issues(repo)
102
110
  untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
103
111
 
@@ -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
- issues = fetch_issues(track.repo, issue_nums)
154
- blocker_nums = track.meta.get("blockers") or []
155
- track_milestone = track.meta.get("milestone_alignment") or None
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
- )
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, {})
@@ -290,6 +290,19 @@ class ExportCommandUntrackedTest(unittest.TestCase):
290
290
  self.assertEqual(rc, 0)
291
291
  self.assertEqual(out["untracked"], [])
292
292
 
293
+ def test_empty_track_still_surfaces_untracked(self):
294
+ """A repo whose only track has issues:[] must still surface its open
295
+ issues as untracked (#342). repo_to_numbers omits such a track, so the
296
+ untracked loop must key off repos-with-tracks, not tracked issues."""
297
+ tracks = [_track("general", _SHARED_REPO, [])] # empty track
298
+ export_map = {} # no tracked issues to fetch
299
+ open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_C]} # but the repo has open issues
300
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
301
+ self.assertEqual(rc, 0)
302
+ entry = next(e for e in out["untracked"] if e["repo"] == _SHARED_REPO)
303
+ nums = sorted(i["number"] for i in entry["issues"])
304
+ self.assertEqual(nums, [1, 3]) # both open issues are untracked
305
+
293
306
  def test_schema_stays_1_with_untracked(self):
294
307
  tracks = [_track("alpha", _SHARED_REPO, [1])]
295
308
  open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
@@ -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()
@@ -1,44 +0,0 @@
1
- #!/usr/bin/env node
2
- // Postinstall check for the @stylusnexus/work-plan npm package.
3
- //
4
- // The work-plan CLI is pure Python and shells out to three external tools at
5
- // runtime. npm can't install them (they're not npm packages), so we just check
6
- // they're on PATH and print a friendly heads-up if any are missing. This NEVER
7
- // fails the install — a missing tool is the user's to fix, and the CLI prints
8
- // its own clear error if one is absent when actually run.
9
-
10
- const { execSync } = require("node:child_process");
11
-
12
- const TOOLS = [
13
- { cmd: "python3", why: "runs the CLI", hint: "https://www.python.org/downloads/ (or `brew install python`)" },
14
- { cmd: "yq", why: "parses config + frontmatter (mikefarah/yq, the Go one — NOT the python yq)", hint: "brew install yq · https://github.com/mikefarah/yq" },
15
- { cmd: "gh", why: "reads GitHub issue state", hint: "brew install gh · https://cli.github.com" },
16
- ];
17
-
18
- function have(cmd) {
19
- try {
20
- const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
21
- execSync(probe, { stdio: "ignore", shell: true });
22
- return true;
23
- } catch {
24
- return false;
25
- }
26
- }
27
-
28
- try {
29
- const missing = TOOLS.filter((t) => !have(t.cmd));
30
- if (missing.length) {
31
- const lines = [
32
- "",
33
- " work-plan installed. It needs a few tools on your PATH that npm can't install:",
34
- ...missing.map((t) => ` • ${t.cmd} — ${t.why}\n ${t.hint}`),
35
- "",
36
- " (The CLI will tell you specifically if one is missing when you run it.)",
37
- "",
38
- ];
39
- console.warn(lines.join("\n"));
40
- }
41
- } catch {
42
- // Never let the check itself break the install.
43
- }
44
- process.exit(0);