@stylusnexus/work-plan 2026.6.15 → 2026.6.16
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 +12 -10
- package/VERSION +1 -1
- package/package.json +1 -5
- package/skills/work-plan/commands/brief.py +29 -3
- package/skills/work-plan/commands/dedupe_tiers.py +104 -0
- package/skills/work-plan/commands/export.py +29 -4
- package/skills/work-plan/commands/handoff.py +90 -26
- package/skills/work-plan/commands/hygiene.py +24 -9
- package/skills/work-plan/commands/set_next_up.py +64 -8
- package/skills/work-plan/commands/which_repo.py +52 -0
- package/skills/work-plan/lib/cwd_repo.py +133 -0
- package/skills/work-plan/lib/drift.py +6 -1
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/tracks.py +64 -2
- package/skills/work-plan/tests/test_brief_autoscope.py +126 -0
- package/skills/work-plan/tests/test_cwd_repo.py +164 -0
- package/skills/work-plan/tests/test_dedupe_tiers.py +147 -0
- package/skills/work-plan/tests/test_drift.py +32 -0
- package/skills/work-plan/tests/test_export.py +63 -0
- package/skills/work-plan/tests/test_export_command.py +56 -0
- package/skills/work-plan/tests/test_handoff_suggest_next.py +130 -0
- package/skills/work-plan/tests/test_register_which_repo.py +22 -0
- package/skills/work-plan/tests/test_set_next_up.py +95 -0
- package/skills/work-plan/work_plan.py +15 -5
- package/scripts/npm-check-deps.js +0 -44
|
@@ -197,5 +197,100 @@ class SetNextUpTest(unittest.TestCase):
|
|
|
197
197
|
self.assertIn("--order is ignored", err.getvalue())
|
|
198
198
|
|
|
199
199
|
|
|
200
|
+
class SetNextUpAutoFlagTest(unittest.TestCase):
|
|
201
|
+
"""Tests for --auto=on|off flag on set-next-up."""
|
|
202
|
+
|
|
203
|
+
def _drive_with_stderr(self, args, vis="PRIVATE", cfg=None, track=None):
|
|
204
|
+
"""Like _drive but also captures stderr."""
|
|
205
|
+
base_cfg = {"notes_root": "/tmp"}
|
|
206
|
+
if cfg is not None:
|
|
207
|
+
base_cfg.update(cfg)
|
|
208
|
+
t = track if track is not None else _t()
|
|
209
|
+
with patch("commands.set_next_up.load_config", return_value=base_cfg), \
|
|
210
|
+
patch("commands.set_next_up.discover_tracks", return_value=[t]), \
|
|
211
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
212
|
+
patch("commands.set_next_up.write_file") as mw:
|
|
213
|
+
out, err = io.StringIO(), io.StringIO()
|
|
214
|
+
with redirect_stdout(out), redirect_stderr(err):
|
|
215
|
+
rc = set_next_up.run(args)
|
|
216
|
+
return rc, mw, out.getvalue(), err.getvalue()
|
|
217
|
+
|
|
218
|
+
def test_auto_on_writes_next_up_auto_true(self):
|
|
219
|
+
"""--auto=on sets next_up_auto: True in track meta."""
|
|
220
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"])
|
|
221
|
+
self.assertEqual(rc, 0)
|
|
222
|
+
mw.assert_called_once()
|
|
223
|
+
meta = mw.call_args[0][1]
|
|
224
|
+
self.assertTrue(meta.get("next_up_auto"))
|
|
225
|
+
|
|
226
|
+
def test_auto_off_removes_next_up_auto(self):
|
|
227
|
+
"""--auto=off removes next_up_auto from track meta."""
|
|
228
|
+
t = _t(meta={"status": "active", "next_up_auto": True})
|
|
229
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=off"], track=t)
|
|
230
|
+
self.assertEqual(rc, 0)
|
|
231
|
+
mw.assert_called_once()
|
|
232
|
+
meta = mw.call_args[0][1]
|
|
233
|
+
self.assertNotIn("next_up_auto", meta)
|
|
234
|
+
|
|
235
|
+
def test_auto_off_on_track_without_key_still_succeeds(self):
|
|
236
|
+
"""--auto=off on a track with no next_up_auto key still writes ok (no KeyError)."""
|
|
237
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=off"])
|
|
238
|
+
self.assertEqual(rc, 0)
|
|
239
|
+
mw.assert_called_once()
|
|
240
|
+
meta = mw.call_args[0][1]
|
|
241
|
+
self.assertNotIn("next_up_auto", meta)
|
|
242
|
+
|
|
243
|
+
def test_auto_bogus_returns_rc2_no_write(self):
|
|
244
|
+
"""--auto=bogus → rc=2 and no write."""
|
|
245
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=bogus"])
|
|
246
|
+
self.assertEqual(rc, 2)
|
|
247
|
+
mw.assert_not_called()
|
|
248
|
+
|
|
249
|
+
def test_auto_standalone_private_writes(self):
|
|
250
|
+
"""--auto=on alone (no --preset/--order/--clear) is accepted on private repo."""
|
|
251
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"])
|
|
252
|
+
self.assertEqual(rc, 0)
|
|
253
|
+
mw.assert_called_once()
|
|
254
|
+
|
|
255
|
+
def test_auto_standalone_public_needs_confirm(self):
|
|
256
|
+
"""--auto=on alone on a PUBLIC repo → needs_confirm, no write."""
|
|
257
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"], vis="PUBLIC")
|
|
258
|
+
self.assertEqual(rc, 0)
|
|
259
|
+
mw.assert_not_called()
|
|
260
|
+
self.assertIn("needs_confirm", out)
|
|
261
|
+
|
|
262
|
+
def test_auto_standalone_public_with_valid_confirm_writes(self):
|
|
263
|
+
"""--auto=on alone on PUBLIC repo with valid --confirm token proceeds to write."""
|
|
264
|
+
tok = make_token("o/r", "ph")
|
|
265
|
+
rc, mw, out, err = self._drive_with_stderr(
|
|
266
|
+
["ph", "--auto=on", f"--confirm={tok}"], vis="PUBLIC"
|
|
267
|
+
)
|
|
268
|
+
self.assertEqual(rc, 0)
|
|
269
|
+
mw.assert_called_once()
|
|
270
|
+
meta = mw.call_args[0][1]
|
|
271
|
+
self.assertTrue(meta.get("next_up_auto"))
|
|
272
|
+
|
|
273
|
+
def test_auto_on_combined_with_preset_writes_both(self):
|
|
274
|
+
"""--auto=on --preset=backlog sets BOTH next_up_auto AND next_up_order in one write."""
|
|
275
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on", "--preset=backlog"])
|
|
276
|
+
self.assertEqual(rc, 0)
|
|
277
|
+
mw.assert_called_once()
|
|
278
|
+
meta = mw.call_args[0][1]
|
|
279
|
+
self.assertTrue(meta.get("next_up_auto"))
|
|
280
|
+
self.assertEqual(meta.get("next_up_order"), {"preset": "backlog"})
|
|
281
|
+
|
|
282
|
+
def test_auto_on_prints_success_message(self):
|
|
283
|
+
"""--auto=on prints a clear success line."""
|
|
284
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=on"])
|
|
285
|
+
self.assertIn("next_up_auto", out)
|
|
286
|
+
self.assertIn("true", out.lower())
|
|
287
|
+
|
|
288
|
+
def test_auto_off_prints_success_message(self):
|
|
289
|
+
"""--auto=off prints a clear success line."""
|
|
290
|
+
t = _t(meta={"status": "active", "next_up_auto": True})
|
|
291
|
+
rc, mw, out, err = self._drive_with_stderr(["ph", "--auto=off"], track=t)
|
|
292
|
+
self.assertIn("next_up_auto", out)
|
|
293
|
+
|
|
294
|
+
|
|
200
295
|
if __name__ == "__main__":
|
|
201
296
|
unittest.main()
|
|
@@ -47,6 +47,7 @@ SUBCOMMANDS = {
|
|
|
47
47
|
"duplicates": "commands.duplicates",
|
|
48
48
|
"coverage": "commands.coverage",
|
|
49
49
|
"canonicalize": "commands.canonicalize",
|
|
50
|
+
"dedupe-tiers": "commands.dedupe_tiers",
|
|
50
51
|
"hygiene": "commands.hygiene",
|
|
51
52
|
"--hygiene": "commands.hygiene", # flag-style alias
|
|
52
53
|
"plan-status": "commands.plan_status",
|
|
@@ -57,6 +58,7 @@ SUBCOMMANDS = {
|
|
|
57
58
|
"close-issue": "commands.close_issue",
|
|
58
59
|
"in-progress": "commands.in_progress",
|
|
59
60
|
"export": "commands.export",
|
|
61
|
+
"which-repo": "commands.which_repo",
|
|
60
62
|
"auth-status": "commands.auth_status",
|
|
61
63
|
"list-open-issues": "commands.list_open_issues",
|
|
62
64
|
"set": "commands.set_field",
|
|
@@ -147,8 +149,12 @@ DESCRIPTIONS = [
|
|
|
147
149
|
"Insert a canonical master issue table at the top of a track. The table has a Milestone column and is ordered active-milestone-first (the track's milestone_alignment milestone, then other milestones grouped with a blank divider row, then no-milestone last) so near-term work sits above someday work (#101). Refresh-md then targets ONLY this table, re-deriving it (so the order self-heals) and leaving narrative tables alone. Use --repo=<key> or track@repo to disambiguate; with --all, --repo=<key> scopes to one repo.",
|
|
148
150
|
"ONE-TIME for hand-written tracks with multiple narrative tables, OR after restructuring a track.",
|
|
149
151
|
"/work-plan canonicalize ux-redesign"),
|
|
152
|
+
("dedupe-tiers", "[--repo=<key>] [--apply]",
|
|
153
|
+
"Remove private track copies that a shared twin in a repo's .work-plan/ supersedes (#359). When a track is promoted to the shared tier, the private original under notes_root is sometimes left behind (bulk/manual promotion, or a failed unlink) — discover_tracks then warns 'exists in both shared and private' on every run with no cleanup path. This removes the safe orphans and REFUSES any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is issue_refs(private) ⊆ issue_refs(shared)). Covers active and archived tiers. Default is a dry-run report; --apply deletes (auto-committed to notes_root, so undoable).",
|
|
154
|
+
"When `exists in both shared and private` warnings appear, or after a bulk promote that left private originals behind.",
|
|
155
|
+
"/work-plan dedupe-tiers --repo=critforge --apply"),
|
|
150
156
|
("hygiene", "[--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]",
|
|
151
|
-
"Weekly cleanup wrapper: refresh-md + reconcile + duplicates. With --repo=<key>, steps 1
|
|
157
|
+
"Weekly cleanup wrapper: refresh-md + reconcile + dedupe-tiers (report-only) + duplicates. With --repo=<key>, steps 1–3 scope to that repo; the duplicates step (a global similarity scan) is skipped. --timeout=N sets the gh subprocess timeout for the duplicates step (default 30s).",
|
|
152
158
|
"WEEKLY — runs all three hygiene commands in sequence so you don't have to remember each. Use --repo=<key> to clean up one project without touching the others.",
|
|
153
159
|
"/work-plan hygiene --repo=myproject"),
|
|
154
160
|
("export", "--json",
|
|
@@ -167,10 +173,10 @@ DESCRIPTIONS = [
|
|
|
167
173
|
"Guarded edit of a track's frontmatter fields (status, launch_priority, milestone_alignment, blockers, next_up). Validates field names + status values; blockers/next_up take comma-separated issue numbers. Setting `next_up` here writes ONLY the frontmatter field — for next_up plus a session-log entry (and a body refresh), use `handoff --set-next` instead. Writes into a PUBLIC repo only with a confirm token: without one it prints {needs_confirm, reason, token} and makes no change (the VS Code viewer surfaces that as a modal, then re-invokes with --confirm=<token>).",
|
|
168
174
|
"Programmatic/GUI edits that have no dedicated verb — e.g. the VS Code extension changing a status or blockers list. On the terminal you'll usually use the named verbs instead.",
|
|
169
175
|
"/work-plan set ux-redesign status=parked"),
|
|
170
|
-
("set-next-up", "<track | track@repo> (--preset=<name> | --order=a,b,c | --clear) [--repo=<key>] [--confirm=<token>]",
|
|
171
|
-
"Configure the ranking preset for a track's
|
|
172
|
-
"When you want a track to use a different ranking order than the default (flow). Use priority-driven for pure backlog work with no milestones, backlog to surface oldest stalled issues first.",
|
|
173
|
-
"/work-plan set-next-up my-track --preset=priority-driven"),
|
|
176
|
+
("set-next-up", "<track | track@repo> (--preset=<name> | --order=a,b,c | --clear | --auto=on|off) [--repo=<key>] [--confirm=<token>]",
|
|
177
|
+
"Configure the ranking preset and/or auto-derivation flag for a track's next_up list. --preset sets one of the named presets (flow, priority-driven, backlog) or 'custom' (which requires --order). --order=a,b,c sets a custom comma-separated criterion list (milestone, dependency, priority, recency, aging). --clear reverts to the global or default preset. --auto=on activates auto-derivation (brief/orient/export derive next-up live via the ranking preset instead of the curated list); --auto=off reverts to the curated list. --auto can be used standalone or combined with --preset/--order/--clear. Writes next_up_order and/or next_up_auto into the track's frontmatter (does NOT touch the next_up issue list). Public-repo gated: without --confirm it prints {needs_confirm, reason, token} and makes no change.",
|
|
178
|
+
"When you want a track to use a different ranking order than the default (flow), or to activate auto-derivation so the viewer always shows a freshly-ranked list. Use priority-driven for pure backlog work with no milestones, backlog to surface oldest stalled issues first.",
|
|
179
|
+
"/work-plan set-next-up my-track --preset=priority-driven --auto=on"),
|
|
174
180
|
("new-track", "<repo> <slug> [--priority=P0..P3] [--milestone=<m>] [--private] [--confirm=<token>]",
|
|
175
181
|
"Create a brand-new track file under notes_root in one headless call. <repo> is either a configured key (e.g. 'myproject') or a bare org/repo slug (e.g. 'your-org/myproject'). Writes frontmatter with status=active and optional priority/milestone. Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token> to proceed.",
|
|
176
182
|
"When a new feature branch or initiative starts and you want the track file created immediately — especially from a non-terminal caller like the VS Code extension that can't interactively run init.",
|
|
@@ -219,6 +225,10 @@ DESCRIPTIONS = [
|
|
|
219
225
|
"Promote a PRIVATE track (local-only, in notes_root) to the repo's SHARED tier and publish it (#306). Moves the track's `.md` into the repo's `.work-plan/` (on its `plan_branch`, via a worktree), removes the private copy so it isn't duplicated, commits to the plan branch, and pushes — unless `--no-push` (keeps it local). The tier is derived from location, so this is a file move, not a frontmatter edit. Requires the repo to have a local clone + a `plan_branch` (else hints `plan-branch init`). Pushing to a PUBLIC repo makes the track world-visible, so the push is confirm-token gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`).",
|
|
220
226
|
"When a private track is ready to share with teammates — promote it to the shared plan branch in one step instead of hand-moving the file.",
|
|
221
227
|
"/work-plan push-track my-feature --repo=myproject"),
|
|
228
|
+
("which-repo", "[--json]",
|
|
229
|
+
"Resolve the current directory to one configured repo — by local clone path first, then the git `origin` remote. Prints the matched config key + GitHub slug, or reports no match. Read-only. Underlies `brief` cwd auto-scope and the VS Code viewer's repo auto-focus.",
|
|
230
|
+
"Rarely run by hand — it's the shared resolver the viewer and `brief` call. Useful to confirm which repo a checkout maps to.",
|
|
231
|
+
"/work-plan which-repo --json"),
|
|
222
232
|
]
|
|
223
233
|
|
|
224
234
|
|
|
@@ -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);
|