@stylusnexus/work-plan 2026.6.10 → 2026.6.11

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 (42) hide show
  1. package/README.md +13 -7
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +6 -4
  5. package/skills/work-plan/commands/canonicalize.py +7 -92
  6. package/skills/work-plan/commands/handoff.py +15 -6
  7. package/skills/work-plan/commands/init.py +13 -3
  8. package/skills/work-plan/commands/init_repo.py +8 -2
  9. package/skills/work-plan/commands/new_track.py +7 -0
  10. package/skills/work-plan/commands/notes_vcs.py +172 -0
  11. package/skills/work-plan/commands/refresh_md.py +106 -37
  12. package/skills/work-plan/commands/rename_track.py +243 -0
  13. package/skills/work-plan/commands/set_notes_root.py +8 -4
  14. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  15. package/skills/work-plan/lib/config.py +11 -0
  16. package/skills/work-plan/lib/frontmatter.py +12 -3
  17. package/skills/work-plan/lib/git_state.py +61 -52
  18. package/skills/work-plan/lib/github_state.py +46 -13
  19. package/skills/work-plan/lib/notes_vcs.py +276 -0
  20. package/skills/work-plan/lib/prompts.py +12 -1
  21. package/skills/work-plan/lib/status_table.py +95 -5
  22. package/skills/work-plan/lib/tracks.py +9 -4
  23. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  24. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  25. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  26. package/skills/work-plan/tests/test_config.py +12 -12
  27. package/skills/work-plan/tests/test_github_state.py +3 -3
  28. package/skills/work-plan/tests/test_init_repo.py +12 -7
  29. package/skills/work-plan/tests/test_new_track.py +7 -7
  30. package/skills/work-plan/tests/test_notes_vcs.py +426 -0
  31. package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
  32. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  33. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  34. package/skills/work-plan/tests/test_rename_track.py +351 -0
  35. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  36. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  37. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  38. package/skills/work-plan/tests/test_status_table.py +61 -0
  39. package/skills/work-plan/tests/test_track_resolution.py +2 -2
  40. package/skills/work-plan/tests/test_tracks.py +4 -4
  41. package/skills/work-plan/work_plan.py +97 -17
  42. /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
@@ -3,7 +3,10 @@ from lib.config import load_config, ConfigError
3
3
  from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
4
4
  from lib.github_state import fetch_issues, state_to_status_label
5
5
  from lib.frontmatter import write_file
6
- from lib.status_table import find_all_status_tables, find_canonical_status_tables, sync_missing_rows, ISSUE_NUM_RE
6
+ from lib.status_table import (
7
+ find_all_status_tables, find_canonical_status_tables, sync_missing_rows,
8
+ render_canonical_table, insert_canonical_block, ISSUE_NUM_RE,
9
+ )
7
10
  from lib.prompts import prompt_yes_no, parse_flags
8
11
 
9
12
 
@@ -81,7 +84,7 @@ def _refresh_many(tracks: list, yes: bool) -> int:
81
84
 
82
85
  # Frontmatter is canonical for membership: issues listed there but
83
86
  # missing from the table need a fresh row (issue #77). Fetch the union
84
- # so appended rows carry live title/assignee/status too.
87
+ # so rows carry live title/assignee/status too.
85
88
  frontmatter_nums = track.meta.get("github", {}).get("issues") or []
86
89
  fetch_nums = sorted(all_issue_nums | set(frontmatter_nums))
87
90
  if not fetch_nums:
@@ -91,55 +94,121 @@ def _refresh_many(tracks: list, yes: bool) -> int:
91
94
  issues_by_num = {i["number"]: i for i in issues}
92
95
  state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
93
96
 
94
- lines = track.body.split("\n")
95
- cell_updates = 0
96
- for table in tables:
97
- sidx = table["status_col_index"]
98
- for row in table["rows"]:
99
- nums = []
100
- for cell in row["cells"]:
101
- nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
102
- for num in nums:
103
- if num not in state_by_num:
104
- continue
105
- new_status = state_by_num[num]
106
- if sidx >= len(row["cells"]):
107
- continue
108
- current = row["cells"][sidx].strip()
109
- if current == new_status.strip():
110
- continue
111
- new_label = new_status.strip().split(" ", 1)[-1].lower()
112
- if new_label and new_label in current.lower():
113
- continue
114
- new_cells = list(row["cells"])
115
- new_cells[sidx] = " " + new_status + " "
116
- lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
117
- cell_updates += 1
118
-
119
- new_body = "\n".join(lines)
120
- # Slot in rows for frontmatter issues missing from the table, each at
121
- # its frontmatter-order position. Cell updates above preserve the line
122
- # count, so the table's line indices stay valid for sync_missing_rows.
123
- new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
97
+ if canonical:
98
+ # Canonical table → RE-DERIVE the whole block from frontmatter
99
+ # membership + live data, milestone-ordered (#101). Re-deriving from
100
+ # the one shared renderer is what keeps the markdown table from
101
+ # decaying: order, columns, missing rows, and statuses are all
102
+ # rebuilt every run, so it can't drift from the viewer.
103
+ new_body, detail = _rederive_canonical(
104
+ track, canonical, frontmatter_nums, issues_by_num, state_by_num
105
+ )
106
+ else:
107
+ new_body, detail = _refresh_narrative(
108
+ track, tables, frontmatter_nums, issues_by_num, state_by_num
109
+ )
124
110
 
125
111
  if new_body == track.body:
126
112
  continue
127
- pending.append((track, new_body, cell_updates, rows_added))
113
+ pending.append((track, new_body, detail))
128
114
 
129
115
  if not pending:
130
116
  print("All tracks in sync.")
131
117
  return 0
132
118
 
133
119
  print(f"Pending updates across {len(pending)} track(s):\n")
134
- for track, _, cells, added in pending:
135
- added_str = f", {added} row(s) added" if added else ""
136
- print(f" {track.path.name:50} {cells} cell(s){added_str}")
120
+ for track, _, detail in pending:
121
+ print(f" {track.path.name:50} {detail}")
137
122
 
138
123
  if not yes and not prompt_yes_no("\nApply all? [y/N]"):
139
124
  print("Cancelled.")
140
125
  return 0
141
126
 
142
- for track, new_body, _, _ in pending:
127
+ for track, new_body, _ in pending:
143
128
  write_file(track.path, track.meta, new_body)
144
129
  print(f"\n✓ Updated {len(pending)} file(s).")
145
130
  return 0
131
+
132
+
133
+ def _rederive_canonical(track, canonical_tables, frontmatter_nums,
134
+ issues_by_num, state_by_num):
135
+ """Rebuild the canonical block, milestone-ordered, from live data.
136
+
137
+ Returns (new_body, detail_str). detail reports rows added vs. the old table
138
+ and status changes, falling back to a format/order note when the only
139
+ change is reordering or the one-time 4→5 column migration."""
140
+ old_nums, old_status = set(), {}
141
+ for table in canonical_tables:
142
+ sidx = table["status_col_index"]
143
+ for row in table["rows"]:
144
+ row_nums = [int(m) for cell in row["cells"]
145
+ for m in ISSUE_NUM_RE.findall(cell)]
146
+ for num in row_nums:
147
+ old_nums.add(num)
148
+ if sidx < len(row["cells"]):
149
+ old_status[num] = row["cells"][sidx].strip()
150
+
151
+ table_md = render_canonical_table(
152
+ frontmatter_nums, issues_by_num,
153
+ milestone_alignment=track.meta.get("milestone_alignment"),
154
+ )
155
+ new_body = insert_canonical_block(track.body, table_md, replace=True)
156
+
157
+ rows_added = len(set(frontmatter_nums) - old_nums)
158
+ # Frontmatter is membership truth: a row in the old table but no longer in
159
+ # frontmatter is dropped on re-derive. Surface it so an approving user can
160
+ # see a deletion, not just additions.
161
+ rows_removed = len(old_nums - set(frontmatter_nums))
162
+ status_changes = sum(
163
+ 1 for n in frontmatter_nums
164
+ if n in old_status and n in state_by_num
165
+ and old_status[n] != state_by_num[n].strip()
166
+ )
167
+ bits = []
168
+ if status_changes:
169
+ bits.append(f"{status_changes} status change(s)")
170
+ if rows_added:
171
+ bits.append(f"{rows_added} row(s) added")
172
+ if rows_removed:
173
+ bits.append(f"{rows_removed} row(s) removed")
174
+ detail = ", ".join(bits) if bits else "canonical table re-derived"
175
+ return new_body, detail
176
+
177
+
178
+ def _refresh_narrative(track, tables, frontmatter_nums, issues_by_num, state_by_num):
179
+ """Original behavior for tracks WITHOUT a canonical table: update status
180
+ cells in narrative tables in place, then slot in missing frontmatter rows.
181
+ Conservative — never reorders or restructures a hand-written table."""
182
+ lines = track.body.split("\n")
183
+ cell_updates = 0
184
+ for table in tables:
185
+ sidx = table["status_col_index"]
186
+ for row in table["rows"]:
187
+ nums = []
188
+ for cell in row["cells"]:
189
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
190
+ for num in nums:
191
+ if num not in state_by_num:
192
+ continue
193
+ new_status = state_by_num[num]
194
+ if sidx >= len(row["cells"]):
195
+ continue
196
+ current = row["cells"][sidx].strip()
197
+ if current == new_status.strip():
198
+ continue
199
+ new_label = new_status.strip().split(" ", 1)[-1].lower()
200
+ if new_label and new_label in current.lower():
201
+ continue
202
+ new_cells = list(row["cells"])
203
+ new_cells[sidx] = " " + new_status + " "
204
+ lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
205
+ cell_updates += 1
206
+
207
+ new_body = "\n".join(lines)
208
+ # Slot in rows for frontmatter issues missing from the table, each at its
209
+ # frontmatter-order position. Cell updates preserve the line count, so the
210
+ # table's line indices stay valid for sync_missing_rows.
211
+ new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
212
+
213
+ added_str = f", {rows_added} row(s) added" if rows_added else ""
214
+ return new_body, f"{cell_updates} cell(s){added_str}"
@@ -0,0 +1,243 @@
1
+ """rename-track subcommand — rename an existing active track's slug + file.
2
+
3
+ Resolves <old-slug> to a single active Track, renames its .md file on disk,
4
+ updates the frontmatter `track` field + `last_touched`, and (for shared tracks)
5
+ optionally commits the move with --commit. Cross-references in sibling tracks'
6
+ `depends_on` lists are warned about, or rewritten with --fix-refs.
7
+
8
+ Non-goals: no bulk rename, no body search-and-replace, no archive rename
9
+ (archived tracks aren't discovered, so they can't be targeted).
10
+
11
+ Usage:
12
+ rename-track <old-slug | old@repo> <new-slug>
13
+ [--repo=<key>] [--fix-refs] [--commit] [--confirm=<token>]
14
+ """
15
+ import json
16
+ import re
17
+ import subprocess
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ from lib.config import load_config, ConfigError, is_valid_git_repo
22
+ from lib.tracks import (
23
+ discover_tracks,
24
+ find_track_by_name,
25
+ parse_track_repo_arg,
26
+ AmbiguousTrackError,
27
+ )
28
+ from lib.frontmatter import write_file
29
+ from lib.write_guard import needs_confirm, make_token, valid_token
30
+ from lib.prompts import parse_flags
31
+
32
+ # Same slug rule as new-track: lowercase letters/digits/hyphens, starts with letter.
33
+ _SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
34
+
35
+
36
+ def _git_commit_rename(
37
+ old_path: Path, new_path: Path, old_slug: str, new_slug: str
38
+ ) -> None:
39
+ """Stage the old + new paths and commit a single shared-track rename.
40
+
41
+ Path-scoped (never `git add .`). git detects the move as a rename at commit
42
+ time from content similarity. Non-fatal: any git failure warns and returns.
43
+ """
44
+ # The clone root is .work-plan/'s parent.
45
+ clone_root = new_path.parent.parent
46
+ if not is_valid_git_repo(clone_root):
47
+ print("⚠ --commit ignored: track is private (not in a git repo)")
48
+ return
49
+
50
+ # Determine current branch name for the success message.
51
+ branch = "HEAD"
52
+ try:
53
+ result = subprocess.run(
54
+ ["git", "-C", str(clone_root), "rev-parse", "--abbrev-ref", "HEAD"],
55
+ capture_output=True, text=True, check=False,
56
+ )
57
+ if result.returncode == 0:
58
+ branch = result.stdout.strip()
59
+ except OSError:
60
+ pass
61
+
62
+ # Stage ONLY the two affected paths (old deletion + new addition).
63
+ try:
64
+ subprocess.run(
65
+ ["git", "-C", str(clone_root), "add", str(old_path), str(new_path)],
66
+ capture_output=True, text=True, check=True,
67
+ )
68
+ except (subprocess.CalledProcessError, OSError) as e:
69
+ msg = getattr(e, "stderr", str(e))
70
+ print(f"⚠ --commit: git add failed ({msg.strip()!r}) — continuing without commit")
71
+ return
72
+
73
+ commit_msg = f"chore: rename shared track '{old_slug}' → '{new_slug}'"
74
+ try:
75
+ subprocess.run(
76
+ ["git", "-C", str(clone_root), "commit", "-m", commit_msg],
77
+ capture_output=True, text=True, check=True,
78
+ )
79
+ except (subprocess.CalledProcessError, OSError) as e:
80
+ msg = getattr(e, "stderr", str(e))
81
+ print(f"⚠ --commit: git commit failed ({msg.strip()!r}) — continuing without commit")
82
+ return
83
+
84
+ print(f"✓ committed rename '{old_slug}' → '{new_slug}' to {branch}")
85
+
86
+
87
+ def _fix_cross_references(
88
+ tracks: list, renamed: object, old_slug: str, new_slug: str, *, apply: bool
89
+ ) -> int:
90
+ """Find sibling tracks in the same repo whose `depends_on` lists old_slug.
91
+
92
+ With apply=True, rewrite each occurrence to new_slug and persist the file;
93
+ otherwise just report. Returns the number of referring tracks found.
94
+ """
95
+ referrers = [
96
+ t for t in tracks
97
+ if t is not renamed
98
+ and t.has_frontmatter
99
+ and t.repo == renamed.repo
100
+ and old_slug in (t.meta.get("depends_on") or [])
101
+ ]
102
+ if not referrers:
103
+ return 0
104
+
105
+ if apply:
106
+ for t in referrers:
107
+ t.meta["depends_on"] = [
108
+ new_slug if dep == old_slug else dep
109
+ for dep in t.meta.get("depends_on") or []
110
+ ]
111
+ write_file(t.path, t.meta, t.body)
112
+ print(
113
+ f"✓ updated depends_on in {len(referrers)} track(s): "
114
+ + ", ".join(t.name for t in referrers)
115
+ )
116
+ else:
117
+ print(
118
+ f"⚠ {len(referrers)} track(s) still depend on '{old_slug}': "
119
+ + ", ".join(t.name for t in referrers)
120
+ )
121
+ print(" Re-run with --fix-refs to rewrite their depends_on to the new slug.")
122
+ return len(referrers)
123
+
124
+
125
+ def run(args: list[str]) -> int:
126
+ flags, positional = parse_flags(
127
+ args, {"--repo", "--confirm", "--fix-refs", "--commit"}
128
+ )
129
+
130
+ if len(positional) < 2:
131
+ print(
132
+ "usage: work_plan.py rename-track <old-slug | old@repo> <new-slug>"
133
+ " [--repo=<key>] [--fix-refs] [--commit] [--confirm=<token>]"
134
+ )
135
+ return 2
136
+
137
+ old_arg = positional[0]
138
+ new_slug = positional[1]
139
+
140
+ name_from_arg, repo_from_arg = parse_track_repo_arg(old_arg)
141
+ old_name = name_from_arg
142
+ repo_qualifier = repo_from_arg or (
143
+ flags.get("--repo") if flags.get("--repo") is not True else None
144
+ )
145
+
146
+ # Validate the new slug up front (cheap, no I/O).
147
+ if not _SLUG_RE.fullmatch(new_slug):
148
+ print(
149
+ f"ERROR: '{new_slug}' is not a valid slug."
150
+ " Use lowercase letters, digits, hyphens; must start with a letter."
151
+ )
152
+ return 2
153
+
154
+ try:
155
+ cfg = load_config()
156
+ except ConfigError as e:
157
+ print(f"ERROR: {e}")
158
+ return 1
159
+
160
+ tracks = discover_tracks(cfg)
161
+ try:
162
+ track = find_track_by_name(old_name, tracks, repo=repo_qualifier)
163
+ except AmbiguousTrackError as e:
164
+ print(str(e))
165
+ return 1
166
+ if not track:
167
+ print(f"No track matching '{old_name}'.")
168
+ return 1
169
+
170
+ if new_slug == track.name:
171
+ print(f"ERROR: '{new_slug}' is already the track's slug — nothing to rename.")
172
+ return 2
173
+
174
+ # Reject if a track with new_slug already exists in the same repo/tier
175
+ # (same target directory). new_path.exists() is the authoritative check.
176
+ new_path = track.path.parent / f"{new_slug}.md"
177
+ if new_path.exists():
178
+ print(f"ERROR: a track '{new_slug}' already exists at {new_path}")
179
+ return 2
180
+
181
+ # Public-repo confirm gate — fires BEFORE any write or move. Mirrors close.
182
+ confirm = flags.get("--confirm")
183
+ if track.repo and needs_confirm(track.repo, cfg) and not (
184
+ isinstance(confirm, str) and valid_token(confirm, track.repo, new_slug)
185
+ ):
186
+ print(json.dumps({
187
+ "needs_confirm": True,
188
+ "reason": (
189
+ f"{track.repo} is PUBLIC (or visibility unknown); "
190
+ f"renaming '{track.name}' → '{new_slug}' will be written there."
191
+ ),
192
+ "token": make_token(track.repo, new_slug),
193
+ }))
194
+ return 0
195
+
196
+ # ------------------------------------------------------------------
197
+ # Perform the rename: write the rewritten frontmatter (track slug +
198
+ # last_touched) to the NEW path FIRST, then remove the old file. Doing
199
+ # it in this order means a write_file failure (yq error, symlink refusal)
200
+ # leaves the original intact — no half-renamed state where the filename
201
+ # and the frontmatter `track` field disagree.
202
+ # ------------------------------------------------------------------
203
+ old_path = track.path
204
+ old_slug = track.name
205
+
206
+ track.meta["track"] = new_slug
207
+ track.meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
208
+
209
+ write_file(new_path, track.meta, track.body)
210
+ old_path.unlink()
211
+ track.path = new_path
212
+ track.name = new_slug
213
+
214
+ is_shared = getattr(track, "tier", None) == "shared"
215
+ if is_shared:
216
+ print(f"✓ Renamed shared track '{old_slug}' → '{new_slug}' at {new_path}")
217
+ else:
218
+ notes_root = Path(cfg["notes_root"]).expanduser()
219
+ try:
220
+ display = new_path.relative_to(notes_root)
221
+ except ValueError:
222
+ display = new_path
223
+ print(f"✓ Renamed track '{old_slug}' → '{new_slug}' at {display}")
224
+
225
+ # ------------------------------------------------------------------
226
+ # --commit: stage + commit the rename to the shared repo (non-fatal).
227
+ # ------------------------------------------------------------------
228
+ if "--commit" in flags:
229
+ if is_shared:
230
+ _git_commit_rename(old_path, new_path, old_slug, new_slug)
231
+ else:
232
+ print("⚠ --commit ignored: track is private (not in a git repo)")
233
+ elif is_shared:
234
+ print(" ↑ shared track — commit + push to share this rename with teammates.")
235
+
236
+ # ------------------------------------------------------------------
237
+ # Cross-reference hygiene: sibling tracks that depend_on the old slug.
238
+ # ------------------------------------------------------------------
239
+ _fix_cross_references(
240
+ tracks, track, old_slug, new_slug, apply="--fix-refs" in flags
241
+ )
242
+
243
+ return 0
@@ -5,6 +5,7 @@ folder. Config writes stay in the CLI (the engine), not the extension.
5
5
 
6
6
  Usage: set-notes-root <path>
7
7
  """
8
+ import os
8
9
  import subprocess
9
10
  from pathlib import Path
10
11
 
@@ -38,12 +39,15 @@ def run(args: list[str]) -> int:
38
39
  # Ensure the target directory exists
39
40
  new_root.mkdir(parents=True, exist_ok=True)
40
41
 
41
- # Write the new notes_root into config via yq (mikefarah/yq)
42
- yq_expr = f'.notes_root = "{new_root}"'
42
+ # Write the new notes_root into config via yq (mikefarah/yq). The path is
43
+ # passed as an OPAQUE env value via strenv() — never interpolated into the
44
+ # yq expression — so a path containing `"` or yq operators cannot break out
45
+ # of the string literal and rewrite arbitrary config keys (#191).
46
+ env = {**os.environ, "WP_NEW_ROOT": str(new_root)}
43
47
  try:
44
48
  subprocess.run(
45
- ["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
46
- check=True, capture_output=True, text=True,
49
+ ["yq", "-i", ".notes_root = strenv(WP_NEW_ROOT)", str(DEFAULT_CONFIG_PATH)],
50
+ check=True, capture_output=True, text=True, env=env,
47
51
  )
48
52
  except subprocess.CalledProcessError as e:
49
53
  print(f"ERROR: yq failed to update config: {e.stderr}")
@@ -113,8 +113,18 @@ def _apply(cfg: dict) -> int:
113
113
 
114
114
  print(f"Applying {len(answers)} priority labels to {repo}...")
115
115
  for ans in answers:
116
- num = ans["number"]
117
- priority = ans["priority"]
116
+ # The answers file is model-written; coerce the issue number to int and
117
+ # skip malformed entries so a non-numeric value can't reach `gh` argv
118
+ # (and a malformed file can't crash the apply). (#196)
119
+ if not isinstance(ans, dict):
120
+ print(f" SKIP: answer is not an object: {ans!r}")
121
+ continue
122
+ try:
123
+ num = int(ans["number"])
124
+ except (KeyError, TypeError, ValueError):
125
+ print(f" SKIP: answer missing a numeric 'number': {ans!r}")
126
+ continue
127
+ priority = ans.get("priority")
118
128
  if priority not in ("P0", "P1", "P2", "P3"):
119
129
  print(f" SKIP #{num}: invalid priority '{priority}'")
120
130
  continue
@@ -76,6 +76,17 @@ def is_valid_git_repo(path: Path) -> bool:
76
76
  return p.is_dir() and (p / ".git").exists()
77
77
 
78
78
 
79
+ def notes_vcs_auto_commit(cfg: dict) -> bool:
80
+ """True when opt-in local VCS auto-commit is enabled for notes_root (#103).
81
+
82
+ Reads `notes_vcs.auto_commit` from config. Absent/malformed → False
83
+ (opt-in: the feature does nothing until the user turns it on, e.g. via
84
+ `work-plan notes-vcs init` or `notes-vcs enable`).
85
+ """
86
+ block = cfg.get("notes_vcs")
87
+ return bool(block.get("auto_commit")) if isinstance(block, dict) else False
88
+
89
+
79
90
  def resolve_github_for_folder(folder_name: str, cfg: dict) -> Optional[str]:
80
91
  entry = cfg.get("repos", {}).get(folder_name)
81
92
  return entry.get("github") if entry else None
@@ -21,12 +21,21 @@ def parse_file(path: Path) -> Tuple[dict, str]:
21
21
 
22
22
 
23
23
  def write_file(path: Path, meta: dict, body: str) -> None:
24
- """Write markdown with frontmatter. Empty meta = body only."""
24
+ """Write markdown with frontmatter. Empty meta = body only.
25
+
26
+ Refuses to write through a symlink (#195): a track file that is a symlink to
27
+ a target outside the notes tree would otherwise let a write land on an
28
+ arbitrary file. Track files are never legitimately symlinks, so this rejects
29
+ nothing valid; raises ValueError if one is encountered.
30
+ """
31
+ p = Path(path)
32
+ if p.is_symlink():
33
+ raise ValueError(f"refusing to write through symlink: {p}")
25
34
  if not meta:
26
- Path(path).write_text(body, encoding="utf-8")
35
+ p.write_text(body, encoding="utf-8")
27
36
  return
28
37
  yaml_text = _dict_to_yaml(meta)
29
- Path(path).write_text(f"---\n{yaml_text}---\n{body}", encoding="utf-8")
38
+ p.write_text(f"---\n{yaml_text}---\n{body}", encoding="utf-8")
30
39
 
31
40
 
32
41
  def _yaml_to_dict(yaml_text: str) -> dict: