@stylusnexus/work-plan 2026.6.9

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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +36 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +119 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/brief.py +247 -0
  11. package/skills/work-plan/commands/canonicalize.py +122 -0
  12. package/skills/work-plan/commands/close.py +83 -0
  13. package/skills/work-plan/commands/duplicates.py +111 -0
  14. package/skills/work-plan/commands/export.py +69 -0
  15. package/skills/work-plan/commands/group.py +234 -0
  16. package/skills/work-plan/commands/handoff.py +855 -0
  17. package/skills/work-plan/commands/hygiene.py +104 -0
  18. package/skills/work-plan/commands/init.py +96 -0
  19. package/skills/work-plan/commands/init_repo.py +90 -0
  20. package/skills/work-plan/commands/list_cmd.py +39 -0
  21. package/skills/work-plan/commands/new_track.py +148 -0
  22. package/skills/work-plan/commands/plan_status.py +296 -0
  23. package/skills/work-plan/commands/reconcile.py +172 -0
  24. package/skills/work-plan/commands/refresh_md.py +132 -0
  25. package/skills/work-plan/commands/set_field.py +54 -0
  26. package/skills/work-plan/commands/set_notes_root.py +53 -0
  27. package/skills/work-plan/commands/slot.py +139 -0
  28. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  29. package/skills/work-plan/commands/where_was_i.py +325 -0
  30. package/skills/work-plan/lib/__init__.py +0 -0
  31. package/skills/work-plan/lib/closure.py +72 -0
  32. package/skills/work-plan/lib/config.py +82 -0
  33. package/skills/work-plan/lib/doc_discovery.py +41 -0
  34. package/skills/work-plan/lib/drift.py +32 -0
  35. package/skills/work-plan/lib/export_model.py +40 -0
  36. package/skills/work-plan/lib/frontmatter.py +48 -0
  37. package/skills/work-plan/lib/git_state.py +180 -0
  38. package/skills/work-plan/lib/github_state.py +296 -0
  39. package/skills/work-plan/lib/llm_evidence.py +45 -0
  40. package/skills/work-plan/lib/manifest.py +164 -0
  41. package/skills/work-plan/lib/new_issues.py +69 -0
  42. package/skills/work-plan/lib/next_up.py +98 -0
  43. package/skills/work-plan/lib/prompts.py +68 -0
  44. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  45. package/skills/work-plan/lib/render.py +83 -0
  46. package/skills/work-plan/lib/scratch.py +14 -0
  47. package/skills/work-plan/lib/session_log.py +39 -0
  48. package/skills/work-plan/lib/status_header.py +60 -0
  49. package/skills/work-plan/lib/status_table.py +227 -0
  50. package/skills/work-plan/lib/tracks.py +109 -0
  51. package/skills/work-plan/lib/verdict.py +51 -0
  52. package/skills/work-plan/lib/write_guard.py +39 -0
  53. package/skills/work-plan/tests/__init__.py +0 -0
  54. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  55. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  56. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  58. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  59. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  60. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  61. package/skills/work-plan/tests/test_close.py +273 -0
  62. package/skills/work-plan/tests/test_closure.py +51 -0
  63. package/skills/work-plan/tests/test_config.py +85 -0
  64. package/skills/work-plan/tests/test_config_seed.py +41 -0
  65. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  66. package/skills/work-plan/tests/test_drift.py +38 -0
  67. package/skills/work-plan/tests/test_export.py +91 -0
  68. package/skills/work-plan/tests/test_export_command.py +295 -0
  69. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  70. package/skills/work-plan/tests/test_git_state.py +51 -0
  71. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  72. package/skills/work-plan/tests/test_github_state.py +508 -0
  73. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  74. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  75. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  76. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  77. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  78. package/skills/work-plan/tests/test_init.py +289 -0
  79. package/skills/work-plan/tests/test_init_repo.py +251 -0
  80. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  81. package/skills/work-plan/tests/test_manifest.py +162 -0
  82. package/skills/work-plan/tests/test_new_issues.py +130 -0
  83. package/skills/work-plan/tests/test_new_track.py +445 -0
  84. package/skills/work-plan/tests/test_next_up.py +149 -0
  85. package/skills/work-plan/tests/test_plan_status.py +68 -0
  86. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  87. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  88. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  89. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  90. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  91. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  92. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  93. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  94. package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
  95. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  96. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  97. package/skills/work-plan/tests/test_render.py +110 -0
  98. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  99. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  100. package/skills/work-plan/tests/test_session_log.py +39 -0
  101. package/skills/work-plan/tests/test_set_field.py +77 -0
  102. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  103. package/skills/work-plan/tests/test_slot.py +243 -0
  104. package/skills/work-plan/tests/test_slot_move.py +128 -0
  105. package/skills/work-plan/tests/test_smoke.py +46 -0
  106. package/skills/work-plan/tests/test_status_header.py +79 -0
  107. package/skills/work-plan/tests/test_status_table.py +162 -0
  108. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  109. package/skills/work-plan/tests/test_tracks.py +56 -0
  110. package/skills/work-plan/tests/test_verdict.py +60 -0
  111. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  112. package/skills/work-plan/tests/test_write_guard.py +53 -0
  113. package/skills/work-plan/work_plan.py +210 -0
@@ -0,0 +1,162 @@
1
+ """Tests for manifest parsing + scoring."""
2
+ import unittest
3
+ import sys
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.manifest import (
11
+ DeclaredPath, strip_range, parse_declared_paths,
12
+ count_checkboxes, plan_date_from_filename,
13
+ ManifestScore, score_manifest,
14
+ is_in_tree, out_of_tree_ratio,
15
+ )
16
+
17
+
18
+ class InTreeTest(unittest.TestCase):
19
+ ROOT = Path("/repo")
20
+
21
+ def test_relative_is_in_tree(self):
22
+ self.assertTrue(is_in_tree("src/foo.ts", self.ROOT))
23
+
24
+ def test_tilde_is_out_of_tree(self):
25
+ self.assertFalse(is_in_tree("~/.claude/skills/x.py", self.ROOT))
26
+
27
+ def test_absolute_elsewhere_is_out_of_tree(self):
28
+ self.assertFalse(is_in_tree("/Applications/other/x.ts", self.ROOT))
29
+
30
+ def test_absolute_under_root_is_in_tree(self):
31
+ self.assertTrue(is_in_tree("/repo/src/x.ts", self.ROOT))
32
+
33
+ def test_dotdot_escape_is_out_of_tree(self):
34
+ self.assertFalse(is_in_tree("../sibling/x.ts", self.ROOT))
35
+
36
+
37
+ class OutOfTreeRatioTest(unittest.TestCase):
38
+ def test_all_foreign(self):
39
+ decls = [DeclaredPath("create", "~/.claude/a.py"),
40
+ DeclaredPath("create", "/Applications/other/b.ts")]
41
+ self.assertEqual(out_of_tree_ratio(decls, Path("/repo")), 1.0)
42
+
43
+ def test_mixed(self):
44
+ decls = [DeclaredPath("create", "src/a.ts"),
45
+ DeclaredPath("create", "~/b.py")]
46
+ self.assertEqual(out_of_tree_ratio(decls, Path("/repo")), 0.5)
47
+
48
+ def test_empty_is_zero(self):
49
+ self.assertEqual(out_of_tree_ratio([], Path("/repo")), 0.0)
50
+
51
+
52
+ class StripRangeTest(unittest.TestCase):
53
+ def test_strips_line_range(self):
54
+ self.assertEqual(strip_range("src/foo.ts:120-145"), "src/foo.ts")
55
+
56
+ def test_strips_single_line(self):
57
+ self.assertEqual(strip_range("src/foo.ts:12"), "src/foo.ts")
58
+
59
+ def test_strips_multi_range(self):
60
+ self.assertEqual(strip_range("src/foo.tsx:104-115,217-247"), "src/foo.tsx")
61
+
62
+ def test_leaves_bare_path(self):
63
+ self.assertEqual(strip_range("src/foo.ts"), "src/foo.ts")
64
+
65
+
66
+ class ParseDeclaredPathsTest(unittest.TestCase):
67
+ SAMPLE = (
68
+ "**Files:**\n"
69
+ "- Create: `src/lib/idea.ts`\n"
70
+ "- Modify: `src/app/route.ts:10-22`\n"
71
+ "- Test: `tests/idea.test.ts`\n"
72
+ "Run: `npm test`\n" # not a declared path (no Create/Modify/Test)
73
+ "See `SomeType` for details\n" # not a path
74
+ )
75
+
76
+ def test_extracts_three_kinds(self):
77
+ decls = parse_declared_paths(self.SAMPLE)
78
+ kinds = {d.kind for d in decls}
79
+ self.assertEqual(kinds, {"create", "modify", "test"})
80
+
81
+ def test_strips_range_on_modify(self):
82
+ decls = parse_declared_paths(self.SAMPLE)
83
+ modify = [d for d in decls if d.kind == "modify"][0]
84
+ self.assertEqual(modify.path, "src/app/route.ts")
85
+
86
+ def test_ignores_non_declaration_backticks(self):
87
+ decls = parse_declared_paths(self.SAMPLE)
88
+ paths = {d.path for d in decls}
89
+ self.assertNotIn("npm test", paths)
90
+ self.assertNotIn("SomeType", paths)
91
+
92
+ def test_dedupes_first_kind_wins(self):
93
+ text = "- Create: `a/b.ts`\n- Modify: `a/b.ts`\n"
94
+ decls = parse_declared_paths(text)
95
+ self.assertEqual(len(decls), 1)
96
+ self.assertEqual(decls[0].kind, "create")
97
+
98
+
99
+ class CountCheckboxesTest(unittest.TestCase):
100
+ def test_counts_done_and_total_multiline(self):
101
+ text = "- [x] one\n- [ ] two\n - [X] three\n- [ ] four\n"
102
+ done, total = count_checkboxes(text)
103
+ self.assertEqual((done, total), (2, 4))
104
+
105
+ def test_no_checkboxes(self):
106
+ self.assertEqual(count_checkboxes("plain prose"), (0, 0))
107
+
108
+
109
+ class PlanDateTest(unittest.TestCase):
110
+ def test_extracts_iso_prefix(self):
111
+ self.assertEqual(plan_date_from_filename("2026-03-16-idea-mode-ui.md"),
112
+ date(2026, 3, 16))
113
+
114
+ def test_returns_none_without_date(self):
115
+ self.assertIsNone(plan_date_from_filename("idea-mode-ui.md"))
116
+
117
+
118
+ class ScoreManifestTest(unittest.TestCase):
119
+ def _decls(self):
120
+ return [
121
+ DeclaredPath("create", "src/new.ts"),
122
+ DeclaredPath("create", "src/missing.ts"),
123
+ DeclaredPath("modify", "src/existing.ts"),
124
+ DeclaredPath("test", "tests/new.test.ts"),
125
+ ]
126
+
127
+ def test_scores_with_injected_predicates(self):
128
+ present = {"src/new.ts", "tests/new.test.ts", "src/existing.ts"}
129
+ committed = {"src/existing.ts"}
130
+ score = score_manifest(
131
+ self._decls(), Path("/repo"), date(2026, 3, 1),
132
+ exists=lambda rel: rel in present,
133
+ committed_since=lambda rel: rel in committed,
134
+ )
135
+ # create: new.ts present(yes), missing.ts(no) -> 1/2
136
+ # modify: existing.ts committed-since(yes) -> 1/1
137
+ # test: new.test.ts present(yes) -> 1/1
138
+ self.assertEqual(score.total, 4)
139
+ self.assertEqual(score.satisfied, 3)
140
+ self.assertEqual(score.by_kind["create"], (1, 2))
141
+ self.assertEqual(score.by_kind["modify"], (1, 1))
142
+ self.assertEqual(score.by_kind["test"], (1, 1))
143
+ self.assertAlmostEqual(score.pct, 75.0)
144
+
145
+ def test_modify_existing_but_not_committed_is_unsatisfied(self):
146
+ score = score_manifest(
147
+ [DeclaredPath("modify", "src/old.ts")], Path("/repo"), date(2026, 3, 1),
148
+ exists=lambda rel: True, # file exists...
149
+ committed_since=lambda rel: False, # ...but untouched since plan date
150
+ )
151
+ self.assertEqual(score.satisfied, 0)
152
+
153
+ def test_empty_manifest_pct_none(self):
154
+ score = score_manifest([], Path("/repo"), None,
155
+ exists=lambda rel: False,
156
+ committed_since=lambda rel: False)
157
+ self.assertEqual(score.total, 0)
158
+ self.assertIsNone(score.pct)
159
+
160
+
161
+ if __name__ == "__main__":
162
+ unittest.main()
@@ -0,0 +1,130 @@
1
+ """Tests for new-issue matching."""
2
+ import unittest
3
+ import sys
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
8
+ sys.path.insert(0, str(SKILL_ROOT))
9
+
10
+ from lib.new_issues import build_slug_labels, match_issue_to_tracks
11
+
12
+
13
+ class MatchIssueTest(unittest.TestCase):
14
+ def test_label_match_wins(self):
15
+ issue = {"number": 9, "title": "unrelated", "labels": [{"name": "track/tabletop"}]}
16
+ matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
17
+ self.assertEqual(matches, ["tabletop"])
18
+
19
+ def test_keyword_in_title(self):
20
+ issue = {"number": 10, "title": "fix tabletop initiative tracker", "labels": []}
21
+ matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
22
+ self.assertEqual(matches, ["tabletop"])
23
+
24
+ def test_no_match_returns_empty(self):
25
+ issue = {"number": 11, "title": "boring thing", "labels": []}
26
+ self.assertEqual(match_issue_to_tracks(issue, ["tabletop", "ux-redesign"]), [])
27
+
28
+ def test_multiple_matches(self):
29
+ issue = {"number": 12, "title": "tabletop ux redesign for combat", "labels": []}
30
+ matches = match_issue_to_tracks(issue, ["tabletop", "ux-redesign"])
31
+ self.assertEqual(set(matches), {"tabletop", "ux-redesign"})
32
+
33
+ def test_slug_labels_override_single(self):
34
+ # Repo uses flat label `storytelling` instead of `track/storytelling-enhancements`.
35
+ issue = {"number": 100, "title": "unrelated", "labels": [{"name": "storytelling"}]}
36
+ slug_labels = {"storytelling-enhancements": ["storytelling"]}
37
+ matches = match_issue_to_tracks(issue, ["storytelling-enhancements"],
38
+ slug_labels=slug_labels)
39
+ self.assertEqual(matches, ["storytelling-enhancements"])
40
+
41
+ def test_slug_labels_override_multiple_or_semantics(self):
42
+ # Track configured to match if EITHER label is present (OR semantics).
43
+ slug_labels = {"ai-generators": ["ai", "generators"]}
44
+
45
+ issue_a = {"number": 200, "title": "x", "labels": [{"name": "ai"}]}
46
+ self.assertEqual(
47
+ match_issue_to_tracks(issue_a, ["ai-generators"], slug_labels=slug_labels),
48
+ ["ai-generators"],
49
+ )
50
+
51
+ issue_b = {"number": 201, "title": "x", "labels": [{"name": "generators"}]}
52
+ self.assertEqual(
53
+ match_issue_to_tracks(issue_b, ["ai-generators"], slug_labels=slug_labels),
54
+ ["ai-generators"],
55
+ )
56
+
57
+ issue_c = {"number": 202, "title": "x", "labels": [{"name": "unrelated"}]}
58
+ self.assertEqual(
59
+ match_issue_to_tracks(issue_c, ["ai-generators"], slug_labels=slug_labels),
60
+ [],
61
+ )
62
+
63
+ def test_default_track_slug_label_still_works_when_other_track_overrides(self):
64
+ # Two tracks: one overridden, one using default `track/<slug>`.
65
+ slug_labels = {"storytelling-enhancements": ["storytelling"]}
66
+ issue = {"number": 300, "title": "unrelated", "labels": [{"name": "track/tabletop"}]}
67
+ matches = match_issue_to_tracks(
68
+ issue, ["storytelling-enhancements", "tabletop"], slug_labels=slug_labels
69
+ )
70
+ self.assertEqual(matches, ["tabletop"])
71
+
72
+ def test_type_label_does_not_leak_into_track_match(self):
73
+ # A `type:feature` label on its own should NOT auto-match a track
74
+ # unless a track explicitly opts into it via slug_labels.
75
+ issue = {
76
+ "number": 400,
77
+ "title": "boring thing",
78
+ "labels": [{"name": "type:feature"}, {"name": "priority:P3"}],
79
+ }
80
+ # No slug_labels override → default behaviour, no match.
81
+ self.assertEqual(match_issue_to_tracks(issue, ["tabletop"]), [])
82
+ # Explicit opt-in for one track → that track matches.
83
+ slug_labels = {"feature-work": ["type:feature"]}
84
+ matches = match_issue_to_tracks(
85
+ issue, ["feature-work", "tabletop"], slug_labels=slug_labels
86
+ )
87
+ self.assertEqual(matches, ["feature-work"])
88
+
89
+
90
+ class BuildSlugLabelsTest(unittest.TestCase):
91
+ def _track(self, slug, labels=None, has_fm=True):
92
+ meta = {"track": slug}
93
+ if labels is not None:
94
+ meta["github"] = {"labels": labels}
95
+ return SimpleNamespace(name=slug, has_frontmatter=has_fm, meta=meta)
96
+
97
+ def test_extracts_labels_from_frontmatter(self):
98
+ tracks = [
99
+ self._track("storytelling-enhancements", ["storytelling"]),
100
+ self._track("ai-generators", ["ai", "generators"]),
101
+ ]
102
+ result = build_slug_labels(tracks)
103
+ self.assertEqual(result, {
104
+ "storytelling-enhancements": ["storytelling"],
105
+ "ai-generators": ["ai", "generators"],
106
+ })
107
+
108
+ def test_omits_tracks_without_labels(self):
109
+ # Tracks without `github.labels` are absent from the map; callers fall
110
+ # back to the default `track/<slug>` pattern for those.
111
+ tracks = [
112
+ self._track("tabletop"), # no labels
113
+ self._track("storytelling-enhancements", ["storytelling"]),
114
+ ]
115
+ result = build_slug_labels(tracks)
116
+ self.assertEqual(result, {"storytelling-enhancements": ["storytelling"]})
117
+
118
+ def test_skips_tracks_without_frontmatter(self):
119
+ tracks = [
120
+ self._track("ghost", ["foo"], has_fm=False),
121
+ ]
122
+ self.assertEqual(build_slug_labels(tracks), {})
123
+
124
+ def test_strips_blank_label_entries(self):
125
+ tracks = [self._track("foo", ["a", " ", ""])]
126
+ self.assertEqual(build_slug_labels(tracks), {"foo": ["a"]})
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()