@stylusnexus/work-plan 2026.6.10-2 → 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.
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
 
@@ -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,11 +491,11 @@ 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." 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. |
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. |
@@ -499,11 +503,12 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
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. |
500
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. |
501
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. |
502
507
  | `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
503
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). |
504
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). |
505
510
  | `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`. |
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`. |
507
512
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
508
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. |
509
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. |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.10+a3d10bf
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-2",
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
@@ -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
@@ -76,6 +76,17 @@ def is_valid_git_repo(path: Path) -> bool:
76
76
  return p.is_dir() and (p / ".git").exists()
77
77
 
78
78
 
79
+ def notes_vcs_auto_commit(cfg: dict) -> bool:
80
+ """True when opt-in local VCS auto-commit is enabled for notes_root (#103).
81
+
82
+ Reads `notes_vcs.auto_commit` from config. Absent/malformed → False
83
+ (opt-in: the feature does nothing until the user turns it on, e.g. via
84
+ `work-plan notes-vcs init` or `notes-vcs enable`).
85
+ """
86
+ block = cfg.get("notes_vcs")
87
+ return bool(block.get("auto_commit")) if isinstance(block, dict) else False
88
+
89
+
79
90
  def resolve_github_for_folder(folder_name: str, cfg: dict) -> Optional[str]:
80
91
  entry = cfg.get("repos", {}).get(folder_name)
81
92
  return entry.get("github") if entry else None
@@ -0,0 +1,276 @@
1
+ """Opt-in local version control for the private notes_root tier (#103).
2
+
3
+ The shared tier (`<repo>/.work-plan/`) is version-controlled by the repo it
4
+ lives in. The private tier (`notes_root`, e.g. `Project Notes/`) is not — so a
5
+ track edit (slot/group/handoff/close/set) has no history or undo. This module
6
+ adds an opt-in, *personal, never-pushed* git repo at notes_root: every
7
+ track-mutating command becomes an undoable commit + diff.
8
+
9
+ Every helper here NEVER raises — git absence, a stuck lock, a slow filesystem,
10
+ or a non-repo all resolve to "do nothing". A VCS failure must never change a
11
+ command's exit code (the dispatcher relies on this).
12
+
13
+ Safety rule: we only ever operate when notes_root is the git TOPLEVEL. If
14
+ notes_root sits inside someone else's repo (the workspace, a clone), we refuse
15
+ to `git add -A` there — that would sweep unrelated files into a foreign repo.
16
+ `notes-vcs init` makes notes_root its own root, satisfying this.
17
+ """
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ # Bound every git subprocess so a stuck lock or slow FS can't stall the CLI
23
+ # (mirrors git_state.GIT_TIMEOUT; kept local to avoid a cross-module coupling).
24
+ GIT_TIMEOUT = 20
25
+
26
+ _GITIGNORE = ".DS_Store\nThumbs.db\n"
27
+ _INIT_COMMIT_MSG = "work-plan: initialize notes_root local history"
28
+
29
+ # Local git-config marker stamped on the repos work-plan creates for local
30
+ # history. We only ever auto-commit a repo carrying this marker, so we never
31
+ # adopt (and commit into) an arbitrary pre-existing repo the user pointed
32
+ # notes_root at.
33
+ _OWNED_KEY = "workplan.localhistory"
34
+
35
+
36
+ def _git(notes_root, *args, timeout: int = GIT_TIMEOUT):
37
+ """Run `git -C <notes_root> <args>`; return CompletedProcess or None.
38
+
39
+ None means git is missing, timed out, or the spawn failed — callers treat
40
+ it as "no data / could not act", preserving the never-raise contract.
41
+ """
42
+ try:
43
+ return subprocess.run(
44
+ ["git", "-C", str(notes_root), *args],
45
+ capture_output=True, text=True, timeout=timeout,
46
+ )
47
+ except (subprocess.TimeoutExpired, OSError):
48
+ return None
49
+
50
+
51
+ def is_git_root(notes_root: Path) -> bool:
52
+ """True only if notes_root is itself the toplevel of a git work tree.
53
+
54
+ A bare `.git` existence check would also pass for a subdirectory of a larger
55
+ repo, where `git add -A` would stage unrelated files. We compare the
56
+ resolved toplevel to the resolved notes_root so auto-commit only ever fires
57
+ on a repo we own.
58
+ """
59
+ if not notes_root:
60
+ return False
61
+ root = Path(notes_root).expanduser()
62
+ if not root.is_dir():
63
+ return False
64
+ proc = _git(root, "rev-parse", "--show-toplevel")
65
+ if proc is None or proc.returncode != 0:
66
+ return False
67
+ top = proc.stdout.strip()
68
+ if not top:
69
+ return False
70
+ try:
71
+ return Path(top).resolve() == root.resolve()
72
+ except OSError:
73
+ return False
74
+
75
+
76
+ def is_under_git(notes_root: Path) -> bool:
77
+ """True if notes_root is inside ANY git work tree (root or subdir).
78
+
79
+ Used by status/nudge messaging to distinguish "not a repo at all" from
80
+ "inside a repo but not its root" (the latter we deliberately won't touch).
81
+ """
82
+ if not notes_root:
83
+ return False
84
+ root = Path(notes_root).expanduser()
85
+ if not root.is_dir():
86
+ return False
87
+ proc = _git(root, "rev-parse", "--is-inside-work-tree")
88
+ return proc is not None and proc.returncode == 0 and proc.stdout.strip() == "true"
89
+
90
+
91
+ def has_changes(notes_root: Path) -> bool:
92
+ """True if the notes_root work tree has staged or unstaged changes."""
93
+ proc = _git(notes_root, "status", "--short")
94
+ return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
95
+
96
+
97
+ def has_remotes(notes_root: Path) -> bool:
98
+ """True if the repo has ANY configured remote.
99
+
100
+ A personal local-history repo must have none — otherwise private notes
101
+ could be pushed off the machine. Used to refuse enabling/committing history
102
+ on a remote-backed repo.
103
+ """
104
+ proc = _git(notes_root, "remote")
105
+ return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
106
+
107
+
108
+ def is_owned(notes_root: Path) -> bool:
109
+ """True only if work-plan created this repo for local history.
110
+
111
+ `init_repo` stamps a local git-config marker; auto-commit requires it, so we
112
+ never commit into a repo we don't control (e.g. an existing clone the user
113
+ pointed notes_root at).
114
+ """
115
+ proc = _git(notes_root, "config", "--local", "--get", _OWNED_KEY)
116
+ return proc is not None and proc.returncode == 0 and proc.stdout.strip() == "true"
117
+
118
+
119
+ def mark_owned(notes_root: Path) -> bool:
120
+ """Stamp the ownership marker into the repo's local config. Returns success."""
121
+ proc = _git(notes_root, "config", "--local", _OWNED_KEY, "true")
122
+ return proc is not None and proc.returncode == 0
123
+
124
+
125
+ def head_parent_sha(notes_root: Path) -> Optional[str]:
126
+ """Short sha of HEAD's first parent, or None (root commit / no commits / not
127
+ a repo). The viewer compares this to the previously-seen HEAD to confirm a
128
+ post-write commit sits directly on top before offering Undo (#224 safety)."""
129
+ proc = _git(notes_root, "rev-parse", "--short", "--verify", "HEAD^")
130
+ if proc is None or proc.returncode != 0 or not proc.stdout.strip():
131
+ return None
132
+ return proc.stdout.strip()
133
+
134
+
135
+ def dirty_paths(notes_root: Path) -> set:
136
+ """Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
137
+
138
+ Empty set on any failure. Renames collapse to the destination path. Used by
139
+ the dispatcher to commit ONLY what a command changed, leaving pre-existing
140
+ dirty files untouched.
141
+ """
142
+ proc = _git(notes_root, "-c", "core.quotepath=false", "status", "--porcelain")
143
+ if proc is None or proc.returncode != 0:
144
+ return set()
145
+ paths = set()
146
+ for line in proc.stdout.splitlines():
147
+ if len(line) < 4:
148
+ continue
149
+ path = line[3:]
150
+ if " -> " in path: # rename / copy → the destination path
151
+ path = path.split(" -> ", 1)[1]
152
+ if path:
153
+ paths.add(path)
154
+ return paths
155
+
156
+
157
+ def last_commit_summary(notes_root: Path) -> Optional[str]:
158
+ """'<short-sha> <subject>' of HEAD, or None (no commits / not a repo)."""
159
+ proc = _git(notes_root, "log", "-1", "--pretty=format:%h %s")
160
+ if proc is None or proc.returncode != 0 or not proc.stdout.strip():
161
+ return None
162
+ return proc.stdout.strip()
163
+
164
+
165
+ def last_commit_sha(notes_root: Path) -> Optional[str]:
166
+ """Short sha of HEAD, or None (no commits / not a repo). The undo handle the
167
+ VS Code viewer diffs across a write to decide whether to offer Undo (#224)."""
168
+ proc = _git(notes_root, "log", "-1", "--pretty=format:%h")
169
+ if proc is None or proc.returncode != 0 or not proc.stdout.strip():
170
+ return None
171
+ return proc.stdout.strip()
172
+
173
+
174
+ def revert(notes_root: Path, sha: Optional[str] = None) -> Optional[str]:
175
+ """Revert `sha` (default HEAD) in notes_root; return the new commit's sha.
176
+
177
+ Keeps git inside the engine so callers (the CLI `undo` verb, the viewer's
178
+ Undo button) never shell out to git themselves. No-op (None) when notes_root
179
+ isn't a git root we OWN with no remote (same boundary as auto_commit — we
180
+ must never rewrite an unrelated project clone's history), when there's no
181
+ commit to revert, or when `sha` is unsafe (empty / dash-led — git would read
182
+ a dash-led value as an option). Never raises. Uses --no-edit (non-interactive).
183
+ """
184
+ root = Path(notes_root).expanduser()
185
+ if not is_git_root(root) or not is_owned(root) or has_remotes(root):
186
+ return None
187
+ target = sha if sha is not None else "HEAD"
188
+ # A dash-led ref would be parsed by git as an option, not a revision.
189
+ if not target or target.startswith("-"):
190
+ return None
191
+ proc = _git(root, "revert", "--no-edit", target)
192
+ if proc is None or proc.returncode != 0:
193
+ return None
194
+ return last_commit_sha(root)
195
+
196
+
197
+ def init_repo(notes_root: Path) -> bool:
198
+ """git-init notes_root as a personal repo and make an initial commit.
199
+
200
+ Writes a small `.gitignore` (OS cruft only), stages everything, and commits
201
+ existing tracks so there's a baseline to diff against. Deliberately adds NO
202
+ remote — the private tier is never pushed. Returns True on a clean init +
203
+ initial commit, False on any failure (never raises).
204
+
205
+ Idempotent-ish: re-running on a repo WE own re-commits only if there's
206
+ something new. Refuses (returns False) to adopt a repo we did not create or
207
+ one that has a remote — private notes must never be pushable, and we won't
208
+ sweep an unrelated existing repo's files into history.
209
+ """
210
+ root = Path(notes_root).expanduser()
211
+ if not root.is_dir():
212
+ return False
213
+ if is_git_root(root):
214
+ # An existing repo: only proceed if it's one we own AND has no remote.
215
+ if has_remotes(root) or not is_owned(root):
216
+ return False
217
+ else:
218
+ if _git(root, "init") is None:
219
+ return False
220
+ # Stamp ownership immediately, before any commit, so this repo can never
221
+ # later be mistaken for a foreign one (and a remote added after init is
222
+ # still caught by auto_commit's per-commit no-remote check).
223
+ if not mark_owned(root):
224
+ return False
225
+ gitignore = root / ".gitignore"
226
+ if not gitignore.exists():
227
+ try:
228
+ gitignore.write_text(_GITIGNORE, encoding="utf-8")
229
+ except OSError:
230
+ return False
231
+ if _git(root, "add", "-A") is None:
232
+ return False
233
+ # Nothing to commit (e.g. re-init of an unchanged repo) is success, not error.
234
+ if not has_changes(root) and last_commit_summary(root) is not None:
235
+ return True
236
+ proc = _git(root, "commit", "-m", _INIT_COMMIT_MSG)
237
+ return proc is not None and proc.returncode == 0
238
+
239
+
240
+ def auto_commit(notes_root: Path, message: str,
241
+ paths: Optional[list] = None) -> Optional[str]:
242
+ """Commit notes_root changes with `message`; return the new short SHA.
243
+
244
+ Safety gates (all must hold, else no-op None):
245
+ - notes_root is the git toplevel,
246
+ - it carries the ownership marker (work-plan created it), and
247
+ - it has NO remote (a personal, never-pushed history).
248
+
249
+ When `paths` is given, stages ONLY those paths so unrelated pre-existing
250
+ dirty files stay out of the commit; otherwise stages everything. Commits
251
+ only if something is actually staged. Never raises — a git failure here must
252
+ not change the calling command's exit code.
253
+ """
254
+ root = Path(notes_root).expanduser()
255
+ if not is_git_root(root) or not is_owned(root) or has_remotes(root):
256
+ return None
257
+ if paths is None:
258
+ if _git(root, "add", "-A") is None:
259
+ return None
260
+ else:
261
+ if not paths:
262
+ return None
263
+ if _git(root, "add", "--", *paths) is None:
264
+ return None
265
+ # Commit only what's staged — scoped `add` above keeps unrelated dirty files
266
+ # unstaged, so they're preserved rather than folded into this commit.
267
+ staged = _git(root, "diff", "--cached", "--quiet")
268
+ if staged is None or staged.returncode == 0:
269
+ return None
270
+ proc = _git(root, "commit", "-m", message)
271
+ if proc is None or proc.returncode != 0:
272
+ return None
273
+ head = _git(root, "rev-parse", "--short", "HEAD")
274
+ if head is None or head.returncode != 0:
275
+ return None
276
+ return head.stdout.strip() or None