@stylusnexus/work-plan 2026.6.9-4 → 2026.6.10-1

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 (38) hide show
  1. package/README.md +5 -5
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +2 -2
  5. package/skills/work-plan/commands/brief.py +6 -6
  6. package/skills/work-plan/commands/handoff.py +15 -6
  7. package/skills/work-plan/commands/hygiene.py +2 -0
  8. package/skills/work-plan/commands/init.py +13 -3
  9. package/skills/work-plan/commands/init_repo.py +8 -2
  10. package/skills/work-plan/commands/list_cmd.py +34 -6
  11. package/skills/work-plan/commands/new_track.py +7 -0
  12. package/skills/work-plan/commands/reconcile.py +106 -17
  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/frontmatter.py +12 -3
  16. package/skills/work-plan/lib/git_state.py +61 -52
  17. package/skills/work-plan/lib/github_state.py +46 -13
  18. package/skills/work-plan/lib/prompts.py +46 -4
  19. package/skills/work-plan/lib/tracks.py +36 -4
  20. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  21. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  22. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  23. package/skills/work-plan/tests/test_config.py +12 -12
  24. package/skills/work-plan/tests/test_github_state.py +3 -3
  25. package/skills/work-plan/tests/test_init_repo.py +12 -7
  26. package/skills/work-plan/tests/test_list_sort.py +162 -0
  27. package/skills/work-plan/tests/test_new_track.py +7 -7
  28. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  29. package/skills/work-plan/tests/test_prompts.py +121 -0
  30. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  31. package/skills/work-plan/tests/test_reconcile_readonly.py +19 -0
  32. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  33. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  34. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  35. package/skills/work-plan/tests/test_track_resolution.py +2 -2
  36. package/skills/work-plan/tests/test_tracks.py +4 -4
  37. package/skills/work-plan/work_plan.py +13 -13
  38. /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
package/README.md CHANGED
@@ -51,7 +51,7 @@ The five essentials you'll use 80% of the time are:
51
51
  | `/work-plan brief` | Morning. Multi-track snapshot — what's on your plate across every active track. Add `--repo=<key>` to scope to one project. |
52
52
  | `/work-plan handoff <track>` | End of a work block. Captures what you touched. Use `--auto-next` for an algorithmic priority-sorted `next_up` (no LLM), `--set-next 1,2,3` for explicit numbers, or pair with Claude in chat for a curated pick. |
53
53
  | `/work-plan orient <track>` | Switching context. ~15-line paste-block of priority / last session / next pick / git state — drop into a fresh Claude Code terminal. |
54
- | `/work-plan reconcile <track> \| --all \| --repo=<key> [--draft]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. `--draft` previews proposed ADDs/FLAGs without prompting or writing. `--repo=<key>` scopes the sweep to one repo. |
54
+ | `/work-plan reconcile <track> \| --all \| --repo=<key> [--draft] [--yes]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. In an `--all`/`--repo` sweep it also moves issues relabeled from one track to another in the same repo. `--draft` previews proposed ADDs/MOVEs/FLAGs; `--yes` applies without prompting. `--repo=<key>` scopes the sweep to one repo. |
55
55
  | `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Runs three steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `duplicates` (flag likely-duplicate issues). `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode. |
56
56
 
57
57
  A dozen more subcommands cover slotting new issues into tracks, closing tracks (shipped/abandoned/parked), and one-time priority-label backfill. Three capabilities worth calling out explicitly:
@@ -148,8 +148,8 @@ The CLI never auto-pushes. When you create or update a shared track, it prints a
148
148
  **Multi-repo disambiguation:** if the same track slug exists in two repos, qualify with `@<repo>` or `--repo=<key>`:
149
149
 
150
150
  ```bash
151
- /work-plan slot 4234 auth-flow@critforge
152
- /work-plan close auth-flow --repo=critforge
151
+ /work-plan slot 4234 auth-flow@myproject
152
+ /work-plan close auth-flow --repo=myproject
153
153
  ```
154
154
 
155
155
  ## Plan & doc liveness (`plan-status`)
@@ -493,7 +493,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
493
493
  | `close <track> [--state=shipped\|parked\|abandoned] [--note=<text>]` | Mark track shipped, parked, or abandoned. Moves to `archive/<state>/` for shipped/abandoned. Pass `--state=` (and an optional `--note=`) to run without prompts. |
494
494
  | `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Update issue STATE (open/closed, status labels) inside the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
495
495
  | `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `duplicates`. With `--repo=<key>`, steps 1 and 2 scope to that repo and the global `duplicates` step is skipped. |
496
- | `list [--all]` | List active tracks (or all including parked/archived). |
496
+ | `list [--all] [--sort=recent\|priority]` | List active tracks (or all including parked/archived). `--sort=recent` orders by `last_touched` (most recent first); `--sort=priority` orders by `launch_priority` (P0→P3) with recency as tiebreaker. Default keeps discovery order. |
497
497
  | `init <path> [--priority=P0..P3] [--milestone=<m>]` | Add frontmatter to a brand-new track .md file (the file must already exist). Pass `--priority=`/`--milestone=` to skip the prompts. |
498
498
  | `init-repo <key> --github=<slug> [--local=<path>]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required; `--local` is optional. |
499
499
  | `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
@@ -502,7 +502,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
502
502
  | `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
503
503
  | `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
504
504
  | `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
505
- | `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). `--draft` previews ADDs/FLAGs without prompting or writing. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
505
+ | `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews ADDs/MOVEs/FLAGs without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
506
506
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
507
507
  | `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). |
508
508
  | `plan-status [--repo=<key>] [--json] [--stamp [--draft]] [--llm [--apply]] [--archive \| --issues] [--draft]` | Reach a verdict on every plan/spec doc in a repo by correlating its declared file-manifest against git + the filesystem: ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less / 🧳 foreign. Read-only by default. `--stamp` writes an idempotent status header into each doc (`--draft` previews); `--llm` runs a two-step AI verdict on prose/ambiguous docs; `--archive` moves dead plans to `archive/abandoned/` and `--issues` opens issues for partial plans (both gated, both honor `--draft`); `--json` for machine output. |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.09+f25e6e1
1
+ 2026.06.10+9dce675
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.9-4",
3
+ "version": "2026.6.10-1",
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"
@@ -97,8 +97,8 @@ Track files live in one of two places:
97
97
 
98
98
  **Disambiguation when the same track slug exists in two repos:**
99
99
  ```
100
- /work-plan slot 4234 auth-flow@critforge # @repo qualifier
101
- /work-plan close auth-flow --repo=critforge # --repo=<key> flag
100
+ /work-plan slot 4234 auth-flow@myproject # @repo qualifier
101
+ /work-plan close auth-flow --repo=myproject # --repo=<key> flag
102
102
  ```
103
103
 
104
104
  Both forms work on: `slot`, `close`, `handoff`, `canonicalize`, `refresh-md`, `reconcile`, `set`.
@@ -3,7 +3,10 @@ from datetime import datetime
3
3
  from pathlib import Path
4
4
 
5
5
  from lib.config import load_config, ConfigError
6
- from lib.tracks import discover_tracks, discover_archived_tracks, filter_tracks_by_repo
6
+ from lib.tracks import (
7
+ discover_tracks, discover_archived_tracks, filter_tracks_by_repo,
8
+ priority_rank, recency_sort_key,
9
+ )
7
10
  from lib.github_state import fetch_issues, extract_priority, short_milestone
8
11
  from lib.prompts import parse_flags
9
12
  from lib.git_state import (
@@ -193,11 +196,8 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
193
196
  return gap_seconds_to_label(int(gs))
194
197
 
195
198
  in_prog_rank = 0 if operational_status == "in-progress" else 1
196
- pri_rank = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(meta.get("launch_priority", "P3"), 3)
197
- recency_key = (
198
- -parse_iso_timestamp(meta["last_touched"]).timestamp()
199
- if meta.get("last_touched") else 0
200
- )
199
+ pri_rank = priority_rank(meta)
200
+ recency_key = recency_sort_key(meta)
201
201
 
202
202
  return {
203
203
  "name": meta.get("track", track.name),
@@ -19,6 +19,7 @@ from lib.session_log import append_session_log, SESSION_LOG_HEADER
19
19
  from lib.git_state import (
20
20
  has_uncommitted, current_branch, parse_iso_timestamp,
21
21
  gap_seconds_to_label, uncommitted_file_count, commits_ahead,
22
+ is_safe_ref, GIT_TIMEOUT,
22
23
  )
23
24
  from lib.github_state import fetch_issues, state_to_status_label, extract_priority, short_milestone
24
25
  from lib.status_table import update_row_status, sync_missing_rows, find_canonical_status_tables, ISSUE_NUM_RE
@@ -514,12 +515,20 @@ def _recent_commits(track, since_dt) -> list[dict]:
514
515
 
515
516
  if branches:
516
517
  for b in branches:
517
- proc = subprocess.run(
518
- ["git", "-C", str(track.local_path), "log", b,
519
- f"--since={since_iso}",
520
- "--pretty=format:%H|%s|%cI"],
521
- capture_output=True, text=True,
522
- )
518
+ # A branch name from frontmatter is passed as a positional rev; a
519
+ # dash-led value (e.g. `--output=/path`) would be read by git as an
520
+ # option → arbitrary-file write. Reject before use (#192).
521
+ if not is_safe_ref(str(b)):
522
+ continue
523
+ try:
524
+ proc = subprocess.run(
525
+ ["git", "-C", str(track.local_path), "log", b,
526
+ f"--since={since_iso}",
527
+ "--pretty=format:%H|%s|%cI"],
528
+ capture_output=True, text=True, timeout=GIT_TIMEOUT,
529
+ )
530
+ except (subprocess.TimeoutExpired, OSError):
531
+ continue
523
532
  if proc.returncode != 0 or not proc.stdout.strip():
524
533
  continue
525
534
  for line in proc.stdout.strip().split("\n"):
@@ -78,6 +78,8 @@ def run(args: list[str]) -> int:
78
78
  print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
79
79
  print("=" * 60)
80
80
  reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
81
+ if yes:
82
+ reconcile_args.append("--yes")
81
83
  rc = reconcile.run(reconcile_args)
82
84
  if rc != 0:
83
85
  print(f"\n⚠ reconcile exited with code {rc}; continuing.")
@@ -67,12 +67,22 @@ def run(args: list[str]) -> int:
67
67
  return 1
68
68
  folder = None
69
69
  else:
70
- notes_root = Path(cfg["notes_root"])
70
+ # Containment guard (#195): a non-shared target MUST live under
71
+ # notes_root. Without this, `init /etc/anything` (any user-writable
72
+ # file with no frontmatter) would get frontmatter prepended via
73
+ # write_file, clobbering it. `path` is already resolved; resolve
74
+ # notes_root too so the comparison is symlink/relative-safe.
75
+ notes_root = Path(cfg["notes_root"]).expanduser().resolve()
71
76
  try:
72
77
  rel = path.relative_to(notes_root)
73
- folder = rel.parts[0] if len(rel.parts) > 1 else None
74
78
  except ValueError:
75
- folder = None
79
+ print(
80
+ f"ERROR: {path} is not inside notes_root ({notes_root}) or a"
81
+ " registered .work-plan/ directory — refusing to write"
82
+ " frontmatter outside the tracked tree."
83
+ )
84
+ return 1
85
+ folder = rel.parts[0] if len(rel.parts) > 1 else None
76
86
  repo = resolve_github_for_folder(folder, cfg) if folder else None
77
87
 
78
88
  issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
@@ -3,6 +3,7 @@
3
3
  Non-interactive: --github is required; --local is optional (no prompts).
4
4
  """
5
5
  import json
6
+ import os
6
7
  import re
7
8
  import subprocess
8
9
  from pathlib import Path
@@ -113,11 +114,16 @@ def run(args: list[str]) -> int:
113
114
  if local:
114
115
  repo_block["local"] = local
115
116
 
116
- yq_expr = f'.repos.{key} = {json.dumps(repo_block)}'
117
+ # `key` is validated against ^[a-z][a-z0-9-]*$ above, so it's safe in the yq
118
+ # path. The repo block is passed as an OPAQUE env value via env() (parsed as
119
+ # YAML/JSON) rather than interpolated into the expression — uniform with the
120
+ # strenv() hardening in set-notes-root (#196).
121
+ env = {**os.environ, "WP_REPO_BLOCK": json.dumps(repo_block)}
122
+ yq_expr = f".repos.{key} = env(WP_REPO_BLOCK)"
117
123
  try:
118
124
  subprocess.run(
119
125
  ["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
120
- check=True, capture_output=True, text=True,
126
+ check=True, capture_output=True, text=True, env=env,
121
127
  )
122
128
  except subprocess.CalledProcessError as e:
123
129
  print(f"ERROR: yq failed to update config: {e.stderr}")
@@ -1,10 +1,22 @@
1
1
  """list subcommand."""
2
2
  from lib.config import load_config, ConfigError
3
- from lib.tracks import discover_tracks, discover_archived_tracks
3
+ from lib.tracks import (
4
+ discover_tracks, discover_archived_tracks,
5
+ priority_rank, recency_sort_key,
6
+ )
7
+ from lib.prompts import parse_flags
8
+
9
+ _VALID_SORTS = ("recent", "priority")
4
10
 
5
11
 
6
12
  def run(args: list[str]) -> int:
7
- show_all = "--all" in args
13
+ flags, _ = parse_flags(args, {"--all", "--sort"})
14
+ show_all = "--all" in flags
15
+ sort_mode = flags.get("--sort")
16
+ if sort_mode is True or (sort_mode and sort_mode not in _VALID_SORTS):
17
+ print(f"usage: work_plan.py list [--all] [--sort={'|'.join(_VALID_SORTS)}]")
18
+ return 2
19
+
8
20
  try:
9
21
  cfg = load_config()
10
22
  except ConfigError as e:
@@ -16,17 +28,19 @@ def run(args: list[str]) -> int:
16
28
  print(f"No tracks found under {cfg['notes_root']}")
17
29
  return 0
18
30
 
31
+ tracks = _sort_tracks(tracks, sort_mode)
32
+
19
33
  print(f"Tracks under {cfg['notes_root']}:\n")
20
34
  for t in tracks:
21
35
  status = t.meta.get("status", "(no frontmatter)")
22
36
  priority = t.meta.get("launch_priority", "—")
23
37
  repo = t.repo or "(no repo)"
24
- flags = []
38
+ flags_out = []
25
39
  if t.needs_init:
26
- flags.append("NEEDS INIT")
40
+ flags_out.append("NEEDS INIT")
27
41
  if t.needs_filing:
28
- flags.append("NEEDS FILING")
29
- flag_str = f" [{', '.join(flags)}]" if flags else ""
42
+ flags_out.append("NEEDS FILING")
43
+ flag_str = f" [{', '.join(flags_out)}]" if flags_out else ""
30
44
  print(f" {t.name:30} {status:14} {priority:3} {repo}{flag_str}")
31
45
 
32
46
  if show_all:
@@ -37,3 +51,17 @@ def run(args: list[str]) -> int:
37
51
  end_state = a.meta.get("status", "?")
38
52
  print(f" {a.name:30} {end_state:14} {a.repo or '(no repo)'}")
39
53
  return 0
54
+
55
+
56
+ def _sort_tracks(tracks: list, sort_mode):
57
+ """Order active tracks per --sort. None preserves discovery order.
58
+
59
+ - "recent": by last_touched descending (missing last_touched sorts last).
60
+ - "priority": by launch_priority ascending (P0→P3, then missing/other),
61
+ with last_touched recency as tiebreaker.
62
+ """
63
+ if sort_mode == "recent":
64
+ return sorted(tracks, key=lambda t: recency_sort_key(t.meta))
65
+ if sort_mode == "priority":
66
+ return sorted(tracks, key=lambda t: (priority_rank(t.meta), recency_sort_key(t.meta)))
67
+ return tracks
@@ -103,6 +103,13 @@ def run(args: list[str]) -> int:
103
103
  elif "/" in repo_arg:
104
104
  github = repo_arg
105
105
  folder = repo_arg.rsplit("/", 1)[-1]
106
+ # Validate the derived folder segment (#195). `rsplit` caps traversal at
107
+ # one segment, but a slug like `x/..` yields folder=".." → the track
108
+ # would be written one level ABOVE notes_root. A real GitHub repo name
109
+ # matches [A-Za-z0-9._-]+ and is never "." / ".." — reject anything else.
110
+ if folder in ("", ".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", folder):
111
+ print(f"ERROR: cannot derive a safe notes folder from '{repo_arg}'.")
112
+ return 2
106
113
  else:
107
114
  print(
108
115
  f"ERROR: unknown repo '{repo_arg}' — pass a configured key"
@@ -12,8 +12,11 @@ For a given track:
12
12
  of "anything closed looks unlabeled."
13
13
  - Compare against frontmatter `github.issues`.
14
14
  - Propose ADDS (labeled in GitHub but missing from frontmatter).
15
- - Propose FLAGS (in frontmatter but no longer labeled possible move out).
16
- - User confirms before writing to the LOCAL frontmatter file.
15
+ - Propose MOVES (in track A's frontmatter, but now labeled for exactly one
16
+ other active track B in the same repo — a relabel; remove from A, add to B).
17
+ - Propose FLAGS (in frontmatter but no longer labeled, with no single move
18
+ target — possible orphan).
19
+ - User confirms before writing to the LOCAL frontmatter file(s).
17
20
 
18
21
  READ-ONLY GITHUB CONTRACT
19
22
  reconcile only READS GitHub via `gh issue list` and `gh pr list`. It NEVER
@@ -32,6 +35,7 @@ from lib.config import load_config, ConfigError
32
35
  from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
33
36
  from lib.frontmatter import write_file
34
37
  from lib.prompts import parse_flags, prompt_input
38
+ from lib.write_guard import needs_confirm
35
39
 
36
40
 
37
41
  PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
@@ -86,17 +90,18 @@ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
86
90
 
87
91
 
88
92
  def run(args: list[str]) -> int:
89
- flags, positional = parse_flags(args, {"--all", "--draft", "--repo"})
93
+ flags, positional = parse_flags(args, {"--all", "--draft", "--repo", "--yes"})
90
94
  do_all = flags.get("--all", False)
91
95
  draft = flags.get("--draft", False)
96
+ yes = flags.get("--yes", False)
92
97
  repo_key = flags.get("--repo")
93
98
  if repo_key is True:
94
- print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
99
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
95
100
  return 2
96
101
  track_arg = positional[0] if positional else None
97
102
 
98
103
  if not do_all and not track_arg and not repo_key:
99
- print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
104
+ print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
100
105
  return 2
101
106
 
102
107
  track_name = track_arg
@@ -167,7 +172,52 @@ def run(args: list[str]) -> int:
167
172
  print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
168
173
  results[track.name] = None
169
174
 
170
- # Phase 2: serial diff, report, and confirm (prompts must NOT be in threads)
175
+ # Phase 2a: index which fetched track(s) label each issue. Used to turn a
176
+ # bare FLAG (in a track's frontmatter, but it has lost that track's label)
177
+ # into a MOVE when the issue is now labeled for exactly one OTHER active
178
+ # track in the same repo.
179
+ labeled_index: dict = {} # issue number -> list[track]
180
+ for track in targets:
181
+ if not track.repo or results.get(track.name) is None:
182
+ continue
183
+ for num in {i["number"] for i in results[track.name]}:
184
+ labeled_index.setdefault(num, []).append(track)
185
+
186
+ # Phase 2b: detect cross-track moves (#163). An issue qualifies when it is
187
+ # in track A's frontmatter, no longer carries A's label, and is now labeled
188
+ # by exactly one OTHER active track B in the same repo. Ambiguous cases
189
+ # (two or more candidate targets) stay as plain FLAGs.
190
+ moved_out: dict = {} # src track name -> set(num)
191
+ moved_in: dict = {} # dst track name -> set(num)
192
+ move_dst: dict = {} # (src track name, num) -> dst track
193
+ for track in targets:
194
+ if not track.repo or results.get(track.name) is None:
195
+ continue
196
+ labeled_nums = {i["number"] for i in results[track.name]}
197
+ listed_nums = set(track.meta.get("github", {}).get("issues") or [])
198
+ for num in sorted(listed_nums - labeled_nums):
199
+ cands = [b for b in labeled_index.get(num, [])
200
+ if b is not track and b.repo == track.repo]
201
+ if len(cands) == 1:
202
+ dst = cands[0]
203
+ moved_out.setdefault(track.name, set()).add(num)
204
+ moved_in.setdefault(dst.name, set()).add(num)
205
+ move_dst[(track.name, num)] = dst
206
+
207
+ # Phase 2c: per-track diff, report, confirm. Membership changes accumulate
208
+ # in `final` (track name -> desired issue set); each affected track is
209
+ # written exactly ONCE at the end, so a move that touches two tracks never
210
+ # double-writes or clobbers a sibling's accepted ADDs. A move is governed by
211
+ # the confirmation on its SOURCE track (where the issue currently lives).
212
+ final: dict = {} # track name -> set(num)
213
+ affected: dict = {} # track name -> track (only those we may write)
214
+
215
+ def _final_for(t):
216
+ if t.name not in final:
217
+ final[t.name] = set(t.meta.get("github", {}).get("issues") or [])
218
+ affected[t.name] = t
219
+ return final[t.name]
220
+
171
221
  any_changes = False
172
222
  for track in targets:
173
223
  slug = track.meta.get("track", track.name)
@@ -181,11 +231,14 @@ def run(args: list[str]) -> int:
181
231
  labels = _resolve_labels(track)
182
232
  labeled_nums = {i["number"] for i in labeled}
183
233
  listed_nums = set(track.meta.get("github", {}).get("issues") or [])
234
+ out_moves = sorted(moved_out.get(track.name, set()))
184
235
 
185
- adds = sorted(labeled_nums - listed_nums)
186
- flag_nums = sorted(listed_nums - labeled_nums)
236
+ # MOVE issues are reported (and applied) as moves, not as ADD on the
237
+ # destination or FLAG on the source.
238
+ adds = sorted(labeled_nums - listed_nums - moved_in.get(track.name, set()))
239
+ flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(track.name, set()))
187
240
 
188
- if not adds and not flag_nums:
241
+ if not adds and not flag_nums and not out_moves:
189
242
  continue
190
243
 
191
244
  any_changes = True
@@ -197,6 +250,13 @@ def run(args: list[str]) -> int:
197
250
  for num in adds:
198
251
  i = issue_lookup[num]
199
252
  print(f" #{num} ({i['state'].lower()}) {i['title']}")
253
+ if out_moves:
254
+ print(f" MOVE ({len(out_moves)}) — relabeled to another track in this repo:")
255
+ for num in out_moves:
256
+ dst = move_dst[(track.name, num)]
257
+ dst_slug = dst.meta.get("track", dst.name)
258
+ pub = " [dst PUBLIC]" if needs_confirm(dst.repo, cfg) else ""
259
+ print(f" #{num} {slug} → {dst_slug}{pub}")
200
260
  if flag_nums:
201
261
  print(f" FLAG ({len(flag_nums)}) — in frontmatter but missing every configured label:")
202
262
  for num in flag_nums:
@@ -204,8 +264,8 @@ def run(args: list[str]) -> int:
204
264
 
205
265
  if listed_nums and len(flag_nums) / len(listed_nums) > 0.5:
206
266
  print(f"\n ⓘ {len(flag_nums)}/{len(listed_nums)} frontmatter issues lack the configured label(s).")
207
- print(f" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
208
- print(f" If you just want to update issue state in the body table, try:")
267
+ print(" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
268
+ print(" If you just want to update issue state in the body table, try:")
209
269
  print(f" /work-plan refresh-md {slug}")
210
270
 
211
271
  if draft:
@@ -213,12 +273,41 @@ def run(args: list[str]) -> int:
213
273
  # Useful for sweep audits and scripted reports.
214
274
  continue
215
275
 
216
- choice = prompt_input(f"\n Apply ADDs to {track.path.name}? [y/N/skip-flags]").lower()
217
- if choice == "y":
218
- new_issues = sorted(listed_nums | labeled_nums)
219
- track.meta.setdefault("github", {})["issues"] = new_issues
220
- write_file(track.path, track.meta, track.body)
221
- print(f" ✓ Updated {track.path.name} ({len(adds)} added)")
276
+ if yes:
277
+ # Non-interactive: apply ADDs + MOVEs without prompting. All writes
278
+ # are local frontmatter — the read-only-GitHub contract is unchanged.
279
+ print(f"\n --yes: applying changes from {track.path.name}")
280
+ choice = "y"
281
+ else:
282
+ choice = prompt_input(f"\n Apply ADDs/MOVEs from {track.path.name}? [y/N]").lower()
283
+ if choice != "y":
284
+ continue
285
+
286
+ if adds:
287
+ _final_for(track).update(adds)
288
+ for num in out_moves:
289
+ dst = move_dst[(track.name, num)]
290
+ # Public-repo guard (#163): under --yes we never silently write
291
+ # membership into a PUBLIC/shared destination track — that move is
292
+ # skipped with a pointer to the gated `move` verb. Interactive runs
293
+ # treat the prompt above as the confirmation.
294
+ if yes and needs_confirm(dst.repo, cfg):
295
+ dst_slug = dst.meta.get("track", dst.name)
296
+ print(f" ⏭ skipped MOVE #{num} → {dst_slug} ({dst.repo} is PUBLIC; "
297
+ f"run `/work-plan move {num} {slug} {dst_slug} --confirm` instead)")
298
+ continue
299
+ _final_for(track).discard(num)
300
+ _final_for(dst).add(num)
301
+
302
+ # Write each affected track exactly once, only if its set actually changed.
303
+ for name, issues in final.items():
304
+ track = affected[name]
305
+ original = set(track.meta.get("github", {}).get("issues") or [])
306
+ if issues == original:
307
+ continue
308
+ track.meta.setdefault("github", {})["issues"] = sorted(issues)
309
+ write_file(track.path, track.meta, track.body)
310
+ print(f" ✓ Updated {track.path.name}")
222
311
 
223
312
  if not any_changes:
224
313
  print("All tracks in sync with configured labels.")
@@ -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
@@ -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: