@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,46 @@
|
|
|
1
|
+
"""Smoke test: importable + main() exists."""
|
|
2
|
+
import io
|
|
3
|
+
import unittest
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
10
|
+
|
|
11
|
+
import work_plan
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SmokeTest(unittest.TestCase):
|
|
15
|
+
def test_main_exists(self):
|
|
16
|
+
self.assertTrue(callable(work_plan.main))
|
|
17
|
+
|
|
18
|
+
def test_main_no_args_returns_2(self):
|
|
19
|
+
self.assertEqual(work_plan.main(["work_plan.py"]), 2)
|
|
20
|
+
|
|
21
|
+
def test_version_loads_from_file(self):
|
|
22
|
+
# VERSION lives at the repo root in source mode and next to work_plan.py
|
|
23
|
+
# in installed mode. The loader walks upward; either layout must produce
|
|
24
|
+
# a non-empty string. Guards against future moves of the VERSION file.
|
|
25
|
+
v = work_plan._load_version()
|
|
26
|
+
self.assertTrue(v, "VERSION file unreachable from work_plan.py")
|
|
27
|
+
self.assertNotEqual(v, "unknown",
|
|
28
|
+
"VERSION file present but resolved to 'unknown' fallback")
|
|
29
|
+
|
|
30
|
+
def test_version_flag_prints_and_exits_zero(self):
|
|
31
|
+
for flag in ("--version", "-v"):
|
|
32
|
+
with self.subTest(flag=flag):
|
|
33
|
+
out_buf = io.StringIO()
|
|
34
|
+
err_buf = io.StringIO()
|
|
35
|
+
with redirect_stdout(out_buf), redirect_stderr(err_buf):
|
|
36
|
+
rc = work_plan.main(["work_plan.py", flag])
|
|
37
|
+
out = out_buf.getvalue().strip()
|
|
38
|
+
err = err_buf.getvalue()
|
|
39
|
+
self.assertEqual(rc, 0)
|
|
40
|
+
self.assertTrue(out, f"{flag} produced empty stdout")
|
|
41
|
+
self.assertIn(work_plan.VERSION, out)
|
|
42
|
+
self.assertEqual(err, "", f"{flag} wrote to stderr: {err!r}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
unittest.main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tests for idempotent status-header stamping."""
|
|
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.status_header import BEGIN, END, render_block, stamp
|
|
10
|
+
|
|
11
|
+
ROW = {
|
|
12
|
+
"glyph": "✅", "verdict": "shipped",
|
|
13
|
+
"files_present": 9, "files_declared": 9,
|
|
14
|
+
"last_touched": "2026-04-02",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RenderBlockTest(unittest.TestCase):
|
|
19
|
+
def test_block_is_delimited_and_evidence_only(self):
|
|
20
|
+
block = render_block(ROW)
|
|
21
|
+
self.assertTrue(block.startswith(BEGIN))
|
|
22
|
+
self.assertTrue(block.rstrip().endswith(END))
|
|
23
|
+
self.assertIn("shipped", block)
|
|
24
|
+
self.assertIn("9/9 files", block)
|
|
25
|
+
self.assertIn("2026-04-02", block)
|
|
26
|
+
|
|
27
|
+
def test_none_last_touched_renders_unknown(self):
|
|
28
|
+
row = dict(ROW, last_touched=None)
|
|
29
|
+
self.assertIn("unknown", render_block(row))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StampTest(unittest.TestCase):
|
|
33
|
+
DOC = "# My Plan\n\nSome body text.\n"
|
|
34
|
+
|
|
35
|
+
def test_inserts_after_h1(self):
|
|
36
|
+
out = stamp(self.DOC, ROW)
|
|
37
|
+
self.assertIn(BEGIN, out)
|
|
38
|
+
self.assertLess(out.index(BEGIN), out.index("Some body text."))
|
|
39
|
+
self.assertGreater(out.index(BEGIN), out.index("# My Plan"))
|
|
40
|
+
|
|
41
|
+
def test_idempotent_same_evidence_zero_diff(self):
|
|
42
|
+
once = stamp(self.DOC, ROW)
|
|
43
|
+
twice = stamp(once, ROW)
|
|
44
|
+
self.assertEqual(once, twice)
|
|
45
|
+
|
|
46
|
+
def test_rewrites_only_block_on_evidence_change(self):
|
|
47
|
+
once = stamp(self.DOC, ROW)
|
|
48
|
+
changed = stamp(once, dict(ROW, files_present=5, verdict="partial"))
|
|
49
|
+
self.assertNotEqual(once, changed)
|
|
50
|
+
self.assertEqual(changed.count(BEGIN), 1)
|
|
51
|
+
self.assertIn("partial", changed)
|
|
52
|
+
self.assertNotIn("shipped", changed)
|
|
53
|
+
|
|
54
|
+
def test_prepends_when_no_h1(self):
|
|
55
|
+
out = stamp("no heading here\n", ROW)
|
|
56
|
+
self.assertTrue(out.startswith(BEGIN))
|
|
57
|
+
|
|
58
|
+
def test_duplicate_blocks_collapse_to_one_fresh(self):
|
|
59
|
+
# Two stale blocks (e.g. from a bad merge) -> one fresh, no stale leftover.
|
|
60
|
+
block = render_block(dict(ROW, verdict="partial", files_present=1))
|
|
61
|
+
doc = f"# Plan\n\n{block}\n\nbody\n\n{block}\n"
|
|
62
|
+
out = stamp(doc, ROW)
|
|
63
|
+
self.assertEqual(out.count(BEGIN), 1)
|
|
64
|
+
self.assertEqual(out.count(END), 1)
|
|
65
|
+
self.assertIn("shipped", out)
|
|
66
|
+
self.assertNotIn("partial", out)
|
|
67
|
+
self.assertEqual(out, stamp(out, ROW)) # stable on re-run
|
|
68
|
+
|
|
69
|
+
def test_dangling_begin_does_not_duplicate(self):
|
|
70
|
+
# An orphan BEGIN (no END) must not cause a second block to be stacked.
|
|
71
|
+
doc = f"# Plan\n\n{BEGIN}\n> **Status:** truncated\n\nbody\n"
|
|
72
|
+
out = stamp(doc, ROW)
|
|
73
|
+
self.assertEqual(out.count(BEGIN), 1)
|
|
74
|
+
self.assertEqual(out.count(END), 1)
|
|
75
|
+
self.assertEqual(out, stamp(out, ROW)) # idempotent afterward
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
unittest.main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Tests for status_table parser/updater."""
|
|
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.status_table import (
|
|
10
|
+
find_status_table, update_row_status, ISSUE_NUM_RE,
|
|
11
|
+
render_issue_row, append_rows, sync_missing_rows,
|
|
12
|
+
find_canonical_status_tables, CANONICAL_MARKER,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _canonical_body(rows):
|
|
19
|
+
"""Build a body with a canonical issue table containing the given rows.
|
|
20
|
+
|
|
21
|
+
`rows` is a list of pre-rendered row strings (without leading/trailing
|
|
22
|
+
newline). A trailing narrative section is included to prove appends land
|
|
23
|
+
before it, not at end-of-body.
|
|
24
|
+
"""
|
|
25
|
+
lines = [
|
|
26
|
+
"## Issues (canonical)",
|
|
27
|
+
"",
|
|
28
|
+
f"{CANONICAL_MARKER} — auto-managed. -->",
|
|
29
|
+
"",
|
|
30
|
+
"| # | Title | Assignee | Status |",
|
|
31
|
+
"|---|---|---|---|",
|
|
32
|
+
]
|
|
33
|
+
lines.extend(rows)
|
|
34
|
+
lines.extend(["", "---", "", "## Notes", "", "Some narrative here."])
|
|
35
|
+
return "\n".join(lines)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FindStatusTableTest(unittest.TestCase):
|
|
39
|
+
def test_finds_table_with_status_col(self):
|
|
40
|
+
body = (FIXTURES / "with_status_table.md").read_text()
|
|
41
|
+
table = find_status_table(body)
|
|
42
|
+
self.assertIsNotNone(table)
|
|
43
|
+
self.assertEqual(table["status_col_index"], 2)
|
|
44
|
+
self.assertEqual(len(table["rows"]), 3)
|
|
45
|
+
|
|
46
|
+
def test_returns_none_when_no_status_table(self):
|
|
47
|
+
self.assertIsNone(find_status_table("# Just text"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UpdateRowStatusTest(unittest.TestCase):
|
|
51
|
+
def test_updates_one_row(self):
|
|
52
|
+
body = (FIXTURES / "with_status_table.md").read_text()
|
|
53
|
+
new = update_row_status(body, 4254, "✅ Shipped (PR #9999)")
|
|
54
|
+
self.assertIn("✅ Shipped (PR #9999)", new)
|
|
55
|
+
self.assertIn("✅ Shipped ", new)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RenderIssueRowTest(unittest.TestCase):
|
|
59
|
+
def test_renders_canonical_row_shape(self):
|
|
60
|
+
row = render_issue_row(487, "fix the thing", "@alice", "🔲 Open")
|
|
61
|
+
self.assertEqual(row, "| #487 | fix the thing | @alice | 🔲 Open |")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AppendRowsTest(unittest.TestCase):
|
|
65
|
+
def test_inserts_after_last_row_before_narrative(self):
|
|
66
|
+
body = _canonical_body(["| #678 | a | — | 🔲 Open |"])
|
|
67
|
+
table = find_canonical_status_tables(body)[0]
|
|
68
|
+
new = append_rows(body, table, ["| #999 | b | — | 🔲 Open |"])
|
|
69
|
+
lines = new.split("\n")
|
|
70
|
+
# New row sits directly after the existing data row...
|
|
71
|
+
self.assertEqual(lines[7], "| #999 | b | — | 🔲 Open |")
|
|
72
|
+
# ...and the narrative section survives below it.
|
|
73
|
+
self.assertIn("## Notes", new)
|
|
74
|
+
self.assertLess(new.index("#999"), new.index("## Notes"))
|
|
75
|
+
|
|
76
|
+
def test_no_rows_is_noop(self):
|
|
77
|
+
body = _canonical_body(["| #678 | a | — | 🔲 Open |"])
|
|
78
|
+
table = find_canonical_status_tables(body)[0]
|
|
79
|
+
self.assertEqual(append_rows(body, table, []), body)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class SyncMissingRowsTest(unittest.TestCase):
|
|
83
|
+
def test_appends_missing_in_frontmatter_order(self):
|
|
84
|
+
body = _canonical_body([
|
|
85
|
+
"| #678 | first | — | 🔲 Open |",
|
|
86
|
+
"| #925 | second | — | ✅ Shipped |",
|
|
87
|
+
])
|
|
88
|
+
# Frontmatter lists three more than the table, deliberately out of
|
|
89
|
+
# numeric order to prove frontmatter order (not sort) is honored.
|
|
90
|
+
frontmatter_nums = [678, 925, 5195, 487, 2196]
|
|
91
|
+
issues_by_num = {
|
|
92
|
+
5195: {"number": 5195, "title": "newer", "state": "OPEN",
|
|
93
|
+
"assignees": [{"login": "bob"}]},
|
|
94
|
+
487: {"number": 487, "title": "older", "state": "CLOSED", "assignees": []},
|
|
95
|
+
2196: {"number": 2196, "title": "mid", "state": "OPEN", "assignees": []},
|
|
96
|
+
}
|
|
97
|
+
new, added = sync_missing_rows(body, frontmatter_nums, issues_by_num)
|
|
98
|
+
self.assertEqual(added, 3)
|
|
99
|
+
table = find_canonical_status_tables(new)[0]
|
|
100
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
|
|
101
|
+
self.assertEqual(nums, [678, 925, 5195, 487, 2196])
|
|
102
|
+
self.assertIn("| #5195 | newer | @bob | 🔲 Open |", new)
|
|
103
|
+
self.assertIn("| #487 | older | — | ✅ Shipped |", new)
|
|
104
|
+
|
|
105
|
+
def test_slots_low_rank_row_above_existing(self):
|
|
106
|
+
# The #79 case: frontmatter lists #487 first, but it's missing from a
|
|
107
|
+
# table whose existing rows start at #678. Option A slots #487 to the
|
|
108
|
+
# top instead of tacking it onto the end.
|
|
109
|
+
body = _canonical_body([
|
|
110
|
+
"| #678 | first | — | 🔲 Open |",
|
|
111
|
+
"| #2528 | last | — | ✅ Shipped |",
|
|
112
|
+
])
|
|
113
|
+
frontmatter_nums = [487, 678, 1556, 2528, 5195]
|
|
114
|
+
issues_by_num = {
|
|
115
|
+
487: {"number": 487, "title": "earliest", "state": "OPEN", "assignees": []},
|
|
116
|
+
1556: {"number": 1556, "title": "middle", "state": "OPEN", "assignees": []},
|
|
117
|
+
5195: {"number": 5195, "title": "newest", "state": "OPEN", "assignees": []},
|
|
118
|
+
}
|
|
119
|
+
new, added = sync_missing_rows(body, frontmatter_nums, issues_by_num)
|
|
120
|
+
self.assertEqual(added, 3)
|
|
121
|
+
table = find_canonical_status_tables(new)[0]
|
|
122
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
|
|
123
|
+
# Full table order now mirrors frontmatter: #487 at top, #1556 between
|
|
124
|
+
# #678 and #2528, #5195 at the end.
|
|
125
|
+
self.assertEqual(nums, frontmatter_nums)
|
|
126
|
+
# Existing rows are re-emitted verbatim (minimal diff).
|
|
127
|
+
self.assertIn("| #678 | first | — | 🔲 Open |", new)
|
|
128
|
+
self.assertIn("| #2528 | last | — | ✅ Shipped |", new)
|
|
129
|
+
|
|
130
|
+
def test_unranked_existing_row_imposes_no_constraint(self):
|
|
131
|
+
# A row whose issue isn't in frontmatter (manual addition) shouldn't
|
|
132
|
+
# block a missing row from slotting past it.
|
|
133
|
+
body = _canonical_body([
|
|
134
|
+
"| #9001 | manual | — | 🔲 Open |",
|
|
135
|
+
"| #678 | tracked | — | 🔲 Open |",
|
|
136
|
+
])
|
|
137
|
+
new, added = sync_missing_rows(
|
|
138
|
+
body, [487, 678],
|
|
139
|
+
{487: {"number": 487, "title": "early", "state": "OPEN", "assignees": []}},
|
|
140
|
+
)
|
|
141
|
+
self.assertEqual(added, 1)
|
|
142
|
+
table = find_canonical_status_tables(new)[0]
|
|
143
|
+
nums = [int(ISSUE_NUM_RE.search(r["cells"][0]).group(1)) for r in table["rows"]]
|
|
144
|
+
# #487 slots immediately before #678 (its frontmatter successor),
|
|
145
|
+
# leaving the unranked #9001 put.
|
|
146
|
+
self.assertEqual(nums, [9001, 487, 678])
|
|
147
|
+
|
|
148
|
+
def test_no_drift_is_noop(self):
|
|
149
|
+
body = _canonical_body(["| #678 | first | — | 🔲 Open |"])
|
|
150
|
+
new, added = sync_missing_rows(body, [678], {678: {"number": 678}})
|
|
151
|
+
self.assertEqual(added, 0)
|
|
152
|
+
self.assertEqual(new, body)
|
|
153
|
+
|
|
154
|
+
def test_missing_issue_without_fetched_data_still_appends(self):
|
|
155
|
+
body = _canonical_body(["| #678 | first | — | 🔲 Open |"])
|
|
156
|
+
new, added = sync_missing_rows(body, [678, 999], {})
|
|
157
|
+
self.assertEqual(added, 1)
|
|
158
|
+
self.assertIn("#999", new)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
unittest.main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tests for the `Suggested first action` line in handoff's fresh-session prompt.
|
|
2
|
+
|
|
3
|
+
Issue #57: the resume hook surfaced `Pick up #4790 from the 'next_up' list.`
|
|
4
|
+
even though #4790 was rendered as `(state: closed)` directly above. The fix
|
|
5
|
+
filters next_up by state and picks the first non-closed entry; if every entry
|
|
6
|
+
is closed, it emits a 'run handoff to rotate' hint instead.
|
|
7
|
+
"""
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
14
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
15
|
+
|
|
16
|
+
from commands import handoff
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FirstActionableNextUpTest(unittest.TestCase):
|
|
20
|
+
def test_returns_first_open_when_leading_entry_closed(self):
|
|
21
|
+
issues_by_num = {
|
|
22
|
+
4790: {"number": 4790, "state": "CLOSED"},
|
|
23
|
+
4789: {"number": 4789, "state": "OPEN"},
|
|
24
|
+
4788: {"number": 4788, "state": "OPEN"},
|
|
25
|
+
}
|
|
26
|
+
result = handoff._first_actionable_next_up([4790, 4789, 4788], issues_by_num)
|
|
27
|
+
self.assertEqual(result, 4789)
|
|
28
|
+
|
|
29
|
+
def test_returns_first_when_already_open(self):
|
|
30
|
+
issues_by_num = {
|
|
31
|
+
4789: {"number": 4789, "state": "OPEN"},
|
|
32
|
+
4790: {"number": 4790, "state": "CLOSED"},
|
|
33
|
+
}
|
|
34
|
+
result = handoff._first_actionable_next_up([4789, 4790], issues_by_num)
|
|
35
|
+
self.assertEqual(result, 4789)
|
|
36
|
+
|
|
37
|
+
def test_returns_none_when_every_entry_closed(self):
|
|
38
|
+
issues_by_num = {
|
|
39
|
+
4790: {"number": 4790, "state": "CLOSED"},
|
|
40
|
+
4791: {"number": 4791, "state": "MERGED"},
|
|
41
|
+
}
|
|
42
|
+
result = handoff._first_actionable_next_up([4790, 4791], issues_by_num)
|
|
43
|
+
self.assertIsNone(result)
|
|
44
|
+
|
|
45
|
+
def test_unknown_issue_is_treated_as_actionable(self):
|
|
46
|
+
result = handoff._first_actionable_next_up([9999], {})
|
|
47
|
+
self.assertEqual(result, 9999)
|
|
48
|
+
|
|
49
|
+
def test_unknown_returned_before_closed(self):
|
|
50
|
+
issues_by_num = {4790: {"number": 4790, "state": "CLOSED"}}
|
|
51
|
+
result = handoff._first_actionable_next_up([4790, 9999], issues_by_num)
|
|
52
|
+
self.assertEqual(result, 9999)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _track_meta(next_up):
|
|
56
|
+
return SimpleNamespace(
|
|
57
|
+
meta={"track": "demo", "launch_priority": "P3", "milestone_alignment": "v1.0.0"},
|
|
58
|
+
repo="org/repo",
|
|
59
|
+
local_path=None,
|
|
60
|
+
path=Path("/tmp/demo.md"),
|
|
61
|
+
name="demo",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BuildFreshSessionPromptTest(unittest.TestCase):
|
|
66
|
+
def test_suggests_first_open_when_leading_next_up_closed(self):
|
|
67
|
+
track = _track_meta(None)
|
|
68
|
+
next_up = [4790, 4789]
|
|
69
|
+
issues_by_num = {
|
|
70
|
+
4790: {"number": 4790, "title": "shipped already", "state": "CLOSED"},
|
|
71
|
+
4789: {"number": 4789, "title": "still open", "state": "OPEN"},
|
|
72
|
+
}
|
|
73
|
+
prompt = handoff._build_fresh_session_prompt(
|
|
74
|
+
track, commits=[], uncommitted=[], last_session="",
|
|
75
|
+
open_items=[], open_source=None,
|
|
76
|
+
next_up=next_up, issues_by_num=issues_by_num,
|
|
77
|
+
repo_wide_commits=0,
|
|
78
|
+
)
|
|
79
|
+
self.assertIn("Pick up #4789 from the `next_up` list.", prompt)
|
|
80
|
+
self.assertNotIn("Pick up #4790 from the `next_up` list.", prompt)
|
|
81
|
+
|
|
82
|
+
def test_emits_rotate_hint_when_all_next_up_closed(self):
|
|
83
|
+
track = _track_meta(None)
|
|
84
|
+
next_up = [4790, 4791]
|
|
85
|
+
issues_by_num = {
|
|
86
|
+
4790: {"number": 4790, "title": "x", "state": "CLOSED"},
|
|
87
|
+
4791: {"number": 4791, "title": "y", "state": "MERGED"},
|
|
88
|
+
}
|
|
89
|
+
prompt = handoff._build_fresh_session_prompt(
|
|
90
|
+
track, commits=[], uncommitted=[], last_session="",
|
|
91
|
+
open_items=[], open_source=None,
|
|
92
|
+
next_up=next_up, issues_by_num=issues_by_num,
|
|
93
|
+
repo_wide_commits=0,
|
|
94
|
+
)
|
|
95
|
+
self.assertIn("All `next_up` items are closed", prompt)
|
|
96
|
+
self.assertIn("/work-plan handoff demo", prompt)
|
|
97
|
+
self.assertNotIn("Pick up #", prompt)
|
|
98
|
+
|
|
99
|
+
def test_uncommitted_takes_precedence_over_next_up(self):
|
|
100
|
+
track = _track_meta(None)
|
|
101
|
+
prompt = handoff._build_fresh_session_prompt(
|
|
102
|
+
track, commits=[], uncommitted=["src/foo.ts"], last_session="",
|
|
103
|
+
open_items=[], open_source=None,
|
|
104
|
+
next_up=[4790], issues_by_num={4790: {"state": "CLOSED"}},
|
|
105
|
+
repo_wide_commits=0,
|
|
106
|
+
)
|
|
107
|
+
self.assertIn("Resume the uncommitted work above.", prompt)
|
|
108
|
+
self.assertNotIn("Pick up #", prompt)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Tests for track discovery."""
|
|
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, discover_archived_tracks
|
|
10
|
+
|
|
11
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "notes_root"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DiscoverTracksTest(unittest.TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.cfg = {
|
|
17
|
+
"notes_root": str(FIXTURES),
|
|
18
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
def test_active_track_discovered(self):
|
|
22
|
+
names = [t.name for t in discover_tracks(self.cfg) if t.has_frontmatter]
|
|
23
|
+
self.assertIn("example", names)
|
|
24
|
+
|
|
25
|
+
def test_repo_inferred_from_folder(self):
|
|
26
|
+
ex = next(t for t in discover_tracks(self.cfg) if t.name == "example")
|
|
27
|
+
self.assertEqual(ex.repo, "stylusnexus/CritForge")
|
|
28
|
+
|
|
29
|
+
def test_no_frontmatter_flagged_needs_init(self):
|
|
30
|
+
nf = next(t for t in discover_tracks(self.cfg) if t.path.name == "no_frontmatter.md")
|
|
31
|
+
self.assertTrue(nf.needs_init)
|
|
32
|
+
|
|
33
|
+
def test_loose_file_flagged_needs_filing(self):
|
|
34
|
+
loose = next(t for t in discover_tracks(self.cfg) if t.path.name == "loose_at_root.md")
|
|
35
|
+
self.assertTrue(loose.needs_filing)
|
|
36
|
+
|
|
37
|
+
def test_archived_excluded_from_discover_tracks(self):
|
|
38
|
+
names = [t.name for t in discover_tracks(self.cfg)]
|
|
39
|
+
self.assertNotIn("old", names)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DiscoverArchivedTracksTest(unittest.TestCase):
|
|
43
|
+
def setUp(self):
|
|
44
|
+
self.cfg = {
|
|
45
|
+
"notes_root": str(FIXTURES),
|
|
46
|
+
"repos": {"critforge": {"github": "stylusnexus/CritForge", "local": None}},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def test_finds_shipped_track_in_archive(self):
|
|
50
|
+
archived = discover_archived_tracks(self.cfg)
|
|
51
|
+
slugs = [a.meta.get("track") for a in archived]
|
|
52
|
+
self.assertIn("old", slugs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
unittest.main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tests for pure verdict classification."""
|
|
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 ManifestScore
|
|
11
|
+
from lib.verdict import classify, Verdict
|
|
12
|
+
|
|
13
|
+
TODAY = date(2026, 5, 30)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _score(sat, tot):
|
|
17
|
+
return ManifestScore(total=tot, satisfied=sat,
|
|
18
|
+
by_kind={"create": (sat, tot), "modify": (0, 0), "test": (0, 0)})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClassifyTest(unittest.TestCase):
|
|
22
|
+
def test_shipped_when_all_files_present(self):
|
|
23
|
+
v = classify(_score(9, 9), checkbox_done=0, checkbox_total=24,
|
|
24
|
+
last_touched=date(2026, 4, 1), today=TODAY)
|
|
25
|
+
self.assertEqual(v.label, "shipped")
|
|
26
|
+
self.assertEqual(v.glyph, "✅")
|
|
27
|
+
self.assertIn("boxes stale", v.rationale) # 0/24 boxes -> stale note
|
|
28
|
+
|
|
29
|
+
def test_shipped_without_stale_note_when_boxes_checked(self):
|
|
30
|
+
v = classify(_score(9, 9), checkbox_done=20, checkbox_total=24,
|
|
31
|
+
last_touched=date(2026, 4, 1), today=TODAY)
|
|
32
|
+
self.assertEqual(v.label, "shipped")
|
|
33
|
+
self.assertNotIn("boxes stale", v.rationale)
|
|
34
|
+
|
|
35
|
+
def test_partial_when_some_files(self):
|
|
36
|
+
v = classify(_score(3, 9), checkbox_done=0, checkbox_total=9,
|
|
37
|
+
last_touched=date(2026, 5, 1), today=TODAY)
|
|
38
|
+
self.assertEqual(v.label, "partial")
|
|
39
|
+
self.assertEqual(v.glyph, "\U0001f7e1")
|
|
40
|
+
|
|
41
|
+
def test_dead_when_no_files_and_stale(self):
|
|
42
|
+
v = classify(_score(0, 9), checkbox_done=0, checkbox_total=9,
|
|
43
|
+
last_touched=date(2026, 1, 1), today=TODAY, dead_days=60)
|
|
44
|
+
self.assertEqual(v.label, "dead")
|
|
45
|
+
self.assertEqual(v.glyph, "\U0001f480")
|
|
46
|
+
|
|
47
|
+
def test_early_not_dead_when_recent(self):
|
|
48
|
+
v = classify(_score(0, 9), checkbox_done=0, checkbox_total=9,
|
|
49
|
+
last_touched=date(2026, 5, 20), today=TODAY, dead_days=60)
|
|
50
|
+
self.assertEqual(v.label, "partial")
|
|
51
|
+
|
|
52
|
+
def test_manifest_less_routes_to_llm(self):
|
|
53
|
+
v = classify(_score(0, 0), checkbox_done=0, checkbox_total=0,
|
|
54
|
+
last_touched=None, today=TODAY)
|
|
55
|
+
self.assertEqual(v.label, "manifest-less")
|
|
56
|
+
self.assertEqual(v.glyph, "\U0001f47b")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
unittest.main()
|