@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 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 a `.installed-from` marker so `uninstall` knows what's safe to remove
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.10+92f8eef
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.10",
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 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
@@ -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
@@ -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,
@@ -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
  # ---------------------------------------------------------------------------
@@ -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="myrepo",
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"], "myrepo")
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="myrepo", meta=meta)
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="myrepo", meta=meta)
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="myrepo",
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: