@stylusnexus/work-plan 2026.7.10 → 2026.7.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 +7 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/export.py +14 -11
- package/skills/work-plan/commands/new_track.py +21 -2
- package/skills/work-plan/commands/plan_status.py +48 -6
- package/skills/work-plan/lib/doc_discovery.py +22 -2
- package/skills/work-plan/lib/export_model.py +16 -3
- package/skills/work-plan/lib/plan_worktree.py +47 -8
- package/skills/work-plan/tests/test_discover_archived.py +15 -0
- package/skills/work-plan/tests/test_doc_discovery.py +11 -0
- package/skills/work-plan/tests/test_export.py +34 -8
- package/skills/work-plan/tests/test_export_command.py +101 -0
- package/skills/work-plan/tests/test_group_apply.py +4 -0
- package/skills/work-plan/tests/test_new_track.py +79 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +68 -0
- package/skills/work-plan/tests/test_plan_worktree.py +31 -0
- package/skills/work-plan/tests/test_tracks.py +17 -0
package/README.md
CHANGED
|
@@ -383,7 +383,10 @@ The installer:
|
|
|
383
383
|
- **Copies** (not symlinks — for Windows compatibility) `skills/work-plan` and `skills/repo-activity-summary` into `~/.claude/skills/`
|
|
384
384
|
- Installs the `work-plan` launcher (`bin/work-plan` + `bin/work-plan.cmd` on Windows) and copies the standalone dispatcher (`installer/work-plan.md`) into `~/.claude/commands/work-plan.md` (the per-verb suite is plugin-only)
|
|
385
385
|
- **Self-seeds** `~/.claude/work-plan/config.yml` on first run if absent (one config home for every install mode), with `notes_root` at `~/.claude/work-plan/notes`
|
|
386
|
-
- Drops
|
|
386
|
+
- Drops `.installed-from` ownership markers. Skill copies retain their source
|
|
387
|
+
marker; launchers use a stable product marker plus a SHA-256 of the installed
|
|
388
|
+
bytes, so modified or unmanaged launchers are preserved by default on both
|
|
389
|
+
reinstall and uninstall
|
|
387
390
|
|
|
388
391
|
External dependencies (verified by the installer): `gh`, `git`, `yq`, `python3`.
|
|
389
392
|
|
|
@@ -503,6 +506,9 @@ The bundled `notes/` folder stays empty until you run `/work-plan init-repo <key
|
|
|
503
506
|
- **AI subcommands (`group`, `suggest-priorities`) send issue titles to Claude** via Claude Code's existing integration. Body content, code, and PR contents are NOT sent. If your repo is private and you're cautious about what reaches the model, skip these subcommands.
|
|
504
507
|
- **`init-repo` writes to your config via `yq -i`.** Inputs are JSON-encoded before being passed to `yq`, so a maliciously crafted `--github=` value can't break out of the YAML edit.
|
|
505
508
|
- **`install.sh` / `install.ps1` only touch user-owned dirs.** No `sudo`, no system-wide changes, no privilege escalation.
|
|
509
|
+
- **Plan and shared-track paths are repository-contained.** Plan discovery, stamping, export badges, and the VS Code opener reject absolute, traversal, and symlink escapes; stamped docs are atomically replaced so a hard link cannot write through to an outside inode. Shared `.work-plan/` creation likewise refuses a symlinked or escaping tier root.
|
|
510
|
+
- **Cross-repo identity stays explicit.** The export model and VS Code viewer qualify tracks and issues by repository, so same-named tracks and same-numbered issues in different repos cannot collide in graph state, selections, or write actions.
|
|
511
|
+
- **Installer ownership is content-verified.** Script-installed launchers carry a stable product marker plus a SHA-256 of the installed bytes. Reinstall and uninstall preserve unmanaged or user-modified launchers by default, and an incomplete required-skill install or failed smoke test exits nonzero.
|
|
506
512
|
- **Least-privilege tool access.** The `work-plan` and `repo-activity-summary` skills declare `allowed-tools` frontmatter, so Claude Code grants them a scoped allowlist — `work-plan` gets `Bash(work-plan:*)`, `Bash(python3:*)`, and `Write` (the two-step AI subcommands' JSON cache); `repo-activity-summary` gets `Bash(gh:*)` — rather than unrestricted shell.
|
|
507
513
|
|
|
508
514
|
For vulnerability reporting, threat model, and past advisories, see [SECURITY.md](./SECURITY.md).
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.07.
|
|
1
|
+
2026.07.13+6341563
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.7.
|
|
3
|
+
"version": "2026.7.13",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""export subcommand — emit the viewer-ready JSON read surface."""
|
|
2
2
|
import json
|
|
3
3
|
from datetime import datetime, date
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
from lib.config import load_config, ConfigError, resolve_local_path_for_folder
|
|
5
6
|
from lib.tracks import discover_tracks, discover_archived_tracks, find_tier_duplicates, issue_refs
|
|
6
7
|
from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
|
|
7
8
|
from lib.git_state import hot_issue_numbers
|
|
8
|
-
from lib.export_model import build_export
|
|
9
|
+
from lib.export_model import build_export, track_key
|
|
9
10
|
from lib.prompts import parse_flags
|
|
10
11
|
from lib import doc_discovery
|
|
11
12
|
from lib import verdict as verdict_mod
|
|
@@ -25,11 +26,13 @@ def _plan_badge(track, cfg, today, dead_days, stall_days):
|
|
|
25
26
|
if not isinstance(rel, str) or not rel.strip():
|
|
26
27
|
return None
|
|
27
28
|
rel = rel.strip()
|
|
29
|
+
if Path(rel).is_absolute():
|
|
30
|
+
return {"rel": rel, "resolved": False}
|
|
28
31
|
local = resolve_local_path_for_folder(track.folder, cfg) if track.folder else None
|
|
29
32
|
if not local or not local.exists():
|
|
30
33
|
return {"rel": rel, "resolved": False}
|
|
31
34
|
doc_path = local / rel
|
|
32
|
-
if not
|
|
35
|
+
if not doc_discovery.is_safe_doc_path(doc_path, local):
|
|
33
36
|
return {"rel": rel, "resolved": False}
|
|
34
37
|
doc = doc_discovery.Doc(path=doc_path, rel=rel, kind=doc_discovery.classify_kind(rel))
|
|
35
38
|
row = evaluate_doc(doc, local, today, dead_days, stall_days)
|
|
@@ -85,19 +88,19 @@ def run(args: list[str]) -> int:
|
|
|
85
88
|
issue_map = fetch_export_issues(repo_to_numbers)
|
|
86
89
|
|
|
87
90
|
# Reassemble per-track lists, preserving each track's declared issue order.
|
|
88
|
-
#
|
|
89
|
-
issues_by_track: dict[tuple, list] = {}
|
|
91
|
+
# Canonical track identity keeps same-named tracks in different repos apart.
|
|
92
|
+
issues_by_track: dict[tuple[str, str], list] = {}
|
|
90
93
|
visibility: dict[str, object] = {}
|
|
91
94
|
for t in tracks:
|
|
92
95
|
nums = (t.meta.get("github", {}).get("issues")) or []
|
|
93
96
|
if t.repo and nums:
|
|
94
|
-
issues_by_track[(t
|
|
97
|
+
issues_by_track[track_key(t)] = [
|
|
95
98
|
issue_map[(t.repo, n)]
|
|
96
99
|
for n in nums
|
|
97
100
|
if (t.repo, n) in issue_map
|
|
98
101
|
]
|
|
99
102
|
else:
|
|
100
|
-
issues_by_track[(t
|
|
103
|
+
issues_by_track[track_key(t)] = []
|
|
101
104
|
if t.repo and t.repo not in visibility:
|
|
102
105
|
visibility[t.repo] = repo_visibility(t.repo)
|
|
103
106
|
|
|
@@ -140,14 +143,14 @@ def run(args: list[str]) -> int:
|
|
|
140
143
|
today = date.today()
|
|
141
144
|
cfg_stall = cfg.get("stall_days")
|
|
142
145
|
stall_days = cfg_stall if isinstance(cfg_stall, int) else verdict_mod.STALL_DAYS
|
|
143
|
-
plan_by_track: dict[str, dict] = {}
|
|
146
|
+
plan_by_track: dict[tuple[str, str], dict] = {}
|
|
144
147
|
for t in tracks:
|
|
145
148
|
badge = _plan_badge(t, cfg, today, verdict_mod.DEAD_DAYS, stall_days)
|
|
146
149
|
if badge is not None:
|
|
147
|
-
plan_by_track[t
|
|
150
|
+
plan_by_track[track_key(t)] = badge
|
|
148
151
|
|
|
149
|
-
# Per-track branch heat
|
|
150
|
-
hot_by_track: dict = {}
|
|
152
|
+
# Per-track branch heat uses the same identity as issue rows and plan badges.
|
|
153
|
+
hot_by_track: dict[tuple[str, str], set] = {}
|
|
151
154
|
for t in tracks:
|
|
152
155
|
if not t.repo:
|
|
153
156
|
continue
|
|
@@ -155,7 +158,7 @@ def run(args: list[str]) -> int:
|
|
|
155
158
|
if local and local.exists():
|
|
156
159
|
nums = hot_issue_numbers(local)
|
|
157
160
|
if nums:
|
|
158
|
-
hot_by_track[(t
|
|
161
|
+
hot_by_track[track_key(t)] = nums
|
|
159
162
|
|
|
160
163
|
# Shared/private tier duplicates (#361): the viewer is otherwise blind to
|
|
161
164
|
# them — discover_tracks drops the private copy with a stderr-only WARN. We
|
|
@@ -17,7 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import Optional
|
|
18
18
|
|
|
19
19
|
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
20
|
-
from lib.plan_worktree import
|
|
20
|
+
from lib.plan_worktree import resolve_shared_tier
|
|
21
21
|
from lib.frontmatter import write_file
|
|
22
22
|
from lib.prompts import parse_flags
|
|
23
23
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
@@ -158,7 +158,10 @@ def run(args: list[str]) -> int:
|
|
|
158
158
|
if is_valid_git_repo(local_path):
|
|
159
159
|
# Worktree-aware (#260): plan_branch repos write into the
|
|
160
160
|
# worktree's .work-plan/; None → fall back to the private tier.
|
|
161
|
-
sd =
|
|
161
|
+
sd, unsafe_reason = resolve_shared_tier(repo_entry)
|
|
162
|
+
if unsafe_reason:
|
|
163
|
+
print(f"ERROR: {unsafe_reason}")
|
|
164
|
+
return 1
|
|
162
165
|
if sd is not None:
|
|
163
166
|
shared_path = sd / f"{slug}.md"
|
|
164
167
|
|
|
@@ -197,8 +200,24 @@ def run(args: list[str]) -> int:
|
|
|
197
200
|
# ------------------------------------------------------------------
|
|
198
201
|
# Create folder if missing, then write the track file
|
|
199
202
|
# ------------------------------------------------------------------
|
|
203
|
+
if is_shared:
|
|
204
|
+
# Re-resolve at the mutation boundary. Discovery and confirmation can
|
|
205
|
+
# take time, during which an attacker could replace `.work-plan` with a
|
|
206
|
+
# symlink. Refuse if the selected root changed or became unavailable.
|
|
207
|
+
current_shared, unsafe_reason = resolve_shared_tier(repo_entry)
|
|
208
|
+
if unsafe_reason or current_shared is None or current_shared != path.parent:
|
|
209
|
+
print(f"ERROR: {unsafe_reason or 'shared track directory changed before write'}")
|
|
210
|
+
return 1
|
|
200
211
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
212
|
|
|
213
|
+
if is_shared:
|
|
214
|
+
# mkdir may have created the tier; validate that concrete directory once
|
|
215
|
+
# more before handing the file path to the frontmatter writer.
|
|
216
|
+
current_shared, unsafe_reason = resolve_shared_tier(repo_entry)
|
|
217
|
+
if unsafe_reason or current_shared is None or current_shared != path.parent:
|
|
218
|
+
print(f"ERROR: {unsafe_reason or 'shared track directory changed before write'}")
|
|
219
|
+
return 1
|
|
220
|
+
|
|
202
221
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
203
222
|
meta = {
|
|
204
223
|
"track": slug,
|
|
@@ -5,7 +5,10 @@ Phase 1: read-only. Reports a human table or --json. Never mutates a doc.
|
|
|
5
5
|
Manifest-less (prose) docs are flagged 👻 for the Phase 1b LLM pass.
|
|
6
6
|
"""
|
|
7
7
|
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
8
10
|
import sys
|
|
11
|
+
import tempfile
|
|
9
12
|
from datetime import date, timedelta
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
|
|
@@ -247,21 +250,60 @@ def _render(rows, repo_root) -> None:
|
|
|
247
250
|
print()
|
|
248
251
|
|
|
249
252
|
|
|
250
|
-
def _stamp_docs(docs, rows, draft: bool) -> None:
|
|
253
|
+
def _stamp_docs(docs, rows, repo_root, draft: bool) -> None:
|
|
251
254
|
changed = []
|
|
252
255
|
for doc, row in zip(docs, rows):
|
|
256
|
+
if not doc_discovery.is_safe_doc_path(doc.path, repo_root):
|
|
257
|
+
print(f"WARN: refusing to stamp unsafe path: {doc.rel}")
|
|
258
|
+
continue
|
|
253
259
|
text = doc.path.read_text(encoding="utf-8", errors="replace")
|
|
254
260
|
new = status_header.stamp(text, row)
|
|
255
261
|
if new != text:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
262
|
+
if draft:
|
|
263
|
+
changed.append(doc.rel)
|
|
264
|
+
elif _replace_doc_text(doc.path, new, repo_root):
|
|
265
|
+
changed.append(doc.rel)
|
|
266
|
+
else:
|
|
267
|
+
print(f"WARN: refusing to stamp unsafe path: {doc.rel}")
|
|
259
268
|
verb = "would stamp" if draft else "stamped"
|
|
260
269
|
print(f"\n{verb} {len(changed)} doc(s):")
|
|
261
270
|
for rel in changed:
|
|
262
271
|
print(f" {rel}")
|
|
263
272
|
|
|
264
273
|
|
|
274
|
+
def _replace_doc_text(path: Path, text: str, repo_root: Path) -> bool:
|
|
275
|
+
"""Atomically replace a validated doc without writing through hard links."""
|
|
276
|
+
path = Path(path)
|
|
277
|
+
tmp_path = None
|
|
278
|
+
try:
|
|
279
|
+
original_mode = stat.S_IMODE(path.stat().st_mode)
|
|
280
|
+
with tempfile.NamedTemporaryFile(
|
|
281
|
+
mode="w", encoding="utf-8", dir=str(path.parent),
|
|
282
|
+
prefix=f".{path.name}.", suffix=".tmp", delete=False) as tmp:
|
|
283
|
+
tmp.write(text)
|
|
284
|
+
tmp.flush()
|
|
285
|
+
os.fsync(tmp.fileno())
|
|
286
|
+
tmp_path = Path(tmp.name)
|
|
287
|
+
os.chmod(str(tmp_path), original_mode)
|
|
288
|
+
|
|
289
|
+
# Recheck immediately before replacement. os.replace swaps the directory
|
|
290
|
+
# entry instead of following a final-component symlink and also breaks any
|
|
291
|
+
# hard-link relationship held by the original inode.
|
|
292
|
+
if not doc_discovery.is_safe_doc_path(path, repo_root):
|
|
293
|
+
return False
|
|
294
|
+
os.replace(str(tmp_path), str(path))
|
|
295
|
+
tmp_path = None
|
|
296
|
+
return True
|
|
297
|
+
except OSError:
|
|
298
|
+
return False
|
|
299
|
+
finally:
|
|
300
|
+
if tmp_path is not None:
|
|
301
|
+
try:
|
|
302
|
+
tmp_path.unlink()
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
265
307
|
_LLM_VERDICTS = {"shipped", "partial", "dead"}
|
|
266
308
|
_LLM_GLYPH = {"shipped": "✅", "partial": "🟡", "dead": "💀"}
|
|
267
309
|
|
|
@@ -336,7 +378,7 @@ def _llm_apply(docs, rows, repo_root, stamp: bool, draft: bool) -> int:
|
|
|
336
378
|
|
|
337
379
|
_render(rows, repo_root)
|
|
338
380
|
if stamp:
|
|
339
|
-
_stamp_docs(docs, rows, draft=draft)
|
|
381
|
+
_stamp_docs(docs, rows, repo_root=repo_root, draft=draft)
|
|
340
382
|
return 0
|
|
341
383
|
|
|
342
384
|
|
|
@@ -551,5 +593,5 @@ def run(args: list) -> int:
|
|
|
551
593
|
if flags.get("--stamp"):
|
|
552
594
|
live_docs = [d for d in docs if not d.archived]
|
|
553
595
|
_stamp_docs(live_docs, [r for r in rows if not r.get("archived")],
|
|
554
|
-
draft=bool(flags.get("--draft")))
|
|
596
|
+
repo_root=repo_root, draft=bool(flags.get("--draft")))
|
|
555
597
|
return 0
|
|
@@ -30,6 +30,26 @@ def classify_kind(rel: str) -> str:
|
|
|
30
30
|
return "adhoc"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def is_safe_doc_path(path: Path, repo_root: Path) -> bool:
|
|
34
|
+
"""Return whether an existing doc resolves to a regular file inside the repo.
|
|
35
|
+
|
|
36
|
+
``Path.is_file()`` follows symlinks, so it is not sufficient for paths that
|
|
37
|
+
may later be stamped. Reject a symlink at the final component and require
|
|
38
|
+
the fully-resolved target to remain beneath the resolved repository root.
|
|
39
|
+
The write path calls this again to avoid trusting discovery-time state.
|
|
40
|
+
"""
|
|
41
|
+
path = Path(path)
|
|
42
|
+
try:
|
|
43
|
+
if path.is_symlink():
|
|
44
|
+
return False
|
|
45
|
+
resolved_root = Path(repo_root).resolve(strict=True)
|
|
46
|
+
resolved_path = path.resolve(strict=True)
|
|
47
|
+
resolved_path.relative_to(resolved_root)
|
|
48
|
+
return resolved_path.is_file()
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
def discover_docs(repo_root: Path, globs: Optional[list] = None,
|
|
34
54
|
include_archived: bool = False) -> list:
|
|
35
55
|
globs = globs or DEFAULT_GLOBS
|
|
@@ -38,7 +58,7 @@ def discover_docs(repo_root: Path, globs: Optional[list] = None,
|
|
|
38
58
|
seen = set()
|
|
39
59
|
for g in globs:
|
|
40
60
|
for p in sorted(repo_root.glob(g)):
|
|
41
|
-
if not p
|
|
61
|
+
if not is_safe_doc_path(p, repo_root) or p in seen:
|
|
42
62
|
continue
|
|
43
63
|
seen.add(p)
|
|
44
64
|
rel = p.relative_to(repo_root).as_posix()
|
|
@@ -48,7 +68,7 @@ def discover_docs(repo_root: Path, globs: Optional[list] = None,
|
|
|
48
68
|
parent = g.rsplit("/", 1)[0] # "docs/plans/*.md" -> "docs/plans"
|
|
49
69
|
for sub, kind in ARCHIVE_SUBDIRS:
|
|
50
70
|
for p in sorted(repo_root.glob(f"{parent}/{sub}/*.md")):
|
|
51
|
-
if not p
|
|
71
|
+
if not is_safe_doc_path(p, repo_root) or p in seen:
|
|
52
72
|
continue
|
|
53
73
|
seen.add(p)
|
|
54
74
|
rel = p.relative_to(repo_root).as_posix()
|
|
@@ -5,6 +5,18 @@ from lib.next_up import resolve_next_up_order, suggest_next_up
|
|
|
5
5
|
SCHEMA = 1
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
def track_key(track) -> tuple[str, str]:
|
|
9
|
+
"""Return the canonical internal identity for a track.
|
|
10
|
+
|
|
11
|
+
The config folder key is the CLI repo qualifier and keeps separately
|
|
12
|
+
configured sources distinct even when they point at the same GitHub repo.
|
|
13
|
+
Older/minimal tracks without a folder fall back to the GitHub slug. The key
|
|
14
|
+
is internal to export assembly and does not change the schema-1 JSON surface.
|
|
15
|
+
"""
|
|
16
|
+
scope = getattr(track, "folder", None) or getattr(track, "repo", None) or ""
|
|
17
|
+
return (scope, track.name)
|
|
18
|
+
|
|
19
|
+
|
|
8
20
|
def milestone_sort_key(issue: dict, milestone_alignment=None):
|
|
9
21
|
"""Sort key for an issue dict (must have 'number' and 'milestone').
|
|
10
22
|
|
|
@@ -92,8 +104,9 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
92
104
|
out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
|
|
93
105
|
for t in tracks:
|
|
94
106
|
from lib.in_progress import issue_in_progress, IN_PROGRESS_LABEL
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
key = track_key(t)
|
|
108
|
+
hot = hot_by_track.get(key, set())
|
|
109
|
+
raw = issues_by_track.get(key, [])
|
|
97
110
|
issues = [
|
|
98
111
|
normalize_issue(
|
|
99
112
|
i,
|
|
@@ -158,7 +171,7 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
158
171
|
# The track's declared plan/spec doc + its execution badge (#285), or
|
|
159
172
|
# null when the track declares no `plan:`. `{rel, resolved:false}` when
|
|
160
173
|
# the link can't be resolved (no local clone / file absent).
|
|
161
|
-
"plan": plan_by_track.get(
|
|
174
|
+
"plan": plan_by_track.get(key),
|
|
162
175
|
# Effective next_up ranking preset for this track (#326 Phase 2).
|
|
163
176
|
"next_up_preset": next_up_preset_name,
|
|
164
177
|
# True when the track has `next_up_auto: true` set in its frontmatter,
|
|
@@ -190,6 +190,51 @@ def commit_shared_tier(worktree: Path, message: str, paths) -> Optional[str]:
|
|
|
190
190
|
return head.stdout.strip() or None
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
def _contained_shared_tier(root: Path) -> tuple:
|
|
194
|
+
"""Return ``(<root>/.work-plan, None)`` when the tier is safely contained.
|
|
195
|
+
|
|
196
|
+
The shared-tier directory itself is never allowed to be a symlink, even if
|
|
197
|
+
it currently points back inside ``root``. Refusing the indirection keeps a
|
|
198
|
+
later retarget from changing the write destination after validation.
|
|
199
|
+
``resolve(strict=False)`` also catches symlinked ancestors and proves that
|
|
200
|
+
the resulting directory remains below the resolved repository/worktree.
|
|
201
|
+
"""
|
|
202
|
+
candidate = root / ".work-plan"
|
|
203
|
+
try:
|
|
204
|
+
resolved_root = root.resolve(strict=True)
|
|
205
|
+
if not resolved_root.is_dir():
|
|
206
|
+
return None, f"shared track root is not a directory: {root}"
|
|
207
|
+
if candidate.is_symlink():
|
|
208
|
+
return None, f"unsafe shared track directory is a symlink: {candidate}"
|
|
209
|
+
if candidate.exists() and not candidate.is_dir():
|
|
210
|
+
return None, f"unsafe shared track path is not a directory: {candidate}"
|
|
211
|
+
resolved_candidate = candidate.resolve(strict=False)
|
|
212
|
+
resolved_candidate.relative_to(resolved_root)
|
|
213
|
+
except (OSError, RuntimeError, ValueError):
|
|
214
|
+
return None, f"unsafe shared track directory escapes its root: {candidate}"
|
|
215
|
+
return candidate, None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resolve_shared_tier(entry: dict) -> tuple:
|
|
219
|
+
"""Resolve a repo's shared tier as ``(path, unsafe_reason)``.
|
|
220
|
+
|
|
221
|
+
``unsafe_reason`` is populated only when a candidate root exists but fails
|
|
222
|
+
containment validation. Operational absence (no local path, unavailable
|
|
223
|
+
plan branch) remains ``(None, None)`` so read-only callers retain the
|
|
224
|
+
existing graceful-degradation behaviour.
|
|
225
|
+
"""
|
|
226
|
+
if not entry or not entry.get("local"):
|
|
227
|
+
return None, None
|
|
228
|
+
local_path = Path(entry["local"]).expanduser()
|
|
229
|
+
branch = entry.get("plan_branch")
|
|
230
|
+
if branch:
|
|
231
|
+
worktree = ensure_worktree(local_path, branch)
|
|
232
|
+
if worktree is None:
|
|
233
|
+
return None, None
|
|
234
|
+
return _contained_shared_tier(worktree)
|
|
235
|
+
return _contained_shared_tier(local_path)
|
|
236
|
+
|
|
237
|
+
|
|
193
238
|
def shared_tier_dir(entry: dict) -> Optional[Path]:
|
|
194
239
|
"""The `.work-plan/` directory to read/write for a repo config `entry`.
|
|
195
240
|
|
|
@@ -199,14 +244,8 @@ def shared_tier_dir(entry: dict) -> Optional[Path]:
|
|
|
199
244
|
|
|
200
245
|
Returns None when the entry has no `local` path. Never raises.
|
|
201
246
|
"""
|
|
202
|
-
|
|
203
|
-
|
|
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"
|
|
247
|
+
path, _unsafe_reason = resolve_shared_tier(entry)
|
|
248
|
+
return path
|
|
210
249
|
|
|
211
250
|
|
|
212
251
|
# ---------------------------------------------------------------------------
|
|
@@ -42,6 +42,21 @@ class DiscoverArchivedTest(unittest.TestCase):
|
|
|
42
42
|
by_rel["docs/plans/archive/abandoned/dead.md"].archive_kind, "abandoned")
|
|
43
43
|
self.assertFalse(by_rel["docs/plans/live.md"].archived)
|
|
44
44
|
|
|
45
|
+
def test_include_archived_skips_symlinked_doc_outside_repo(self):
|
|
46
|
+
with tempfile.TemporaryDirectory() as d:
|
|
47
|
+
root = _repo(d)
|
|
48
|
+
victim = Path(d) / "outside.md"
|
|
49
|
+
victim.write_text("# outside\n")
|
|
50
|
+
link = root / "docs/plans/archive/shipped/linked.md"
|
|
51
|
+
link.symlink_to(victim)
|
|
52
|
+
|
|
53
|
+
docs = doc_discovery.discover_docs(root, include_archived=True)
|
|
54
|
+
|
|
55
|
+
self.assertNotIn(
|
|
56
|
+
"docs/plans/archive/shipped/linked.md",
|
|
57
|
+
{doc.rel for doc in docs},
|
|
58
|
+
)
|
|
59
|
+
|
|
45
60
|
|
|
46
61
|
if __name__ == "__main__":
|
|
47
62
|
unittest.main()
|
|
@@ -46,6 +46,17 @@ class DiscoverDocsTest(unittest.TestCase):
|
|
|
46
46
|
self.assertEqual(kinds["docs/superpowers/plans/2026-03-16-a.md"], "plan")
|
|
47
47
|
self.assertEqual(kinds["docs/plans/2026-02-17-b-design.md"], "spec")
|
|
48
48
|
|
|
49
|
+
def test_skips_symlinked_doc_that_resolves_outside_repo(self):
|
|
50
|
+
with tempfile.TemporaryDirectory() as d:
|
|
51
|
+
root = Path(d) / "repo"
|
|
52
|
+
plans = root / "docs/plans"
|
|
53
|
+
plans.mkdir(parents=True)
|
|
54
|
+
victim = Path(d) / "victim.md"
|
|
55
|
+
victim.write_text("# outside\n")
|
|
56
|
+
(plans / "evil.md").symlink_to(victim)
|
|
57
|
+
|
|
58
|
+
self.assertEqual(discover_docs(root), [])
|
|
59
|
+
|
|
49
60
|
|
|
50
61
|
if __name__ == "__main__":
|
|
51
62
|
unittest.main()
|
|
@@ -3,18 +3,44 @@ import sys, json, unittest
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from types import SimpleNamespace
|
|
5
5
|
SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
|
|
6
|
-
from lib.export_model import build_export
|
|
6
|
+
from lib.export_model import build_export, track_key
|
|
7
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=
|
|
11
|
+
path=Path(f"/tmp/notes/{name}.md"), folder=repo,
|
|
12
12
|
meta={"status": status, "launch_priority": "P2", "milestone_alignment": "v1",
|
|
13
13
|
"blockers": blockers or [], "next_up": next_up or [],
|
|
14
14
|
"depends_on": depends_on or [],
|
|
15
15
|
"github": {"repo": repo, "issues": issues}})
|
|
16
16
|
|
|
17
17
|
class BuildExportTest(unittest.TestCase):
|
|
18
|
+
def test_track_key_prefers_config_folder_over_repo_slug(self):
|
|
19
|
+
track = SimpleNamespace(name="alpha", repo="org/repo", folder="config-folder")
|
|
20
|
+
self.assertEqual(track_key(track), ("config-folder", "alpha"))
|
|
21
|
+
|
|
22
|
+
def test_track_key_falls_back_to_folder_without_repo(self):
|
|
23
|
+
track = SimpleNamespace(name="alpha", repo=None, folder="config-folder")
|
|
24
|
+
self.assertEqual(track_key(track), ("config-folder", "alpha"))
|
|
25
|
+
|
|
26
|
+
def test_folder_identity_is_shared_by_issue_hot_and_plan_maps(self):
|
|
27
|
+
track = _track("alpha", None, [7])
|
|
28
|
+
track.folder = "config-folder"
|
|
29
|
+
key = ("config-folder", "alpha")
|
|
30
|
+
badge = {"rel": "docs/plans/p.md", "resolved": False}
|
|
31
|
+
issues = [{"number": 7, "title": "folder-only", "state": "OPEN",
|
|
32
|
+
"assignees": [], "labels": []}]
|
|
33
|
+
|
|
34
|
+
out = build_export(
|
|
35
|
+
[track], {key: issues}, {}, now="t",
|
|
36
|
+
plan_by_track={key: badge}, hot_by_track={key: {7}},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
exported = out["tracks"][0]
|
|
40
|
+
self.assertEqual(exported["plan"], badge)
|
|
41
|
+
self.assertEqual(exported["issues"][0]["number"], 7)
|
|
42
|
+
self.assertTrue(exported["issues"][0]["in_progress"])
|
|
43
|
+
|
|
18
44
|
def test_schema_and_shape(self):
|
|
19
45
|
tracks = [_track("ph", "o/r", [1, 2], blockers=[9], next_up=[1])]
|
|
20
46
|
issues_by_track = {("o/r", "ph"): [
|
|
@@ -31,7 +57,7 @@ class BuildExportTest(unittest.TestCase):
|
|
|
31
57
|
# the platform — str(Path) yields backslashes on Windows.
|
|
32
58
|
self.assertEqual(t["path"], str(Path("/tmp/notes/ph.md")))
|
|
33
59
|
# Config repo key surfaces for the Plans view's --repo arg (#164).
|
|
34
|
-
self.assertEqual(t["folder"], "
|
|
60
|
+
self.assertEqual(t["folder"], "o/r")
|
|
35
61
|
self.assertEqual(t["blockers"], [9]); self.assertEqual(t["next_up"], [1])
|
|
36
62
|
self.assertEqual(t["rollup"], {"open": 1, "closed": 1})
|
|
37
63
|
self.assertEqual(t["issues"][0], {"number": 1, "title": "a", "state": "open", "assignee": "@eve", "milestone": None, "in_progress": False, "in_progress_label": False, "blocked_by": [], "blocking": []})
|
|
@@ -74,7 +100,7 @@ class BuildExportNextUpAutoTest(unittest.TestCase):
|
|
|
74
100
|
if auto:
|
|
75
101
|
meta["next_up_auto"] = True
|
|
76
102
|
return SimpleNamespace(name="t", repo="o/r", tier="private",
|
|
77
|
-
path=Path("/tmp/notes/t.md"), folder="
|
|
103
|
+
path=Path("/tmp/notes/t.md"), folder="o/r", meta=meta)
|
|
78
104
|
|
|
79
105
|
def _issues(self):
|
|
80
106
|
# P0 #3, P1 #2, P2 #1 — flow ranks by priority within the same milestone.
|
|
@@ -113,7 +139,7 @@ class BuildExportNextUpAutoTest(unittest.TestCase):
|
|
|
113
139
|
from types import SimpleNamespace
|
|
114
140
|
from pathlib import Path
|
|
115
141
|
t = SimpleNamespace(name="t", repo="o/r", tier="private",
|
|
116
|
-
path=Path("/tmp/notes/t.md"), folder="
|
|
142
|
+
path=Path("/tmp/notes/t.md"), folder="o/r", meta=meta)
|
|
117
143
|
out = build_export([t],
|
|
118
144
|
{("o/r", "t"): []}, # zero issues fetched
|
|
119
145
|
{"o/r": "PRIVATE"}, now="t")
|
|
@@ -381,13 +407,13 @@ class BuildExportPlanTest(unittest.TestCase):
|
|
|
381
407
|
"checkboxes_done": 0, "checkboxes_total": 24, "lie_gap": False,
|
|
382
408
|
"stalled": False, "override": "shipped"}
|
|
383
409
|
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
|
|
384
|
-
plan_by_track={"alpha": badge})
|
|
410
|
+
plan_by_track={("o/r", "alpha"): badge})
|
|
385
411
|
self.assertEqual(out["tracks"][0]["plan"], badge)
|
|
386
412
|
|
|
387
413
|
def test_unresolved_badge_passed_through(self):
|
|
388
414
|
tracks = [_track("alpha", "o/r", [1])]
|
|
389
415
|
out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t",
|
|
390
|
-
plan_by_track={"alpha": {"rel": "docs/plans/p.md", "resolved": False}})
|
|
416
|
+
plan_by_track={("o/r", "alpha"): {"rel": "docs/plans/p.md", "resolved": False}})
|
|
391
417
|
self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
|
|
392
418
|
|
|
393
419
|
|
|
@@ -583,7 +609,7 @@ class BuildExportNextUpPresetTest(unittest.TestCase):
|
|
|
583
609
|
if track_meta_override:
|
|
584
610
|
meta.update(track_meta_override)
|
|
585
611
|
t = SimpleNamespace(name="alpha", repo="o/r", tier="private",
|
|
586
|
-
path=Path("/tmp/notes/alpha.md"), folder="
|
|
612
|
+
path=Path("/tmp/notes/alpha.md"), folder="o/r",
|
|
587
613
|
meta=meta)
|
|
588
614
|
out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"},
|
|
589
615
|
now="2026-06-14T00:00", next_up_default=next_up_default)
|
|
@@ -263,6 +263,53 @@ class ExportRunJsonTest(unittest.TestCase):
|
|
|
263
263
|
# repoB: issue 1 — once
|
|
264
264
|
self.assertEqual(repo_to_numbers[repo_b], [1])
|
|
265
265
|
|
|
266
|
+
def test_same_name_tracks_in_different_repos_keep_distinct_plan_badges(self):
|
|
267
|
+
"""Plan badges must follow track identity, not collide on track name."""
|
|
268
|
+
repo_a = "org/repoA"
|
|
269
|
+
repo_b = "org/repoB"
|
|
270
|
+
track_a = _track("shared-name", repo_a, [])
|
|
271
|
+
track_b = _track("shared-name", repo_b, [])
|
|
272
|
+
track_a.folder = "repo-a"
|
|
273
|
+
track_b.folder = "repo-b"
|
|
274
|
+
track_a.meta["plan"] = "docs/plans/a.md"
|
|
275
|
+
track_b.meta["plan"] = "docs/plans/b.md"
|
|
276
|
+
|
|
277
|
+
def _badge(track, *_args):
|
|
278
|
+
return {"rel": track.meta["plan"], "resolved": False}
|
|
279
|
+
|
|
280
|
+
with patch("commands.export._plan_badge", side_effect=_badge):
|
|
281
|
+
rc, out, _ = self._run_with_mocks(
|
|
282
|
+
[track_a, track_b], {}, vis={repo_a: "PUBLIC", repo_b: "PRIVATE"}
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
self.assertEqual(rc, 0)
|
|
286
|
+
by_repo = {track["repo"]: track for track in out["tracks"]}
|
|
287
|
+
self.assertEqual(by_repo[repo_a]["plan"]["rel"], "docs/plans/a.md")
|
|
288
|
+
self.assertEqual(by_repo[repo_b]["plan"]["rel"], "docs/plans/b.md")
|
|
289
|
+
|
|
290
|
+
def test_same_repo_alias_folders_keep_distinct_plan_badges(self):
|
|
291
|
+
"""Folder keys remain distinct even when aliases share a GitHub slug."""
|
|
292
|
+
repo = "org/shared"
|
|
293
|
+
track_a = _track("shared-name", repo, [])
|
|
294
|
+
track_b = _track("shared-name", repo, [])
|
|
295
|
+
track_a.folder = "alias-a"
|
|
296
|
+
track_b.folder = "alias-b"
|
|
297
|
+
track_a.meta["plan"] = "docs/plans/a.md"
|
|
298
|
+
track_b.meta["plan"] = "docs/plans/b.md"
|
|
299
|
+
|
|
300
|
+
def _badge(track, *_args):
|
|
301
|
+
return {"rel": track.meta["plan"], "resolved": False}
|
|
302
|
+
|
|
303
|
+
with patch("commands.export._plan_badge", side_effect=_badge):
|
|
304
|
+
rc, out, _ = self._run_with_mocks(
|
|
305
|
+
[track_a, track_b], {}, vis={repo: "PRIVATE"}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self.assertEqual(rc, 0)
|
|
309
|
+
by_folder = {track["folder"]: track for track in out["tracks"]}
|
|
310
|
+
self.assertEqual(by_folder["alias-a"]["plan"]["rel"], "docs/plans/a.md")
|
|
311
|
+
self.assertEqual(by_folder["alias-b"]["plan"]["rel"], "docs/plans/b.md")
|
|
312
|
+
|
|
266
313
|
|
|
267
314
|
class ExportCommandUntrackedTest(unittest.TestCase):
|
|
268
315
|
"""Verify export.run computes untracked = open issues minus tracked ones."""
|
|
@@ -435,6 +482,60 @@ class ExportPlanBadgeTest(unittest.TestCase):
|
|
|
435
482
|
badge = self._badge(self._track_with_plan(rel="docs/plans/missing.md"), root)
|
|
436
483
|
self.assertEqual(badge, {"rel": "docs/plans/missing.md", "resolved": False})
|
|
437
484
|
|
|
485
|
+
def test_unresolved_when_plan_traverses_outside_repo(self):
|
|
486
|
+
import tempfile
|
|
487
|
+
with tempfile.TemporaryDirectory() as d:
|
|
488
|
+
root = Path(d) / "repo"
|
|
489
|
+
root.mkdir()
|
|
490
|
+
outside = Path(d) / "outside.md"
|
|
491
|
+
outside.write_text(self.BODY)
|
|
492
|
+
rel = "../outside.md"
|
|
493
|
+
|
|
494
|
+
badge = self._badge(self._track_with_plan(rel=rel), root)
|
|
495
|
+
|
|
496
|
+
self.assertEqual(badge, {"rel": rel, "resolved": False})
|
|
497
|
+
|
|
498
|
+
def test_unresolved_when_plan_is_absolute_path(self):
|
|
499
|
+
import tempfile
|
|
500
|
+
with tempfile.TemporaryDirectory() as d:
|
|
501
|
+
root = Path(d) / "repo"
|
|
502
|
+
root.mkdir()
|
|
503
|
+
outside = Path(d) / "outside.md"
|
|
504
|
+
outside.write_text(self.BODY)
|
|
505
|
+
rel = str(outside)
|
|
506
|
+
|
|
507
|
+
badge = self._badge(self._track_with_plan(rel=rel), root)
|
|
508
|
+
|
|
509
|
+
self.assertEqual(badge, {"rel": rel, "resolved": False})
|
|
510
|
+
|
|
511
|
+
def test_unresolved_when_plan_is_absolute_path_inside_repo(self):
|
|
512
|
+
import tempfile
|
|
513
|
+
with tempfile.TemporaryDirectory() as d:
|
|
514
|
+
root = self._repo_with_plan(d, self.BODY)
|
|
515
|
+
rel = str(root / "docs/plans/p.md")
|
|
516
|
+
|
|
517
|
+
badge = self._badge(self._track_with_plan(rel=rel), root)
|
|
518
|
+
|
|
519
|
+
self.assertEqual(badge, {"rel": rel, "resolved": False})
|
|
520
|
+
|
|
521
|
+
def test_unresolved_when_plan_symlink_resolves_outside_repo(self):
|
|
522
|
+
import tempfile
|
|
523
|
+
with tempfile.TemporaryDirectory() as d:
|
|
524
|
+
root = Path(d) / "repo"
|
|
525
|
+
plans = root / "docs/plans"
|
|
526
|
+
plans.mkdir(parents=True)
|
|
527
|
+
outside = Path(d) / "outside.md"
|
|
528
|
+
outside.write_text(self.BODY)
|
|
529
|
+
link = plans / "p.md"
|
|
530
|
+
link.symlink_to(outside)
|
|
531
|
+
|
|
532
|
+
badge = self._badge(self._track_with_plan(), root)
|
|
533
|
+
|
|
534
|
+
self.assertEqual(
|
|
535
|
+
badge,
|
|
536
|
+
{"rel": "docs/plans/p.md", "resolved": False},
|
|
537
|
+
)
|
|
538
|
+
|
|
438
539
|
def test_no_folder_is_unresolved(self):
|
|
439
540
|
t = self._track_with_plan(folder=None)
|
|
440
541
|
from datetime import date
|
|
@@ -77,6 +77,8 @@ def _drive_apply(args, *, cfg, batch, answers, vis="PRIVATE"):
|
|
|
77
77
|
patch("commands.group.load_config", return_value=cfg), \
|
|
78
78
|
patch("lib.write_guard.repo_visibility", return_value=vis), \
|
|
79
79
|
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
80
|
+
patch("commands.group.shared_tier_dir",
|
|
81
|
+
return_value=Path(cfg["repos"]["myrepo"]["local"]) / ".work-plan"), \
|
|
80
82
|
patch("commands.group.write_file") as mw, \
|
|
81
83
|
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
82
84
|
patch("commands.group.seed_readme") as mseed, \
|
|
@@ -193,6 +195,8 @@ class GroupApplyTierRoutingTest(unittest.TestCase):
|
|
|
193
195
|
patch("commands.group.load_config", return_value=cfg), \
|
|
194
196
|
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
195
197
|
patch("commands.group.is_valid_git_repo", return_value=True), \
|
|
198
|
+
patch("commands.group.shared_tier_dir",
|
|
199
|
+
return_value=Path(cfg["repos"]["myrepo"]["local"]) / ".work-plan"), \
|
|
196
200
|
patch("commands.group.write_file"), \
|
|
197
201
|
patch("commands.group.parse_file", return_value=({}, "")), \
|
|
198
202
|
patch("commands.group.seed_readme") as mseed, \
|
|
@@ -20,6 +20,7 @@ import io
|
|
|
20
20
|
import json
|
|
21
21
|
import subprocess
|
|
22
22
|
import sys
|
|
23
|
+
import tempfile
|
|
23
24
|
import unittest
|
|
24
25
|
from contextlib import redirect_stdout
|
|
25
26
|
from pathlib import Path
|
|
@@ -441,6 +442,82 @@ class NewTrackCommandTest(unittest.TestCase):
|
|
|
441
442
|
self.assertEqual(rc, 0)
|
|
442
443
|
mw.assert_called_once()
|
|
443
444
|
|
|
445
|
+
def test_refuses_shared_track_when_work_plan_root_is_symlinked_outside_repo(self):
|
|
446
|
+
"""A repo-controlled shared root must not redirect creation elsewhere."""
|
|
447
|
+
with tempfile.TemporaryDirectory() as d:
|
|
448
|
+
base = Path(d)
|
|
449
|
+
clone = base / "clone"
|
|
450
|
+
outside = base / "outside"
|
|
451
|
+
notes = base / "notes"
|
|
452
|
+
(clone / ".git").mkdir(parents=True)
|
|
453
|
+
outside.mkdir()
|
|
454
|
+
notes.mkdir()
|
|
455
|
+
try:
|
|
456
|
+
(clone / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
457
|
+
except OSError as exc:
|
|
458
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
459
|
+
cfg = {
|
|
460
|
+
"notes_root": str(notes),
|
|
461
|
+
"repos": {
|
|
462
|
+
"myrepo": {"github": "org/myrepo", "local": str(clone)},
|
|
463
|
+
},
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
467
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"):
|
|
468
|
+
buf = io.StringIO()
|
|
469
|
+
with redirect_stdout(buf):
|
|
470
|
+
rc = new_track.run(["myrepo", "escaped"])
|
|
471
|
+
|
|
472
|
+
self.assertEqual(rc, 1)
|
|
473
|
+
self.assertFalse((outside / "escaped.md").exists())
|
|
474
|
+
self.assertIn("unsafe shared track directory", buf.getvalue())
|
|
475
|
+
|
|
476
|
+
def test_rechecks_shared_root_after_directory_creation_before_write(self):
|
|
477
|
+
"""A root swapped after mkdir is rejected before write_file opens it."""
|
|
478
|
+
with tempfile.TemporaryDirectory() as d:
|
|
479
|
+
base = Path(d)
|
|
480
|
+
clone = base / "clone"
|
|
481
|
+
outside = base / "outside"
|
|
482
|
+
notes = base / "notes"
|
|
483
|
+
(clone / ".git").mkdir(parents=True)
|
|
484
|
+
outside.mkdir()
|
|
485
|
+
notes.mkdir()
|
|
486
|
+
cfg = {
|
|
487
|
+
"notes_root": str(notes),
|
|
488
|
+
"repos": {
|
|
489
|
+
"myrepo": {"github": "org/myrepo", "local": str(clone)},
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
real_resolve = new_track.resolve_shared_tier
|
|
493
|
+
calls = 0
|
|
494
|
+
|
|
495
|
+
def _swap_after_mkdir(entry):
|
|
496
|
+
nonlocal calls
|
|
497
|
+
calls += 1
|
|
498
|
+
if calls == 3:
|
|
499
|
+
shared = clone / ".work-plan"
|
|
500
|
+
shared.rmdir()
|
|
501
|
+
try:
|
|
502
|
+
shared.symlink_to(outside, target_is_directory=True)
|
|
503
|
+
except OSError as exc:
|
|
504
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
505
|
+
return real_resolve(entry)
|
|
506
|
+
|
|
507
|
+
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
508
|
+
patch("commands.new_track.resolve_shared_tier",
|
|
509
|
+
side_effect=_swap_after_mkdir), \
|
|
510
|
+
patch("commands.new_track.write_file") as write, \
|
|
511
|
+
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"):
|
|
512
|
+
buf = io.StringIO()
|
|
513
|
+
with redirect_stdout(buf):
|
|
514
|
+
rc = new_track.run(["myrepo", "escaped"])
|
|
515
|
+
|
|
516
|
+
self.assertEqual(rc, 1)
|
|
517
|
+
write.assert_not_called()
|
|
518
|
+
self.assertFalse((outside / "escaped.md").exists())
|
|
519
|
+
self.assertIn("unsafe shared track directory", buf.getvalue())
|
|
520
|
+
|
|
444
521
|
|
|
445
522
|
# ---------------------------------------------------------------------------
|
|
446
523
|
# Phase D: --commit flag tests
|
|
@@ -504,6 +581,8 @@ class NewTrackCommitFlagTest(unittest.TestCase):
|
|
|
504
581
|
|
|
505
582
|
with patch("commands.new_track.load_config", return_value=cfg), \
|
|
506
583
|
patch("commands.new_track.write_file") as mw, \
|
|
584
|
+
patch("commands.new_track.resolve_shared_tier",
|
|
585
|
+
return_value=(Path(CLONE_ROOT) / ".work-plan", None)), \
|
|
507
586
|
patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
|
|
508
587
|
patch("pathlib.Path.exists", _path_exists), \
|
|
509
588
|
patch("pathlib.Path.is_dir", _is_dir), \
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Stamp / draft behaviour for plan-status (offline)."""
|
|
2
2
|
import io
|
|
3
|
+
import os
|
|
3
4
|
import unittest
|
|
4
5
|
import sys
|
|
5
6
|
import tempfile
|
|
@@ -11,6 +12,7 @@ SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
|
11
12
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
12
13
|
|
|
13
14
|
from commands import plan_status
|
|
15
|
+
from lib.doc_discovery import Doc
|
|
14
16
|
from lib.status_header import BEGIN
|
|
15
17
|
|
|
16
18
|
PLAN = (
|
|
@@ -21,6 +23,14 @@ PLAN = (
|
|
|
21
23
|
"- [ ] Step 1\n"
|
|
22
24
|
)
|
|
23
25
|
|
|
26
|
+
ROW = {
|
|
27
|
+
"glyph": "✅",
|
|
28
|
+
"verdict": "shipped",
|
|
29
|
+
"files_present": 1,
|
|
30
|
+
"files_declared": 1,
|
|
31
|
+
"last_touched": "2026-07-12",
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
|
|
25
35
|
class StampBehaviourTest(unittest.TestCase):
|
|
26
36
|
def _repo(self, d):
|
|
@@ -65,6 +75,64 @@ class StampBehaviourTest(unittest.TestCase):
|
|
|
65
75
|
self._run(root, ["--stamp"])
|
|
66
76
|
self.assertEqual(first, self.plan_path.read_text())
|
|
67
77
|
|
|
78
|
+
def test_stamp_rechecks_symlink_before_write(self):
|
|
79
|
+
with tempfile.TemporaryDirectory() as d:
|
|
80
|
+
root = Path(d) / "repo"
|
|
81
|
+
plans = root / "docs/plans"
|
|
82
|
+
plans.mkdir(parents=True)
|
|
83
|
+
victim = Path(d) / "victim.md"
|
|
84
|
+
victim.write_text("# outside\n")
|
|
85
|
+
link = plans / "evil.md"
|
|
86
|
+
link.symlink_to(victim)
|
|
87
|
+
doc = Doc(path=link, rel="docs/plans/evil.md", kind="plan")
|
|
88
|
+
|
|
89
|
+
buf = io.StringIO()
|
|
90
|
+
with redirect_stdout(buf):
|
|
91
|
+
plan_status._stamp_docs([doc], [ROW], repo_root=root, draft=False)
|
|
92
|
+
|
|
93
|
+
self.assertEqual(victim.read_text(), "# outside\n")
|
|
94
|
+
self.assertIn("refusing to stamp unsafe path", buf.getvalue())
|
|
95
|
+
|
|
96
|
+
def test_stamp_cli_does_not_modify_outside_symlink_target(self):
|
|
97
|
+
with tempfile.TemporaryDirectory() as d:
|
|
98
|
+
root = Path(d) / "repo"
|
|
99
|
+
plans = root / "docs/superpowers/plans"
|
|
100
|
+
plans.mkdir(parents=True)
|
|
101
|
+
victim = Path(d) / "victim.md"
|
|
102
|
+
victim.write_text(PLAN)
|
|
103
|
+
before = victim.read_bytes()
|
|
104
|
+
(plans / "evil.md").symlink_to(victim)
|
|
105
|
+
|
|
106
|
+
rc, out = self._run(root, ["--stamp"])
|
|
107
|
+
|
|
108
|
+
self.assertEqual(rc, 0)
|
|
109
|
+
self.assertEqual(victim.read_bytes(), before)
|
|
110
|
+
self.assertNotIn(BEGIN, victim.read_text())
|
|
111
|
+
self.assertIn("stamped 0 doc(s)", out)
|
|
112
|
+
|
|
113
|
+
def test_stamp_replaces_hard_link_without_modifying_outside_inode(self):
|
|
114
|
+
with tempfile.TemporaryDirectory() as d:
|
|
115
|
+
root = Path(d) / "repo"
|
|
116
|
+
plans = root / "docs/superpowers/plans"
|
|
117
|
+
plans.mkdir(parents=True)
|
|
118
|
+
victim = Path(d) / "victim.md"
|
|
119
|
+
victim.write_text(PLAN)
|
|
120
|
+
linked_plan = plans / "linked.md"
|
|
121
|
+
try:
|
|
122
|
+
os.link(str(victim), str(linked_plan))
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
self.skipTest(f"hard links unavailable: {exc}")
|
|
125
|
+
original_inode = victim.stat().st_ino
|
|
126
|
+
|
|
127
|
+
rc, out = self._run(root, ["--stamp"])
|
|
128
|
+
|
|
129
|
+
self.assertEqual(rc, 0)
|
|
130
|
+
self.assertEqual(victim.read_text(), PLAN)
|
|
131
|
+
self.assertEqual(victim.stat().st_ino, original_inode)
|
|
132
|
+
self.assertNotEqual(linked_plan.stat().st_ino, original_inode)
|
|
133
|
+
self.assertIn(BEGIN, linked_plan.read_text())
|
|
134
|
+
self.assertIn("stamped 1 doc(s)", out)
|
|
135
|
+
|
|
68
136
|
|
|
69
137
|
if __name__ == "__main__":
|
|
70
138
|
unittest.main()
|
|
@@ -97,6 +97,20 @@ class SharedTierDirTest(unittest.TestCase):
|
|
|
97
97
|
got = pw.shared_tier_dir({"local": d})
|
|
98
98
|
self.assertEqual(got, Path(d).expanduser() / ".work-plan")
|
|
99
99
|
|
|
100
|
+
def test_no_plan_branch_refuses_symlinked_work_plan_root(self):
|
|
101
|
+
with tempfile.TemporaryDirectory() as d:
|
|
102
|
+
base = Path(d)
|
|
103
|
+
clone = base / "clone"
|
|
104
|
+
outside = base / "outside"
|
|
105
|
+
clone.mkdir()
|
|
106
|
+
outside.mkdir()
|
|
107
|
+
try:
|
|
108
|
+
(clone / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
111
|
+
|
|
112
|
+
self.assertIsNone(pw.shared_tier_dir({"local": str(clone)}))
|
|
113
|
+
|
|
100
114
|
def test_plan_branch_uses_worktree_when_present(self):
|
|
101
115
|
with tempfile.TemporaryDirectory() as d:
|
|
102
116
|
dest = Path(d) / "wt"
|
|
@@ -107,6 +121,23 @@ class SharedTierDirTest(unittest.TestCase):
|
|
|
107
121
|
got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
|
|
108
122
|
self.assertEqual(got, dest / ".work-plan")
|
|
109
123
|
|
|
124
|
+
def test_plan_branch_refuses_symlinked_work_plan_root(self):
|
|
125
|
+
with tempfile.TemporaryDirectory() as d:
|
|
126
|
+
base = Path(d)
|
|
127
|
+
dest = base / "wt"
|
|
128
|
+
outside = base / "outside"
|
|
129
|
+
dest.mkdir()
|
|
130
|
+
outside.mkdir()
|
|
131
|
+
(dest / ".git").write_text("gitdir: ...\n")
|
|
132
|
+
try:
|
|
133
|
+
(dest / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
136
|
+
with patch.object(pw, "_worktree_dir", return_value=dest), \
|
|
137
|
+
patch.object(pw, "_git", _FakeGit()):
|
|
138
|
+
got = pw.shared_tier_dir({"local": d, "plan_branch": "plan"})
|
|
139
|
+
self.assertIsNone(got)
|
|
140
|
+
|
|
110
141
|
def test_plan_branch_none_when_branch_missing(self):
|
|
111
142
|
with tempfile.TemporaryDirectory() as d:
|
|
112
143
|
dest = Path(d) / "wt" # no .git → must be created, but branch missing
|
|
@@ -120,6 +120,23 @@ class SharedTrackDiscoveryTest(unittest.TestCase):
|
|
|
120
120
|
shared = next(t for t in tracks if t.name == "feat-x")
|
|
121
121
|
self.assertEqual(shared.tier, "shared")
|
|
122
122
|
|
|
123
|
+
def test_symlinked_shared_root_outside_repo_is_not_discovered(self):
|
|
124
|
+
with tempfile.TemporaryDirectory() as d:
|
|
125
|
+
base = Path(d)
|
|
126
|
+
clone = _make_git_repo(base / "clone")
|
|
127
|
+
outside = base / "outside"
|
|
128
|
+
_write_track_md(outside / "escaped.md", "escaped", "org/myrepo")
|
|
129
|
+
try:
|
|
130
|
+
(clone / ".work-plan").symlink_to(outside, target_is_directory=True)
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
self.skipTest(f"directory symlinks unavailable: {exc}")
|
|
133
|
+
notes = base / "notes"
|
|
134
|
+
notes.mkdir()
|
|
135
|
+
|
|
136
|
+
tracks = discover_tracks(self._make_cfg(clone, notes))
|
|
137
|
+
|
|
138
|
+
self.assertNotIn("escaped", [track.name for track in tracks])
|
|
139
|
+
|
|
123
140
|
def test_shared_track_repo_from_folder_config(self):
|
|
124
141
|
"""repo and local_path on a shared track come from folder config, not frontmatter."""
|
|
125
142
|
with tempfile.TemporaryDirectory() as d:
|