@stylusnexus/work-plan 2026.6.11 → 2026.6.13

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 (31) hide show
  1. package/README.md +26 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/commands/export.py +20 -2
  5. package/skills/work-plan/commands/group.py +5 -1
  6. package/skills/work-plan/commands/init_repo.py +84 -14
  7. package/skills/work-plan/commands/list_open_issues.py +52 -0
  8. package/skills/work-plan/commands/new_track.py +8 -2
  9. package/skills/work-plan/commands/plan_branch.py +314 -0
  10. package/skills/work-plan/commands/plan_status.py +76 -9
  11. package/skills/work-plan/commands/reconcile.py +49 -34
  12. package/skills/work-plan/commands/refresh_md.py +49 -1
  13. package/skills/work-plan/commands/remove_repo.py +69 -0
  14. package/skills/work-plan/lib/export_model.py +21 -4
  15. package/skills/work-plan/lib/git_state.py +22 -0
  16. package/skills/work-plan/lib/manifest.py +10 -0
  17. package/skills/work-plan/lib/plan_worktree.py +288 -0
  18. package/skills/work-plan/lib/tracks.py +6 -2
  19. package/skills/work-plan/lib/verdict.py +1 -0
  20. package/skills/work-plan/tests/test_export.py +40 -0
  21. package/skills/work-plan/tests/test_export_command.py +19 -0
  22. package/skills/work-plan/tests/test_init_repo.py +100 -1
  23. package/skills/work-plan/tests/test_list_open_issues.py +83 -0
  24. package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
  25. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  26. package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
  27. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  28. package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
  29. package/skills/work-plan/tests/test_refresh_md.py +75 -0
  30. package/skills/work-plan/tests/test_remove_repo.py +77 -0
  31. package/skills/work-plan/work_plan.py +95 -6
@@ -65,8 +65,17 @@ def run(args: list[str]) -> int:
65
65
 
66
66
  def _refresh_many(tracks: list, yes: bool) -> int:
67
67
  """Refresh one or more tracks. Computes proposed updates, then asks one
68
- confirmation (or applies all if --yes)."""
68
+ confirmation (or applies all if --yes).
69
+
70
+ A track whose live fetch comes back incomplete (GitHub timeout, permission
71
+ error, or a frontmatter issue that no longer resolves) is SKIPPED, not
72
+ refreshed: the canonical table is rebuilt from frontmatter membership, so a
73
+ missing issue would render as '(not fetched)' and silently overwrite its
74
+ valid last-known row (#256). Skipped tracks are reported and force a nonzero
75
+ exit so `--yes` / `hygiene` callers can tell a degraded run from a clean one.
76
+ """
69
77
  pending = []
78
+ degraded = [] # (track, missing_nums) — fetch was incomplete; left untouched
70
79
  for i, track in enumerate(tracks, 1):
71
80
  print(f" [{i}/{len(tracks)}] {track.path.name}...", flush=True)
72
81
  canonical = find_canonical_status_tables(track.body)
@@ -94,6 +103,22 @@ def _refresh_many(tracks: list, yes: bool) -> int:
94
103
  issues_by_num = {i["number"]: i for i in issues}
95
104
  state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
96
105
 
106
+ # Both render paths rebuild the table from frontmatter membership, so a
107
+ # frontmatter issue we couldn't fetch would land as a '(not fetched)'
108
+ # row, replacing its valid last-known values. Refuse to publish that:
109
+ # skip the track and surface the gap (#256). Table-only numbers that
110
+ # aren't in frontmatter don't feed the rebuild, so they don't gate.
111
+ unique_fm = set(frontmatter_nums)
112
+ missing = sorted(n for n in unique_fm if n not in issues_by_num)
113
+ if missing:
114
+ degraded.append((track, missing))
115
+ scope = ("no issues" if len(missing) == len(unique_fm)
116
+ else f"{len(missing)}/{len(unique_fm)} issues")
117
+ nums = ", ".join(f"#{n}" for n in missing)
118
+ print(f" ⚠ fetch returned {scope} short ({nums}) "
119
+ f"— skipping to preserve current rows")
120
+ continue
121
+
97
122
  if canonical:
98
123
  # Canonical table → RE-DERIVE the whole block from frontmatter
99
124
  # membership + live data, milestone-ordered (#101). Re-deriving from
@@ -113,6 +138,9 @@ def _refresh_many(tracks: list, yes: bool) -> int:
113
138
  pending.append((track, new_body, detail))
114
139
 
115
140
  if not pending:
141
+ if degraded:
142
+ _report_degraded(degraded)
143
+ return 1
116
144
  print("All tracks in sync.")
117
145
  return 0
118
146
 
@@ -127,9 +155,29 @@ def _refresh_many(tracks: list, yes: bool) -> int:
127
155
  for track, new_body, _ in pending:
128
156
  write_file(track.path, track.meta, new_body)
129
157
  print(f"\n✓ Updated {len(pending)} file(s).")
158
+
159
+ if degraded:
160
+ _report_degraded(degraded)
161
+ return 1
130
162
  return 0
131
163
 
132
164
 
165
+ def _report_degraded(degraded: list) -> None:
166
+ """Summarize tracks skipped because their live fetch was incomplete (#256).
167
+
168
+ Their tables are left exactly as they were — better a stale-but-valid row
169
+ than a '(not fetched)' placeholder published as truth. A persistently
170
+ missing number usually means the issue was deleted/transferred and should
171
+ be dropped from frontmatter."""
172
+ print(f"\n⚠ Skipped {len(degraded)} track(s) — live fetch was incomplete, "
173
+ f"so their tables were left untouched:")
174
+ for track, missing in degraded:
175
+ nums = ", ".join(f"#{n}" for n in missing)
176
+ print(f" {track.path.name}: could not fetch {nums}")
177
+ print(" Re-run once GitHub is reachable, or drop deleted issues from "
178
+ "frontmatter (`/work-plan reconcile`).")
179
+
180
+
133
181
  def _rederive_canonical(track, canonical_tables, frontmatter_nums,
134
182
  issues_by_num, state_by_num):
135
183
  """Rebuild the canonical block, milestone-ordered, from live data.
@@ -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
@@ -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,27 @@ 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) -> dict:
68
71
  out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
69
72
  for t in tracks:
70
- issues = [_issue(i) for i in issues_by_track.get(t.name, [])]
73
+ issues = [normalize_issue(i) for i in issues_by_track.get(t.name, [])]
71
74
  milestone_alignment = t.meta.get("milestone_alignment")
72
75
  issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
73
76
  opened = sum(1 for i in issues if i["state"] == "open")
74
77
  closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
75
78
  next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
79
+ track_path = getattr(t, "path", None)
76
80
  out["tracks"].append({
77
81
  "name": t.name,
78
82
  "repo": t.repo,
83
+ # Absolute path to the track's .md, so the viewer can open it in an
84
+ # editor (#211). null when a track has no backing file path (the
85
+ # viewer disables its open-file affordance rather than erroring).
86
+ "path": str(track_path) if track_path else None,
87
+ # Config repo key (the key under `repos:` in config.yml). The Plans
88
+ # view passes this as `plan-status --repo=<key>` (#164), which
89
+ # resolves a local checkout via folder key, not github slug.
90
+ "folder": getattr(t, "folder", None),
79
91
  "tier": getattr(t, "tier", "private") or "private",
80
92
  "status": t.meta.get("status"),
81
93
  "launch_priority": t.meta.get("launch_priority"),
@@ -88,8 +100,13 @@ def build_export(tracks, issues_by_track, visibility, now: str,
88
100
  "issues": issues,
89
101
  })
90
102
  out["untracked"] = [
91
- {"repo": repo, "issues": [_issue(r) for r in rows]}
103
+ {"repo": repo, "issues": [normalize_issue(r) for r in rows]}
92
104
  for repo, rows in (untracked_by_repo or {}).items()
93
105
  if rows
94
106
  ]
107
+ # Every CONFIGURED repo, independent of track membership (#288): so the
108
+ # viewer can show a registered repo even when it has no tracks/plans yet —
109
+ # the starting point for adding fresh tracks. Each entry:
110
+ # {folder, repo(slug), local, has_local, visibility}.
111
+ out["repos"] = list(config_repos or [])
95
112
  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
 
@@ -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)
@@ -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)
@@ -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
 
@@ -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
 
@@ -292,3 +308,27 @@ class BuildExportDependsOnTest(unittest.TestCase):
292
308
  ]}
293
309
  out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="t")
294
310
  self.assertEqual(out["tracks"][0]["depends_on"], [])
311
+
312
+
313
+ class BuildExportReposListTest(unittest.TestCase):
314
+ """build_export emits a top-level `repos` list of ALL configured repos,
315
+ independent of track membership (#288)."""
316
+
317
+ def test_emits_config_repos_including_trackless(self):
318
+ tracks = [_track("ph", "o/r", [1])]
319
+ issues_by_track = {"ph": [{"number": 1, "title": "a", "state": "OPEN", "assignees": []}]}
320
+ config_repos = [
321
+ {"folder": "r", "repo": "o/r", "local": "/x/r", "has_local": True, "visibility": "PRIVATE"},
322
+ {"folder": "fresh", "repo": "o/fresh", "local": None, "has_local": False, "visibility": "PUBLIC"},
323
+ ]
324
+ out = build_export(tracks, issues_by_track, {"o/r": "PRIVATE"}, now="2026-06-12T00:00",
325
+ config_repos=config_repos)
326
+ self.assertEqual([r["folder"] for r in out["repos"]], ["r", "fresh"])
327
+ # the trackless repo is present even though no track references it
328
+ fresh = next(r for r in out["repos"] if r["folder"] == "fresh")
329
+ self.assertEqual(fresh["has_local"], False)
330
+ self.assertEqual(fresh["repo"], "o/fresh")
331
+
332
+ def test_repos_defaults_to_empty_list(self):
333
+ out = build_export([], {}, {}, now="2026-06-12T00:00")
334
+ self.assertEqual(out["repos"], [])