@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 +2 -2
- package/VERSION +1 -1
- package/package.json +1 -5
- package/skills/work-plan/commands/export.py +10 -2
- package/skills/work-plan/commands/handoff.py +90 -26
- package/skills/work-plan/tests/test_export_command.py +13 -0
- package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
- package/scripts/npm-check-deps.js +0 -44
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
|
|
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+
|
|
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"
|
|
@@ -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
|
|
100
|
-
tracked = set(repo_to_numbers[
|
|
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
|
-
|
|
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, {})
|
|
@@ -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);
|