@stylusnexus/work-plan 2026.6.10-2 → 2026.6.11-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.
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
 
@@ -138,6 +138,22 @@ The CLI never auto-pushes. When you create or update a shared track, it prints a
138
138
  ↑ shared — commit + push to share with teammates.
139
139
  ```
140
140
 
141
+ ### Canonical plan branch (`plan-branch`)
142
+
143
+ Storing the plan as files *in* the repo makes it branch-scoped — but a track's status and priorities are facts about the **project**, not about a code branch. On a repo with `dev` + `main` + feature branches that means cross-branch plan divergence, merge conflicts on status-table edits, and planning churn polluting feature PRs and the deploy diff.
144
+
145
+ `plan-branch` pins the shared tier to **one canonical branch** per repo, read and written through a dedicated git worktree, so the plan lives off your code branches entirely — yet the CLI and VS Code viewer always show it from any checkout.
146
+
147
+ ```bash
148
+ /work-plan plan-branch init myproject # orphan branch `work-plan/plan` + skeleton (local only)
149
+ /work-plan plan-branch status myproject # exists? published? how many unpushed commits?
150
+ /work-plan plan-branch push myproject # share it — gated with a confirm token on PUBLIC repos
151
+ ```
152
+
153
+ The default branch is an **orphan** `work-plan/plan` (no shared history with your code, like `gh-pages`) — override with `--branch=<name>`. `init` is **local only**; a teammate who already published the branch is auto-**connected** instead of re-created. Once `plan_branch` is set, every shared-track write is committed onto that branch via the worktree (never your working branch), and `push` is the one deliberate step that shares it. `push --dry-run` previews exactly what would be exposed first.
154
+
155
+ > **Tip:** exclude the plan branch from CI by adding `!work-plan/**` to your workflow's `on: push` branch filter, so planning commits never trigger builds or deploys.
156
+
141
157
  **Opt out per-command:** pass `--private` to route a specific track to `notes_root` instead:
142
158
 
143
159
  ```bash
@@ -448,8 +464,12 @@ repos:
448
464
  myproject:
449
465
  github: your-org/myproject
450
466
  local: /path/to/local/checkout # optional, enables in-progress detection
467
+ notes_vcs:
468
+ auto_commit: true # opt-in local history for notes_root (set by `notes-vcs init`)
451
469
  ```
452
470
 
471
+ `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.
472
+
453
473
  ### Where your config lives
454
474
 
455
475
  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,11 +507,11 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
487
507
  | Subcommand | What it does |
488
508
  |---|---|
489
509
  | `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`. |
510
+ | `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
511
  | `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
512
  | `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
513
  | `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." 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. |
514
+ | `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
515
  | `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
516
  | `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
517
  | `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. |
@@ -499,11 +519,13 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
499
519
  | `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. |
500
520
  | `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. |
501
521
  | `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. |
522
+ | `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. |
523
+ | `plan-branch <init\|status\|push> <repo> [--branch=<name>] [--confirm=<token>] [--dry-run] [--json]` | Set up and share a repo's canonical **shared-tier** plan branch. The `.work-plan/` tier is pinned to ONE per-repo `plan_branch`, read/written through a dedicated git worktree, so planning never diverges across code branches or pollutes PR / deploy diffs. `init` creates that branch + a `.work-plan/` skeleton (default an **orphan** `work-plan/plan` — zero shared history with code, like `gh-pages`; override with `--branch`) and records `plan_branch` in config — or **connects** to a teammate's already-published branch if one exists. `init` is **local only** (no push). `status` reports whether the branch exists, is published to origin, and how many commits are unpushed (`--json` for the machine shape). `push` shares it: on a **public** repo it prints a confirm heads-up + token and exits (re-run with `--confirm=<token>`); `--dry-run` previews the commits that would push. Requires a repo registered via `init-repo` with a local clone path. |
502
524
  | `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
503
525
  | `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). |
504
526
  | `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). |
505
527
  | `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
506
- | `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`. |
528
+ | `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`. |
507
529
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
508
530
  | `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. |
509
531
  | `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.10+a3d10bf
1
+ 2026.06.11+b34452d
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.10-2",
3
+ "version": "2026.6.11-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"
@@ -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
@@ -15,6 +15,7 @@ from datetime import datetime
15
15
  from pathlib import Path
16
16
 
17
17
  from lib.config import load_config, ConfigError, is_valid_git_repo
18
+ from lib.plan_worktree import shared_tier_dir
18
19
  from lib.frontmatter import parse_file, write_file
19
20
  from lib.notes_readme import seed_readme
20
21
  from lib.scratch import cache_dir
@@ -182,7 +183,10 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
182
183
  if not use_private and local_raw:
183
184
  local_path = Path(local_raw).expanduser()
184
185
  if is_valid_git_repo(local_path):
185
- shared_dir = local_path / ".work-plan"
186
+ # Worktree-aware (#260): for a `plan_branch` repo this resolves to
187
+ # the worktree's .work-plan/; otherwise the working tree's. None when
188
+ # a plan_branch isn't bootstrapped yet → falls back to private tier.
189
+ shared_dir = shared_tier_dir(repo_entry)
186
190
 
187
191
  if shared_dir is not None:
188
192
  track_dir = shared_dir
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from typing import Optional
18
18
 
19
19
  from lib.config import load_config, ConfigError, is_valid_git_repo
20
+ from lib.plan_worktree import shared_tier_dir
20
21
  from lib.frontmatter import write_file
21
22
  from lib.prompts import parse_flags
22
23
  from lib.write_guard import needs_confirm, make_token, valid_token
@@ -150,11 +151,16 @@ def run(args: list[str]) -> int:
150
151
 
151
152
  shared_path: Optional[Path] = None
152
153
  if not use_private and folder in cfg.get("repos", {}):
153
- local_raw = cfg["repos"][folder].get("local")
154
+ repo_entry = cfg["repos"][folder]
155
+ local_raw = repo_entry.get("local")
154
156
  if local_raw:
155
157
  local_path = Path(local_raw).expanduser()
156
158
  if is_valid_git_repo(local_path):
157
- shared_path = local_path / ".work-plan" / f"{slug}.md"
159
+ # Worktree-aware (#260): plan_branch repos write into the
160
+ # worktree's .work-plan/; None → fall back to the private tier.
161
+ sd = shared_tier_dir(repo_entry)
162
+ if sd is not None:
163
+ shared_path = sd / f"{slug}.md"
158
164
 
159
165
  notes_root = Path(cfg["notes_root"]).expanduser()
160
166
  if shared_path is not None:
@@ -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