@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.
Files changed (42) hide show
  1. package/README.md +13 -7
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +6 -4
  5. package/skills/work-plan/commands/canonicalize.py +7 -92
  6. package/skills/work-plan/commands/handoff.py +15 -6
  7. package/skills/work-plan/commands/init.py +13 -3
  8. package/skills/work-plan/commands/init_repo.py +8 -2
  9. package/skills/work-plan/commands/new_track.py +7 -0
  10. package/skills/work-plan/commands/notes_vcs.py +172 -0
  11. package/skills/work-plan/commands/refresh_md.py +106 -37
  12. package/skills/work-plan/commands/rename_track.py +243 -0
  13. package/skills/work-plan/commands/set_notes_root.py +8 -4
  14. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  15. package/skills/work-plan/lib/config.py +11 -0
  16. package/skills/work-plan/lib/frontmatter.py +12 -3
  17. package/skills/work-plan/lib/git_state.py +61 -52
  18. package/skills/work-plan/lib/github_state.py +46 -13
  19. package/skills/work-plan/lib/notes_vcs.py +276 -0
  20. package/skills/work-plan/lib/prompts.py +12 -1
  21. package/skills/work-plan/lib/status_table.py +95 -5
  22. package/skills/work-plan/lib/tracks.py +9 -4
  23. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  24. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  25. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  26. package/skills/work-plan/tests/test_config.py +12 -12
  27. package/skills/work-plan/tests/test_github_state.py +3 -3
  28. package/skills/work-plan/tests/test_init_repo.py +12 -7
  29. package/skills/work-plan/tests/test_new_track.py +7 -7
  30. package/skills/work-plan/tests/test_notes_vcs.py +426 -0
  31. package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
  32. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  33. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  34. package/skills/work-plan/tests/test_rename_track.py +351 -0
  35. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  36. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  37. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  38. package/skills/work-plan/tests/test_status_table.py +61 -0
  39. package/skills/work-plan/tests/test_track_resolution.py +2 -2
  40. package/skills/work-plan/tests/test_tracks.py +4 -4
  41. package/skills/work-plan/work_plan.py +97 -17
  42. /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) -> str:
108
- """Render a canonical issue-table row: `| #N | title | assignee | status |`.
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
- Single source of truth for the canonical row shape used by canonicalize
111
- (initial table) and by sync_missing_rows (drift-healing appends)."""
112
- return f"| #{num} | {title} | {assignee} | {status} |"
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
- if md_path.name.startswith((".", "_")):
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 README
216
- if md_path.name.startswith(".") or md_path.name == "README.md":
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
- if md_path.name.startswith((".", "_")):
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
@@ -3,7 +3,7 @@ track: old
3
3
  status: shipped
4
4
  launch_priority: P2
5
5
  github:
6
- repo: stylusnexus/CritForge
6
+ repo: your-org/myproject
7
7
  issues: [50]
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ track: example
3
3
  status: active
4
4
  launch_priority: P1
5
5
  github:
6
- repo: stylusnexus/CritForge
6
+ repo: your-org/myproject
7
7
  issues: [100, 200]
8
8
  next_up: [100]
9
9
  ---
@@ -3,7 +3,7 @@ track: tabletop
3
3
  status: active
4
4
  launch_priority: P1
5
5
  github:
6
- repo: stylusnexus/CritForge
6
+ repo: your-org/myproject
7
7
  issues: [4254, 4127]
8
8
  branches: []
9
9
  next_up: [4254]
@@ -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
- " critforge:\n"
28
- " github: stylusnexus/CritForge\n"
29
- " local: /Applications/Development/Projects/CritForge\n"
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"]["critforge"]["github"], "stylusnexus/CritForge")
34
- self.assertEqual(cfg["repos"]["critforge"]["local"],
35
- "/Applications/Development/Projects/CritForge")
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
- " critforge: stylusnexus/CritForge\n"
43
+ " myproject: your-org/myproject\n"
44
44
  ))
45
45
  cfg = load_config(path)
46
- self.assertEqual(cfg["repos"]["critforge"]["github"], "stylusnexus/CritForge")
47
- self.assertIsNone(cfg["repos"]["critforge"]["local"])
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
- "critforge": {"github": "stylusnexus/CritForge", "local": "/path/to/critforge"},
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("critforge", self.cfg), "stylusnexus/CritForge")
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("critforge", self.cfg), Path("/path/to/critforge"))
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("stylusnexus/CritForge", [4254])
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("stylusnexus/CritForge", []), [])
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("stylusnexus/CritForge", since_iso="2026-04-27")
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
- # Expression should contain both github and local
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.assertIn("org/myrepo", expr)
86
- self.assertIn("/some/path", expr)
87
- self.assertIn("mykey", expr)
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.assertIn("org/myrepo", expr)
104
- # local should NOT appear in the yq expression
105
- self.assertNotIn("local", expr)
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
- "critforge": {"github": "stylusnexus/critforge", "local": None},
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 'critforge' → folder = 'critforge',
118
- github.repo = 'stylusnexus/critforge'."""
119
- rc, mw, out = _drive(["critforge", "encounter-builder"], vis="PRIVATE")
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"], "stylusnexus/critforge")
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 critforge folder
126
+ # Path passed to write_file should be under myproject folder
127
127
  path_arg = mw.call_args[0][0]
128
- self.assertIn("critforge", str(path_arg))
128
+ self.assertIn("myproject", str(path_arg))
129
129
 
130
130
  # ------------------------------------------------------------------
131
131
  # Bare org/repo slug