@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -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 +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,70 @@
1
+ """Stamp / draft behaviour for plan-status (offline)."""
2
+ import io
3
+ import unittest
4
+ import sys
5
+ import tempfile
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from unittest import mock
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import plan_status
14
+ from lib.status_header import BEGIN
15
+
16
+ PLAN = (
17
+ "# Idea Mode Implementation Plan\n\n"
18
+ "**Files:**\n"
19
+ "- Create: `src/new.ts`\n"
20
+ "- Create: `src/missing.ts`\n"
21
+ "- [ ] Step 1\n"
22
+ )
23
+
24
+
25
+ class StampBehaviourTest(unittest.TestCase):
26
+ def _repo(self, d):
27
+ root = Path(d)
28
+ (root / "docs/superpowers/plans").mkdir(parents=True)
29
+ self.plan_path = root / "docs/superpowers/plans/2026-03-16-idea-mode-ui.md"
30
+ self.plan_path.write_text(PLAN)
31
+ (root / "src").mkdir()
32
+ (root / "src/new.ts").write_text("x")
33
+ return root
34
+
35
+ def _run(self, root, args):
36
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
37
+ return_value=None), \
38
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
39
+ buf = io.StringIO()
40
+ with redirect_stdout(buf):
41
+ rc = plan_status.run(args)
42
+ return rc, buf.getvalue()
43
+
44
+ def test_draft_does_not_write(self):
45
+ with tempfile.TemporaryDirectory() as d:
46
+ root = self._repo(d)
47
+ rc, out = self._run(root, ["--stamp", "--draft"])
48
+ self.assertEqual(rc, 0)
49
+ self.assertIn("would stamp", out)
50
+ self.assertNotIn(BEGIN, self.plan_path.read_text())
51
+
52
+ def test_stamp_writes_block(self):
53
+ with tempfile.TemporaryDirectory() as d:
54
+ root = self._repo(d)
55
+ rc, out = self._run(root, ["--stamp"])
56
+ self.assertEqual(rc, 0)
57
+ self.assertIn("stamped", out)
58
+ self.assertIn(BEGIN, self.plan_path.read_text())
59
+
60
+ def test_stamp_is_idempotent_on_disk(self):
61
+ with tempfile.TemporaryDirectory() as d:
62
+ root = self._repo(d)
63
+ self._run(root, ["--stamp"])
64
+ first = self.plan_path.read_text()
65
+ self._run(root, ["--stamp"])
66
+ self.assertEqual(first, self.plan_path.read_text())
67
+
68
+
69
+ if __name__ == "__main__":
70
+ unittest.main()
@@ -0,0 +1,38 @@
1
+ """Plugin manifest(s) parse, carry required fields, and match VERSION (CalVer).
2
+
3
+ Offline. The Codex manifest (.codex-plugin/plugin.json) lands in Phase 2; this
4
+ test tolerates its absence and only asserts equality when it exists.
5
+ """
6
+ import json
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ REPO_ROOT = Path(__file__).resolve().parents[3]
11
+
12
+
13
+ def _load(rel):
14
+ return json.loads((REPO_ROOT / rel).read_text(encoding="utf-8"))
15
+
16
+
17
+ class ClaudeManifestTest(unittest.TestCase):
18
+ def test_required_fields(self):
19
+ m = _load(".claude-plugin/plugin.json")
20
+ self.assertEqual(m["name"], "work-plan")
21
+ self.assertTrue(m["description"])
22
+ self.assertEqual(m["license"], "MIT")
23
+
24
+ def test_version_matches_VERSION(self):
25
+ m = _load(".claude-plugin/plugin.json")
26
+ ver = (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip()
27
+ self.assertEqual(m["version"], ver) # CalVer string, not semver
28
+
29
+ def test_codex_manifest_agrees_when_present(self):
30
+ codex = REPO_ROOT / ".codex-plugin" / "plugin.json"
31
+ if not codex.exists():
32
+ self.skipTest("Codex manifest is Phase 2")
33
+ self.assertEqual(_load(".codex-plugin/plugin.json")["version"],
34
+ _load(".claude-plugin/plugin.json")["version"])
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()
@@ -0,0 +1,60 @@
1
+ """Tests for reconcile action selection + target/body construction + unsatisfied paths."""
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 DeclaredPath, unsatisfied_paths
11
+ from lib.reconcile_actions import dead_rows, partial_rows, archive_dest, issue_for
12
+
13
+
14
+ def _row(rel, verdict, present=0, declared=0):
15
+ return {"rel": rel, "verdict": verdict, "files_present": present,
16
+ "files_declared": declared, "glyph": "?", "rationale": ""}
17
+
18
+
19
+ class SelectionTest(unittest.TestCase):
20
+ def test_dead_and_partial_filters(self):
21
+ rows = [_row("a.md", "dead"), _row("b.md", "partial", 3, 9),
22
+ _row("c.md", "shipped", 9, 9)]
23
+ self.assertEqual([r["rel"] for r in dead_rows(rows)], ["a.md"])
24
+ self.assertEqual([r["rel"] for r in partial_rows(rows)], ["b.md"])
25
+
26
+
27
+ class ArchiveDestTest(unittest.TestCase):
28
+ def test_dest_under_archive_abandoned(self):
29
+ self.assertEqual(
30
+ archive_dest("docs/superpowers/plans/2026-01-01-x.md"),
31
+ "docs/superpowers/plans/archive/abandoned/2026-01-01-x.md")
32
+
33
+
34
+ class UnsatisfiedPathsTest(unittest.TestCase):
35
+ def test_returns_only_missing(self):
36
+ decls = [DeclaredPath("create", "src/here.ts"),
37
+ DeclaredPath("create", "src/gone.ts"),
38
+ DeclaredPath("modify", "src/old.ts")]
39
+ missing = unsatisfied_paths(
40
+ decls, Path("/repo"), date(2026, 3, 1),
41
+ exists=lambda rel: rel == "src/here.ts",
42
+ committed_since=lambda rel: False)
43
+ self.assertEqual({d.path for d in missing}, {"src/gone.ts", "src/old.ts"})
44
+
45
+
46
+ class IssueForTest(unittest.TestCase):
47
+ def test_title_and_body(self):
48
+ class Doc:
49
+ rel = "docs/superpowers/plans/2026-01-01-feature-x.md"
50
+ row = _row(Doc.rel, "partial", 2, 5)
51
+ missing = [DeclaredPath("create", "src/a.ts"), DeclaredPath("modify", "src/b.ts")]
52
+ title, body = issue_for(Doc(), row, missing)
53
+ self.assertIn("2026-01-01-feature-x", title)
54
+ self.assertIn("2/5", body)
55
+ self.assertIn("`src/a.ts`", body)
56
+ self.assertIn("`src/b.ts`", body)
57
+
58
+
59
+ if __name__ == "__main__":
60
+ unittest.main()
@@ -0,0 +1,239 @@
1
+ """Read-only GitHub contract test for the reconcile subcommand.
2
+
3
+ The docstring at the top of commands/reconcile.py declares:
4
+ "reconcile only READS GitHub via `gh issue list`. It NEVER writes labels,
5
+ edits issues, or modifies remote state."
6
+
7
+ This test enforces that contract. Without it, the docstring is aspirational —
8
+ a future refactor could silently introduce a `gh issue edit` (or close, or
9
+ label, or comment) call and the existing test suite would still pass.
10
+
11
+ The test mocks subprocess.run, drives reconcile against a fake track, and
12
+ asserts every captured `gh` invocation matches an allowlist of read-only
13
+ verbs. It exercises both the default-label path (no `github.labels`
14
+ override) and the new override path from #32.
15
+ """
16
+ import json
17
+ import subprocess
18
+ import sys
19
+ import unittest
20
+ from pathlib import Path
21
+ from types import SimpleNamespace
22
+ from unittest.mock import MagicMock, patch
23
+
24
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
25
+ sys.path.insert(0, str(SKILL_ROOT))
26
+
27
+ from commands import reconcile
28
+
29
+ # Allowlist of `gh` subcommand pairs considered read-only.
30
+ # `gh api` is intentionally excluded — it can be GET or write depending on -X.
31
+ # If reconcile ever needs it, add a more specific check (require -X GET, etc.).
32
+ READ_ONLY_GH_VERBS = {
33
+ ("issue", "list"),
34
+ ("issue", "view"),
35
+ ("pr", "list"),
36
+ ("pr", "view"),
37
+ }
38
+
39
+
40
+ def _fake_track(*, slug, repo, labels=None, issues=None):
41
+ meta = {
42
+ "track": slug,
43
+ "status": "active",
44
+ "github": {"repo": repo, "issues": issues or []},
45
+ }
46
+ if labels is not None:
47
+ meta["github"]["labels"] = labels
48
+ return SimpleNamespace(
49
+ name=slug,
50
+ path=Path(f"/tmp/fake/{slug}.md"),
51
+ body="# fake",
52
+ meta=meta,
53
+ has_frontmatter=True,
54
+ repo=repo,
55
+ )
56
+
57
+
58
+ class ReadOnlyContractTest(unittest.TestCase):
59
+ def _drive(self, *, track, gh_response, user_choice, extra_args=None):
60
+ """Run reconcile.run against mocks; return (exit_code, captured_argvs, write_mock, prompt_mock)."""
61
+ captured = []
62
+
63
+ def fake_run(argv, *args, **kwargs):
64
+ captured.append(list(argv))
65
+ return MagicMock(returncode=0, stdout=json.dumps(gh_response), stderr="")
66
+
67
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/ok"}}}
68
+ # NOTE: find_track_by_name is intentionally NOT mocked. We let the real
69
+ # resolver run against the in-memory [track] list so a regression that
70
+ # broke the active-status filter (or the name-matching logic) would
71
+ # surface here — not just be silently bypassed by the mock.
72
+ with patch("commands.reconcile.subprocess.run", side_effect=fake_run), \
73
+ patch("commands.reconcile.load_config", return_value=cfg), \
74
+ patch("commands.reconcile.discover_tracks", return_value=[track]), \
75
+ patch("commands.reconcile.prompt_input", return_value=user_choice) as mock_prompt, \
76
+ patch("commands.reconcile.write_file") as mock_write:
77
+ args = [track.meta["track"]] + (extra_args or [])
78
+ rc = reconcile.run(args)
79
+ return rc, captured, mock_write, mock_prompt
80
+
81
+ def _assert_read_only(self, captured):
82
+ gh_calls = [a for a in captured if a and a[0] == "gh"]
83
+ self.assertGreater(len(gh_calls), 0,
84
+ "reconcile should have made at least one gh call")
85
+ for argv in gh_calls:
86
+ verb_pair = tuple(argv[1:3])
87
+ self.assertIn(
88
+ verb_pair, READ_ONLY_GH_VERBS,
89
+ f"reconcile invoked a non-read-only gh command: {' '.join(argv)}\n"
90
+ f"This violates the READ-ONLY GITHUB CONTRACT documented at the top "
91
+ f"of commands/reconcile.py. Writes must go through the local "
92
+ f"frontmatter file, never through gh.",
93
+ )
94
+
95
+ def test_default_label_path_is_read_only(self):
96
+ # Track without `github.labels` falls back to default `track/<slug>`.
97
+ track = _fake_track(slug="alpha", repo="ok/ok", labels=None, issues=[1, 2, 3])
98
+ gh_response = [
99
+ {"number": 1, "title": "one", "state": "OPEN"},
100
+ {"number": 4, "title": "four", "state": "OPEN"},
101
+ ]
102
+ rc, captured, mock_write, _ = self._drive(
103
+ track=track, gh_response=gh_response, user_choice="n",
104
+ )
105
+ self.assertEqual(rc, 0)
106
+ self._assert_read_only(captured)
107
+ mock_write.assert_not_called()
108
+
109
+ def test_label_override_path_is_read_only(self):
110
+ # Track WITH `github.labels` override (the new feature from #32).
111
+ # Each label produces a `gh issue list` AND a `gh pr list` — all must
112
+ # be read-only. PRs are queried so frontmatter entries pointing at
113
+ # labeled PRs aren't spuriously FLAGged.
114
+ track = _fake_track(slug="beta", repo="ok/ok",
115
+ labels=["storytelling", "campaigns"], issues=[10])
116
+ gh_response = [{"number": 10, "title": "x", "state": "OPEN"}]
117
+ rc, captured, mock_write, _ = self._drive(
118
+ track=track, gh_response=gh_response, user_choice="n",
119
+ )
120
+ self.assertEqual(rc, 0)
121
+ self._assert_read_only(captured)
122
+ # Two configured labels × two kinds (issue + pr) → four gh invocations
123
+ gh_calls = [a for a in captured if a and a[0] == "gh"]
124
+ self.assertEqual(len(gh_calls), 4,
125
+ f"expected one gh issue + one gh pr call per label, got {len(gh_calls)}")
126
+ kinds = sorted(c[1] for c in gh_calls)
127
+ self.assertEqual(kinds, ["issue", "issue", "pr", "pr"],
128
+ f"expected two issue + two pr calls, got {kinds}")
129
+
130
+ def test_user_accept_writes_local_file_only_not_gh(self):
131
+ # Even when the user accepts the proposed ADDs, the only write should
132
+ # be to the local frontmatter file via write_file — never via gh.
133
+ track = _fake_track(slug="gamma", repo="ok/ok", issues=[5])
134
+ gh_response = [
135
+ {"number": 5, "title": "x", "state": "OPEN"},
136
+ {"number": 99, "title": "new", "state": "OPEN"},
137
+ ]
138
+ rc, captured, mock_write, _ = self._drive(
139
+ track=track, gh_response=gh_response, user_choice="y",
140
+ )
141
+ self.assertEqual(rc, 0)
142
+ self._assert_read_only(captured)
143
+ mock_write.assert_called_once()
144
+
145
+ def test_draft_skips_user_prompt_and_write(self):
146
+ # --draft prints the analysis but never prompts and never writes.
147
+ # Even with proposed ADDs (so the report path is exercised), the user
148
+ # should not be interrupted and the local file should remain untouched.
149
+ # user_choice="y" would normally trigger a write — proves --draft
150
+ # short-circuits before the prompt is reached.
151
+ track = _fake_track(slug="delta", repo="ok/ok", issues=[5])
152
+ gh_response = [
153
+ {"number": 5, "title": "x", "state": "OPEN"},
154
+ {"number": 99, "title": "new", "state": "OPEN"},
155
+ ]
156
+ rc, captured, mock_write, mock_prompt = self._drive(
157
+ track=track, gh_response=gh_response,
158
+ user_choice="y", extra_args=["--draft"],
159
+ )
160
+ self.assertEqual(rc, 0)
161
+ self._assert_read_only(captured)
162
+ mock_prompt.assert_not_called()
163
+ mock_write.assert_not_called()
164
+
165
+
166
+ def test_timeout_skips_track_but_continues_others(self):
167
+ # When _fetch_labeled_issues raises TimeoutExpired for one track, the
168
+ # track is skipped with a ⚠ warning and the rest of --all continues.
169
+ # Verifies: no crash, warning printed, other tracks still processed.
170
+ track_alpha = _fake_track(slug="alpha", repo="ok/ok", issues=[1])
171
+ track_beta = _fake_track(slug="beta", repo="ok/ok", issues=[10])
172
+
173
+ captured = []
174
+ timed_out_labels = set()
175
+
176
+ def fake_run(argv, *args, **kwargs):
177
+ captured.append(list(argv))
178
+ # alpha's default label is "track/alpha" — time it out
179
+ if "--label" in argv:
180
+ label_idx = argv.index("--label") + 1
181
+ label = argv[label_idx]
182
+ if label == "track/alpha":
183
+ timed_out_labels.add(label)
184
+ raise subprocess.TimeoutExpired(cmd=argv, timeout=15)
185
+ return MagicMock(
186
+ returncode=0,
187
+ stdout=json.dumps([{"number": 10, "title": "x", "state": "OPEN"}]),
188
+ stderr="",
189
+ )
190
+
191
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/ok"}}}
192
+ with patch("commands.reconcile.subprocess.run", side_effect=fake_run), \
193
+ patch("commands.reconcile.load_config", return_value=cfg), \
194
+ patch("commands.reconcile.discover_tracks",
195
+ return_value=[track_alpha, track_beta]), \
196
+ patch("commands.reconcile.prompt_input",
197
+ return_value="n") as mock_prompt, \
198
+ patch("commands.reconcile.write_file") as mock_write:
199
+ rc = reconcile.run(["--all"])
200
+
201
+ self.assertEqual(rc, 0)
202
+ self.assertTrue(timed_out_labels, "alpha label should have timed out")
203
+ # Beta's gh calls should have succeeded
204
+ beta_calls = [a for a in captured
205
+ if "--label" in a and a[a.index("--label") + 1] == "track/beta"]
206
+ self.assertTrue(len(beta_calls) > 0,
207
+ "beta should have been fetched after alpha timeout")
208
+ mock_write.assert_not_called()
209
+
210
+ def test_single_track_timeout_skips_cleanly(self):
211
+ # Even with a single track (the non-parallel code path), a timeout
212
+ # should skip the track with a warning and return 0 without crashing.
213
+ track = _fake_track(slug="lonely", repo="ok/ok", issues=[7])
214
+
215
+ captured = []
216
+ timed_out = False
217
+
218
+ def fake_run(argv, *args, **kwargs):
219
+ captured.append(list(argv))
220
+ nonlocal timed_out
221
+ timed_out = True
222
+ raise subprocess.TimeoutExpired(cmd=argv, timeout=15)
223
+
224
+ cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/ok"}}}
225
+ with patch("commands.reconcile.subprocess.run", side_effect=fake_run), \
226
+ patch("commands.reconcile.load_config", return_value=cfg), \
227
+ patch("commands.reconcile.discover_tracks", return_value=[track]), \
228
+ patch("commands.reconcile.prompt_input") as mock_prompt, \
229
+ patch("commands.reconcile.write_file") as mock_write:
230
+ rc = reconcile.run(["lonely"])
231
+
232
+ self.assertEqual(rc, 0)
233
+ self.assertTrue(timed_out)
234
+ mock_prompt.assert_not_called()
235
+ mock_write.assert_not_called()
236
+
237
+
238
+ if __name__ == "__main__":
239
+ unittest.main()
@@ -0,0 +1,55 @@
1
+ """Tests for git_mv + create_issue (mock subprocess; offline)."""
2
+ import unittest
3
+ import sys
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+ from unittest import mock
7
+
8
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(SKILL_ROOT))
10
+
11
+ from lib import git_state, github_state
12
+
13
+
14
+ class GitMvTest(unittest.TestCase):
15
+ def test_creates_dest_dir_and_calls_git_mv(self):
16
+ calls = {}
17
+
18
+ def fake_run(cmd, **kw):
19
+ calls["cmd"] = cmd
20
+ return SimpleNamespace(returncode=0, stdout="", stderr="")
21
+
22
+ with mock.patch("lib.git_state.subprocess.run", side_effect=fake_run), \
23
+ mock.patch("lib.git_state.Path.exists", return_value=True), \
24
+ mock.patch("lib.git_state.Path.mkdir") as mkdir:
25
+ ok = git_state.git_mv("a/x.md", "a/archive/abandoned/x.md", Path("/repo"))
26
+ self.assertTrue(ok)
27
+ self.assertIn("mv", calls["cmd"])
28
+ self.assertIn("a/x.md", calls["cmd"])
29
+ self.assertIn("a/archive/abandoned/x.md", calls["cmd"])
30
+ mkdir.assert_called()
31
+
32
+ def test_returns_false_on_git_error(self):
33
+ fake = SimpleNamespace(returncode=1, stdout="", stderr="not under version control")
34
+ with mock.patch("lib.git_state.subprocess.run", return_value=fake), \
35
+ mock.patch("lib.git_state.Path.exists", return_value=True), \
36
+ mock.patch("lib.git_state.Path.mkdir"):
37
+ self.assertFalse(git_state.git_mv("a.md", "b.md", Path("/repo")))
38
+
39
+
40
+ class CreateIssueTest(unittest.TestCase):
41
+ def test_returns_url_on_success(self):
42
+ fake = SimpleNamespace(returncode=0,
43
+ stdout="https://github.com/o/r/issues/42\n", stderr="")
44
+ with mock.patch("lib.github_state.subprocess.run", return_value=fake):
45
+ url = github_state.create_issue("o/r", "Finish plan: x", "body")
46
+ self.assertEqual(url, "https://github.com/o/r/issues/42")
47
+
48
+ def test_returns_none_on_failure(self):
49
+ fake = SimpleNamespace(returncode=1, stdout="", stderr="gh: error")
50
+ with mock.patch("lib.github_state.subprocess.run", return_value=fake):
51
+ self.assertIsNone(github_state.create_issue("o/r", "t", "b"))
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()
@@ -0,0 +1,98 @@
1
+ """Tests for refresh-md row-append behavior (issue #77).
2
+
3
+ Before #77, refresh-md only rewrote the *status cell* of rows already in the
4
+ canonical table — it never appended rows for issues present in frontmatter but
5
+ missing from the table, yet still printed "All tracks in sync." These tests
6
+ pin the membership-aware behavior: missing frontmatter issues get appended in
7
+ frontmatter order, and the "in sync" message only prints when nothing changed.
8
+ """
9
+ import io
10
+ import sys
11
+ import unittest
12
+ from contextlib import redirect_stdout
13
+ from pathlib import Path
14
+ from types import SimpleNamespace
15
+ from unittest.mock import MagicMock, patch
16
+
17
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(SKILL_ROOT))
19
+
20
+ from commands import refresh_md
21
+ from lib.status_table import find_canonical_status_tables, ISSUE_NUM_RE
22
+
23
+ CANON_HEADER = (
24
+ "## Issues (canonical)\n\n"
25
+ "<!-- canonical-issue-table — auto-managed. -->\n\n"
26
+ "| # | Title | Assignee | Status |\n"
27
+ "|---|---|---|---|\n"
28
+ )
29
+
30
+
31
+ def _track(*, name, repo, issues, rows):
32
+ body = CANON_HEADER + "\n".join(rows) + "\n\n---\n\n## Notes\n\nnarrative\n"
33
+ return SimpleNamespace(
34
+ name=name,
35
+ path=Path(f"/tmp/fake/{name}.md"),
36
+ body=body,
37
+ meta={"track": name, "status": "active",
38
+ "github": {"repo": repo, "issues": list(issues)}},
39
+ has_frontmatter=True,
40
+ repo=repo,
41
+ )
42
+
43
+
44
+ def _issue(num, title, state="OPEN", logins=()):
45
+ return {"number": num, "title": title, "state": state,
46
+ "assignees": [{"login": l} for l in logins]}
47
+
48
+
49
+ class RefreshAppendTest(unittest.TestCase):
50
+ def _drive(self, track, issues, args):
51
+ cfg = {"notes_root": "/tmp/fake"}
52
+ with patch("commands.refresh_md.load_config", return_value=cfg), \
53
+ patch("commands.refresh_md.discover_tracks", return_value=[track]), \
54
+ patch("commands.refresh_md.fetch_issues", return_value=issues), \
55
+ patch("commands.refresh_md.write_file") as mw:
56
+ buf = io.StringIO()
57
+ with redirect_stdout(buf):
58
+ rc = refresh_md.run(args)
59
+ return rc, mw, buf.getvalue()
60
+
61
+ def test_appends_missing_rows_in_frontmatter_order(self):
62
+ track = _track(
63
+ name="platform-health", repo="o/r",
64
+ issues=[1, 2, 30, 40], # 30, 40 newly slotted, not yet in table
65
+ rows=["| #1 | first | — | 🔲 Open |",
66
+ "| #2 | second | — | ✅ Shipped |"],
67
+ )
68
+ issues = [_issue(1, "first"), _issue(2, "second", "CLOSED"),
69
+ _issue(30, "third", "OPEN", ["bob"]), _issue(40, "fourth", "CLOSED")]
70
+ rc, mw, out = self._drive(track, issues, ["platform-health", "--yes"])
71
+
72
+ self.assertEqual(rc, 0)
73
+ mw.assert_called_once()
74
+ new_body = mw.call_args[0][2]
75
+ table = find_canonical_status_tables(new_body)[0]
76
+ nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
77
+ self.assertEqual(nums, [1, 2, 30, 40])
78
+ self.assertIn("| #30 | third | @bob | 🔲 Open |", new_body)
79
+ self.assertIn("| #40 | fourth | — | ✅ Shipped |", new_body)
80
+ self.assertNotIn("All tracks in sync.", out)
81
+ self.assertIn("row", out.lower())
82
+
83
+ def test_no_drift_reports_in_sync(self):
84
+ track = _track(
85
+ name="steady", repo="o/r", issues=[1, 2],
86
+ rows=["| #1 | first | — | 🔲 Open |",
87
+ "| #2 | second | — | ✅ Shipped |"],
88
+ )
89
+ issues = [_issue(1, "first"), _issue(2, "second", "CLOSED")]
90
+ rc, mw, out = self._drive(track, issues, ["steady", "--yes"])
91
+
92
+ self.assertEqual(rc, 0)
93
+ mw.assert_not_called()
94
+ self.assertIn("All tracks in sync.", out)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
@@ -0,0 +1,110 @@
1
+ """Tests for render module."""
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.render import time_aware_framing, render_track_row
10
+
11
+
12
+ class TimeAwareFramingTest(unittest.TestCase):
13
+ def test_long_gap(self):
14
+ self.assertIn("Fresh start", time_aware_framing(7 * 3600, 14))
15
+
16
+ def test_morning_says_fresh_start(self):
17
+ self.assertIn("Fresh start", time_aware_framing(1800, 9))
18
+
19
+ def test_medium_gap(self):
20
+ self.assertIn("Picking back up", time_aware_framing(2 * 3600, 14))
21
+
22
+ def test_short_gap(self):
23
+ self.assertIn("Continuing", time_aware_framing(30 * 60, 14))
24
+
25
+ def test_late_night_handoff_nudge(self):
26
+ f = time_aware_framing(1800, 23, handoff_today=False)
27
+ self.assertIn("handoff", f.lower())
28
+
29
+
30
+ class RenderTrackRowTest(unittest.TestCase):
31
+ def _data(self, **overrides):
32
+ d = {
33
+ "name": "tabletop", "operational_status": "active",
34
+ "launch_priority": "P1", "milestone_alignment": "v1.0.0",
35
+ "last_touched_label": "5d ago", "last_handoff_label": "5d ago",
36
+ "next_up": [], "active_branches": [], "new_issues": [],
37
+ "blockers": [], "drift_items": [], "closure_ready": False,
38
+ "closure_signals_summary": None, "archived_reopen": [],
39
+ }
40
+ d.update(overrides); return d
41
+
42
+ def test_basic_row(self):
43
+ row = render_track_row(self._data())
44
+ for s in ["tabletop", "P1", "v1.0.0", "5d ago"]:
45
+ self.assertIn(s, row)
46
+
47
+ def test_in_progress_badge(self):
48
+ self.assertIn("in-progress", render_track_row(self._data(operational_status="in-progress")))
49
+
50
+ def test_active_branch_shown(self):
51
+ row = render_track_row(self._data(
52
+ active_branches=[{"name": "feat/4254", "ahead": 1, "uncommitted_files": 2}]
53
+ ))
54
+ self.assertIn("feat/4254", row)
55
+ self.assertIn("ahead 1", row)
56
+
57
+ def test_new_issues_shown(self):
58
+ row = render_track_row(self._data(new_issues=[{"number": 9, "title": "new"}]))
59
+ self.assertIn("#9", row)
60
+ self.assertIn("slot 9", row)
61
+
62
+ def test_drift_shown(self):
63
+ row = render_track_row(self._data(
64
+ drift_items=[{"issue": 1, "body_status": "open", "github_state": "CLOSED"}]
65
+ ))
66
+ self.assertIn("Drift:", row)
67
+ self.assertIn("#1", row)
68
+
69
+ def test_closure_ready_shown(self):
70
+ self.assertIn("Closure?: YES", render_track_row(self._data(closure_ready=True)))
71
+
72
+ def test_empty_next_up_default_message(self):
73
+ row = render_track_row(self._data(next_up=[]))
74
+ self.assertIn("<empty — set 'next_up:'", row)
75
+
76
+ def test_next_up_all_closed_message(self):
77
+ row = render_track_row(self._data(
78
+ next_up=[],
79
+ next_up_stale_closed_count=2,
80
+ track_slug="ux-redesign",
81
+ ))
82
+ self.assertIn("all 2 items have shipped", row)
83
+ self.assertIn("/work-plan handoff ux-redesign", row)
84
+
85
+ def test_next_up_single_closed_uses_singular(self):
86
+ row = render_track_row(self._data(
87
+ next_up=[],
88
+ next_up_stale_closed_count=1,
89
+ track_slug="ux-redesign",
90
+ ))
91
+ self.assertIn("all 1 item has shipped", row)
92
+
93
+ def test_next_up_includes_milestone_when_present(self):
94
+ row = render_track_row(self._data(next_up=[
95
+ {"number": 4155, "title": "Armory batch multi-select",
96
+ "priority": "P3", "state": "open", "milestone": "v0.4.0"},
97
+ ]))
98
+ self.assertIn("(P3, open, v0.4.0)", row)
99
+
100
+ def test_next_up_omits_milestone_when_absent(self):
101
+ row = render_track_row(self._data(next_up=[
102
+ {"number": 4155, "title": "Armory batch multi-select",
103
+ "priority": "P3", "state": "open", "milestone": ""},
104
+ ]))
105
+ self.assertIn("(P3, open)", row)
106
+ self.assertNotIn("(P3, open, ", row)
107
+
108
+
109
+ if __name__ == "__main__":
110
+ unittest.main()