@stylusnexus/work-plan 2026.6.10 → 2026.6.11
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 +13 -7
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +6 -4
- package/skills/work-plan/commands/canonicalize.py +7 -92
- package/skills/work-plan/commands/handoff.py +15 -6
- package/skills/work-plan/commands/init.py +13 -3
- package/skills/work-plan/commands/init_repo.py +8 -2
- package/skills/work-plan/commands/new_track.py +7 -0
- package/skills/work-plan/commands/notes_vcs.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +106 -37
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/lib/config.py +11 -0
- package/skills/work-plan/lib/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +46 -13
- package/skills/work-plan/lib/notes_vcs.py +276 -0
- package/skills/work-plan/lib/prompts.py +12 -1
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +9 -4
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_github_state.py +3 -3
- package/skills/work-plan/tests/test_init_repo.py +12 -7
- package/skills/work-plan/tests/test_new_track.py +7 -7
- package/skills/work-plan/tests/test_notes_vcs.py +426 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- package/skills/work-plan/tests/test_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +2 -2
- package/skills/work-plan/tests/test_tracks.py +4 -4
- package/skills/work-plan/work_plan.py +97 -17
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
|
@@ -104,12 +104,102 @@ def update_row_status(body: str, issue_num: int, new_status: str) -> str:
|
|
|
104
104
|
return "\n".join(lines)
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
def render_issue_row(num: int, title: str, assignee: str, status: str
|
|
108
|
-
|
|
107
|
+
def render_issue_row(num: int, title: str, assignee: str, status: str,
|
|
108
|
+
milestone: Optional[str] = None) -> str:
|
|
109
|
+
"""Render a canonical issue-table row.
|
|
110
|
+
|
|
111
|
+
Single source of truth for the canonical row shape. With `milestone=None`
|
|
112
|
+
(the default) renders the 4-column form `| #N | title | assignee | status |`
|
|
113
|
+
used by narrative tables and sync_missing_rows appends. Pass a milestone
|
|
114
|
+
string (possibly empty) to render the 5-column canonical form
|
|
115
|
+
`| #N | title | milestone | assignee | status |` used by render_canonical_table
|
|
116
|
+
(#101). An empty string still renders the column — distinct from None, which
|
|
117
|
+
drops it."""
|
|
118
|
+
if milestone is None:
|
|
119
|
+
return f"| #{num} | {title} | {assignee} | {status} |"
|
|
120
|
+
return f"| #{num} | {title} | {milestone} | {assignee} | {status} |"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_canonical_table(issue_nums: list, issues_by_num: dict,
|
|
124
|
+
milestone_alignment=None) -> str:
|
|
125
|
+
"""Render the canonical issues block: heading, marker, and ONE table.
|
|
126
|
+
|
|
127
|
+
The table carries a `Milestone` column and is ordered active-milestone-first
|
|
128
|
+
(the shared `milestone_sort_key`): issues whose milestone matches the track's
|
|
129
|
+
`milestone_alignment` come first, then other milestones grouped by label,
|
|
130
|
+
then no-milestone issues last; a blank divider row separates each group.
|
|
131
|
+
|
|
132
|
+
Deliberately a SINGLE table (not per-milestone sub-tables): it round-trips
|
|
133
|
+
through refresh-md, which re-derives this whole block on every run, so the
|
|
134
|
+
rendered order can't decay (#101). The blank divider row has no `#NNNN`
|
|
135
|
+
ref, so the table parsers skip it.
|
|
136
|
+
|
|
137
|
+
Returns the block string (heading + marker + table); callers add the
|
|
138
|
+
trailing `---` separator via insert_canonical_block."""
|
|
139
|
+
from lib.github_state import (
|
|
140
|
+
short_milestone, format_assignees, state_to_status_label,
|
|
141
|
+
)
|
|
142
|
+
from lib.export_model import group_issues_by_milestone
|
|
143
|
+
|
|
144
|
+
lines = [
|
|
145
|
+
"## Issues (canonical)",
|
|
146
|
+
"",
|
|
147
|
+
f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
|
|
148
|
+
"",
|
|
149
|
+
"| # | Title | Milestone | Assignee | Status |",
|
|
150
|
+
"|---|---|---|---|---|",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
norm = []
|
|
154
|
+
for num in sorted(issue_nums):
|
|
155
|
+
gh = issues_by_num.get(num, {})
|
|
156
|
+
ms = short_milestone(gh.get("milestone")) or None
|
|
157
|
+
norm.append({"number": num, "milestone": ms, "_gh": gh})
|
|
158
|
+
|
|
159
|
+
groups = group_issues_by_milestone(norm, milestone_alignment)
|
|
160
|
+
for gi, (label, issues) in enumerate(groups):
|
|
161
|
+
if gi > 0:
|
|
162
|
+
lines.append("| | | | | |") # blank divider row between milestone groups
|
|
163
|
+
for it in issues:
|
|
164
|
+
gh = it["_gh"]
|
|
165
|
+
lines.append(render_issue_row(
|
|
166
|
+
it["number"], gh.get("title", "(not fetched)"),
|
|
167
|
+
format_assignees(gh), state_to_status_label(gh.get("state")),
|
|
168
|
+
milestone=it["milestone"] or "",
|
|
169
|
+
))
|
|
170
|
+
lines.append("")
|
|
171
|
+
return "\n".join(lines)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def strip_canonical_block(body: str) -> str:
|
|
175
|
+
"""Remove an existing canonical-table block from the top of the body.
|
|
109
176
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
177
|
+
The block runs from the `## Issues (canonical)` heading (or the marker if
|
|
178
|
+
the heading is absent) through the next `\\n---\\n` separator. Returns the
|
|
179
|
+
body unchanged when no marker is present."""
|
|
180
|
+
if CANONICAL_MARKER not in body:
|
|
181
|
+
return body
|
|
182
|
+
heading_idx = body.find("## Issues (canonical)")
|
|
183
|
+
marker_idx = body.find(CANONICAL_MARKER)
|
|
184
|
+
start = heading_idx if 0 <= heading_idx < marker_idx else marker_idx
|
|
185
|
+
sep_idx = body.find("\n---\n", marker_idx)
|
|
186
|
+
if sep_idx == -1:
|
|
187
|
+
end = body.find("\n", marker_idx) + 1
|
|
188
|
+
else:
|
|
189
|
+
end = sep_idx + len("\n---\n")
|
|
190
|
+
return body[:start] + body[end:].lstrip("\n")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def insert_canonical_block(body: str, table_md: str, replace: bool = False) -> str:
|
|
194
|
+
"""Prepend `table_md` (a render_canonical_table block) at the top of body,
|
|
195
|
+
followed by a `---` separator. With replace=True, strip any existing
|
|
196
|
+
canonical block first (so refresh-md re-derive and canonicalize --force
|
|
197
|
+
produce identical output)."""
|
|
198
|
+
if replace:
|
|
199
|
+
body = strip_canonical_block(body)
|
|
200
|
+
body_stripped = body.lstrip("\n")
|
|
201
|
+
leading = body[: len(body) - len(body_stripped)]
|
|
202
|
+
return leading + table_md + "\n---\n\n" + body_stripped
|
|
113
203
|
|
|
114
204
|
|
|
115
205
|
def append_rows(body: str, table: dict, row_lines: list[str]) -> str:
|
|
@@ -155,7 +155,9 @@ def discover_archived_tracks(cfg: dict) -> list[Track]:
|
|
|
155
155
|
for md_path in sorted(notes_root.rglob("*.md")):
|
|
156
156
|
if "archive" not in md_path.parts:
|
|
157
157
|
continue
|
|
158
|
-
|
|
158
|
+
# '-' prefix rejected so a `--repo.md` file can't become a `--repo`
|
|
159
|
+
# track that the CLI misparses as a flag (#194).
|
|
160
|
+
if md_path.name.startswith((".", "_", "-")):
|
|
159
161
|
continue
|
|
160
162
|
private_archived.append(_build_track(md_path, notes_root, cfg))
|
|
161
163
|
|
|
@@ -212,8 +214,9 @@ def _discover_shared_tracks(cfg: dict, include_archive: bool = False,
|
|
|
212
214
|
if not notes_dir.is_dir():
|
|
213
215
|
continue
|
|
214
216
|
for md_path in sorted(notes_dir.rglob("*.md")):
|
|
215
|
-
# Skip dotfiles and
|
|
216
|
-
|
|
217
|
+
# Skip dotfiles, README, and dash-led names (a `--repo.md` file
|
|
218
|
+
# would otherwise become a `--repo` track the CLI misparses, #194).
|
|
219
|
+
if md_path.name.startswith((".", "-")) or md_path.name == "README.md":
|
|
217
220
|
continue
|
|
218
221
|
in_archive = "archive" in md_path.relative_to(notes_dir).parts
|
|
219
222
|
if archive_only and not in_archive:
|
|
@@ -263,7 +266,9 @@ def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
|
|
|
263
266
|
for md_path in sorted(notes_root.rglob("*.md")):
|
|
264
267
|
if not include_archive and "archive" in md_path.parts:
|
|
265
268
|
continue
|
|
266
|
-
|
|
269
|
+
# '-' prefix rejected so a `--repo.md` file can't become a `--repo`
|
|
270
|
+
# track that the CLI misparses as a flag (#194).
|
|
271
|
+
if md_path.name.startswith((".", "_", "-")):
|
|
267
272
|
continue
|
|
268
273
|
out.append(_build_track(md_path, notes_root, cfg))
|
|
269
274
|
return out
|
|
@@ -24,15 +24,15 @@ class LoadConfigTest(unittest.TestCase):
|
|
|
24
24
|
path = self._write(d, (
|
|
25
25
|
"notes_root: /tmp/notes\n"
|
|
26
26
|
"repos:\n"
|
|
27
|
-
"
|
|
28
|
-
" github:
|
|
29
|
-
" local: /
|
|
27
|
+
" myproject:\n"
|
|
28
|
+
" github: your-org/myproject\n"
|
|
29
|
+
" local: /path/to/myproject\n"
|
|
30
30
|
))
|
|
31
31
|
cfg = load_config(path)
|
|
32
32
|
self.assertEqual(cfg["notes_root"], "/tmp/notes")
|
|
33
|
-
self.assertEqual(cfg["repos"]["
|
|
34
|
-
self.assertEqual(cfg["repos"]["
|
|
35
|
-
"/
|
|
33
|
+
self.assertEqual(cfg["repos"]["myproject"]["github"], "your-org/myproject")
|
|
34
|
+
self.assertEqual(cfg["repos"]["myproject"]["local"],
|
|
35
|
+
"/path/to/myproject")
|
|
36
36
|
|
|
37
37
|
def test_load_string_shape_normalizes_to_dict(self):
|
|
38
38
|
# Backward-friendly: bare string is treated as github-only, no local
|
|
@@ -40,11 +40,11 @@ class LoadConfigTest(unittest.TestCase):
|
|
|
40
40
|
path = self._write(d, (
|
|
41
41
|
"notes_root: /tmp/notes\n"
|
|
42
42
|
"repos:\n"
|
|
43
|
-
"
|
|
43
|
+
" myproject: your-org/myproject\n"
|
|
44
44
|
))
|
|
45
45
|
cfg = load_config(path)
|
|
46
|
-
self.assertEqual(cfg["repos"]["
|
|
47
|
-
self.assertIsNone(cfg["repos"]["
|
|
46
|
+
self.assertEqual(cfg["repos"]["myproject"]["github"], "your-org/myproject")
|
|
47
|
+
self.assertIsNone(cfg["repos"]["myproject"]["local"])
|
|
48
48
|
|
|
49
49
|
def test_missing_file_self_seeds(self):
|
|
50
50
|
# No install hook exists for plugin installs, so a missing config is
|
|
@@ -68,16 +68,16 @@ class ResolveTest(unittest.TestCase):
|
|
|
68
68
|
def setUp(self):
|
|
69
69
|
self.cfg = {
|
|
70
70
|
"repos": {
|
|
71
|
-
"
|
|
71
|
+
"myproject": {"github": "your-org/myproject", "local": "/path/to/myproject"},
|
|
72
72
|
},
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
def test_resolve_github(self):
|
|
76
|
-
self.assertEqual(resolve_github_for_folder("
|
|
76
|
+
self.assertEqual(resolve_github_for_folder("myproject", self.cfg), "your-org/myproject")
|
|
77
77
|
self.assertIsNone(resolve_github_for_folder("unknown", self.cfg))
|
|
78
78
|
|
|
79
79
|
def test_resolve_local_path(self):
|
|
80
|
-
self.assertEqual(resolve_local_path_for_folder("
|
|
80
|
+
self.assertEqual(resolve_local_path_for_folder("myproject", self.cfg), Path("/path/to/myproject"))
|
|
81
81
|
self.assertIsNone(resolve_local_path_for_folder("unknown", self.cfg))
|
|
82
82
|
|
|
83
83
|
|
|
@@ -53,12 +53,12 @@ class FetchIssuesTest(unittest.TestCase):
|
|
|
53
53
|
stdout='{"number": 4254, "state": "OPEN", "labels": [{"name": "priority/P0"}], "title": "polls"}',
|
|
54
54
|
returncode=0,
|
|
55
55
|
)
|
|
56
|
-
result = fetch_issues("
|
|
56
|
+
result = fetch_issues("your-org/myproject", [4254])
|
|
57
57
|
self.assertEqual(len(result), 1)
|
|
58
58
|
self.assertEqual(result[0]["number"], 4254)
|
|
59
59
|
|
|
60
60
|
def test_empty_returns_empty(self):
|
|
61
|
-
self.assertEqual(fetch_issues("
|
|
61
|
+
self.assertEqual(fetch_issues("your-org/myproject", []), [])
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class FetchRecentIssuesTest(unittest.TestCase):
|
|
@@ -68,7 +68,7 @@ class FetchRecentIssuesTest(unittest.TestCase):
|
|
|
68
68
|
stdout='[{"number": 9999, "title": "new", "labels": [], "createdAt": "2026-04-28T10:00:00Z"}]',
|
|
69
69
|
returncode=0,
|
|
70
70
|
)
|
|
71
|
-
result = fetch_recent_issues("
|
|
71
|
+
result = fetch_recent_issues("your-org/myproject", since_iso="2026-04-27")
|
|
72
72
|
self.assertEqual(len(result), 1)
|
|
73
73
|
self.assertEqual(result[0]["number"], 9999)
|
|
74
74
|
called_args = mock_run.call_args[0][0]
|
|
@@ -10,6 +10,7 @@ Covers:
|
|
|
10
10
|
- No input()/prompt_input reached (patch to raise).
|
|
11
11
|
"""
|
|
12
12
|
import io
|
|
13
|
+
import json
|
|
13
14
|
import sys
|
|
14
15
|
import unittest
|
|
15
16
|
from contextlib import redirect_stdout
|
|
@@ -80,11 +81,13 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
|
80
81
|
yq_args = msub.call_args[0][0] # positional first arg is the argv list
|
|
81
82
|
self.assertEqual(yq_args[0], "yq")
|
|
82
83
|
self.assertEqual(yq_args[1], "-i")
|
|
83
|
-
#
|
|
84
|
+
# Hardened (#196): the repo block travels as an OPAQUE env value via
|
|
85
|
+
# env(); only the validated key appears in the expression path.
|
|
84
86
|
expr = yq_args[2]
|
|
85
|
-
self.
|
|
86
|
-
|
|
87
|
-
self.
|
|
87
|
+
self.assertEqual(expr, ".repos.mykey = env(WP_REPO_BLOCK)")
|
|
88
|
+
block = json.loads(msub.call_args.kwargs["env"]["WP_REPO_BLOCK"])
|
|
89
|
+
self.assertEqual(block["github"], "org/myrepo")
|
|
90
|
+
self.assertEqual(block["local"], "/some/path")
|
|
88
91
|
self.assertIn("✓", out)
|
|
89
92
|
|
|
90
93
|
# ------------------------------------------------------------------
|
|
@@ -100,9 +103,11 @@ class InitRepoNonInteractiveTest(unittest.TestCase):
|
|
|
100
103
|
self.assertEqual(rc, 0)
|
|
101
104
|
msub.assert_called_once()
|
|
102
105
|
expr = msub.call_args[0][0][2]
|
|
103
|
-
self.
|
|
104
|
-
|
|
105
|
-
self.
|
|
106
|
+
self.assertEqual(expr, ".repos.mykey = env(WP_REPO_BLOCK)")
|
|
107
|
+
block = json.loads(msub.call_args.kwargs["env"]["WP_REPO_BLOCK"])
|
|
108
|
+
self.assertEqual(block["github"], "org/myrepo")
|
|
109
|
+
# local should NOT appear in the block
|
|
110
|
+
self.assertNotIn("local", block)
|
|
106
111
|
|
|
107
112
|
# ------------------------------------------------------------------
|
|
108
113
|
# Missing --github → rc 2, no yq, no prompt
|
|
@@ -43,7 +43,7 @@ def _make_cfg(*, repos=None):
|
|
|
43
43
|
if repos is None:
|
|
44
44
|
repos = {
|
|
45
45
|
"myrepo": {"github": "org/myrepo", "local": None},
|
|
46
|
-
"
|
|
46
|
+
"myproject": {"github": "your-org/myproject", "local": None},
|
|
47
47
|
}
|
|
48
48
|
return {"notes_root": NOTES_ROOT, "repos": repos}
|
|
49
49
|
|
|
@@ -114,18 +114,18 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
114
114
|
self.assertEqual(meta["status"], "active")
|
|
115
115
|
|
|
116
116
|
def test_config_key_folder_resolves_correctly(self):
|
|
117
|
-
"""Config-key '
|
|
118
|
-
github.repo = '
|
|
119
|
-
rc, mw, out = _drive(["
|
|
117
|
+
"""Config-key 'myproject' → folder = 'myproject',
|
|
118
|
+
github.repo = 'your-org/myproject'."""
|
|
119
|
+
rc, mw, out = _drive(["myproject", "encounter-builder"], vis="PRIVATE")
|
|
120
120
|
self.assertEqual(rc, 0)
|
|
121
121
|
mw.assert_called_once()
|
|
122
122
|
meta = mw.call_args[0][1]
|
|
123
|
-
self.assertEqual(meta["github"]["repo"], "
|
|
123
|
+
self.assertEqual(meta["github"]["repo"], "your-org/myproject")
|
|
124
124
|
# Track name from slug
|
|
125
125
|
self.assertEqual(meta["track"], "encounter-builder")
|
|
126
|
-
# Path passed to write_file should be under
|
|
126
|
+
# Path passed to write_file should be under myproject folder
|
|
127
127
|
path_arg = mw.call_args[0][0]
|
|
128
|
-
self.assertIn("
|
|
128
|
+
self.assertIn("myproject", str(path_arg))
|
|
129
129
|
|
|
130
130
|
# ------------------------------------------------------------------
|
|
131
131
|
# Bare org/repo slug
|