@stylusnexus/work-plan 2026.6.11 → 2026.6.13-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +38 -6
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/close_issue.py +82 -0
  7. package/skills/work-plan/commands/export.py +72 -3
  8. package/skills/work-plan/commands/group.py +5 -1
  9. package/skills/work-plan/commands/init_repo.py +84 -14
  10. package/skills/work-plan/commands/list_open_issues.py +52 -0
  11. package/skills/work-plan/commands/new_track.py +8 -2
  12. package/skills/work-plan/commands/plan_ack.py +71 -0
  13. package/skills/work-plan/commands/plan_baseline.py +85 -0
  14. package/skills/work-plan/commands/plan_branch.py +314 -0
  15. package/skills/work-plan/commands/plan_confirm.py +83 -0
  16. package/skills/work-plan/commands/plan_status.py +140 -9
  17. package/skills/work-plan/commands/push_track.py +156 -0
  18. package/skills/work-plan/commands/reconcile.py +49 -34
  19. package/skills/work-plan/commands/refresh_md.py +49 -1
  20. package/skills/work-plan/commands/remove_repo.py +69 -0
  21. package/skills/work-plan/commands/set_field.py +22 -3
  22. package/skills/work-plan/lib/export_model.py +27 -4
  23. package/skills/work-plan/lib/git_state.py +22 -0
  24. package/skills/work-plan/lib/github_state.py +63 -0
  25. package/skills/work-plan/lib/manifest.py +28 -0
  26. package/skills/work-plan/lib/plan_fm.py +71 -0
  27. package/skills/work-plan/lib/plan_worktree.py +288 -0
  28. package/skills/work-plan/lib/status_header.py +6 -2
  29. package/skills/work-plan/lib/tracks.py +6 -2
  30. package/skills/work-plan/lib/verdict.py +1 -0
  31. package/skills/work-plan/tests/test_auth_status.py +98 -0
  32. package/skills/work-plan/tests/test_close_issue.py +121 -0
  33. package/skills/work-plan/tests/test_export.py +65 -0
  34. package/skills/work-plan/tests/test_export_command.py +95 -0
  35. package/skills/work-plan/tests/test_init_repo.py +100 -1
  36. package/skills/work-plan/tests/test_list_open_issues.py +83 -0
  37. package/skills/work-plan/tests/test_manifest.py +30 -1
  38. package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
  39. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  40. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  41. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  42. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  43. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  44. package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
  45. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  46. package/skills/work-plan/tests/test_push_track.py +131 -0
  47. package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
  48. package/skills/work-plan/tests/test_refresh_md.py +75 -0
  49. package/skills/work-plan/tests/test_remove_repo.py +77 -0
  50. package/skills/work-plan/tests/test_set_field.py +60 -0
  51. package/skills/work-plan/work_plan.py +125 -6
@@ -0,0 +1,288 @@
1
+ """Resolve a repo's shared-tier (.work-plan/) directory, optionally via a
2
+ worktree pinned to a dedicated plan branch (#260).
3
+
4
+ A repo MAY pin its shared planning to its own `plan_branch` so planning churn
5
+ never lands on code branches (dev/main/feature) or in PRs. When `plan_branch`
6
+ is set, the shared tier is read/written through a git WORKTREE checked out at
7
+ that branch, kept in a stable cache dir beside the work-plan config. When it's
8
+ unset, the shared tier is simply the working tree's `.work-plan/` — the legacy
9
+ behaviour, unchanged.
10
+
11
+ Never raises — git absence/failure/timeout degrades to "no shared tier" (None),
12
+ exactly like notes_vcs. A read must never break discovery.
13
+ """
14
+ import hashlib
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ from lib.config import DEFAULT_CONFIG_PATH
20
+
21
+ GIT_TIMEOUT = 20
22
+
23
+ # Worktrees live beside the config (so Claude ~/.claude and Codex ~/.agents both
24
+ # work), keyed by a hash of the repo's local path so two repos never collide.
25
+ _WORKTREE_ROOT = DEFAULT_CONFIG_PATH.parent / "plan-worktrees"
26
+
27
+
28
+ def _git(cwd, *args, timeout: int = GIT_TIMEOUT):
29
+ """Run `git -C <cwd> <args>`; return CompletedProcess or None (never raises)."""
30
+ try:
31
+ return subprocess.run(
32
+ ["git", "-C", str(cwd), *args],
33
+ capture_output=True, text=True, timeout=timeout,
34
+ )
35
+ except (subprocess.TimeoutExpired, OSError):
36
+ return None
37
+
38
+
39
+ def _worktree_dir(local_path: Path) -> Path:
40
+ """Stable cache dir for this repo's plan worktree (keyed by its local path).
41
+
42
+ `resolve()` can touch the filesystem and raise OSError (symlink loop,
43
+ permissions) — fall back to the un-resolved absolute path so this never
44
+ raises and the never-raise contract holds upstream.
45
+ """
46
+ try:
47
+ resolved = str(local_path.resolve())
48
+ except OSError:
49
+ resolved = str(local_path.absolute())
50
+ key = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:16]
51
+ return _WORKTREE_ROOT / key
52
+
53
+
54
+ def _ref_exists(local_path: Path, ref: str) -> bool:
55
+ proc = _git(local_path, "rev-parse", "--verify", "--quiet", ref)
56
+ return proc is not None and proc.returncode == 0
57
+
58
+
59
+ def local_branch_exists(local_path: Path, branch: str) -> bool:
60
+ """True if `branch` exists as a local head. Never raises."""
61
+ return _ref_exists(Path(local_path).expanduser(), f"refs/heads/{branch}")
62
+
63
+
64
+ def remote_branch_exists(local_path: Path, branch: str) -> bool:
65
+ """True if `origin/<branch>` exists in the local remote-tracking refs (may be
66
+ stale — fetch first for an authoritative answer). Never raises."""
67
+ return _ref_exists(Path(local_path).expanduser(),
68
+ f"refs/remotes/origin/{branch}")
69
+
70
+
71
+ def _branch_exists(local_path: Path, branch: str) -> bool:
72
+ """True if `branch` exists locally or as origin/<branch>."""
73
+ return (local_branch_exists(local_path, branch)
74
+ or remote_branch_exists(local_path, branch))
75
+
76
+
77
+ def ensure_worktree(local_path: Path, branch: str) -> Optional[Path]:
78
+ """Ensure a worktree of `local_path` checked out at `branch` exists in the
79
+ cache; return its path, or None on any failure. Never raises.
80
+
81
+ Does NOT create the branch — if `branch` doesn't exist yet (bootstrap not
82
+ run), returns None so callers fall back to "no shared tier". Idempotent: an
83
+ already-present worktree is reused.
84
+ """
85
+ if not branch:
86
+ return None
87
+ local_path = Path(local_path).expanduser()
88
+ dest = _worktree_dir(local_path)
89
+ # A worktree's `.git` is a gitdir-pointer file (exists() catches file + dir).
90
+ if (dest / ".git").exists():
91
+ # Reuse ONLY if it's still checked out at `branch`. A worktree left on
92
+ # another branch (manual `git checkout`, or the branch was renamed)
93
+ # would otherwise get plan commits on the wrong branch — refuse and
94
+ # degrade to "no shared tier" rather than commit somewhere unexpected.
95
+ head = _git(dest, "rev-parse", "--abbrev-ref", "HEAD")
96
+ if head is not None and head.returncode == 0 and head.stdout.strip() == branch:
97
+ return dest
98
+ return None
99
+ if not _branch_exists(local_path, branch):
100
+ return None
101
+ try:
102
+ dest.parent.mkdir(parents=True, exist_ok=True)
103
+ except OSError:
104
+ return None
105
+ proc = _git(local_path, "worktree", "add", "--quiet", str(dest), branch)
106
+ if proc is None or proc.returncode != 0:
107
+ return None
108
+ return dest
109
+
110
+
111
+ def dirty_work_plan_paths(worktree: Path) -> list:
112
+ """Repo-relative paths under `.work-plan/` with uncommitted changes in the
113
+ worktree (staged, unstaged, or untracked). Empty list on any failure or a
114
+ clean tree. Never raises.
115
+
116
+ The dispatcher snapshots these BEFORE a command and commits only the paths
117
+ that appear AFTER — so a pre-existing dirty `.work-plan/` file from unrelated
118
+ manual edits is never swept into a plan commit triggered by an unrelated
119
+ subcommand.
120
+
121
+ Uses NUL-delimited porcelain (`-z`): unlike the line format, `-z` never
122
+ quote-wraps or octal-escapes paths, so filenames with spaces or non-ASCII
123
+ round-trip verbatim back into `git add` (the line format would wrap them in
124
+ quotes and break the commit). `-uall` enumerates untracked files
125
+ individually instead of collapsing a new dir to one `dir/` entry, so the
126
+ before/after delta is per-file. A staged rename's source path is captured
127
+ too, so the rename commits as a unit (dest add + source delete).
128
+ """
129
+ wt = Path(worktree).expanduser()
130
+ proc = _git(wt, "-c", "core.quotepath=false", "status", "--porcelain", "-z",
131
+ "--untracked-files=all", "--", ".work-plan")
132
+ if proc is None or proc.returncode != 0:
133
+ return []
134
+ fields = proc.stdout.split("\0")
135
+ paths = []
136
+ i, n = 0, len(fields)
137
+ while i < n:
138
+ entry = fields[i]
139
+ if len(entry) < 4: # trailing empty field / malformed line
140
+ i += 1
141
+ continue
142
+ status, path = entry[:2], entry[3:]
143
+ if path:
144
+ paths.append(path)
145
+ # Rename/copy: porcelain -z follows the entry with the source path in
146
+ # the NEXT NUL field ("R <dest>\0<source>"). Commit both so the rename
147
+ # lands atomically.
148
+ if "R" in status or "C" in status:
149
+ i += 1
150
+ if i < n and fields[i]:
151
+ paths.append(fields[i])
152
+ i += 1
153
+ return paths
154
+
155
+
156
+ def commit_shared_tier(worktree: Path, message: str, paths) -> Optional[str]:
157
+ """Commit exactly `paths` (repo-relative, all under `.work-plan/`) in the
158
+ worktree with `message`; return the new short SHA, or None. Never raises.
159
+
160
+ Stages ONLY the explicit `paths` — not a blanket `.work-plan/` add — so a
161
+ `plan_branch` worktree never sweeps in code, other files, or pre-existing
162
+ dirty plan files the triggering command didn't touch. No-op when `paths` is
163
+ empty or nothing stages.
164
+
165
+ Commits on whatever branch the worktree is checked out — the caller has
166
+ already verified that's `plan_branch`. Local commit only; pushing the branch
167
+ (to actually share it) is a separate, deliberate step (#260, follow-up).
168
+ """
169
+ wt = Path(worktree).expanduser()
170
+ if not (wt / ".work-plan").is_dir():
171
+ return None
172
+ scoped = [p for p in (paths or []) if p]
173
+ if not scoped:
174
+ return None
175
+ if _git(wt, "add", "--", *scoped) is None:
176
+ return None
177
+ staged = _git(wt, "diff", "--cached", "--quiet", "--", *scoped)
178
+ if staged is None or staged.returncode == 0:
179
+ return None
180
+ proc = _git(wt, "commit", "-m", message, "--", *scoped)
181
+ if proc is None or proc.returncode != 0:
182
+ # Unstage what we just staged so a later, unrelated command doesn't
183
+ # commit this residue under its own message (the worktree index is
184
+ # durable across invocations). Working-tree content is preserved.
185
+ _git(wt, "reset", "--quiet", "--", *scoped)
186
+ return None
187
+ head = _git(wt, "rev-parse", "--short", "HEAD")
188
+ if head is None or head.returncode != 0:
189
+ return None
190
+ return head.stdout.strip() or None
191
+
192
+
193
+ def shared_tier_dir(entry: dict) -> Optional[Path]:
194
+ """The `.work-plan/` directory to read/write for a repo config `entry`.
195
+
196
+ - `plan_branch` set → the worktree's `.work-plan/` (None when the worktree
197
+ can't be ensured, e.g. the branch isn't bootstrapped yet).
198
+ - `plan_branch` unset → the working tree's `.work-plan/` (legacy, unchanged).
199
+
200
+ Returns None when the entry has no `local` path. Never raises.
201
+ """
202
+ if not entry or not entry.get("local"):
203
+ return None
204
+ local_path = Path(entry["local"]).expanduser()
205
+ branch = entry.get("plan_branch")
206
+ if branch:
207
+ worktree = ensure_worktree(local_path, branch)
208
+ return (worktree / ".work-plan") if worktree else None
209
+ return local_path / ".work-plan"
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Phase 3 (#260): bootstrap + push. Creating an orphan plan branch, fetching a
214
+ # teammate's, pushing local plan commits to share them. All never-raise.
215
+ # ---------------------------------------------------------------------------
216
+
217
+ def create_orphan_worktree(local_path: Path, branch: str) -> Optional[Path]:
218
+ """Create a worktree at the stable cache path holding a fresh ORPHAN
219
+ `branch` whose tree is an empty `.work-plan/` (no shared history with the
220
+ repo's code — like gh-pages). Returns the worktree path with `.work-plan/`
221
+ created but NOT yet committed (the caller seeds it and commits via
222
+ commit_shared_tier), or None on any failure. Never raises.
223
+
224
+ Caller must have verified `branch` does not already exist (local or remote);
225
+ this is the create path, not the connect path (use ensure_worktree to
226
+ connect to an existing branch).
227
+ """
228
+ local_path = Path(local_path).expanduser()
229
+ dest = _worktree_dir(local_path)
230
+ if (dest / ".git").exists():
231
+ return None # a worktree is already cached here — caller should connect
232
+ try:
233
+ dest.parent.mkdir(parents=True, exist_ok=True)
234
+ except OSError:
235
+ return None
236
+ # Detached worktree at HEAD, then orphan-checkout the plan branch and clear
237
+ # the code out of it so only .work-plan/ remains.
238
+ add = _git(local_path, "worktree", "add", "--detach", "--quiet", str(dest), "HEAD")
239
+ if add is None or add.returncode != 0:
240
+ return None
241
+ orphan = _git(dest, "checkout", "--orphan", branch)
242
+ if orphan is None or orphan.returncode != 0:
243
+ _git(local_path, "worktree", "remove", "--force", str(dest))
244
+ return None
245
+ # Drop every code file from the orphan's index + working tree.
246
+ if _git(dest, "rm", "-rf", "--quiet", ".") is None:
247
+ _git(local_path, "worktree", "remove", "--force", str(dest))
248
+ return None
249
+ try:
250
+ (dest / ".work-plan").mkdir(parents=True, exist_ok=True)
251
+ except OSError:
252
+ _git(local_path, "worktree", "remove", "--force", str(dest))
253
+ return None
254
+ return dest
255
+
256
+
257
+ def fetch_branch(local_path: Path, branch: str) -> bool:
258
+ """Best-effort `git fetch origin <branch>` so remote_branch_exists is
259
+ authoritative (a teammate may have published the plan branch). True on
260
+ success, False on any failure/offline. Never raises — a read-only op."""
261
+ proc = _git(Path(local_path).expanduser(), "fetch", "--quiet", "origin", branch)
262
+ return proc is not None and proc.returncode == 0
263
+
264
+
265
+ def is_published(local_path: Path, branch: str) -> bool:
266
+ """True if `branch` exists on origin (it's been shared). Never raises."""
267
+ return remote_branch_exists(local_path, branch)
268
+
269
+
270
+ def unpushed_oneline(local_path: Path, branch: str) -> list:
271
+ """One-line summaries of commits on local `branch` not yet on origin
272
+ (`origin/<branch>..<branch>`). If origin/<branch> doesn't exist, every commit
273
+ on `branch` is unpushed. Empty list on any failure. Never raises."""
274
+ local_path = Path(local_path).expanduser()
275
+ rng = (f"origin/{branch}..{branch}"
276
+ if remote_branch_exists(local_path, branch) else branch)
277
+ proc = _git(local_path, "log", "--oneline", "--no-color", rng)
278
+ if proc is None or proc.returncode != 0:
279
+ return []
280
+ return [ln for ln in proc.stdout.splitlines() if ln.strip()]
281
+
282
+
283
+ def push_plan_branch(local_path: Path, branch: str):
284
+ """Push `branch` to origin, setting upstream. Returns the CompletedProcess
285
+ (inspect .returncode / .stderr — the caller surfaces protected-branch and
286
+ other failures) or None if git couldn't run. Never raises."""
287
+ return _git(Path(local_path).expanduser(), "push", "--set-upstream",
288
+ "origin", branch, timeout=120)
@@ -17,12 +17,16 @@ _ORPHAN_RE = re.compile(
17
17
 
18
18
 
19
19
  def render_block(row: dict) -> str:
20
- """Render the delimited status block from an evaluated row dict."""
20
+ """Render the delimited status block from an evaluated row dict.
21
+
22
+ A `verdict_override` (#286) appends a `✋ confirmed` marker so the banner
23
+ reads as a human-affirmed verdict, not a purely mechanical one."""
21
24
  last = row.get("last_touched") or "unknown"
25
+ confirmed = " · ✋ confirmed" if row.get("override") else ""
22
26
  line = (
23
27
  f"> **Status:** {row['glyph']} {row['verdict']} · "
24
28
  f"{row['files_present']}/{row['files_declared']} files · "
25
- f"last touched {last}"
29
+ f"last touched {last}{confirmed}"
26
30
  )
27
31
  return f"{BEGIN}\n{line}\n{END}"
28
32
 
@@ -10,6 +10,7 @@ from lib.config import (
10
10
  resolve_local_path_for_folder,
11
11
  is_valid_git_repo,
12
12
  )
13
+ from lib.plan_worktree import shared_tier_dir
13
14
  from lib.git_state import parse_iso_timestamp
14
15
 
15
16
  _PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
@@ -210,8 +211,11 @@ def _discover_shared_tracks(cfg: dict, include_archive: bool = False,
210
211
  if not is_valid_git_repo(local_path):
211
212
  continue
212
213
  github_repo = entry.get("github")
213
- notes_dir = local_path / ".work-plan"
214
- if not notes_dir.is_dir():
214
+ # The shared tier is the working tree's .work-plan/ — UNLESS the repo
215
+ # pins a `plan_branch`, in which case it's read from a worktree checked
216
+ # out at that branch so planning never rides on code branches (#260).
217
+ notes_dir = shared_tier_dir(entry)
218
+ if notes_dir is None or not notes_dir.is_dir():
215
219
  continue
216
220
  for md_path in sorted(notes_dir.rglob("*.md")):
217
221
  # Skip dotfiles, README, and dash-led names (a `--repo.md` file
@@ -11,6 +11,7 @@ SHIPPED_PCT = 80.0 # >= this % of declared files satisfied -> shipped
11
11
  PARTIAL_PCT = 20.0 # >= this % -> partial
12
12
  BOXES_STALE_PCT = 50.0 # checked-box % below this on a shipped plan -> "boxes stale"
13
13
  DEAD_DAYS = 60 # 0 files satisfied AND untouched beyond this -> dead
14
+ STALL_DAYS = 14 # partial + manifest files cold beyond this -> stalled (#164)
14
15
  FOREIGN_RATIO = 0.7 # >= this fraction of declared paths outside repo -> foreign
15
16
 
16
17
 
@@ -0,0 +1,98 @@
1
+ """auth-status — gh auth probe (#auth). Offline: subprocess is mocked."""
2
+ import io
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import unittest
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+ from unittest import mock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import auth_status
16
+ from lib import github_state
17
+
18
+
19
+ def _proc(returncode, stdout="", stderr=""):
20
+ return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
21
+
22
+
23
+ class GhAuthStatusHelperTest(unittest.TestCase):
24
+ def test_authenticated_parses_user(self):
25
+ out = _proc(0, stderr="✓ Logged in to github.com account evemcgivern (keyring)")
26
+ with mock.patch("lib.github_state.subprocess.run", return_value=out):
27
+ s = github_state.gh_auth_status()
28
+ self.assertTrue(s["authenticated"])
29
+ self.assertTrue(s["gh_present"])
30
+ self.assertEqual(s["user"], "evemcgivern")
31
+ self.assertIsNone(s["error"])
32
+
33
+ def test_authenticated_legacy_phrasing(self):
34
+ out = _proc(0, stderr="✓ Logged in to github.com as evemcgivern")
35
+ with mock.patch("lib.github_state.subprocess.run", return_value=out):
36
+ s = github_state.gh_auth_status()
37
+ self.assertTrue(s["authenticated"])
38
+ self.assertEqual(s["user"], "evemcgivern")
39
+
40
+ def test_not_logged_in(self):
41
+ out = _proc(1, stderr="You are not logged into any GitHub hosts. Run gh auth login")
42
+ with mock.patch("lib.github_state.subprocess.run", return_value=out):
43
+ s = github_state.gh_auth_status()
44
+ self.assertFalse(s["authenticated"])
45
+ self.assertTrue(s["gh_present"]) # gh ran, just not logged in
46
+ self.assertIsNone(s["user"])
47
+ self.assertIn("not logged", s["error"].lower())
48
+
49
+ def test_gh_not_installed(self):
50
+ with mock.patch("lib.github_state.subprocess.run", side_effect=FileNotFoundError()):
51
+ s = github_state.gh_auth_status()
52
+ self.assertFalse(s["gh_present"])
53
+ self.assertFalse(s["authenticated"])
54
+ self.assertIn("not found", s["error"].lower())
55
+
56
+ def test_timeout_is_present_but_unauthenticated(self):
57
+ with mock.patch("lib.github_state.subprocess.run",
58
+ side_effect=subprocess.TimeoutExpired("gh", 30)):
59
+ s = github_state.gh_auth_status()
60
+ self.assertTrue(s["gh_present"])
61
+ self.assertFalse(s["authenticated"])
62
+
63
+
64
+ class AuthStatusCommandTest(unittest.TestCase):
65
+ def _run(self, status, args):
66
+ with mock.patch("commands.auth_status.github_state.gh_auth_status", return_value=status):
67
+ buf = io.StringIO()
68
+ with redirect_stdout(buf):
69
+ rc = auth_status.run(args)
70
+ return rc, buf.getvalue()
71
+
72
+ def test_json_authenticated_exit_0(self):
73
+ status = {"gh_present": True, "authenticated": True, "user": "eve", "error": None}
74
+ rc, out = self._run(status, ["--json"])
75
+ self.assertEqual(rc, 0)
76
+ self.assertEqual(json.loads(out), status)
77
+
78
+ def test_not_logged_in_exit_1(self):
79
+ status = {"gh_present": True, "authenticated": False, "user": None, "error": "x"}
80
+ rc, out = self._run(status, [])
81
+ self.assertEqual(rc, 1)
82
+ self.assertIn("gh auth login", out)
83
+
84
+ def test_gh_missing_exit_2(self):
85
+ status = {"gh_present": False, "authenticated": False, "user": None, "error": "x"}
86
+ rc, out = self._run(status, [])
87
+ self.assertEqual(rc, 2)
88
+ self.assertIn("not found", out.lower())
89
+
90
+ def test_human_authenticated_names_user(self):
91
+ status = {"gh_present": True, "authenticated": True, "user": "eve", "error": None}
92
+ rc, out = self._run(status, [])
93
+ self.assertEqual(rc, 0)
94
+ self.assertIn("eve", out)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()
@@ -0,0 +1,121 @@
1
+ """close-issue (#305): the toolkit's only GitHub-mutating command. Offline —
2
+ the gh subprocess is mocked."""
3
+ import io
4
+ import json
5
+ import sys
6
+ import unittest
7
+ from contextlib import redirect_stdout, redirect_stderr
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+ from unittest import mock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import close_issue
16
+ from lib import github_state
17
+
18
+
19
+ def _proc(rc, stdout="", stderr=""):
20
+ return SimpleNamespace(returncode=rc, stdout=stdout, stderr=stderr)
21
+
22
+
23
+ class CloseIssueHelperTest(unittest.TestCase):
24
+ def test_builds_gh_args_and_succeeds(self):
25
+ captured = {}
26
+ def fake_run(args, **kw):
27
+ captured["args"] = args
28
+ return _proc(0, stdout="✓ Closed issue #287")
29
+ with mock.patch("lib.github_state.subprocess.run", side_effect=fake_run):
30
+ ok, msg = github_state.close_issue("o/r", 287, reason="completed", comment="done")
31
+ self.assertTrue(ok)
32
+ self.assertEqual(
33
+ captured["args"],
34
+ ["gh", "issue", "close", "287", "--repo", "o/r",
35
+ "--reason", "completed", "--comment", "done"],
36
+ )
37
+
38
+ def test_omits_reason_and_comment_when_absent(self):
39
+ captured = {}
40
+ def fake_run(args, **kw):
41
+ captured["args"] = args
42
+ return _proc(0)
43
+ with mock.patch("lib.github_state.subprocess.run", side_effect=fake_run):
44
+ github_state.close_issue("o/r", 5)
45
+ self.assertEqual(captured["args"], ["gh", "issue", "close", "5", "--repo", "o/r"])
46
+
47
+ def test_invalid_repo_rejected(self):
48
+ ok, msg = github_state.close_issue("not-a-slug", 5)
49
+ self.assertFalse(ok)
50
+ self.assertIn("invalid repo", msg)
51
+
52
+ def test_gh_failure_surfaces_stderr(self):
53
+ with mock.patch("lib.github_state.subprocess.run",
54
+ return_value=_proc(1, stderr="could not close: already closed")):
55
+ ok, msg = github_state.close_issue("o/r", 5)
56
+ self.assertFalse(ok)
57
+ self.assertIn("already closed", msg)
58
+
59
+ def test_never_raises_on_subprocess_error(self):
60
+ with mock.patch("lib.github_state.subprocess.run", side_effect=OSError("boom")):
61
+ ok, msg = github_state.close_issue("o/r", 5)
62
+ self.assertFalse(ok)
63
+
64
+
65
+ class CloseIssueCommandTest(unittest.TestCase):
66
+ def _drive(self, args, slug_resolves="o/r", close_ret=(True, "✓ closed #5")):
67
+ with mock.patch("commands.close_issue.config_mod.load_config", return_value={"repos": {}}), \
68
+ mock.patch("commands.close_issue.config_mod.resolve_github_for_folder",
69
+ return_value=slug_resolves), \
70
+ mock.patch("commands.close_issue.github_state.close_issue",
71
+ return_value=close_ret) as mclose:
72
+ out, err = io.StringIO(), io.StringIO()
73
+ with redirect_stdout(out), redirect_stderr(err):
74
+ rc = close_issue.run(args)
75
+ return rc, out.getvalue(), err.getvalue(), mclose
76
+
77
+ def test_closes_with_slug(self):
78
+ rc, out, err, mclose = self._drive(["--repo=o/r", "--reason=completed", "--", "5"])
79
+ self.assertEqual(rc, 0)
80
+ mclose.assert_called_once_with("o/r", 5, reason="completed", comment=None)
81
+ self.assertIn("closed", out)
82
+
83
+ def test_resolves_key_to_slug(self):
84
+ rc, out, err, mclose = self._drive(["--repo=myrepo", "--", "9"], slug_resolves="org/myrepo")
85
+ self.assertEqual(rc, 0)
86
+ self.assertEqual(mclose.call_args[0][0], "org/myrepo")
87
+
88
+ def test_comment_passed_through(self):
89
+ rc, out, err, mclose = self._drive(["--repo=o/r", "--comment=done via dev", "--", "5"])
90
+ self.assertEqual(mclose.call_args[1]["comment"], "done via dev")
91
+ self.assertIn("with comment", out)
92
+
93
+ def test_invalid_reason_rejected(self):
94
+ rc, out, err, mclose = self._drive(["--repo=o/r", "--reason=bogus", "--", "5"])
95
+ self.assertEqual(rc, 2)
96
+ mclose.assert_not_called()
97
+
98
+ def test_non_integer_number_rejected(self):
99
+ rc, out, err, mclose = self._drive(["--repo=o/r", "--", "abc"])
100
+ self.assertEqual(rc, 2)
101
+ mclose.assert_not_called()
102
+
103
+ def test_gh_failure_returns_1(self):
104
+ rc, out, err, mclose = self._drive(["--repo=o/r", "--", "5"],
105
+ close_ret=(False, "no write access"))
106
+ self.assertEqual(rc, 1)
107
+ self.assertIn("no write access", err)
108
+
109
+ def test_unresolvable_repo_returns_1(self):
110
+ rc, out, err, mclose = self._drive(["--repo=ghost", "--", "5"], slug_resolves=None)
111
+ self.assertEqual(rc, 1)
112
+ mclose.assert_not_called()
113
+
114
+ def test_json_output(self):
115
+ rc, out, err, mclose = self._drive(["--repo=o/r", "--json", "--", "5"])
116
+ self.assertEqual(rc, 0)
117
+ self.assertEqual(json.loads(out)["closed"], 5)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()
@@ -8,6 +8,7 @@ import commands.export as export_cmd
8
8
 
9
9
  def _track(name, repo, issues, blockers=None, next_up=None, status="active", depends_on=None):
10
10
  return SimpleNamespace(name=name, repo=repo, tier="private",
11
+ path=Path(f"/tmp/notes/{name}.md"), folder="myrepo",
11
12
  meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
12
13
  "blockers": blockers or [], "next_up": next_up or [],
13
14
  "depends_on": depends_on or [],
@@ -25,11 +26,26 @@ class BuildExportTest(unittest.TestCase):
25
26
  t = out["tracks"][0]
26
27
  self.assertEqual(t["name"], "ph"); self.assertEqual(t["tier"], "private")
27
28
  self.assertEqual(t["visibility"], "PRIVATE")
29
+ # Absolute .md path is emitted so the viewer can open the track file
30
+ # (#211). Compare against str(Path(...)) so the expected separator matches
31
+ # the platform — str(Path) yields backslashes on Windows.
32
+ self.assertEqual(t["path"], str(Path("/tmp/notes/ph.md")))
33
+ # Config repo key surfaces for the Plans view's --repo arg (#164).
34
+ self.assertEqual(t["folder"], "myrepo")
28
35
  self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
29
36
  self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
30
37
  self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None})
31
38
  json.dumps(out) # must be serializable
32
39
 
40
+ def test_path_is_null_when_track_has_no_path(self):
41
+ """A track object without a `path` attribute exports path=None, so the
42
+ viewer disables its open-file affordance instead of erroring (#211)."""
43
+ t0 = SimpleNamespace(name="np", repo="o/r", tier="private",
44
+ meta={"status": "active", "github": {"repo": "o/r", "issues": []}})
45
+ out = build_export([t0], {"np": []}, {"o/r": "PRIVATE"}, now="2026-06-12T00:00")
46
+ self.assertIsNone(out["tracks"][0]["path"])
47
+ json.dumps(out) # null is serializable
48
+
33
49
  class BuildExportNextUpFilterTest(unittest.TestCase):
34
50
  """next_up entries whose issue is closed in the fetched payload are filtered out."""
35
51
 
@@ -274,6 +290,31 @@ class GroupIssuesByMilestoneTest(unittest.TestCase):
274
290
  self.assertEqual(group_issues_by_milestone([]), [])
275
291
 
276
292
 
293
+ class BuildExportPlanTest(unittest.TestCase):
294
+ """The track↔plan link badge on each Track (#285)."""
295
+
296
+ def test_plan_null_when_no_badge(self):
297
+ tracks = [_track("alpha", "o/r", [1])]
298
+ out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t")
299
+ self.assertIsNone(out["tracks"][0]["plan"])
300
+
301
+ def test_plan_badge_passed_through(self):
302
+ tracks = [_track("alpha", "o/r", [1])]
303
+ badge = {"rel": "docs/plans/p.md", "resolved": True, "verdict": "shipped",
304
+ "glyph": "✅", "files_present": 9, "files_declared": 9,
305
+ "checkboxes_done": 0, "checkboxes_total": 24, "lie_gap": False,
306
+ "stalled": False, "override": "shipped"}
307
+ out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t",
308
+ plan_by_track={"alpha": badge})
309
+ self.assertEqual(out["tracks"][0]["plan"], badge)
310
+
311
+ def test_unresolved_badge_passed_through(self):
312
+ tracks = [_track("alpha", "o/r", [1])]
313
+ out = build_export(tracks, {"alpha": []}, {"o/r": "PRIVATE"}, now="t",
314
+ plan_by_track={"alpha": {"rel": "docs/plans/p.md", "resolved": False}})
315
+ self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
316
+
317
+
277
318
  class BuildExportDependsOnTest(unittest.TestCase):
278
319
  """Tests that depends_on is surfaced in the export JSON (#102)."""
279
320
 
@@ -292,3 +333,27 @@ class BuildExportDependsOnTest(unittest.TestCase):
292
333
  ]}
293
334
  out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
294
335
  self.assertEqual(out["tracks"][0]["depends_on"], [])
336
+
337
+
338
+ class BuildExportReposListTest(unittest.TestCase):
339
+ """build_export emits a top-level `repos` list of ALL configured repos,
340
+ independent of track membership (#288)."""
341
+
342
+ def test_emits_config_repos_including_trackless(self):
343
+ tracks = [_track("ph", "o/r", [1])]
344
+ issues_by_track = {"ph": [{"number": 1, "title": "a", "state": "OPEN", "assignees": []}]}
345
+ config_repos = [
346
+ {"folder": "r", "repo": "o/r", "local": "/x/r", "has_local": True, "visibility": "PRIVATE"},
347
+ {"folder": "fresh", "repo": "o/fresh", "local": None, "has_local": False, "visibility": "PUBLIC"},
348
+ ]
349
+ out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="2026-06-12T00:00",
350
+ config_repos=config_repos)
351
+ self.assertEqual([r["folder"] for r in out["repos"]], ["r", "fresh"])
352
+ # the trackless repo is present even though no track references it
353
+ fresh = next(r for r in out["repos"] if r["folder"] == "fresh")
354
+ self.assertEqual(fresh["has_local"], False)
355
+ self.assertEqual(fresh["repo"], "o/fresh")
356
+
357
+ def test_repos_defaults_to_empty_list(self):
358
+ out = build_export([], {}, {}, now="2026-06-12T00:00")
359
+ self.assertEqual(out["repos"], [])