@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,51 @@
|
|
|
1
|
+
"""Pure verdict classification over gathered evidence. No I/O — fully unit-testable.
|
|
2
|
+
|
|
3
|
+
Thresholds are module constants so a later phase can make them configurable
|
|
4
|
+
without touching call sites.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
SHIPPED_PCT = 80.0 # >= this % of declared files satisfied -> shipped
|
|
11
|
+
PARTIAL_PCT = 20.0 # >= this % -> partial
|
|
12
|
+
BOXES_STALE_PCT = 50.0 # checked-box % below this on a shipped plan -> "boxes stale"
|
|
13
|
+
DEAD_DAYS = 60 # 0 files satisfied AND untouched beyond this -> dead
|
|
14
|
+
FOREIGN_RATIO = 0.7 # >= this fraction of declared paths outside repo -> foreign
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Verdict:
|
|
19
|
+
label: str # shipped | partial | dead | foreign | manifest-less
|
|
20
|
+
glyph: str
|
|
21
|
+
rationale: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def classify(
|
|
25
|
+
score,
|
|
26
|
+
checkbox_done: int,
|
|
27
|
+
checkbox_total: int,
|
|
28
|
+
last_touched: Optional[date],
|
|
29
|
+
today: date,
|
|
30
|
+
dead_days: int = DEAD_DAYS,
|
|
31
|
+
) -> Verdict:
|
|
32
|
+
if score.total == 0:
|
|
33
|
+
return Verdict("manifest-less", "\U0001f47b",
|
|
34
|
+
"no file-manifest — needs LLM verdict (Phase 1b)")
|
|
35
|
+
|
|
36
|
+
pct = score.pct
|
|
37
|
+
files = f"{score.satisfied}/{score.total} declared files present"
|
|
38
|
+
|
|
39
|
+
if pct >= SHIPPED_PCT:
|
|
40
|
+
chk_pct = (checkbox_done / checkbox_total * 100.0) if checkbox_total else 0.0
|
|
41
|
+
stale = " (boxes stale)" if chk_pct < BOXES_STALE_PCT else ""
|
|
42
|
+
return Verdict("shipped", "✅", f"{files}{stale}")
|
|
43
|
+
|
|
44
|
+
if pct >= PARTIAL_PCT:
|
|
45
|
+
return Verdict("partial", "\U0001f7e1", files)
|
|
46
|
+
|
|
47
|
+
if last_touched is not None and (today - last_touched).days > dead_days:
|
|
48
|
+
age = (today - last_touched).days
|
|
49
|
+
return Verdict("dead", "\U0001f480", f"{files}, untouched {age}d")
|
|
50
|
+
|
|
51
|
+
return Verdict("partial", "\U0001f7e1", f"{files} (early)")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Confirm-token gate so non-interactive callers (the VS Code extension) can
|
|
2
|
+
surface the public-repo heads-up as their own dialog instead of a TTY prompt.
|
|
3
|
+
|
|
4
|
+
needs_confirm() fails CLOSED: PUBLIC or unknown visibility both require confirm.
|
|
5
|
+
Unknown visibility can be opted out of via cfg["assume_private_when_unknown"]=True
|
|
6
|
+
(for all-private teams that want to avoid the prompt on transient gh failures).
|
|
7
|
+
PUBLIC is never suppressed by this flag — the leak guarantee is unconditional.
|
|
8
|
+
|
|
9
|
+
The token is a deterministic hash of (repo, track) — no randomness (3.9 stdlib,
|
|
10
|
+
and stable so the re-invocation matches). It is not a security boundary; it just
|
|
11
|
+
proves the caller saw the heads-up for THIS write."""
|
|
12
|
+
import hashlib
|
|
13
|
+
from lib.github_state import repo_visibility
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def needs_confirm(repo: str, cfg: dict = None) -> bool:
|
|
17
|
+
"""True when a write to `repo` needs the public-repo confirm heads-up.
|
|
18
|
+
|
|
19
|
+
PUBLIC → always True (never suppressed — the leak guarantee).
|
|
20
|
+
PRIVATE → False.
|
|
21
|
+
Unknown visibility (gh couldn't say / offline) → True (fail CLOSED) UNLESS
|
|
22
|
+
cfg opts out via `assume_private_when_unknown: true`, which lets an
|
|
23
|
+
all-private team avoid the prompt on transient gh-lookup failures. PUBLIC is
|
|
24
|
+
never affected by this flag."""
|
|
25
|
+
vis = repo_visibility(repo)
|
|
26
|
+
if vis == "PUBLIC":
|
|
27
|
+
return True
|
|
28
|
+
if vis == "PRIVATE":
|
|
29
|
+
return False
|
|
30
|
+
# vis is None → unknown / offline. Fail closed unless explicitly opted out.
|
|
31
|
+
if cfg and cfg.get("assume_private_when_unknown"):
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
def make_token(repo: str, track: str) -> str:
|
|
36
|
+
return hashlib.sha256(f"{repo}::{track}".encode("utf-8")).hexdigest()[:16]
|
|
37
|
+
|
|
38
|
+
def valid_token(token: str, repo: str, track: str) -> bool:
|
|
39
|
+
return bool(token) and token == make_token(repo, track)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# No frontmatter
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Loose
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Tests for the non-interactive close command (issue #87, Phase 3a).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- close <track> --state=parked on a PRIVATE repo → status set to parked,
|
|
5
|
+
write_file called, shutil.move NOT called (stays in place), rc 0.
|
|
6
|
+
- --state=shipped on a private repo → status shipped, write_file called,
|
|
7
|
+
shutil.move called to archive/shipped/, rc 0.
|
|
8
|
+
- --state=abandoned → moves to archive/abandoned/.
|
|
9
|
+
- Missing --state → rc 2, no write.
|
|
10
|
+
- Invalid --state=bogus → rc 2, no write.
|
|
11
|
+
- --note="wrapped up" → the body passed to write_file contains the ## Wrap-up
|
|
12
|
+
section with the note.
|
|
13
|
+
- Public repo, no token → prints needs_confirm JSON, no write/move, rc 0;
|
|
14
|
+
token equals make_token(repo, track.name).
|
|
15
|
+
- Public repo with valid --confirm=<token> → performs the close (write/move
|
|
16
|
+
happen), rc 0.
|
|
17
|
+
- No input()/prompt_input is reached on the flagged path (patch them to raise).
|
|
18
|
+
"""
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import unittest
|
|
23
|
+
from contextlib import redirect_stdout
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import SimpleNamespace
|
|
26
|
+
from unittest.mock import patch
|
|
27
|
+
|
|
28
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
29
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
30
|
+
|
|
31
|
+
from commands import close
|
|
32
|
+
from lib.write_guard import make_token
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Helpers
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _track(*, name="alpha", repo="ok/repo", status="active"):
|
|
40
|
+
return SimpleNamespace(
|
|
41
|
+
name=name,
|
|
42
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
43
|
+
body="# fake body",
|
|
44
|
+
meta={
|
|
45
|
+
"track": name,
|
|
46
|
+
"status": status,
|
|
47
|
+
"github": {"repo": repo},
|
|
48
|
+
},
|
|
49
|
+
has_frontmatter=True,
|
|
50
|
+
repo=repo,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _drive(args, track=None, vis="PRIVATE"):
|
|
55
|
+
"""Run close.run(args) with all external I/O mocked.
|
|
56
|
+
|
|
57
|
+
vis controls what repo_visibility returns (used by needs_confirm).
|
|
58
|
+
track defaults to a single private-repo track named 'alpha'.
|
|
59
|
+
"""
|
|
60
|
+
if track is None:
|
|
61
|
+
track = _track()
|
|
62
|
+
# notes_root must be a parent of the track path so relative_to() works
|
|
63
|
+
cfg = {"notes_root": "/tmp/fake", "repos": {"ok": {"github": "ok/repo"}}}
|
|
64
|
+
|
|
65
|
+
with patch("commands.close.load_config", return_value=cfg), \
|
|
66
|
+
patch("commands.close.discover_tracks", return_value=[track]), \
|
|
67
|
+
patch("commands.close.find_track_by_name", return_value=track), \
|
|
68
|
+
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
69
|
+
patch("commands.close.write_file") as mw, \
|
|
70
|
+
patch("commands.close.shutil") as ms, \
|
|
71
|
+
patch("pathlib.Path.mkdir"):
|
|
72
|
+
buf = io.StringIO()
|
|
73
|
+
with redirect_stdout(buf):
|
|
74
|
+
rc = close.run(args)
|
|
75
|
+
return rc, mw, ms, buf.getvalue()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Test cases
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
class CloseNonInteractiveTest(unittest.TestCase):
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# State: parked (stays in place)
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def test_parked_private_write_no_move(self):
|
|
89
|
+
"""close <track> --state=parked on PRIVATE repo → write_file called,
|
|
90
|
+
shutil.move NOT called, rc 0."""
|
|
91
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked"])
|
|
92
|
+
self.assertEqual(rc, 0)
|
|
93
|
+
mw.assert_called_once()
|
|
94
|
+
# status updated to parked
|
|
95
|
+
written_meta = mw.call_args[0][1]
|
|
96
|
+
self.assertEqual(written_meta["status"], "parked")
|
|
97
|
+
# move must NOT be called for parked
|
|
98
|
+
ms.move.assert_not_called()
|
|
99
|
+
self.assertIn("parked", out)
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# State: shipped (moves to archive/shipped/)
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def test_shipped_private_write_and_move(self):
|
|
106
|
+
"""--state=shipped on private repo → write_file called, shutil.move
|
|
107
|
+
called to archive/shipped/, rc 0."""
|
|
108
|
+
rc, mw, ms, out = _drive(["alpha", "--state=shipped"])
|
|
109
|
+
self.assertEqual(rc, 0)
|
|
110
|
+
mw.assert_called_once()
|
|
111
|
+
written_meta = mw.call_args[0][1]
|
|
112
|
+
self.assertEqual(written_meta["status"], "shipped")
|
|
113
|
+
ms.move.assert_called_once()
|
|
114
|
+
# Destination path should contain archive/shipped
|
|
115
|
+
dest_arg = ms.move.call_args[0][1]
|
|
116
|
+
self.assertIn("archive", dest_arg)
|
|
117
|
+
self.assertIn("shipped", dest_arg)
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# State: abandoned (moves to archive/abandoned/)
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def test_abandoned_private_write_and_move(self):
|
|
124
|
+
"""--state=abandoned → moves to archive/abandoned/."""
|
|
125
|
+
rc, mw, ms, out = _drive(["alpha", "--state=abandoned"])
|
|
126
|
+
self.assertEqual(rc, 0)
|
|
127
|
+
mw.assert_called_once()
|
|
128
|
+
written_meta = mw.call_args[0][1]
|
|
129
|
+
self.assertEqual(written_meta["status"], "abandoned")
|
|
130
|
+
ms.move.assert_called_once()
|
|
131
|
+
dest_arg = ms.move.call_args[0][1]
|
|
132
|
+
self.assertIn("archive", dest_arg)
|
|
133
|
+
self.assertIn("abandoned", dest_arg)
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# Missing / invalid --state
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def test_missing_state_returns_rc2(self):
|
|
140
|
+
"""Missing --state → rc 2, no write."""
|
|
141
|
+
rc, mw, ms, out = _drive(["alpha"])
|
|
142
|
+
self.assertEqual(rc, 2)
|
|
143
|
+
mw.assert_not_called()
|
|
144
|
+
ms.move.assert_not_called()
|
|
145
|
+
|
|
146
|
+
def test_invalid_state_returns_rc2(self):
|
|
147
|
+
"""Invalid --state=bogus → rc 2, no write."""
|
|
148
|
+
rc, mw, ms, out = _drive(["alpha", "--state=bogus"])
|
|
149
|
+
self.assertEqual(rc, 2)
|
|
150
|
+
mw.assert_not_called()
|
|
151
|
+
ms.move.assert_not_called()
|
|
152
|
+
|
|
153
|
+
def test_missing_track_name_returns_rc2(self):
|
|
154
|
+
"""No positional args at all → rc 2 (usage error)."""
|
|
155
|
+
rc, mw, ms, out = _drive([])
|
|
156
|
+
self.assertEqual(rc, 2)
|
|
157
|
+
mw.assert_not_called()
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# --note flag
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def test_note_appended_to_body(self):
|
|
164
|
+
"""--note='wrapped up' → body passed to write_file contains
|
|
165
|
+
## Wrap-up section with the note."""
|
|
166
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked", "--note=wrapped up"])
|
|
167
|
+
self.assertEqual(rc, 0)
|
|
168
|
+
mw.assert_called_once()
|
|
169
|
+
written_body = mw.call_args[0][2]
|
|
170
|
+
self.assertIn("## Wrap-up", written_body)
|
|
171
|
+
self.assertIn("wrapped up", written_body)
|
|
172
|
+
|
|
173
|
+
def test_no_note_no_wrap_up_section(self):
|
|
174
|
+
"""No --note flag → body does NOT contain ## Wrap-up section."""
|
|
175
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked"])
|
|
176
|
+
self.assertEqual(rc, 0)
|
|
177
|
+
mw.assert_called_once()
|
|
178
|
+
written_body = mw.call_args[0][2]
|
|
179
|
+
self.assertNotIn("## Wrap-up", written_body)
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Confirm-token gate (public repo)
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def test_public_repo_no_token_returns_needs_confirm_json(self):
|
|
186
|
+
"""Public repo, no token → prints needs_confirm JSON, no write/move,
|
|
187
|
+
rc 0; token equals make_token(repo, track.name)."""
|
|
188
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
189
|
+
rc, mw, ms, out = _drive(["alpha", "--state=shipped"], track=track, vis="PUBLIC")
|
|
190
|
+
self.assertEqual(rc, 0)
|
|
191
|
+
mw.assert_not_called()
|
|
192
|
+
ms.move.assert_not_called()
|
|
193
|
+
data = json.loads(out.strip())
|
|
194
|
+
self.assertTrue(data["needs_confirm"])
|
|
195
|
+
self.assertEqual(data["token"], make_token("ok/repo", "alpha"))
|
|
196
|
+
|
|
197
|
+
def test_public_repo_unknown_visibility_returns_needs_confirm(self):
|
|
198
|
+
"""Unknown visibility (None) → also requires confirm."""
|
|
199
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
200
|
+
rc, mw, ms, out = _drive(["alpha", "--state=parked"], track=track, vis=None)
|
|
201
|
+
self.assertEqual(rc, 0)
|
|
202
|
+
mw.assert_not_called()
|
|
203
|
+
data = json.loads(out.strip())
|
|
204
|
+
self.assertTrue(data["needs_confirm"])
|
|
205
|
+
|
|
206
|
+
def test_public_repo_with_valid_confirm_performs_close(self):
|
|
207
|
+
"""Public repo with valid --confirm=<token> → performs the close
|
|
208
|
+
(write_file called), rc 0."""
|
|
209
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
210
|
+
tok = make_token("ok/repo", "alpha")
|
|
211
|
+
rc, mw, ms, out = _drive(
|
|
212
|
+
["alpha", "--state=parked", f"--confirm={tok}"],
|
|
213
|
+
track=track, vis="PUBLIC"
|
|
214
|
+
)
|
|
215
|
+
self.assertEqual(rc, 0)
|
|
216
|
+
mw.assert_called_once()
|
|
217
|
+
|
|
218
|
+
def test_public_repo_with_valid_confirm_shipped_moves(self):
|
|
219
|
+
"""Public repo with valid --confirm=<token> + --state=shipped →
|
|
220
|
+
write_file AND shutil.move both called, rc 0."""
|
|
221
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
222
|
+
tok = make_token("ok/repo", "alpha")
|
|
223
|
+
rc, mw, ms, out = _drive(
|
|
224
|
+
["alpha", "--state=shipped", f"--confirm={tok}"],
|
|
225
|
+
track=track, vis="PUBLIC"
|
|
226
|
+
)
|
|
227
|
+
self.assertEqual(rc, 0)
|
|
228
|
+
mw.assert_called_once()
|
|
229
|
+
ms.move.assert_called_once()
|
|
230
|
+
|
|
231
|
+
def test_public_repo_wrong_token_blocks_write(self):
|
|
232
|
+
"""Public repo with wrong confirm token → blocked, no write, rc 0."""
|
|
233
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
234
|
+
rc, mw, ms, out = _drive(
|
|
235
|
+
["alpha", "--state=shipped", "--confirm=wrongtoken"],
|
|
236
|
+
track=track, vis="PUBLIC"
|
|
237
|
+
)
|
|
238
|
+
self.assertEqual(rc, 0)
|
|
239
|
+
mw.assert_not_called()
|
|
240
|
+
data = json.loads(out.strip())
|
|
241
|
+
self.assertTrue(data["needs_confirm"])
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
# No input() on non-interactive path
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def test_no_input_called_on_flagged_path(self):
|
|
248
|
+
"""Flagged paths never call input() or prompt_input, even when
|
|
249
|
+
--state/--note are provided and the repo is private."""
|
|
250
|
+
track = _track(name="alpha", repo="ok/repo")
|
|
251
|
+
|
|
252
|
+
def _raise(*a, **kw):
|
|
253
|
+
raise AssertionError("input() must not be called on non-interactive path")
|
|
254
|
+
|
|
255
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {"ok": {"github": "ok/repo"}}}
|
|
256
|
+
|
|
257
|
+
with patch("builtins.input", side_effect=_raise), \
|
|
258
|
+
patch("lib.prompts.prompt_input", side_effect=_raise):
|
|
259
|
+
with patch("commands.close.load_config", return_value=cfg), \
|
|
260
|
+
patch("commands.close.discover_tracks", return_value=[track]), \
|
|
261
|
+
patch("commands.close.find_track_by_name", return_value=track), \
|
|
262
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
263
|
+
patch("commands.close.write_file"), \
|
|
264
|
+
patch("commands.close.shutil"), \
|
|
265
|
+
patch("pathlib.Path.mkdir"):
|
|
266
|
+
buf = io.StringIO()
|
|
267
|
+
with redirect_stdout(buf):
|
|
268
|
+
rc = close.run(["alpha", "--state=parked", "--note=done"])
|
|
269
|
+
self.assertEqual(rc, 0)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
unittest.main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for closure detection."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
|
+
|
|
9
|
+
from lib.closure import is_closure_ready, ClosureSignals
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClosureReadyTest(unittest.TestCase):
|
|
13
|
+
def test_all_signals_green(self):
|
|
14
|
+
signals = ClosureSignals(
|
|
15
|
+
all_issues_closed=True,
|
|
16
|
+
all_branches_done=True,
|
|
17
|
+
next_up_empty=True,
|
|
18
|
+
cold_14d=True,
|
|
19
|
+
no_recent_related_issues=True,
|
|
20
|
+
)
|
|
21
|
+
ready, reasons = is_closure_ready(signals)
|
|
22
|
+
self.assertTrue(ready)
|
|
23
|
+
self.assertEqual(reasons, [])
|
|
24
|
+
|
|
25
|
+
def test_open_issue_blocks_closure(self):
|
|
26
|
+
signals = ClosureSignals(
|
|
27
|
+
all_issues_closed=False,
|
|
28
|
+
all_branches_done=True,
|
|
29
|
+
next_up_empty=True,
|
|
30
|
+
cold_14d=True,
|
|
31
|
+
no_recent_related_issues=True,
|
|
32
|
+
)
|
|
33
|
+
ready, reasons = is_closure_ready(signals)
|
|
34
|
+
self.assertFalse(ready)
|
|
35
|
+
self.assertIn("open issues remain", " ".join(reasons))
|
|
36
|
+
|
|
37
|
+
def test_partial_signals_returns_count(self):
|
|
38
|
+
signals = ClosureSignals(
|
|
39
|
+
all_issues_closed=True,
|
|
40
|
+
all_branches_done=True,
|
|
41
|
+
next_up_empty=False,
|
|
42
|
+
cold_14d=False,
|
|
43
|
+
no_recent_related_issues=True,
|
|
44
|
+
)
|
|
45
|
+
ready, reasons = is_closure_ready(signals)
|
|
46
|
+
self.assertFalse(ready)
|
|
47
|
+
self.assertEqual(len(reasons), 2)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for config loader."""
|
|
2
|
+
import unittest
|
|
3
|
+
import tempfile
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
8
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
9
|
+
|
|
10
|
+
from lib.config import (
|
|
11
|
+
load_config, ConfigError,
|
|
12
|
+
resolve_github_for_folder, resolve_local_path_for_folder,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoadConfigTest(unittest.TestCase):
|
|
17
|
+
def _write(self, d, content):
|
|
18
|
+
path = Path(d) / "config.yml"
|
|
19
|
+
path.write_text(content, encoding="utf-8")
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
def test_load_dict_shape(self):
|
|
23
|
+
with tempfile.TemporaryDirectory() as d:
|
|
24
|
+
path = self._write(d, (
|
|
25
|
+
"notes_root: /tmp/notes\n"
|
|
26
|
+
"repos:\n"
|
|
27
|
+
" critforge:\n"
|
|
28
|
+
" github: stylusnexus/CritForge\n"
|
|
29
|
+
" local: /Applications/Development/Projects/CritForge\n"
|
|
30
|
+
))
|
|
31
|
+
cfg = load_config(path)
|
|
32
|
+
self.assertEqual(cfg["notes_root"], "/tmp/notes")
|
|
33
|
+
self.assertEqual(cfg["repos"]["critforge"]["github"], "stylusnexus/CritForge")
|
|
34
|
+
self.assertEqual(cfg["repos"]["critforge"]["local"],
|
|
35
|
+
"/Applications/Development/Projects/CritForge")
|
|
36
|
+
|
|
37
|
+
def test_load_string_shape_normalizes_to_dict(self):
|
|
38
|
+
# Backward-friendly: bare string is treated as github-only, no local
|
|
39
|
+
with tempfile.TemporaryDirectory() as d:
|
|
40
|
+
path = self._write(d, (
|
|
41
|
+
"notes_root: /tmp/notes\n"
|
|
42
|
+
"repos:\n"
|
|
43
|
+
" critforge: stylusnexus/CritForge\n"
|
|
44
|
+
))
|
|
45
|
+
cfg = load_config(path)
|
|
46
|
+
self.assertEqual(cfg["repos"]["critforge"]["github"], "stylusnexus/CritForge")
|
|
47
|
+
self.assertIsNone(cfg["repos"]["critforge"]["local"])
|
|
48
|
+
|
|
49
|
+
def test_missing_file_self_seeds(self):
|
|
50
|
+
# No install hook exists for plugin installs, so a missing config is
|
|
51
|
+
# seeded on first load rather than raising.
|
|
52
|
+
with tempfile.TemporaryDirectory() as d:
|
|
53
|
+
path = Path(d) / "work-plan" / "config.yml"
|
|
54
|
+
cfg = load_config(path, notes_root=Path(d) / "notes")
|
|
55
|
+
self.assertTrue(path.is_file())
|
|
56
|
+
self.assertEqual(cfg["repos"], {})
|
|
57
|
+
self.assertIn("notes_root", cfg)
|
|
58
|
+
|
|
59
|
+
def test_missing_notes_root_raises(self):
|
|
60
|
+
with tempfile.TemporaryDirectory() as d:
|
|
61
|
+
path = self._write(d, "repos:\n foo: bar/baz\n")
|
|
62
|
+
with self.assertRaises(ConfigError) as ctx:
|
|
63
|
+
load_config(path)
|
|
64
|
+
self.assertIn("notes_root", str(ctx.exception))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ResolveTest(unittest.TestCase):
|
|
68
|
+
def setUp(self):
|
|
69
|
+
self.cfg = {
|
|
70
|
+
"repos": {
|
|
71
|
+
"critforge": {"github": "stylusnexus/CritForge", "local": "/path/to/critforge"},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def test_resolve_github(self):
|
|
76
|
+
self.assertEqual(resolve_github_for_folder("critforge", self.cfg), "stylusnexus/CritForge")
|
|
77
|
+
self.assertIsNone(resolve_github_for_folder("unknown", self.cfg))
|
|
78
|
+
|
|
79
|
+
def test_resolve_local_path(self):
|
|
80
|
+
self.assertEqual(resolve_local_path_for_folder("critforge", self.cfg), Path("/path/to/critforge"))
|
|
81
|
+
self.assertIsNone(resolve_local_path_for_folder("unknown", self.cfg))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
unittest.main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Lazy config seeding — plugin installs run no install hook (issue: org-sharing).
|
|
2
|
+
|
|
3
|
+
The CLI must create a usable config.yml on first run when one is absent, at a
|
|
4
|
+
stable absolute path, idempotently. Offline; uses temp dirs (never the real HOME).
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
12
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
13
|
+
|
|
14
|
+
from lib.config import load_config, ensure_config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EnsureConfigTest(unittest.TestCase):
|
|
18
|
+
def test_load_config_seeds_when_missing(self):
|
|
19
|
+
with tempfile.TemporaryDirectory() as d:
|
|
20
|
+
cfg_path = Path(d) / "work-plan" / "config.yml"
|
|
21
|
+
notes = Path(d) / "notes"
|
|
22
|
+
cfg = load_config(cfg_path, notes_root=notes)
|
|
23
|
+
self.assertTrue(cfg_path.is_file(), "config.yml should be seeded")
|
|
24
|
+
self.assertEqual(cfg["repos"], {})
|
|
25
|
+
# notes_root is an ABSOLUTE path (no literal ~), and the dir exists.
|
|
26
|
+
self.assertEqual(cfg["notes_root"], str(notes))
|
|
27
|
+
self.assertFalse(cfg["notes_root"].startswith("~"))
|
|
28
|
+
self.assertTrue(notes.is_dir())
|
|
29
|
+
|
|
30
|
+
def test_ensure_config_idempotent(self):
|
|
31
|
+
with tempfile.TemporaryDirectory() as d:
|
|
32
|
+
cfg_path = Path(d) / "work-plan" / "config.yml"
|
|
33
|
+
notes = Path(d) / "notes"
|
|
34
|
+
self.assertTrue(ensure_config(cfg_path, notes_root=notes))
|
|
35
|
+
before = cfg_path.read_bytes()
|
|
36
|
+
self.assertFalse(ensure_config(cfg_path, notes_root=notes))
|
|
37
|
+
self.assertEqual(cfg_path.read_bytes(), before)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
unittest.main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests for doc discovery + kind classification."""
|
|
2
|
+
import unittest
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
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.doc_discovery import classify_kind, discover_docs, Doc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClassifyKindTest(unittest.TestCase):
|
|
14
|
+
def test_superpowers_plan(self):
|
|
15
|
+
self.assertEqual(classify_kind("docs/superpowers/plans/2026-03-16-x.md"), "plan")
|
|
16
|
+
|
|
17
|
+
def test_superpowers_spec(self):
|
|
18
|
+
self.assertEqual(classify_kind("docs/superpowers/specs/2026-03-16-x-design.md"), "spec")
|
|
19
|
+
|
|
20
|
+
def test_design_suffix_is_spec(self):
|
|
21
|
+
self.assertEqual(classify_kind("docs/plans/2026-02-17-foo-design.md"), "spec")
|
|
22
|
+
|
|
23
|
+
def test_plain_docs_plan(self):
|
|
24
|
+
self.assertEqual(classify_kind("docs/plans/2026-02-17-foo.md"), "plan")
|
|
25
|
+
|
|
26
|
+
def test_other_is_adhoc(self):
|
|
27
|
+
self.assertEqual(classify_kind("notes/random.md"), "adhoc")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DiscoverDocsTest(unittest.TestCase):
|
|
31
|
+
def test_finds_default_globs_and_dedupes(self):
|
|
32
|
+
with tempfile.TemporaryDirectory() as d:
|
|
33
|
+
root = Path(d)
|
|
34
|
+
(root / "docs/superpowers/plans").mkdir(parents=True)
|
|
35
|
+
(root / "docs/plans").mkdir(parents=True)
|
|
36
|
+
(root / "docs/superpowers/plans/2026-03-16-a.md").write_text("x")
|
|
37
|
+
(root / "docs/plans/2026-02-17-b-design.md").write_text("x")
|
|
38
|
+
(root / "docs/plans/README.txt").write_text("ignore") # not .md
|
|
39
|
+
docs = discover_docs(root)
|
|
40
|
+
rels = sorted(x.rel for x in docs)
|
|
41
|
+
self.assertEqual(rels, [
|
|
42
|
+
"docs/plans/2026-02-17-b-design.md",
|
|
43
|
+
"docs/superpowers/plans/2026-03-16-a.md",
|
|
44
|
+
])
|
|
45
|
+
kinds = {x.rel: x.kind for x in docs}
|
|
46
|
+
self.assertEqual(kinds["docs/superpowers/plans/2026-03-16-a.md"], "plan")
|
|
47
|
+
self.assertEqual(kinds["docs/plans/2026-02-17-b-design.md"], "spec")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
unittest.main()
|