@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.
- package/README.md +26 -4
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/export.py +20 -2
- package/skills/work-plan/commands/group.py +5 -1
- package/skills/work-plan/commands/init_repo.py +84 -14
- package/skills/work-plan/commands/list_open_issues.py +52 -0
- package/skills/work-plan/commands/new_track.py +8 -2
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/commands/plan_status.py +76 -9
- package/skills/work-plan/commands/reconcile.py +49 -34
- package/skills/work-plan/commands/refresh_md.py +49 -1
- package/skills/work-plan/commands/remove_repo.py +69 -0
- package/skills/work-plan/lib/export_model.py +21 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/manifest.py +10 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_export.py +40 -0
- package/skills/work-plan/tests/test_export_command.py +19 -0
- package/skills/work-plan/tests/test_init_repo.py +100 -1
- package/skills/work-plan/tests/test_list_open_issues.py +83 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
- package/skills/work-plan/tests/test_refresh_md.py +75 -0
- package/skills/work-plan/tests/test_remove_repo.py +77 -0
- 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
|
|
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 = [
|
|
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": [
|
|
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
|
-
|
|
214
|
-
|
|
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"], [])
|