@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,38 @@
1
+ """Tests for drift detection."""
2
+ import unittest
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ from lib.drift import detect_drift
10
+
11
+
12
+ class DetectDriftTest(unittest.TestCase):
13
+ def test_no_drift_when_table_matches(self):
14
+ body = (
15
+ "| # | Title | Status |\n"
16
+ "|---|---|---|\n"
17
+ "| #1 | foo | ✅ Shipped |\n"
18
+ )
19
+ github_issues = [{"number": 1, "state": "CLOSED"}]
20
+ self.assertEqual(detect_drift(body, github_issues), [])
21
+
22
+ def test_drift_when_open_in_md_closed_in_github(self):
23
+ body = (
24
+ "| # | Title | Status |\n"
25
+ "|---|---|---|\n"
26
+ "| #1 | foo | 🔲 Open |\n"
27
+ )
28
+ github_issues = [{"number": 1, "state": "CLOSED"}]
29
+ drift = detect_drift(body, github_issues)
30
+ self.assertEqual(len(drift), 1)
31
+ self.assertEqual(drift[0]["issue"], 1)
32
+
33
+ def test_no_table_returns_empty(self):
34
+ self.assertEqual(detect_drift("# No table\n", [{"number": 1, "state": "CLOSED"}]), [])
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -0,0 +1,91 @@
1
+ # tests/test_export.py
2
+ import sys, json, unittest
3
+ from pathlib import Path
4
+ from types import SimpleNamespace
5
+ SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
6
+ from lib.export_model import build_export
7
+ import commands.export as export_cmd
8
+
9
+ def _track(name, repo, issues, blockers=None, next_up=None, status="active"):
10
+ return SimpleNamespace(name=name, repo=repo, tier="private",
11
+ meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
12
+ "blockers": blockers or [], "next_up": next_up or [],
13
+ "github": {"repo": repo, "issues": issues}})
14
+
15
+ class BuildExportTest(unittest.TestCase):
16
+ def test_schema_and_shape(self):
17
+ tracks = [_track("ph", "o/r", [1, 2], blockers=[9], next_up=[1])]
18
+ issues_by_track = {"ph": [
19
+ {"number": 1, "title": "a", "state": "OPEN", "assignees": [{"login": "eve"}]},
20
+ {"number": 2, "title": "b", "state": "CLOSED", "assignees": []}]}
21
+ vis = {"o/r": "PRIVATE"}
22
+ out = build_export(tracks, issues_by_track, vis, now="2026-06-07T00:00")
23
+ self.assertEqual(out["schema"], 1)
24
+ t = out["tracks"][0]
25
+ self.assertEqual(t["name"], "ph"); self.assertEqual(t["tier"], "private")
26
+ self.assertEqual(t["visibility"], "PRIVATE")
27
+ self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
28
+ self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
29
+ self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None})
30
+ json.dumps(out) # must be serializable
31
+
32
+ class BuildExportUntrackedTest(unittest.TestCase):
33
+ """Tests for the untracked kwarg on build_export."""
34
+
35
+ _RAW_ROW = {"number": 9, "title": "x", "state": "OPEN", "assignees": [], "milestone": None}
36
+
37
+ def test_untracked_key_present_when_omitted(self):
38
+ """Back-compat: callers that omit untracked_by_repo still get out['untracked'] == []."""
39
+ out = build_export([], {}, {}, now="2026-06-07T00:00")
40
+ self.assertIn("untracked", out)
41
+ self.assertEqual(out["untracked"], [])
42
+
43
+ def test_untracked_key_present_when_none(self):
44
+ out = build_export([], {}, {}, now="2026-06-07T00:00", untracked_by_repo=None)
45
+ self.assertEqual(out["untracked"], [])
46
+
47
+ def test_untracked_populated(self):
48
+ out = build_export(
49
+ [], {}, {}, now="2026-06-07T00:00",
50
+ untracked_by_repo={"o/r": [self._RAW_ROW]},
51
+ )
52
+ self.assertEqual(len(out["untracked"]), 1)
53
+ entry = out["untracked"][0]
54
+ self.assertEqual(entry["repo"], "o/r")
55
+ self.assertEqual(len(entry["issues"]), 1)
56
+ # _issue normalises state to lowercase "open"
57
+ issue = entry["issues"][0]
58
+ self.assertEqual(issue["number"], 9)
59
+ self.assertEqual(issue["title"], "x")
60
+ self.assertEqual(issue["state"], "open")
61
+
62
+ def test_empty_rows_repo_omitted(self):
63
+ out = build_export(
64
+ [], {}, {}, now="2026-06-07T00:00",
65
+ untracked_by_repo={"o/r": [], "o/q": [self._RAW_ROW]},
66
+ )
67
+ repos = [e["repo"] for e in out["untracked"]]
68
+ self.assertNotIn("o/r", repos)
69
+ self.assertIn("o/q", repos)
70
+
71
+ def test_insertion_order_preserved(self):
72
+ row_a = {"number": 1, "title": "a", "state": "OPEN", "assignees": [], "milestone": None}
73
+ row_b = {"number": 2, "title": "b", "state": "OPEN", "assignees": [], "milestone": None}
74
+ # Python 3.7+ dicts are ordered; pass in explicit order
75
+ untracked = {"repo/b": [row_b], "repo/a": [row_a]}
76
+ out = build_export([], {}, {}, now="t", untracked_by_repo=untracked)
77
+ repos = [e["repo"] for e in out["untracked"]]
78
+ self.assertEqual(repos, ["repo/b", "repo/a"])
79
+
80
+ def test_schema_stays_1(self):
81
+ out = build_export([], {}, {}, now="t", untracked_by_repo={"o/r": [self._RAW_ROW]})
82
+ self.assertEqual(out["schema"], 1)
83
+
84
+ def test_json_serializable(self):
85
+ out = build_export([], {}, {}, now="t", untracked_by_repo={"o/r": [self._RAW_ROW]})
86
+ json.dumps(out) # must not raise
87
+
88
+
89
+ class ExportCommandGateTest(unittest.TestCase):
90
+ def test_requires_json_flag(self):
91
+ self.assertEqual(export_cmd.run([]), 2)
@@ -0,0 +1,295 @@
1
+ # tests/test_export_command.py
2
+ """Tests for the export command's bulk fetch path."""
3
+ import sys, json, unittest
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+ from unittest.mock import patch, MagicMock, call
7
+
8
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(SKILL_ROOT))
10
+
11
+ import commands.export as export_cmd
12
+
13
+
14
+ def _track(name, repo, issues, *, has_frontmatter=True, status="active"):
15
+ return SimpleNamespace(
16
+ name=name,
17
+ repo=repo,
18
+ tier="private",
19
+ has_frontmatter=has_frontmatter,
20
+ meta={
21
+ "status": status,
22
+ "launch_priority": "P2",
23
+ "milestone_alignment": "v1",
24
+ "blockers": [],
25
+ "next_up": [],
26
+ "github": {"repo": repo, "issues": issues},
27
+ },
28
+ )
29
+
30
+
31
+ _ISSUE_A = {"number": 1, "title": "Alpha", "state": "OPEN",
32
+ "assignees": [{"login": "eve"}], "milestone": None}
33
+ _ISSUE_B = {"number": 2, "title": "Beta", "state": "CLOSED",
34
+ "assignees": [], "milestone": None}
35
+ _ISSUE_C = {"number": 3, "title": "Gamma", "state": "OPEN",
36
+ "assignees": [], "milestone": None}
37
+
38
+ # Shared issue key — issue 1 is referenced by BOTH tracks
39
+ _SHARED_REPO = "org/shared"
40
+ _EXPORT_MAP = {
41
+ (_SHARED_REPO, 1): _ISSUE_A,
42
+ (_SHARED_REPO, 2): _ISSUE_B,
43
+ (_SHARED_REPO, 3): _ISSUE_C,
44
+ }
45
+
46
+
47
+ class ExportRunJsonTest(unittest.TestCase):
48
+ """Drive export.run(["--json"]) with mocked deps; verify schema + assembly."""
49
+
50
+ def _run_with_mocks(self, tracks, export_map, vis=None):
51
+ """Helper: run the export command with controlled mocks, capture stdout."""
52
+ import io
53
+ from contextlib import redirect_stdout
54
+
55
+ vis = vis or {_SHARED_REPO: "PUBLIC"}
56
+
57
+ with patch("commands.export.load_config", return_value={}), \
58
+ patch("commands.export.discover_tracks", return_value=tracks), \
59
+ patch("commands.export.fetch_export_issues", return_value=export_map) as mock_fei, \
60
+ patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
61
+ patch("commands.export.datetime") as mock_dt:
62
+ mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
63
+ buf = io.StringIO()
64
+ with redirect_stdout(buf):
65
+ rc = export_cmd.run(["--json"])
66
+ return rc, json.loads(buf.getvalue()), mock_fei
67
+
68
+ def test_schema_is_1(self):
69
+ tracks = [_track("alpha", _SHARED_REPO, [1, 2])]
70
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
71
+ self.assertEqual(rc, 0)
72
+ self.assertEqual(out["schema"], 1)
73
+
74
+ def test_track_issues_assembled_in_declared_order(self):
75
+ # Track declares [2, 1] — output order must match declaration, not map-insertion order
76
+ tracks = [_track("alpha", _SHARED_REPO, [2, 1])]
77
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
78
+ self.assertEqual(rc, 0)
79
+ issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
80
+ self.assertEqual(issue_nums, [2, 1])
81
+
82
+ def test_shared_issue_appears_in_both_tracks(self):
83
+ tracks = [
84
+ _track("alpha", _SHARED_REPO, [1, 2]),
85
+ _track("beta", _SHARED_REPO, [1, 3]),
86
+ ]
87
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
88
+ self.assertEqual(rc, 0)
89
+ alpha_nums = {i["number"] for i in out["tracks"][0]["issues"]}
90
+ beta_nums = {i["number"] for i in out["tracks"][1]["issues"]}
91
+ self.assertIn(1, alpha_nums)
92
+ self.assertIn(1, beta_nums)
93
+
94
+ def test_deduped_repo_to_numbers_passed_to_bulk_fetch(self):
95
+ """Issues shared by two tracks in the same repo should be in the
96
+ repo_to_numbers dict only ONCE per repo (deduplication)."""
97
+ tracks = [
98
+ _track("alpha", _SHARED_REPO, [1, 2]),
99
+ _track("beta", _SHARED_REPO, [1, 3]),
100
+ ]
101
+ rc, out, mock_fei = self._run_with_mocks(tracks, _EXPORT_MAP)
102
+ self.assertEqual(rc, 0)
103
+ # fetch_export_issues called with repo_to_numbers dict
104
+ repo_to_numbers = mock_fei.call_args[0][0]
105
+ nums = repo_to_numbers.get(_SHARED_REPO, [])
106
+ # issue 1 should appear exactly once
107
+ self.assertEqual(nums.count(1), 1)
108
+ # total unique: 1, 2, 3
109
+ self.assertEqual(sorted(nums), [1, 2, 3])
110
+
111
+ def test_missing_fetch_result_is_skipped(self):
112
+ # Issue 99 is in the track but not in the export map (simulates PR/miss)
113
+ track = _track("alpha", _SHARED_REPO, [1, 99])
114
+ partial_map = {(_SHARED_REPO, 1): _ISSUE_A} # 99 absent
115
+ rc, out, _ = self._run_with_mocks([track], partial_map)
116
+ self.assertEqual(rc, 0)
117
+ issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
118
+ self.assertEqual(issue_nums, [1]) # 99 skipped
119
+
120
+ def test_track_without_repo_gets_empty_issues(self):
121
+ track = _track("norep", None, [1, 2])
122
+ rc, out, mock_fei = self._run_with_mocks([track], {})
123
+ self.assertEqual(rc, 0)
124
+ self.assertEqual(out["tracks"][0]["issues"], [])
125
+ # repo_to_numbers should be empty (no repo)
126
+ repo_to_numbers = mock_fei.call_args[0][0]
127
+ self.assertEqual(repo_to_numbers, {})
128
+
129
+ def test_track_without_issues_gets_empty_issues(self):
130
+ track = _track("noissues", _SHARED_REPO, [])
131
+ rc, out, _ = self._run_with_mocks([track], {})
132
+ self.assertEqual(rc, 0)
133
+ self.assertEqual(out["tracks"][0]["issues"], [])
134
+
135
+ def test_visibility_included_in_output(self):
136
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
137
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP, vis={_SHARED_REPO: "PUBLIC"})
138
+ self.assertEqual(out["tracks"][0]["visibility"], "PUBLIC")
139
+
140
+ def test_rollup_counts_correct(self):
141
+ tracks = [_track("alpha", _SHARED_REPO, [1, 2])] # 1=OPEN, 2=CLOSED
142
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
143
+ rollup = out["tracks"][0]["rollup"]
144
+ self.assertEqual(rollup["open"], 1)
145
+ self.assertEqual(rollup["closed"], 1)
146
+
147
+ def test_no_frontmatter_tracks_excluded(self):
148
+ tracks = [
149
+ _track("with_fm", _SHARED_REPO, [1], has_frontmatter=True),
150
+ _track("without_fm", _SHARED_REPO, [2], has_frontmatter=False),
151
+ ]
152
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
153
+ self.assertEqual(rc, 0)
154
+ track_names = [t["name"] for t in out["tracks"]]
155
+ self.assertIn("with_fm", track_names)
156
+ self.assertNotIn("without_fm", track_names)
157
+
158
+ def test_output_is_json_serializable(self):
159
+ tracks = [_track("alpha", _SHARED_REPO, [1, 2])]
160
+ rc, out, _ = self._run_with_mocks(tracks, _EXPORT_MAP)
161
+ self.assertEqual(rc, 0)
162
+ json.dumps(out) # must not raise
163
+
164
+ def test_issue_absent_from_map_even_after_fallback_is_omitted(self):
165
+ """An issue referenced by a track but absent from the returned map
166
+ (simulating a PR/miss where even the fallback didn't find it) must be
167
+ silently omitted from that track's issues list."""
168
+ track = _track("alpha", _SHARED_REPO, [1, 2, 999])
169
+ partial_map = {(_SHARED_REPO, 1): _ISSUE_A, (_SHARED_REPO, 2): _ISSUE_B}
170
+ # 999 is not in the map at all
171
+ rc, out, _ = self._run_with_mocks([track], partial_map)
172
+ self.assertEqual(rc, 0)
173
+ issue_nums = [i["number"] for i in out["tracks"][0]["issues"]]
174
+ self.assertNotIn(999, issue_nums)
175
+ self.assertIn(1, issue_nums)
176
+ self.assertIn(2, issue_nums)
177
+
178
+ def test_two_repos_deduped_independently(self):
179
+ """Each repo's number list is deduped independently; a number shared
180
+ across repos is still fetched once per repo."""
181
+ repo_a = "org/repoA"
182
+ repo_b = "org/repoB"
183
+ tracks = [
184
+ _track("alpha", repo_a, [1, 2]),
185
+ _track("beta", repo_a, [1, 3]), # issue 1 shared in repoA
186
+ _track("gamma", repo_b, [1]), # issue 1 in repoB is a different issue
187
+ ]
188
+ export_map = {
189
+ (repo_a, 1): {"number": 1, "title": "A1", "state": "OPEN", "assignees": [], "milestone": None},
190
+ (repo_a, 2): {"number": 2, "title": "A2", "state": "OPEN", "assignees": [], "milestone": None},
191
+ (repo_a, 3): {"number": 3, "title": "A3", "state": "OPEN", "assignees": [], "milestone": None},
192
+ (repo_b, 1): {"number": 1, "title": "B1", "state": "OPEN", "assignees": [], "milestone": None},
193
+ }
194
+ vis = {repo_a: "PUBLIC", repo_b: "PUBLIC"}
195
+ rc, out, mock_fei = self._run_with_mocks(tracks, export_map, vis=vis)
196
+ self.assertEqual(rc, 0)
197
+ repo_to_numbers = mock_fei.call_args[0][0]
198
+ # repoA: issues 1, 2, 3 — each once
199
+ self.assertEqual(sorted(repo_to_numbers[repo_a]), [1, 2, 3])
200
+ self.assertEqual(repo_to_numbers[repo_a].count(1), 1)
201
+ # repoB: issue 1 — once
202
+ self.assertEqual(repo_to_numbers[repo_b], [1])
203
+
204
+
205
+ class ExportCommandUntrackedTest(unittest.TestCase):
206
+ """Verify export.run computes untracked = open issues minus tracked ones."""
207
+
208
+ def _run_with_mocks(self, tracks, export_map, open_rows_by_repo, vis=None):
209
+ import io
210
+ from contextlib import redirect_stdout
211
+
212
+ vis = vis or {_SHARED_REPO: "PUBLIC"}
213
+
214
+ def _fake_open_issues(repo, limit=1000):
215
+ return open_rows_by_repo.get(repo, [])
216
+
217
+ with patch("commands.export.load_config", return_value={}), \
218
+ patch("commands.export.discover_tracks", return_value=tracks), \
219
+ patch("commands.export.fetch_export_issues", return_value=export_map), \
220
+ patch("commands.export.fetch_open_issues", side_effect=_fake_open_issues), \
221
+ patch("commands.export.repo_visibility", side_effect=lambda r: vis.get(r)), \
222
+ patch("commands.export.datetime") as mock_dt:
223
+ mock_dt.now.return_value.strftime.return_value = "2026-06-07T12:00:00"
224
+ buf = io.StringIO()
225
+ with redirect_stdout(buf):
226
+ rc = export_cmd.run(["--json"])
227
+ return rc, json.loads(buf.getvalue())
228
+
229
+ def test_untracked_key_present_in_output(self):
230
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
231
+ rc, out = self._run_with_mocks(tracks, {(_SHARED_REPO, 1): _ISSUE_A}, {})
232
+ self.assertEqual(rc, 0)
233
+ self.assertIn("untracked", out)
234
+
235
+ def test_open_minus_tracked_yields_untracked(self):
236
+ """Issues 1+2 are open; only 1 is tracked. Issue 2 is untracked."""
237
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
238
+ export_map = {(_SHARED_REPO, 1): _ISSUE_A}
239
+ # gh reports issues 1 and 2 as open
240
+ open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
241
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
242
+ self.assertEqual(rc, 0)
243
+ untracked = out["untracked"]
244
+ self.assertEqual(len(untracked), 1)
245
+ entry = untracked[0]
246
+ self.assertEqual(entry["repo"], _SHARED_REPO)
247
+ untracked_nums = [i["number"] for i in entry["issues"]]
248
+ self.assertNotIn(1, untracked_nums) # tracked — must be absent
249
+ self.assertIn(2, untracked_nums) # untracked — must appear
250
+
251
+ def test_all_tracked_yields_empty_untracked(self):
252
+ tracks = [_track("alpha", _SHARED_REPO, [1, 2])]
253
+ export_map = {(_SHARED_REPO, 1): _ISSUE_A, (_SHARED_REPO, 2): _ISSUE_B}
254
+ open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
255
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
256
+ self.assertEqual(rc, 0)
257
+ # Either empty list or no entry for this repo
258
+ all_issue_nums = [
259
+ i["number"]
260
+ for entry in out["untracked"]
261
+ if entry["repo"] == _SHARED_REPO
262
+ for i in entry["issues"]
263
+ ]
264
+ self.assertEqual(all_issue_nums, [])
265
+
266
+ def test_no_open_issues_yields_empty_untracked(self):
267
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
268
+ export_map = {(_SHARED_REPO, 1): _ISSUE_A}
269
+ open_rows = {_SHARED_REPO: []}
270
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
271
+ self.assertEqual(rc, 0)
272
+ self.assertEqual(out["untracked"], [])
273
+
274
+ def test_schema_stays_1_with_untracked(self):
275
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
276
+ open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_B]}
277
+ export_map = {(_SHARED_REPO, 1): _ISSUE_A}
278
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
279
+ self.assertEqual(out["schema"], 1)
280
+
281
+ def test_output_serializable_with_untracked(self):
282
+ tracks = [_track("alpha", _SHARED_REPO, [1])]
283
+ open_rows = {_SHARED_REPO: [_ISSUE_A, _ISSUE_C]}
284
+ export_map = {(_SHARED_REPO, 1): _ISSUE_A}
285
+ rc, out = self._run_with_mocks(tracks, export_map, open_rows)
286
+ json.dumps(out) # must not raise
287
+
288
+
289
+ class ExportCommandGateTest(unittest.TestCase):
290
+ def test_requires_json_flag(self):
291
+ self.assertEqual(export_cmd.run([]), 2)
292
+
293
+
294
+ if __name__ == "__main__":
295
+ unittest.main()
@@ -0,0 +1,52 @@
1
+ """Tests for frontmatter parser/writer."""
2
+ import unittest
3
+ import tempfile
4
+ import sys
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.frontmatter import parse_file, write_file
11
+
12
+ FIXTURES = Path(__file__).parent / "fixtures"
13
+
14
+
15
+ class FrontmatterTest(unittest.TestCase):
16
+ def test_parse_file_with_frontmatter(self):
17
+ meta, body = parse_file(FIXTURES / "track_with_frontmatter.md")
18
+ self.assertEqual(meta["track"], "tabletop")
19
+ self.assertEqual(meta["github"]["issues"], [4254, 4127])
20
+ self.assertIn("Body content.", body)
21
+
22
+ def test_parse_file_without_frontmatter_returns_empty_meta(self):
23
+ meta, body = parse_file(FIXTURES / "track_without_frontmatter.md")
24
+ self.assertEqual(meta, {})
25
+ self.assertIn("# Some plan", body)
26
+
27
+ def test_write_then_parse_roundtrip(self):
28
+ meta = {
29
+ "track": "test",
30
+ "status": "active",
31
+ "github": {"repo": "org/repo", "issues": [42]},
32
+ }
33
+ body = "\n# Body\n\nProse.\n"
34
+ with tempfile.TemporaryDirectory() as d:
35
+ path = Path(d) / "t.md"
36
+ write_file(path, meta, body)
37
+ m2, b2 = parse_file(path)
38
+ self.assertEqual(m2, meta)
39
+ self.assertEqual(b2, body)
40
+
41
+ def test_write_with_empty_meta_writes_body_only(self):
42
+ body = "# Title\n\nProse.\n"
43
+ with tempfile.TemporaryDirectory() as d:
44
+ path = Path(d) / "t.md"
45
+ write_file(path, {}, body)
46
+ m, b = parse_file(path)
47
+ self.assertEqual(m, {})
48
+ self.assertEqual(b, body)
49
+
50
+
51
+ if __name__ == "__main__":
52
+ unittest.main()
@@ -0,0 +1,51 @@
1
+ """Tests for git_state pure functions."""
2
+ import unittest
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ from lib.git_state import (
10
+ gap_seconds_to_label, parse_iso_timestamp,
11
+ branch_in_progress,
12
+ )
13
+
14
+
15
+ class GapLabelTest(unittest.TestCase):
16
+ def test_minutes(self):
17
+ self.assertEqual(gap_seconds_to_label(30 * 60), "30m ago")
18
+
19
+ def test_one_hour(self):
20
+ self.assertEqual(gap_seconds_to_label(3600), "1h ago")
21
+
22
+ def test_six_hours(self):
23
+ self.assertEqual(gap_seconds_to_label(6 * 3600), "6h ago")
24
+
25
+ def test_one_day(self):
26
+ self.assertEqual(gap_seconds_to_label(86400), "1d ago")
27
+
28
+ def test_multi_days(self):
29
+ self.assertEqual(gap_seconds_to_label(5 * 86400 + 3600), "5d ago")
30
+
31
+
32
+ class ParseTimestampTest(unittest.TestCase):
33
+ def test_iso_with_hour(self):
34
+ dt = parse_iso_timestamp("2026-04-23T22:14")
35
+ self.assertEqual(dt.hour, 22)
36
+
37
+ def test_iso_date_only(self):
38
+ dt = parse_iso_timestamp("2026-04-23")
39
+ self.assertEqual(dt.year, 2026)
40
+
41
+
42
+ class BranchInProgressTest(unittest.TestCase):
43
+ def test_returns_false_when_repo_path_missing(self):
44
+ self.assertFalse(branch_in_progress("any-branch", None))
45
+
46
+ def test_returns_false_when_path_doesnt_exist(self):
47
+ self.assertFalse(branch_in_progress("any-branch", Path("/nonexistent")))
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()
@@ -0,0 +1,51 @@
1
+ """Tests for path-level git helpers (mock subprocess; offline)."""
2
+ import unittest
3
+ import sys
4
+ from datetime import date, datetime
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ from unittest import mock
8
+
9
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(SKILL_ROOT))
11
+
12
+ from lib import git_state
13
+
14
+
15
+ class PathLastCommitDateTest(unittest.TestCase):
16
+ def test_returns_none_when_path_missing(self):
17
+ self.assertIsNone(git_state.path_last_commit_date("x", None))
18
+
19
+ def test_parses_iso(self):
20
+ fake = SimpleNamespace(returncode=0, stdout="2026-04-02T13:05:11-05:00\n")
21
+ with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
22
+ mock.patch("lib.git_state.Path.exists", return_value=True):
23
+ dt = git_state.path_last_commit_date("docs/x.md", Path("/repo"))
24
+ self.assertIsInstance(dt, datetime)
25
+ self.assertEqual(dt.date(), date(2026, 4, 2))
26
+
27
+ def test_empty_output_is_none(self):
28
+ fake = SimpleNamespace(returncode=0, stdout="")
29
+ with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
30
+ mock.patch("lib.git_state.Path.exists", return_value=True):
31
+ self.assertIsNone(git_state.path_last_commit_date("docs/x.md", Path("/repo")))
32
+
33
+
34
+ class PathCommittedSinceTest(unittest.TestCase):
35
+ def test_true_when_log_nonempty(self):
36
+ fake = SimpleNamespace(returncode=0, stdout="abc123\n")
37
+ with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
38
+ mock.patch("lib.git_state.Path.exists", return_value=True):
39
+ self.assertTrue(
40
+ git_state.path_committed_since("src/a.ts", date(2026, 3, 1), Path("/repo")))
41
+
42
+ def test_false_when_empty(self):
43
+ fake = SimpleNamespace(returncode=0, stdout="")
44
+ with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
45
+ mock.patch("lib.git_state.Path.exists", return_value=True):
46
+ self.assertFalse(
47
+ git_state.path_committed_since("src/a.ts", date(2026, 3, 1), Path("/repo")))
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()