@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
package/README.md CHANGED
@@ -109,7 +109,7 @@ flowchart TB
109
109
  - For tracks where you don't want to bother curating at all, set `next_up_auto: true` in the track's frontmatter — `brief` will then derive the list live each invocation, ignoring whatever's stored.
110
110
  - **Weekly** → `hygiene` runs `refresh-md --all` + `reconcile --all` + `duplicates` in sequence to keep status icons, GitHub labels, and dedup state honest.
111
111
 
112
- > **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md <track>` (or **Refresh Track Body** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly.
112
+ > **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md <track>` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly.
113
113
 
114
114
  > **GitHub access is read-only.** The toolkit never writes to GitHub. All issue data comes from read-only `gh` CLI calls (`gh issue list`, `gh issue view`). Every write (frontmatter, status table, session log) goes to your local markdown files only.
115
115
 
@@ -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`)
@@ -448,8 +448,12 @@ repos:
448
448
  myproject:
449
449
  github: your-org/myproject
450
450
  local: /path/to/local/checkout # optional, enables in-progress detection
451
+ notes_vcs:
452
+ auto_commit: true # opt-in local history for notes_root (set by `notes-vcs init`)
451
453
  ```
452
454
 
455
+ `notes_vcs.auto_commit` is added by `/work-plan notes-vcs init` (or `enable`) — when on, every track-mutating command commits `notes_root` so private-tier edits are undoable. Absent → off.
456
+
453
457
  ### Where your config lives
454
458
 
455
459
  The active config the skill reads is **`~/.claude/work-plan/config.yml`** — created by `install.sh` (or `install.ps1`) on first run. There's no template file in the repo to confuse with the runtime config; install just writes the right two lines directly.
@@ -487,24 +491,26 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
487
491
  | Subcommand | What it does |
488
492
  |---|---|
489
493
  | `brief [--repo=<key>]` | Multi-track snapshot of all active tracks across configured repos. `--repo=<key>` filters to one project (matches the folder name under `notes_root` or the `org/repo` GitHub slug; archived-reopen callouts are also scoped). |
490
- | `handoff <track> [--auto-next \| --set-next 1,2,3]` | Wrap up a work block. Writes a `### Session — <ts>` entry. `--auto-next` suggests a priority-sorted top-3 from open issues (interactive: apply / edit / skip). `--set-next 1,2,3` is the explicit form. Without either flag, just captures the session summary and reads any pre-existing `next_up`. |
494
+ | `handoff <track> [--auto-next \| --set-next 1,2,3]` | Wrap up a work block. Writes a `### Session — <ts>` entry. `--auto-next` suggests a priority-sorted top-3 from open issues (interactive: apply / edit / skip). `--set-next 1,2,3` is the explicit form — note it writes the session entry too; for a field-only `next_up` change with no session log, use `set next_up=…`. Without either flag, just captures the session summary and reads any pre-existing `next_up`. |
491
495
  | `orient [track]` (alias: `where-was-i`) | Read-only paste block. With a track name: ~15-line track summary (priority, last session, next pick, git state). With no track: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
492
496
  | `slot <issue-num> [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=<token>` (public-repo gate, see below). |
493
497
  | `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
- | `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. |
498
+ | `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
495
499
  | `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
500
  | `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
501
  | `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
502
  | `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
503
  | `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. |
504
+ | `rename-track <old-slug \| old@repo> <new-slug> [--repo=<key>] [--fix-refs] [--commit]` | Rename an active track's slug: moves its `.md` file and updates the frontmatter `track` field + `last_touched`. Validates `<new-slug>` like `new-track` and rejects a name already taken in the same repo/tier. For shared tracks, `--commit` stages + commits the move (otherwise it prints a "commit to share" hint). `--fix-refs` rewrites sibling tracks' `depends_on` that reference the old slug; without it they're just warned about. Archived tracks are immutable. Public-repo gated. |
500
505
  | `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
506
+ | `notes-vcs <init\|enable\|disable\|status\|undo> [<sha>] [--no-enable] [--json]` | Opt-in **local** version control for the private `notes_root` tier — history/undo for tracks on your machine, never pushed. `init` git-inits `notes_root` as a personal repo (baseline commit of existing tracks) and turns on auto-commit (`--no-enable` skips that). For safety it **refuses** a `notes_root` that already has a git remote or is a repo work-plan didn't create. When on, every track-mutating command (`slot`/`group`/`handoff`/`close`/`set`/…) commits **only the files it changed** (pre-existing uncommitted edits are left alone) as an undoable commit. The shared tier is unaffected — it's versioned by its own repo. `status` shows whether `notes_root` is a repo, whether auto-commit is on, and the last commit (`--json` for the machine shape the VS Code viewer polls). `undo [<sha>]` reverts a commit (default HEAD) — reverses the last edit. |
501
507
  | `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
502
508
  | `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
509
  | `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
510
  | `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] [--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`. |
511
+ | `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 the label drift (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
512
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
507
- | `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). |
513
+ | `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). The table carries a `Milestone` column and is ordered active-milestone-first — issues in the track's `milestone_alignment` milestone, then other milestones grouped (blank divider row between groups), then no-milestone last — so "what's next" sits above "someday" (#101). It's one table (not per-milestone sub-tables) so `refresh-md` re-derives it cleanly. |
508
514
  | `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. |
509
515
 
510
516
  Run `python3 ~/.claude/skills/work-plan/work_plan.py --help` for the full list with examples.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.10+a6052bf
1
+ 2026.06.11+8c21445
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.10",
3
+ "version": "2026.6.11",
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"
@@ -15,10 +15,10 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
15
15
  | `/work-plan brief` | Starting work or after a gap. Multi-track snapshot. |
16
16
  | `/work-plan handoff [track] [--auto-next \| --set-next 1,2,3]` | Wrapping up a work block. Captures touched + next + blockers; writes session log. Add `--auto-next` to suggest a priority-sorted next_up list from open issues (interactive: apply / edit / skip). Tracks with `next_up_auto: true` in frontmatter get the auto-derived list surfaced in `brief` automatically. |
17
17
  | `/work-plan orient [track]` (alias `where-was-i`) | Re-orienting. With a track: ~15-line track paste-block. Without: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
18
- | `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Three steps in sequence: ① `refresh-md --all` — pull live GitHub state into every active track's status table (same as "Refresh Track Body" but for all tracks); ② `reconcile --all` — sync track frontmatter membership against GitHub labels; ③ `duplicates` — flag likely-duplicate issues for consolidation. Run once a week to keep status icons, labels, and dedup state honest. `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode (it needs a single explicit repo to be unambiguous). |
18
+ | `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Three steps in sequence: ① `refresh-md --all` — pull live GitHub state into every active track's status table (same as "Sync Issue States from GitHub" but for all tracks); ② `reconcile --all` — sync track frontmatter membership against GitHub labels; ③ `duplicates` — flag likely-duplicate issues for consolidation. Run once a week to keep status icons, labels, and dedup state honest. `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode (it needs a single explicit repo to be unambiguous). |
19
19
  | `/work-plan slot <issue-num> [track]` | A new GitHub issue should belong to a track. If the issue is already listed in another active track's frontmatter, you'll be prompted to move it (remove from source) instead of duplicating. |
20
20
  | `/work-plan close [track]` | Track is done (shipped) / paused (parked) / won't ship (abandoned). |
21
- | `/work-plan refresh-md <track> \| --all \| --repo=<key>` | **Pull live GitHub state into a track's status table.** Run this after closing or merging issues — it re-fetches each issue's open/closed state from GitHub and rewrites the status cells in the track body, which refreshes the dependency graph and `next_up` display. `--all` sweeps every active track; `--repo=<key>` scopes to one repo. In VS Code: right-click a track → **Refresh Track Body**. |
21
+ | `/work-plan refresh-md <track> \| --all \| --repo=<key>` | **Pull live GitHub state into a track's status table.** Run this after closing or merging issues — it re-fetches each issue's open/closed state from GitHub and rewrites the status cells in the track body, which refreshes the dependency graph and `next_up` display. `--all` sweeps every active track; `--repo=<key>` scopes to one repo. In VS Code: right-click a track → **Sync Issue States from GitHub**. |
22
22
  | `/work-plan list [--all]` | List active tracks (or all including parked/archived). |
23
23
  | `/work-plan init <path>` | Add frontmatter to a new track .md file. |
24
24
  | `/work-plan 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. |
@@ -88,6 +88,8 @@ Track files live in one of two places:
88
88
 
89
89
  **Routing logic (automatic):** if a repo has a registered `local:` path that is a valid git repo, new tracks go into `.work-plan/` by default. Pass `--private` to any write command to route to `notes_root` instead.
90
90
 
91
+ **Local history for the private tier (opt-in):** the shared tier already gets version control from its repo. For the private tier, `/work-plan notes-vcs init` git-inits `notes_root` as a *personal, never-pushed* repo and turns on auto-commit — every track-mutating command then writes an undoable commit to `notes_root`. `notes-vcs status` reports the state; `enable`/`disable` toggle it. Off until you run it.
92
+
91
93
  **Setup shared tracks for a repo:**
92
94
  ```
93
95
  /work-plan init-repo myproject --github=org/myproject --local=/path/to/clone
@@ -97,8 +99,8 @@ Track files live in one of two places:
97
99
 
98
100
  **Disambiguation when the same track slug exists in two repos:**
99
101
  ```
100
- /work-plan slot 4234 auth-flow@critforge # @repo qualifier
101
- /work-plan close auth-flow --repo=critforge # --repo=<key> flag
102
+ /work-plan slot 4234 auth-flow@myproject # @repo qualifier
103
+ /work-plan close auth-flow --repo=myproject # --repo=<key> flag
102
104
  ```
103
105
 
104
106
  Both forms work on: `slot`, `close`, `handoff`, `canonicalize`, `refresh-md`, `reconcile`, `set`.
@@ -8,9 +8,11 @@ Use --all to canonicalize every active track that doesn't yet have one.
8
8
  """
9
9
  from lib.config import load_config, ConfigError
10
10
  from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
11
- from lib.github_state import fetch_issues, state_to_status_label, format_assignees
11
+ from lib.github_state import fetch_issues
12
12
  from lib.frontmatter import write_file
13
- from lib.status_table import CANONICAL_MARKER, find_canonical_status_tables, render_issue_row
13
+ from lib.status_table import (
14
+ find_canonical_status_tables, render_canonical_table, insert_canonical_block,
15
+ )
14
16
  from lib.prompts import parse_flags
15
17
 
16
18
 
@@ -75,10 +77,11 @@ def run(args: list[str]) -> int:
75
77
  issues = fetch_issues(track.repo, issue_nums)
76
78
  issues_by_num = {i["number"]: i for i in issues}
77
79
 
78
- new_body = _insert_canonical_table(
79
- track.body, issue_nums, issues_by_num, replace=force,
80
+ table_md = render_canonical_table(
81
+ issue_nums, issues_by_num,
80
82
  milestone_alignment=track.meta.get("milestone_alignment"),
81
83
  )
84
+ new_body = insert_canonical_block(track.body, table_md, replace=force)
82
85
  write_file(track.path, track.meta, new_body)
83
86
  print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
84
87
  any_changes = True
@@ -86,91 +89,3 @@ def run(args: list[str]) -> int:
86
89
  if not any_changes:
87
90
  print("Nothing to do.")
88
91
  return 0
89
-
90
-
91
- def _insert_canonical_table(body: str, issue_nums: list[int],
92
- issues_by_num: dict, replace: bool = False,
93
- milestone_alignment=None) -> str:
94
- """Insert (or replace) a canonical table at the top of the body."""
95
- table_md = _render_canonical_table(issue_nums, issues_by_num, milestone_alignment)
96
-
97
- if replace:
98
- # Strip existing canonical block (marker + heading + table + separator)
99
- body = _strip_existing_canonical(body)
100
-
101
- # Prepend table after any leading whitespace
102
- body_stripped = body.lstrip("\n")
103
- leading_whitespace = body[: len(body) - len(body_stripped)]
104
- return leading_whitespace + table_md + "\n---\n\n" + body_stripped
105
-
106
-
107
- def _render_canonical_table(issue_nums: list[int], issues_by_num: dict,
108
- milestone_alignment=None) -> str:
109
- lines = [
110
- "## Issues (canonical)",
111
- "",
112
- f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
113
- "",
114
- ]
115
-
116
- # Build a normalized issue list with compact milestone strings.
117
- from lib.github_state import short_milestone
118
- norm_issues = []
119
- for num in sorted(issue_nums):
120
- gh = issues_by_num.get(num, {})
121
- ms = short_milestone(gh.get("milestone")) or None
122
- norm_issues.append({"number": num, "milestone": ms, "_gh": gh})
123
-
124
- from lib.export_model import group_issues_by_milestone
125
- groups = group_issues_by_milestone(norm_issues, milestone_alignment)
126
-
127
- if len(groups) <= 1:
128
- # Single milestone group (or all null) — render flat, same as before.
129
- lines.append("| # | Title | Assignee | Status |")
130
- lines.append("|---|---|---|---|")
131
- for num in sorted(issue_nums):
132
- i = issues_by_num.get(num, {})
133
- lines.append(render_issue_row(
134
- num, i.get("title", "(not fetched)"),
135
- format_assignees(i), state_to_status_label(i.get("state")),
136
- ))
137
- lines.append("")
138
- return "\n".join(lines)
139
-
140
- # Multiple milestone groups — render with section headings.
141
- for label, issues in groups:
142
- if label:
143
- heading = f"{label} ({len(issues)})"
144
- else:
145
- heading = f"No milestone ({len(issues)})"
146
- lines.append(f"### {heading}")
147
- lines.append("")
148
- lines.append("| # | Title | Assignee | Status |")
149
- lines.append("|---|---|---|---|")
150
- for norm in issues:
151
- num = norm["number"]
152
- i = norm["_gh"]
153
- lines.append(render_issue_row(
154
- num, i.get("title", "(not fetched)"),
155
- format_assignees(i), state_to_status_label(i.get("state")),
156
- ))
157
- lines.append("")
158
- return "\n".join(lines)
159
-
160
-
161
- def _strip_existing_canonical(body: str) -> str:
162
- """Remove an existing canonical-table block from the top of the body."""
163
- if CANONICAL_MARKER not in body:
164
- return body
165
- # Find the start of the heading "## Issues (canonical)" if present, else the marker
166
- heading_idx = body.find("## Issues (canonical)")
167
- marker_idx = body.find(CANONICAL_MARKER)
168
- start = heading_idx if 0 <= heading_idx < marker_idx else marker_idx
169
- # Find end: the next "---\n" separator after the marker
170
- sep_idx = body.find("\n---\n", marker_idx)
171
- if sep_idx == -1:
172
- # No separator — strip just the marker line
173
- end = body.find("\n", marker_idx) + 1
174
- else:
175
- end = sep_idx + len("\n---\n")
176
- return body[:start] + body[end:].lstrip("\n")
@@ -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"):
@@ -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}")
@@ -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"
@@ -0,0 +1,172 @@
1
+ """notes-vcs subcommand — opt-in local version control for notes_root (#103).
2
+
3
+ Actions:
4
+ init git-init notes_root as a personal, never-pushed repo (initial commit
5
+ of existing tracks), then enable auto-commit unless --no-enable.
6
+ enable turn on auto-commit after track-mutating commands.
7
+ disable turn it off (history is kept; just stop adding new commits).
8
+ status report whether notes_root is under git, whether auto-commit is on,
9
+ and the last commit — plus a nudge to `init` when it isn't a repo.
10
+ Add --json for the machine-readable shape the VS Code viewer polls.
11
+ undo revert a commit in notes_root (default HEAD). The git side of the
12
+ viewer's Undo button (#224); on the CLI it reverses the last edit.
13
+
14
+ Config writes use the same opaque-env `yq strenv` pattern as set-notes-root
15
+ (#191): the value is never interpolated into the yq expression.
16
+
17
+ Usage: notes-vcs <init|enable|disable|status|undo> [<sha>] [--no-enable] [--json]
18
+ """
19
+ import json as _json
20
+ import os
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from lib.config import (
25
+ load_config, ConfigError, DEFAULT_CONFIG_PATH, notes_vcs_auto_commit,
26
+ )
27
+ from lib.prompts import parse_flags
28
+ from lib import notes_vcs
29
+
30
+ _ACTIONS = ("init", "enable", "disable", "status", "undo")
31
+
32
+
33
+ def _set_auto_commit(value: bool) -> bool:
34
+ """Persist notes_vcs.auto_commit=<value> into config.yml via yq. The bool
35
+ travels as an opaque env value (strenv), never interpolated into the
36
+ expression. Returns True on success."""
37
+ env = {**os.environ, "WP_VCS": "true" if value else "false"}
38
+ try:
39
+ subprocess.run(
40
+ ["yq", "-i", ".notes_vcs.auto_commit = (strenv(WP_VCS) == \"true\")",
41
+ str(DEFAULT_CONFIG_PATH)],
42
+ check=True, capture_output=True, text=True, env=env,
43
+ )
44
+ return True
45
+ except subprocess.CalledProcessError as e:
46
+ print(f"ERROR: yq failed to update config: {e.stderr}")
47
+ return False
48
+
49
+
50
+ def _status_dict(notes_root: Path, cfg: dict) -> dict:
51
+ """Machine-readable state for the VS Code viewer (#224). last_commit_sha is
52
+ the handle the viewer diffs across a write to decide whether to offer Undo."""
53
+ is_root = notes_vcs.is_git_root(notes_root)
54
+ return {
55
+ "notes_root": str(notes_root),
56
+ "under_git": notes_vcs.is_under_git(notes_root),
57
+ "is_root": is_root,
58
+ "auto_commit": notes_vcs_auto_commit(cfg),
59
+ "last_commit_sha": notes_vcs.last_commit_sha(notes_root) if is_root else None,
60
+ "head_parent_sha": notes_vcs.head_parent_sha(notes_root) if is_root else None,
61
+ "last_commit_subject": notes_vcs.last_commit_summary(notes_root) if is_root else None,
62
+ "dirty": notes_vcs.has_changes(notes_root) if is_root else False,
63
+ }
64
+
65
+
66
+ def _print_status(notes_root: Path, cfg: dict) -> None:
67
+ on = notes_vcs_auto_commit(cfg)
68
+ print(f"notes_root: {notes_root}")
69
+ if notes_vcs.is_git_root(notes_root):
70
+ print("git: ✓ local repo (notes_root is the git root)")
71
+ last = notes_vcs.last_commit_summary(notes_root)
72
+ print(f"last commit: {last}" if last else "last commit: (none yet)")
73
+ if notes_vcs.has_changes(notes_root):
74
+ print("working tree: uncommitted changes present")
75
+ elif notes_vcs.is_under_git(notes_root):
76
+ print("git: ⚠ inside another git repo (NOT its root) — "
77
+ "auto-commit is disabled here; move notes_root to its own folder.")
78
+ else:
79
+ print("git: ✗ not a repo — run `work-plan notes-vcs init` to add local history")
80
+ print(f"auto-commit: {'on' if on else 'off'}")
81
+
82
+
83
+ def run(args: list[str]) -> int:
84
+ flags, positional = parse_flags(args, {"--no-enable", "--json"})
85
+
86
+ action = positional[0] if positional else "status"
87
+ if action not in _ACTIONS:
88
+ print(f"usage: work_plan.py notes-vcs <{'|'.join(_ACTIONS)}> "
89
+ "[<sha>] [--no-enable] [--json]")
90
+ return 2
91
+
92
+ try:
93
+ cfg = load_config()
94
+ except ConfigError as e:
95
+ print(f"ERROR: {e}")
96
+ return 1
97
+ notes_root = Path(cfg["notes_root"]).expanduser()
98
+
99
+ if action == "status":
100
+ if "--json" in flags:
101
+ print(_json.dumps(_status_dict(notes_root, cfg)))
102
+ else:
103
+ _print_status(notes_root, cfg)
104
+ return 0
105
+
106
+ if action == "undo":
107
+ if not notes_vcs.is_git_root(notes_root):
108
+ print(f"ERROR: {notes_root} is not a git repo — nothing to undo.")
109
+ return 1
110
+ # Same boundary as init/auto-commit: never rewrite a repo we don't own
111
+ # or one with a remote (it could be an unrelated project clone).
112
+ if notes_vcs.has_remotes(notes_root) or not notes_vcs.is_owned(notes_root):
113
+ print(f"ERROR: {notes_root} is not a work-plan local-history repo "
114
+ "(it has a git remote, or wasn't created by work-plan). "
115
+ "Refusing to revert — undo only operates on personal history.")
116
+ return 1
117
+ sha = positional[1] if len(positional) > 1 else None
118
+ new_sha = notes_vcs.revert(notes_root, sha)
119
+ if not new_sha:
120
+ print(f"ERROR: failed to revert {sha or 'HEAD'} in {notes_root} "
121
+ "(nothing to revert, or a conflict — resolve it manually).")
122
+ return 1
123
+ print(f"✓ Reverted {sha or 'HEAD'} — new commit {new_sha}.")
124
+ return 0
125
+
126
+ if action == "init":
127
+ if notes_vcs.is_under_git(notes_root) and not notes_vcs.is_git_root(notes_root):
128
+ print(f"ERROR: {notes_root} is inside another git repo but is not its "
129
+ "root. Auto-commit would stage unrelated files there. Move "
130
+ "notes_root to its own folder first (see set-notes-root).")
131
+ return 1
132
+ if notes_vcs.is_git_root(notes_root):
133
+ # Don't adopt an existing repo we didn't create, or one with a
134
+ # remote — private notes must never be pushable.
135
+ if notes_vcs.has_remotes(notes_root):
136
+ print(f"ERROR: {notes_root} already has a git remote. Local "
137
+ "history must stay personal and never-pushed — point "
138
+ "notes_root at a folder with no remote, or remove the "
139
+ "remote first.")
140
+ return 1
141
+ if not notes_vcs.is_owned(notes_root):
142
+ print(f"ERROR: {notes_root} is an existing git repo not created "
143
+ "by work-plan. Refusing to adopt it (it may hold unrelated "
144
+ "history). Point notes_root at a fresh folder for local "
145
+ "history.")
146
+ return 1
147
+ if not notes_vcs.init_repo(notes_root):
148
+ print(f"ERROR: failed to git-init {notes_root}. Is git installed?")
149
+ return 1
150
+ print(f"✓ Initialized local history at {notes_root} (personal repo — no remote).")
151
+ if "--no-enable" in flags:
152
+ print(" auto-commit left off — run `notes-vcs enable` to turn it on.")
153
+ return 0
154
+ if not _set_auto_commit(True):
155
+ return 1
156
+ print("✓ auto-commit enabled — track edits now create undoable commits.")
157
+ return 0
158
+
159
+ if action == "enable":
160
+ if not notes_vcs.is_git_root(notes_root):
161
+ print(f"WARN: {notes_root} is not a git repo yet. Run "
162
+ "`work-plan notes-vcs init` first, or commits will be skipped.")
163
+ if not _set_auto_commit(True):
164
+ return 1
165
+ print("✓ auto-commit enabled.")
166
+ return 0
167
+
168
+ # action == "disable"
169
+ if not _set_auto_commit(False):
170
+ return 1
171
+ print("✓ auto-commit disabled (existing history kept).")
172
+ return 0