@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.
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +210 -0
|
@@ -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()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for the --repo=<key> filter shared by brief / refresh-md / reconcile / hygiene."""
|
|
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.tracks import discover_tracks, filter_tracks_by_repo
|
|
10
|
+
|
|
11
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FilterTracksByRepoTest(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.cfg = {
|
|
17
|
+
"notes_root": str(FIXTURES),
|
|
18
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
19
|
+
}
|
|
20
|
+
self.tracks = discover_tracks(self.cfg)
|
|
21
|
+
|
|
22
|
+
def test_matches_folder_key(self):
|
|
23
|
+
scoped = filter_tracks_by_repo(self.tracks, "critforge")
|
|
24
|
+
names = {t.name for t in scoped}
|
|
25
|
+
self.assertIn("example", names)
|
|
26
|
+
self.assertNotIn("loose_at_root", names)
|
|
27
|
+
|
|
28
|
+
def test_matches_github_slug(self):
|
|
29
|
+
scoped = filter_tracks_by_repo(self.tracks, "stylusnexus/CritForge")
|
|
30
|
+
names = {t.name for t in scoped}
|
|
31
|
+
self.assertIn("example", names)
|
|
32
|
+
|
|
33
|
+
def test_case_insensitive(self):
|
|
34
|
+
scoped = filter_tracks_by_repo(self.tracks, "CRITFORGE")
|
|
35
|
+
names = {t.name for t in scoped}
|
|
36
|
+
self.assertIn("example", names)
|
|
37
|
+
|
|
38
|
+
def test_unknown_key_returns_empty(self):
|
|
39
|
+
self.assertEqual(filter_tracks_by_repo(self.tracks, "nonexistent"), [])
|
|
40
|
+
|
|
41
|
+
def test_excludes_loose_filing_track(self):
|
|
42
|
+
scoped = filter_tracks_by_repo(self.tracks, "critforge")
|
|
43
|
+
for t in scoped:
|
|
44
|
+
self.assertFalse(t.needs_filing)
|
|
45
|
+
|
|
46
|
+
def test_track_folder_field_populated(self):
|
|
47
|
+
ex = next(t for t in self.tracks if t.name == "example")
|
|
48
|
+
self.assertEqual(ex.folder, "critforge")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
unittest.main()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Regression tests for the /tmp planting hardening (#18).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- lib.scratch.cache_dir() creates ~/.claude/work-plan/cache/ with mode 0700.
|
|
5
|
+
- commands.group._apply() rejects a batch whose `folder` is not in cfg.
|
|
6
|
+
- commands.suggest_priorities._apply() rejects a batch whose `repo` is not in cfg.
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from unittest import mock
|
|
16
|
+
|
|
17
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
19
|
+
|
|
20
|
+
from lib import scratch
|
|
21
|
+
from commands import group, suggest_priorities
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# POSIX file mode bits aren't honored on Windows NTFS — os.chmod(0o700) is a
|
|
25
|
+
# no-op for directories there and stat.S_IMODE reports 0o777 regardless. The
|
|
26
|
+
# /tmp planting hardening these tests cover is itself a POSIX concern.
|
|
27
|
+
_POSIX_MODE_ONLY = unittest.skipIf(
|
|
28
|
+
sys.platform == "win32", "POSIX file mode bits not honored on Windows"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CacheDirTest(unittest.TestCase):
|
|
33
|
+
@_POSIX_MODE_ONLY
|
|
34
|
+
def test_creates_with_mode_0700(self):
|
|
35
|
+
with tempfile.TemporaryDirectory() as td:
|
|
36
|
+
with mock.patch.object(scratch.Path, "home", return_value=Path(td)):
|
|
37
|
+
p = scratch.cache_dir()
|
|
38
|
+
self.assertTrue(p.is_dir())
|
|
39
|
+
self.assertEqual(p, Path(td) / ".claude" / "work-plan" / "cache")
|
|
40
|
+
mode = stat.S_IMODE(os.stat(p).st_mode)
|
|
41
|
+
self.assertEqual(mode, 0o700)
|
|
42
|
+
|
|
43
|
+
@_POSIX_MODE_ONLY
|
|
44
|
+
def test_tightens_existing_loose_perms(self):
|
|
45
|
+
with tempfile.TemporaryDirectory() as td:
|
|
46
|
+
existing = Path(td) / ".claude" / "work-plan" / "cache"
|
|
47
|
+
existing.mkdir(parents=True, mode=0o755)
|
|
48
|
+
self.assertEqual(stat.S_IMODE(os.stat(existing).st_mode), 0o755)
|
|
49
|
+
with mock.patch.object(scratch.Path, "home", return_value=Path(td)):
|
|
50
|
+
scratch.cache_dir()
|
|
51
|
+
self.assertEqual(stat.S_IMODE(os.stat(existing).st_mode), 0o700)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GroupApplyValidationTest(unittest.TestCase):
|
|
55
|
+
def test_rejects_folder_not_in_cfg(self):
|
|
56
|
+
with tempfile.TemporaryDirectory() as td:
|
|
57
|
+
cache = Path(td) / "cache"
|
|
58
|
+
cache.mkdir()
|
|
59
|
+
(cache / "groups.json").write_text(json.dumps({
|
|
60
|
+
"repo": "x/y", "folder": "../../etc",
|
|
61
|
+
"milestone": "v1", "issues": [],
|
|
62
|
+
}))
|
|
63
|
+
(cache / "groups.answers.json").write_text("[]")
|
|
64
|
+
with mock.patch.object(group, "_batch_path", return_value=cache / "groups.json"), \
|
|
65
|
+
mock.patch.object(group, "_answers_path", return_value=cache / "groups.answers.json"):
|
|
66
|
+
cfg = {"notes_root": td, "repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
67
|
+
rc = group._apply(cfg)
|
|
68
|
+
self.assertEqual(rc, 1)
|
|
69
|
+
|
|
70
|
+
def test_accepts_folder_in_cfg(self):
|
|
71
|
+
with tempfile.TemporaryDirectory() as td:
|
|
72
|
+
cache = Path(td) / "cache"
|
|
73
|
+
cache.mkdir()
|
|
74
|
+
notes = Path(td) / "notes"
|
|
75
|
+
(notes / "legitrepo").mkdir(parents=True)
|
|
76
|
+
(cache / "groups.json").write_text(json.dumps({
|
|
77
|
+
"repo": "ok/ok", "folder": "legitrepo",
|
|
78
|
+
"milestone": "v1", "issues": [],
|
|
79
|
+
}))
|
|
80
|
+
(cache / "groups.answers.json").write_text("[]")
|
|
81
|
+
with mock.patch.object(group, "_batch_path", return_value=cache / "groups.json"), \
|
|
82
|
+
mock.patch.object(group, "_answers_path", return_value=cache / "groups.answers.json"):
|
|
83
|
+
cfg = {"notes_root": str(notes), "repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
84
|
+
rc = group._apply(cfg)
|
|
85
|
+
self.assertEqual(rc, 0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SuggestPrioritiesApplyValidationTest(unittest.TestCase):
|
|
89
|
+
def test_rejects_repo_not_in_cfg(self):
|
|
90
|
+
with tempfile.TemporaryDirectory() as td:
|
|
91
|
+
cache = Path(td) / "cache"
|
|
92
|
+
cache.mkdir()
|
|
93
|
+
(cache / "priorities.json").write_text(json.dumps({
|
|
94
|
+
"repo": "attacker/target", "issues": [],
|
|
95
|
+
}))
|
|
96
|
+
(cache / "priorities.answers.json").write_text("[]")
|
|
97
|
+
with mock.patch.object(suggest_priorities, "_batch_path", return_value=cache / "priorities.json"):
|
|
98
|
+
cfg = {"repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
99
|
+
rc = suggest_priorities._apply(cfg)
|
|
100
|
+
self.assertEqual(rc, 1)
|
|
101
|
+
|
|
102
|
+
def test_accepts_repo_in_cfg(self):
|
|
103
|
+
with tempfile.TemporaryDirectory() as td:
|
|
104
|
+
cache = Path(td) / "cache"
|
|
105
|
+
cache.mkdir()
|
|
106
|
+
(cache / "priorities.json").write_text(json.dumps({
|
|
107
|
+
"repo": "ok/ok", "issues": [],
|
|
108
|
+
}))
|
|
109
|
+
(cache / "priorities.answers.json").write_text("[]")
|
|
110
|
+
with mock.patch.object(suggest_priorities, "_batch_path", return_value=cache / "priorities.json"):
|
|
111
|
+
cfg = {"repos": {"legitrepo": {"github": "ok/ok"}}}
|
|
112
|
+
rc = suggest_priorities._apply(cfg)
|
|
113
|
+
self.assertEqual(rc, 0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for session_log."""
|
|
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.session_log import append_session_log, SESSION_LOG_HEADER
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppendSessionLogTest(unittest.TestCase):
|
|
13
|
+
def test_appends_under_existing_section(self):
|
|
14
|
+
body = (
|
|
15
|
+
"# Track\n\nProse.\n\n"
|
|
16
|
+
f"{SESSION_LOG_HEADER}\n\n"
|
|
17
|
+
"### Session — 2026-04-23 22:14\n\n- Touched: prior\n"
|
|
18
|
+
)
|
|
19
|
+
new = append_session_log(
|
|
20
|
+
body, timestamp="2026-04-28 18:30",
|
|
21
|
+
touched=["#4254 polls"], next_up=["#925 wmsr"], blockers=[],
|
|
22
|
+
)
|
|
23
|
+
self.assertIn("### Session — 2026-04-28 18:30", new)
|
|
24
|
+
self.assertIn("### Session — 2026-04-23 22:14", new)
|
|
25
|
+
self.assertIn("- Touched: #4254 polls", new)
|
|
26
|
+
|
|
27
|
+
def test_creates_section_when_missing(self):
|
|
28
|
+
body = "# Track\n\nProse.\n"
|
|
29
|
+
new = append_session_log(
|
|
30
|
+
body, timestamp="2026-04-28 18:30",
|
|
31
|
+
touched=["#1 foo"], next_up=["#2 bar"],
|
|
32
|
+
blockers=[{"number": 3, "reason": "waiting"}],
|
|
33
|
+
)
|
|
34
|
+
self.assertIn(SESSION_LOG_HEADER, new)
|
|
35
|
+
self.assertIn("- Blocker: #3 — waiting", new)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
unittest.main()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# tests/test_set_field.py
|
|
2
|
+
import io, sys, unittest
|
|
3
|
+
from contextlib import redirect_stdout
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
from commands import set_field
|
|
9
|
+
from lib.write_guard import make_token
|
|
10
|
+
|
|
11
|
+
def _t(name="ph", repo="o/r"):
|
|
12
|
+
return SimpleNamespace(name=name, repo=repo, path=Path(f"/tmp/{name}.md"),
|
|
13
|
+
has_frontmatter=True, meta={"status":"active","github":{"repo":repo}}, body="# b")
|
|
14
|
+
|
|
15
|
+
def _drive(args, vis="PRIVATE", cfg=None):
|
|
16
|
+
base_cfg = {"notes_root": "/tmp"}
|
|
17
|
+
if cfg is not None:
|
|
18
|
+
base_cfg.update(cfg)
|
|
19
|
+
with patch("commands.set_field.load_config", return_value=base_cfg), \
|
|
20
|
+
patch("commands.set_field.discover_tracks", return_value=[_t()]), \
|
|
21
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
22
|
+
patch("commands.set_field.write_file") as mw:
|
|
23
|
+
buf = io.StringIO()
|
|
24
|
+
with redirect_stdout(buf):
|
|
25
|
+
rc = set_field.run(args)
|
|
26
|
+
return rc, mw, buf.getvalue()
|
|
27
|
+
|
|
28
|
+
class SetFieldTest(unittest.TestCase):
|
|
29
|
+
def test_sets_status_private(self):
|
|
30
|
+
rc, mw, out = _drive(["ph", "status=parked"])
|
|
31
|
+
self.assertEqual(rc, 0); mw.assert_called_once()
|
|
32
|
+
self.assertEqual(mw.call_args[0][1]["status"], "parked")
|
|
33
|
+
def test_public_blocks_without_confirm(self):
|
|
34
|
+
rc, mw, out = _drive(["ph", "status=parked"], vis="PUBLIC")
|
|
35
|
+
self.assertEqual(rc, 0); mw.assert_not_called()
|
|
36
|
+
self.assertIn("needs_confirm", out)
|
|
37
|
+
def test_public_with_valid_confirm_writes(self):
|
|
38
|
+
tok = make_token("o/r", "ph")
|
|
39
|
+
rc, mw, out = _drive(["ph", "status=parked", f"--confirm={tok}"], vis="PUBLIC")
|
|
40
|
+
self.assertEqual(rc, 0); mw.assert_called_once()
|
|
41
|
+
self.assertEqual(mw.call_args[0][1]["status"], "parked")
|
|
42
|
+
def test_rejects_unknown_field(self):
|
|
43
|
+
rc, mw, out = _drive(["ph", "bogus=x"])
|
|
44
|
+
self.assertEqual(rc, 2); mw.assert_not_called()
|
|
45
|
+
def test_rejects_invalid_status(self):
|
|
46
|
+
rc, mw, out = _drive(["ph", "status=nonsense"])
|
|
47
|
+
self.assertEqual(rc, 2); mw.assert_not_called()
|
|
48
|
+
def test_rejects_non_integer_blockers(self):
|
|
49
|
+
rc, mw, out = _drive(["ph", "blockers=abc"])
|
|
50
|
+
self.assertEqual(rc, 2); mw.assert_not_called()
|
|
51
|
+
def test_repoless_track_writes_without_confirm(self):
|
|
52
|
+
with patch("commands.set_field.load_config", return_value={"notes_root":"/tmp"}), \
|
|
53
|
+
patch("commands.set_field.discover_tracks", return_value=[_t(repo=None)]), \
|
|
54
|
+
patch("commands.set_field.write_file") as mw:
|
|
55
|
+
buf = io.StringIO()
|
|
56
|
+
with redirect_stdout(buf):
|
|
57
|
+
rc = set_field.run(["ph", "status=parked"])
|
|
58
|
+
self.assertEqual(rc, 0); mw.assert_called_once()
|
|
59
|
+
|
|
60
|
+
# --- assume_private_when_unknown: caller-level integration ---
|
|
61
|
+
def test_unknown_vis_with_flag_writes(self):
|
|
62
|
+
"""Unknown visibility + assume_private_when_unknown=True → write proceeds."""
|
|
63
|
+
rc, mw, out = _drive(
|
|
64
|
+
["ph", "status=parked"],
|
|
65
|
+
vis=None,
|
|
66
|
+
cfg={"assume_private_when_unknown": True},
|
|
67
|
+
)
|
|
68
|
+
self.assertEqual(rc, 0)
|
|
69
|
+
mw.assert_called_once()
|
|
70
|
+
self.assertNotIn("needs_confirm", out)
|
|
71
|
+
|
|
72
|
+
def test_unknown_vis_without_flag_emits_needs_confirm(self):
|
|
73
|
+
"""Unknown visibility + no flag → still emits needs_confirm JSON."""
|
|
74
|
+
rc, mw, out = _drive(["ph", "status=parked"], vis=None)
|
|
75
|
+
self.assertEqual(rc, 0)
|
|
76
|
+
mw.assert_not_called()
|
|
77
|
+
self.assertIn("needs_confirm", out)
|