@stylusnexus/work-plan 2026.7.10 → 2026.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +9 -1
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +1 -0
  5. package/skills/work-plan/commands/doctor.py +529 -0
  6. package/skills/work-plan/commands/export.py +14 -11
  7. package/skills/work-plan/commands/init_repo.py +3 -6
  8. package/skills/work-plan/commands/new_track.py +21 -2
  9. package/skills/work-plan/commands/plan_status.py +48 -6
  10. package/skills/work-plan/lib/config.py +29 -0
  11. package/skills/work-plan/lib/doc_discovery.py +22 -2
  12. package/skills/work-plan/lib/export_model.py +16 -3
  13. package/skills/work-plan/lib/github_state.py +25 -0
  14. package/skills/work-plan/lib/notes_vcs.py +21 -9
  15. package/skills/work-plan/lib/plan_worktree.py +47 -8
  16. package/skills/work-plan/lib/tracks.py +17 -3
  17. package/skills/work-plan/tests/test_config.py +59 -0
  18. package/skills/work-plan/tests/test_discover_archived.py +15 -0
  19. package/skills/work-plan/tests/test_doc_discovery.py +11 -0
  20. package/skills/work-plan/tests/test_doctor.py +852 -0
  21. package/skills/work-plan/tests/test_export.py +34 -8
  22. package/skills/work-plan/tests/test_export_command.py +101 -0
  23. package/skills/work-plan/tests/test_github_state.py +41 -1
  24. package/skills/work-plan/tests/test_group_apply.py +4 -0
  25. package/skills/work-plan/tests/test_new_track.py +79 -0
  26. package/skills/work-plan/tests/test_notes_vcs.py +30 -0
  27. package/skills/work-plan/tests/test_plan_status_stamp.py +68 -0
  28. package/skills/work-plan/tests/test_plan_worktree.py +31 -0
  29. package/skills/work-plan/tests/test_tracks.py +55 -1
  30. package/skills/work-plan/work_plan.py +20 -0
@@ -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 doc_path.is_file():
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
- # Keyed by (repo, name) so same-named tracks in different repos don't collide.
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.repo, t.name)] = [
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.repo, t.name)] = []
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.name] = badge
150
+ plan_by_track[track_key(t)] = badge
148
151
 
149
- # Per-track branch heat, keyed (repo, name) track names collide across repos.
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.repo, t.name)] = nums
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
@@ -61,6 +61,8 @@ def _update_existing(key: str, github: str, local: "str | None", clear_local: bo
61
61
  while keeping github + every other field. Mutually exclusive with `local`
62
62
  (enforced in run() before this is called).
63
63
  """
64
+ from lib.config import write_repo_field
65
+
64
66
  updates = {}
65
67
  if clear_local:
66
68
  # JSON null → YAML null; the * merge overwrites local with null, leaving
@@ -77,13 +79,8 @@ def _update_existing(key: str, github: str, local: "str | None", clear_local: bo
77
79
  # `key` is validated against ^[a-z][a-z0-9-]*$ in run() before this is called,
78
80
  # so it's safe in the yq path. Field values travel as an OPAQUE env value via
79
81
  # env() (parsed as JSON), never interpolated — uniform with the add path.
80
- env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
81
- yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
82
82
  try:
83
- subprocess.run(
84
- ["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
85
- check=True, capture_output=True, text=True, env=env,
86
- )
83
+ write_repo_field(key, updates)
87
84
  except subprocess.CalledProcessError as e:
88
85
  print(f"ERROR: yq failed to update config: {e.stderr}")
89
86
  return 1
@@ -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 shared_tier_dir
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 = shared_tier_dir(repo_entry)
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
- changed.append(doc.rel)
257
- if not draft:
258
- doc.path.write_text(new, encoding="utf-8")
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
@@ -1,5 +1,6 @@
1
1
  """Load + validate ~/.claude/work-plan/config.yml."""
2
2
  import json
3
+ import os
3
4
  import subprocess
4
5
  from pathlib import Path
5
6
  from typing import Optional
@@ -57,9 +58,11 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH,
57
58
  if "notes_root" not in cfg:
58
59
  raise ConfigError("config.yml missing required key 'notes_root'.")
59
60
  cfg.setdefault("repos", {})
61
+ scalar_shape_keys = set()
60
62
  # Normalize string-shape entries to dict shape
61
63
  for folder, val in list(cfg["repos"].items()):
62
64
  if isinstance(val, str):
65
+ scalar_shape_keys.add(folder)
63
66
  cfg["repos"][folder] = {"github": val, "local": None}
64
67
  elif isinstance(val, dict):
65
68
  val.setdefault("local", None)
@@ -67,6 +70,7 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH,
67
70
  raise ConfigError(f"repo '{folder}' missing 'github' key")
68
71
  else:
69
72
  raise ConfigError(f"repo '{folder}' must be string or dict, got {type(val).__name__}")
73
+ cfg["_scalar_shape_keys"] = scalar_shape_keys
70
74
  return cfg
71
75
 
72
76
 
@@ -97,3 +101,28 @@ def resolve_local_path_for_folder(folder_name: str, cfg: dict) -> Optional[Path]
97
101
  if not entry or not entry.get("local"):
98
102
  return None
99
103
  return Path(entry["local"]).expanduser()
104
+
105
+
106
+ def write_repo_field(key: str, updates: dict, path: Path = DEFAULT_CONFIG_PATH) -> None:
107
+ """Merge `updates` into `repos.<key>` in config.yml via an opaque-env `yq`
108
+ merge — the same mechanic `init_repo.py::_update_existing` already used
109
+ (extracted here so `doctor` and `init-repo` share one implementation).
110
+
111
+ `updates` values travel as JSON through an env var, never interpolated
112
+ into the yq expression, so they can't break out of the merge. `key` is
113
+ interpolated directly into the yq path — callers MUST validate it against
114
+ a safe-key pattern (e.g. `^[a-z][a-z0-9-]*$`) before calling this; this
115
+ function does not re-validate, matching `_update_existing`'s existing
116
+ contract (its caller already validates via `init-repo`'s own regex check).
117
+
118
+ Raises `subprocess.CalledProcessError` on any yq failure — including the
119
+ known case of `repos.<key>` being a scalar string on disk (yq cannot
120
+ multiply a string with a map). Callers must catch this and treat it as
121
+ "entry not fixable this way", not crash the whole command.
122
+ """
123
+ env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
124
+ yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
125
+ subprocess.run(
126
+ ["yq", "-i", yq_expr, str(path)],
127
+ check=True, capture_output=True, text=True, env=env,
128
+ )
@@ -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.is_file() or p in seen:
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.is_file() or p in seen:
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
- hot = hot_by_track.get((t.repo, t.name), set())
96
- raw = issues_by_track.get((t.repo, t.name), [])
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(t.name),
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,
@@ -433,6 +433,31 @@ def repo_visibility(repo: str) -> Optional[str]:
433
433
  return vis
434
434
 
435
435
 
436
+ def repo_full_name(slug: str) -> Optional[str]:
437
+ """Live GitHub identity check via `gh api repos/<slug> --jq .full_name`.
438
+
439
+ Returns the repo's current canonical `owner/repo` — following GitHub's own
440
+ rename redirect if `slug` was renamed and the redirect is still live — or
441
+ None on any failure (404, no access, rate-limited, offline, bad slug
442
+ shape). Never raises. Used by `doctor` to detect a GitHub-confirmed rename;
443
+ NOT a stable identity check (see doctor's own docs) — a slug that was
444
+ reused by an unrelated repo after the redirect lapses is indistinguishable
445
+ from a genuine rename here.
446
+ """
447
+ if not _valid_repo(slug):
448
+ return None
449
+ try:
450
+ proc = subprocess.run(
451
+ ["gh", "api", f"repos/{slug}", "--jq", ".full_name"],
452
+ capture_output=True, text=True, timeout=GH_TIMEOUT,
453
+ )
454
+ except Exception:
455
+ return None
456
+ if proc.returncode != 0:
457
+ return None
458
+ return proc.stdout.strip() or None
459
+
460
+
436
461
  def extract_priority(labels: list[dict]) -> str:
437
462
  label_names = {lbl["name"] for lbl in labels}
438
463
  for p in PRIORITY_LABELS:
@@ -132,26 +132,38 @@ def head_parent_sha(notes_root: Path) -> Optional[str]:
132
132
  return proc.stdout.strip()
133
133
 
134
134
 
135
- def dirty_paths(notes_root: Path) -> set:
136
- """Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
137
-
138
- Empty set on any failure. Renames collapse to the destination path. Used by
139
- the dispatcher to commit ONLY what a command changed, leaving pre-existing
140
- dirty files untouched.
135
+ def dirty_paths_checked(notes_root: Path) -> tuple:
136
+ """Like `dirty_paths`, but distinguishes "clean tree" from "the git status
137
+ call itself failed" (timeout, spawn error, not a repo) — both of which
138
+ `dirty_paths` alone reports as an empty set. Returns (call_succeeded, paths).
139
+
140
+ Callers that need to know WHETHER they can trust an empty result (e.g.
141
+ doctor's dirty-file safety check, which must not treat "couldn't check" as
142
+ "definitely clean") should call this instead of `dirty_paths`.
141
143
  """
142
144
  proc = _git(notes_root, "-c", "core.quotepath=false", "status", "--porcelain")
143
145
  if proc is None or proc.returncode != 0:
144
- return set()
146
+ return (False, set())
145
147
  paths = set()
146
148
  for line in proc.stdout.splitlines():
147
149
  if len(line) < 4:
148
150
  continue
149
151
  path = line[3:]
150
- if " -> " in path: # rename / copy → the destination path
152
+ if " -> " in path:
151
153
  path = path.split(" -> ", 1)[1]
152
154
  if path:
153
155
  paths.add(path)
154
- return paths
156
+ return (True, paths)
157
+
158
+
159
+ def dirty_paths(notes_root: Path) -> set:
160
+ """Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
161
+
162
+ Empty set on any failure. Renames collapse to the destination path. Used by
163
+ the dispatcher to commit ONLY what a command changed, leaving pre-existing
164
+ dirty files untouched.
165
+ """
166
+ return dirty_paths_checked(notes_root)[1]
155
167
 
156
168
 
157
169
  def last_commit_summary(notes_root: Path) -> Optional[str]:
@@ -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
- 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"
247
+ path, _unsafe_reason = resolve_shared_tier(entry)
248
+ return path
210
249
 
211
250
 
212
251
  # ---------------------------------------------------------------------------
@@ -327,15 +327,29 @@ def _build_shared_track(md_path: Path, folder_key: str,
327
327
  )
328
328
 
329
329
 
330
- def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
330
+ def iter_private_track_paths(notes_root: Path, include_archive: bool) -> list:
331
+ """Eligible track markdown paths under `notes_root`, in sorted order.
332
+
333
+ Recursive. Skips `archive/` subtrees unless `include_archive` is True.
334
+ Skips filenames starting with '.', '_', or '-' (dotfiles, drafts, and
335
+ dash-led names that would otherwise misparse as a CLI flag, #194). This is
336
+ the single shared definition of "which private track files count" — both
337
+ `_walk` (track discovery) and `doctor` (drift scanning) call this, so they
338
+ cannot diverge on which files are in scope.
339
+ """
331
340
  out = []
332
341
  for md_path in sorted(notes_root.rglob("*.md")):
333
342
  if not include_archive and "archive" in md_path.parts:
334
343
  continue
335
- # '-' prefix rejected so a `--repo.md` file can't become a `--repo`
336
- # track that the CLI misparses as a flag (#194).
337
344
  if md_path.name.startswith((".", "_", "-")):
338
345
  continue
346
+ out.append(md_path)
347
+ return out
348
+
349
+
350
+ def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
351
+ out = []
352
+ for md_path in iter_private_track_paths(notes_root, include_archive):
339
353
  out.append(_build_track(md_path, notes_root, cfg))
340
354
  return out
341
355
 
@@ -2,6 +2,7 @@
2
2
  import unittest
3
3
  import tempfile
4
4
  import sys
5
+ import subprocess
5
6
  from pathlib import Path
6
7
 
7
8
  SKILL_ROOT = Path(__file__).resolve().parents[1]
@@ -10,6 +11,7 @@ sys.path.insert(0, str(SKILL_ROOT))
10
11
  from lib.config import (
11
12
  load_config, ConfigError,
12
13
  resolve_github_for_folder, resolve_local_path_for_folder,
14
+ write_repo_field,
13
15
  )
14
16
 
15
17
 
@@ -81,5 +83,62 @@ class ResolveTest(unittest.TestCase):
81
83
  self.assertIsNone(resolve_local_path_for_folder("unknown", self.cfg))
82
84
 
83
85
 
86
+ class BaseConfigWriteTest(unittest.TestCase):
87
+ """Base class for tests that need to write and read config files."""
88
+ def _write_config(self, content):
89
+ """Write content to a temporary config file and return its path."""
90
+ d = tempfile.mkdtemp()
91
+ path = Path(d) / "config.yml"
92
+ path.write_text(content, encoding="utf-8")
93
+ return path
94
+
95
+
96
+ class TestScalarShapeKeys(BaseConfigWriteTest):
97
+ def test_scalar_entry_is_tracked(self):
98
+ cfg_path = self._write_config(
99
+ "notes_root: /tmp/notes\n"
100
+ "repos:\n"
101
+ " foo: org/foo\n"
102
+ " bar:\n"
103
+ " github: org/bar\n"
104
+ )
105
+ cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
106
+ self.assertEqual(cfg["_scalar_shape_keys"], {"foo"})
107
+
108
+ def test_no_scalar_entries_is_empty_set(self):
109
+ cfg_path = self._write_config(
110
+ "notes_root: /tmp/notes\n"
111
+ "repos:\n"
112
+ " bar:\n"
113
+ " github: org/bar\n"
114
+ )
115
+ cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
116
+ self.assertEqual(cfg["_scalar_shape_keys"], set())
117
+
118
+
119
+ class TestWriteRepoField(BaseConfigWriteTest):
120
+ def test_writes_only_the_given_fields(self):
121
+ cfg_path = self._write_config(
122
+ "notes_root: /tmp/notes\n"
123
+ "repos:\n"
124
+ " bar:\n"
125
+ " github: org/bar\n"
126
+ " local: /code/bar\n"
127
+ )
128
+ write_repo_field("bar", {"github": "org/bar-renamed"}, path=cfg_path)
129
+ cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
130
+ self.assertEqual(cfg["repos"]["bar"]["github"], "org/bar-renamed")
131
+ self.assertEqual(cfg["repos"]["bar"]["local"], "/code/bar")
132
+
133
+ def test_raises_on_scalar_entry(self):
134
+ cfg_path = self._write_config(
135
+ "notes_root: /tmp/notes\n"
136
+ "repos:\n"
137
+ " foo: org/foo\n"
138
+ )
139
+ with self.assertRaises(subprocess.CalledProcessError):
140
+ write_repo_field("foo", {"github": "org/foo-renamed"}, path=cfg_path)
141
+
142
+
84
143
  if __name__ == "__main__":
85
144
  unittest.main()
@@ -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()