@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.
- package/README.md +38 -6
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +3 -0
- package/skills/work-plan/commands/auth_status.py +35 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +72 -3
- 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_ack.py +71 -0
- package/skills/work-plan/commands/plan_baseline.py +85 -0
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +140 -9
- package/skills/work-plan/commands/push_track.py +156 -0
- 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/commands/set_field.py +22 -3
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/github_state.py +63 -0
- package/skills/work-plan/lib/manifest.py +28 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_auth_status.py +98 -0
- package/skills/work-plan/tests/test_close_issue.py +121 -0
- package/skills/work-plan/tests/test_export.py +65 -0
- package/skills/work-plan/tests/test_export_command.py +95 -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_manifest.py +30 -1
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_ack.py +104 -0
- package/skills/work-plan/tests/test_plan_baseline.py +86 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -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_push_track.py +131 -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/tests/test_set_field.py +60 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = [
|
|
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": [
|
|
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
|