@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,69 @@
1
+ """remove-repo subcommand — unregister a repo from config (config-only).
2
+
3
+ Removes the repo block from ~/.claude/work-plan/config.yml. Deliberately leaves
4
+ the notes folder, any tracks, and the local clone untouched — those are the
5
+ user's data and removal here is purely a config edit. Non-interactive (the VS
6
+ Code side confirms before invoking).
7
+ """
8
+ import re
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH
13
+
14
+
15
+ def run(args: list[str]) -> int:
16
+ # No flags — a single positional key.
17
+ positional = [a for a in args if a != "--"]
18
+ if not positional:
19
+ print("usage: work_plan.py remove-repo <key>")
20
+ return 2
21
+
22
+ key = positional[0]
23
+ if not re.fullmatch(r"[a-z][a-z0-9-]*", key):
24
+ print(f"ERROR: '{key}' is not a valid key. Use lowercase letters, digits, hyphens; must start with a letter.")
25
+ return 2
26
+
27
+ try:
28
+ cfg = load_config()
29
+ except ConfigError as e:
30
+ print(f"ERROR: {e}")
31
+ print("\nRun ./install.sh from the toolkit root to seed your config first.")
32
+ return 1
33
+
34
+ repos = cfg.get("repos") or {}
35
+ if key not in repos:
36
+ print(f"ERROR: repo '{key}' not found in {DEFAULT_CONFIG_PATH}.")
37
+ return 1
38
+
39
+ # `key` is validated against ^[a-z][a-z0-9-]*$ above, so it is safe to
40
+ # interpolate into the yq path (no env() needed — del takes no value).
41
+ yq_expr = f"del(.repos.{key})"
42
+ try:
43
+ subprocess.run(
44
+ ["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
45
+ check=True, capture_output=True, text=True,
46
+ )
47
+ except subprocess.CalledProcessError as e:
48
+ print(f"ERROR: yq failed to update config: {e.stderr}")
49
+ return 1
50
+
51
+ print(f"✓ Removed repo '{key}' from {DEFAULT_CONFIG_PATH}")
52
+
53
+ # Config-only: surface what was deliberately left in place so the user knows
54
+ # nothing was deleted from disk.
55
+ print()
56
+ print("This was a config-only change — nothing on disk was deleted:")
57
+ notes_root = Path(cfg["notes_root"]).expanduser()
58
+ repo_dir = notes_root / key
59
+ if repo_dir.exists():
60
+ print(f" • Notes folder {repo_dir}/ is now orphaned — remove it manually if you don't need it.")
61
+ else:
62
+ print(f" • Its notes folder (if any) under {notes_root}/ is left untouched.")
63
+ print(" • Any tracks that referenced this repo are now orphaned (clean up by hand).")
64
+ local = repos[key].get("local") if isinstance(repos[key], dict) else None
65
+ if local:
66
+ print(f" • The local clone at {local} is left untouched.")
67
+ else:
68
+ print(" • Any local clone is left untouched.")
69
+ return 0
@@ -1,12 +1,13 @@
1
1
  """set subcommand — guarded edit of a track's frontmatter scalar/list fields."""
2
2
  import json
3
- from lib.config import load_config, ConfigError
3
+ import sys
4
+ from lib.config import load_config, ConfigError, resolve_local_path_for_folder
4
5
  from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
5
6
  from lib.frontmatter import write_file
6
7
  from lib.write_guard import needs_confirm, make_token, valid_token
7
8
  from lib.prompts import parse_flags
8
9
 
9
- ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on"}
10
+ ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on", "plan"}
10
11
  LIST_FIELDS = {"blockers", "next_up"}
11
12
  STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
12
13
 
@@ -39,6 +40,10 @@ def run(args: list[str]) -> int:
39
40
  print(f"ERROR: {k} takes comma-separated integers (got {v!r})"); return 2
40
41
  elif k == "status" and v not in STATUSES:
41
42
  print(f"ERROR: status {v!r} invalid (allowed: {sorted(STATUSES)})"); return 2
43
+ elif k == "plan":
44
+ # Repo-relative path to the track's plan/spec doc (#285). Empty value
45
+ # clears the link. Stored as a scalar string; validated (advisory) below.
46
+ parsed[k] = v.strip()
42
47
  else:
43
48
  parsed[k] = v
44
49
  try:
@@ -58,7 +63,21 @@ def run(args: list[str]) -> int:
58
63
  "reason": f"{track.repo} is PUBLIC (or visibility unknown); edit will be written there.",
59
64
  "token": make_token(track.repo, track.name)}))
60
65
  return 0
66
+ # An empty `plan=` clears the link rather than writing `plan: ""` (#285).
67
+ if "plan" in parsed and parsed["plan"] == "":
68
+ parsed.pop("plan")
69
+ track.meta.pop("plan", None)
70
+ # Advisory validation: a non-empty plan path that doesn't resolve to a file in
71
+ # the track's repo checkout is saved anyway (the doc may not exist yet, or the
72
+ # repo may have no local clone) but flagged so a typo is caught early.
73
+ if parsed.get("plan") and track.folder:
74
+ local = resolve_local_path_for_folder(track.folder, cfg)
75
+ if local and local.exists() and not (local / parsed["plan"]).is_file():
76
+ print(f"WARN: plan path {parsed['plan']!r} does not resolve to a file "
77
+ f"under {local} — link saved anyway.", file=sys.stderr)
78
+
61
79
  track.meta.update(parsed)
62
80
  write_file(track.path, track.meta, track.body)
63
- print(f"✓ set {', '.join(parsed)} on {track.name}")
81
+ fields = ", ".join(parsed) if parsed else "plan (cleared)"
82
+ print(f"✓ set {fields} on {track.name}")
64
83
  return 0
@@ -52,7 +52,10 @@ def group_issues_by_milestone(issues, milestone_alignment=None):
52
52
  return groups
53
53
 
54
54
 
55
- def _issue(i: dict) -> dict:
55
+ def normalize_issue(i: dict) -> dict:
56
+ """Reshape a raw gh issue row into the viewer's `Issue` shape
57
+ ({number,title,state,assignee,milestone}). Shared by the export and the
58
+ `list-open-issues` command (#282) so both emit an identical issue surface."""
56
59
  state = (i.get("state") or "OPEN").lower()
57
60
  return {
58
61
  "number": i.get("number"),
@@ -64,18 +67,29 @@ def _issue(i: dict) -> dict:
64
67
 
65
68
 
66
69
  def build_export(tracks, issues_by_track, visibility, now: str,
67
- untracked_by_repo=None) -> dict:
70
+ untracked_by_repo=None, config_repos=None,
71
+ plan_by_track=None) -> dict:
72
+ plan_by_track = plan_by_track or {}
68
73
  out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
69
74
  for t in tracks:
70
- issues = [_issue(i) for i in issues_by_track.get(t.name, [])]
75
+ issues = [normalize_issue(i) for i in issues_by_track.get(t.name, [])]
71
76
  milestone_alignment = t.meta.get("milestone_alignment")
72
77
  issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
73
78
  opened = sum(1 for i in issues if i["state"] == "open")
74
79
  closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
75
80
  next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
81
+ track_path = getattr(t, "path", None)
76
82
  out["tracks"].append({
77
83
  "name": t.name,
78
84
  "repo": t.repo,
85
+ # Absolute path to the track's .md, so the viewer can open it in an
86
+ # editor (#211). null when a track has no backing file path (the
87
+ # viewer disables its open-file affordance rather than erroring).
88
+ "path": str(track_path) if track_path else None,
89
+ # Config repo key (the key under `repos:` in config.yml). The Plans
90
+ # view passes this as `plan-status --repo=<key>` (#164), which
91
+ # resolves a local checkout via folder key, not github slug.
92
+ "folder": getattr(t, "folder", None),
79
93
  "tier": getattr(t, "tier", "private") or "private",
80
94
  "status": t.meta.get("status"),
81
95
  "launch_priority": t.meta.get("launch_priority"),
@@ -86,10 +100,19 @@ def build_export(tracks, issues_by_track, visibility, now: str,
86
100
  "depends_on": list(t.meta.get("depends_on") or []),
87
101
  "rollup": {"open": opened, "closed": len(issues) - opened},
88
102
  "issues": issues,
103
+ # The track's declared plan/spec doc + its execution badge (#285), or
104
+ # null when the track declares no `plan:`. `{rel, resolved:false}` when
105
+ # the link can't be resolved (no local clone / file absent).
106
+ "plan": plan_by_track.get(t.name),
89
107
  })
90
108
  out["untracked"] = [
91
- {"repo": repo, "issues": [_issue(r) for r in rows]}
109
+ {"repo": repo, "issues": [normalize_issue(r) for r in rows]}
92
110
  for repo, rows in (untracked_by_repo or {}).items()
93
111
  if rows
94
112
  ]
113
+ # Every CONFIGURED repo, independent of track membership (#288): so the
114
+ # viewer can show a registered repo even when it has no tracks/plans yet —
115
+ # the starting point for adding fresh tracks. Each entry:
116
+ # {folder, repo(slug), local, has_local, visibility}.
117
+ out["repos"] = list(config_repos or [])
95
118
  return out
@@ -161,6 +161,28 @@ def path_last_commit_date(rel_path: str, repo_path: Path) -> Optional[datetime]:
161
161
  return None
162
162
 
163
163
 
164
+ def paths_last_commit_date(rel_paths, repo_path: Path) -> Optional[datetime]:
165
+ """Timestamp of the most recent commit touching ANY of `rel_paths` (naive).
166
+
167
+ One `git log -1` over the whole pathspec, so the result is the latest commit
168
+ date across the set. None for empty input, a bad repo, or no commit found.
169
+ Used by the staleness clock (#164), which keys off a plan's declared manifest
170
+ files (committed) rather than the plan doc itself (gitignored, so dateless).
171
+ """
172
+ if not rel_paths:
173
+ return None
174
+ if not repo_path or not Path(repo_path).exists():
175
+ return None
176
+ proc = _git(repo_path, "log", "-1", "--pretty=format:%cI", "--", *rel_paths)
177
+ if proc is None or proc.returncode != 0 or not proc.stdout.strip():
178
+ return None
179
+ try:
180
+ s = proc.stdout.strip().split("+")[0].split("Z")[0]
181
+ return datetime.fromisoformat(s)
182
+ except (ValueError, IndexError):
183
+ return None
184
+
185
+
164
186
  def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
165
187
  """True if `rel_path` has any commit on/around `since` or later (a datetime.date).
166
188
 
@@ -28,6 +28,69 @@ def _valid_repo(repo: str) -> bool:
28
28
  return bool(repo) and _REPO_RE.match(repo) is not None
29
29
 
30
30
 
31
+ def close_issue(repo: str, number: int, reason=None, comment=None) -> tuple:
32
+ """Close a GitHub issue via `gh issue close` — the toolkit's ONLY
33
+ GitHub-mutating call (#305). Everything else here is read-only.
34
+
35
+ Returns (ok, message). `reason` ∈ {completed, not_planned} maps to
36
+ `--reason`; `comment` (if given) posts a closing comment. The issue number
37
+ is coerced to str for argv (never shell-interpolated), and `repo` is
38
+ validated as owner/name first, so neither can inject. Never raises — a gh
39
+ failure (already-closed, no write access, network, not-found) comes back as
40
+ (False, <gh stderr>)."""
41
+ if not _valid_repo(repo):
42
+ return (False, f"invalid repo '{repo}'")
43
+ args = ["gh", "issue", "close", str(int(number)), "--repo", repo]
44
+ if reason in ("completed", "not_planned"):
45
+ args += ["--reason", reason]
46
+ if comment:
47
+ args += ["--comment", str(comment)]
48
+ try:
49
+ proc = subprocess.run(args, capture_output=True, text=True, timeout=GH_TIMEOUT)
50
+ except Exception as e:
51
+ return (False, f"gh issue close failed: {e}")
52
+ if proc.returncode != 0:
53
+ return (False, (proc.stderr or proc.stdout or "gh issue close failed").strip())
54
+ return (True, (proc.stdout or f"closed #{number}").strip())
55
+
56
+
57
+ def gh_auth_status() -> dict:
58
+ """Probe `gh` authentication so callers can fast-fail instead of silently
59
+ degrading (#auth). Returns:
60
+
61
+ {"gh_present": bool, "authenticated": bool,
62
+ "user": str | None, "error": str | None}
63
+
64
+ Distinguishes the two failure modes the UI must handle differently:
65
+ `gh` not installed (`gh_present` False — fix is "install gh") vs installed
66
+ but not logged in (`authenticated` False — fix is "gh auth login").
67
+
68
+ Never raises. `gh auth status` exits 0 when at least one host is logged in,
69
+ non-zero otherwise; it prints the human status to STDERR. We parse a
70
+ best-effort `user` from that text but treat the EXIT CODE as authoritative."""
71
+ try:
72
+ proc = subprocess.run(
73
+ ["gh", "auth", "status"],
74
+ capture_output=True, text=True, timeout=GH_TIMEOUT,
75
+ )
76
+ except FileNotFoundError:
77
+ return {"gh_present": False, "authenticated": False,
78
+ "user": None, "error": "gh CLI not found on PATH"}
79
+ except Exception as e: # timeout / OS error — gh present but unusable now
80
+ return {"gh_present": True, "authenticated": False,
81
+ "user": None, "error": f"gh auth status failed: {e}"}
82
+
83
+ blob = f"{proc.stdout}\n{proc.stderr}"
84
+ authenticated = proc.returncode == 0
85
+ # `gh auth status` prints e.g. "✓ Logged in to github.com account USER" or
86
+ # the older "Logged in to github.com as USER". Match either phrasing.
87
+ m = re.search(r"Logged in to \S+ (?:account|as) (\S+)", blob)
88
+ user = m.group(1) if (authenticated and m) else None
89
+ error = None if authenticated else (blob.strip() or "not logged in to GitHub")
90
+ return {"gh_present": True, "authenticated": authenticated,
91
+ "user": user, "error": error}
92
+
93
+
31
94
  def fetch_issue(repo: str, number: int) -> Optional[dict]:
32
95
  """Fetch a single issue via gh. Returns parsed dict on success, None on failure.
33
96
  Never raises — a missing `gh` binary, a timeout, or a bad repo yields None."""
@@ -14,6 +14,7 @@ PATH_RE = re.compile(r"\b(Create|Modify|Test):\s*`([^`]+)`")
14
14
  _RANGE_RE = re.compile(r":\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$")
15
15
  _CHK_DONE = re.compile(r"^\s*- \[x\]", re.I | re.M)
16
16
  _CHK_TODO = re.compile(r"^\s*- \[ \]", re.M)
17
+ _CHK_TODO_LABEL = re.compile(r"^\s*- \[ \]\s*(.+?)\s*$", re.M)
17
18
  _DATE_RE = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
18
19
 
19
20
 
@@ -48,6 +49,15 @@ def count_checkboxes(text: str) -> tuple:
48
49
  return done, done + todo
49
50
 
50
51
 
52
+ def unchecked_checkbox_labels(text: str, cap: int = 10) -> list:
53
+ """Labels of unticked `- [ ]` checkboxes, in document order, capped at `cap`.
54
+
55
+ Surfaces the still-open work items of a stalled plan (#164) so the report can
56
+ show what's left rather than just a count.
57
+ """
58
+ return [m.group(1) for m in _CHK_TODO_LABEL.finditer(text)][:cap]
59
+
60
+
51
61
  def plan_date_from_filename(filename: str) -> Optional[date]:
52
62
  """Pull a YYYY-MM-DD prefix out of a plan filename, if present."""
53
63
  m = _DATE_RE.search(filename)
@@ -82,6 +92,24 @@ def out_of_tree_ratio(decls: list, repo_root) -> float:
82
92
  return out / len(decls)
83
93
 
84
94
 
95
+ def offtree_declared_paths(decls: list, repo_root) -> list:
96
+ """Declared paths that resolve OUTSIDE repo_root — absolute paths, `~`-rooted,
97
+ `..`-escapes, or junk like a literal `/` (#286 slice 3, surfaced read-only).
98
+
99
+ Distinct from "not yet created": these can never be satisfied by THIS repo,
100
+ so they silently drag the file score down and usually mean a typo or a
101
+ misfiled plan. Returned de-duped in first-declared order so the viewer can
102
+ flag them; the toolkit never auto-edits the manifest (that'd be a body
103
+ write, which #286 forbids). Below `out_of_tree_ratio`'s FOREIGN_RATIO the
104
+ 🧳 verdict doesn't fire, so without this they'd be invisible."""
105
+ out, seen = [], set()
106
+ for d in decls:
107
+ if d.path not in seen and not is_in_tree(d.path, repo_root):
108
+ seen.add(d.path)
109
+ out.append(d.path)
110
+ return out
111
+
112
+
85
113
  @dataclass
86
114
  class ManifestScore:
87
115
  total: int
@@ -0,0 +1,71 @@
1
+ """Shared frontmatter-write helpers for viewer-driven plan writes (#286).
2
+
3
+ Every viewer-driven write to a plan/spec doc is **frontmatter-only** by hard
4
+ constraint. These helpers are the single, security-critical write path so the
5
+ escape guard and the public-repo confirm gate aren't copy-pasted (and can't
6
+ drift) across `plan-confirm`, `plan-ack`, and any future frontmatter writer.
7
+ """
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from lib import frontmatter
13
+ from lib.write_guard import needs_confirm, make_token, valid_token
14
+
15
+
16
+ def resolve_doc_path(repo_root: Path, rel: str) -> Optional[Path]:
17
+ """Absolute path of `rel` iff it is a real file inside repo_root, else None.
18
+
19
+ Guards the write surface: the resolved path must live under the repo root,
20
+ so a `../escape`, an absolute `rel`, or an in-repo symlink pointing outside
21
+ can't steer a frontmatter write at an arbitrary file."""
22
+ root = Path(repo_root).resolve()
23
+ try:
24
+ p = Path(repo_root) / rel
25
+ if not p.is_file():
26
+ return None
27
+ resolved = p.resolve()
28
+ except OSError:
29
+ return None
30
+ if resolved == root or root in resolved.parents:
31
+ return resolved
32
+ return None
33
+
34
+
35
+ def public_repo_gate(slug, rel: str, cfg: dict, confirm, action: str) -> bool:
36
+ """Public-repo confirm-token gate (the viewer surfaces this as a modal).
37
+
38
+ Returns True when the write may proceed. When a gate is required and no valid
39
+ token was supplied, prints the `{needs_confirm, reason, token}` JSON the
40
+ viewer's executeWrite flow consumes and returns False — the caller must then
41
+ make NO write. `action` is the verb phrase spliced into the reason."""
42
+ if slug and needs_confirm(slug, cfg) and not (
43
+ isinstance(confirm, str) and valid_token(confirm, slug, rel)
44
+ ):
45
+ print(json.dumps({
46
+ "needs_confirm": True,
47
+ "reason": (f"{slug} is PUBLIC (or visibility unknown); {action} "
48
+ f"a frontmatter write will be committed there."),
49
+ "token": make_token(slug, rel),
50
+ }))
51
+ return False
52
+ return True
53
+
54
+
55
+ def set_key(doc_path: Path, key: str, value) -> bool:
56
+ """Set (`value` not None) or delete (`value` None) ONE frontmatter key,
57
+ preserving the body byte-for-byte. Returns True iff the file changed
58
+ (idempotent no-op → False), so callers can report "nothing to do"."""
59
+ meta, body = frontmatter.parse_file(doc_path)
60
+ if not isinstance(meta, dict):
61
+ meta = {}
62
+ if value is None:
63
+ if key not in meta:
64
+ return False
65
+ del meta[key]
66
+ else:
67
+ if meta.get(key) == value:
68
+ return False
69
+ meta[key] = value
70
+ frontmatter.write_file(doc_path, meta, body)
71
+ return True