@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 +26 -4
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +4 -2
- package/skills/work-plan/commands/group.py +5 -1
- package/skills/work-plan/commands/new_track.py +8 -2
- package/skills/work-plan/commands/notes_vcs.py +172 -0
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/lib/config.py +11 -0
- package/skills/work-plan/lib/notes_vcs.py +276 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/tests/test_notes_vcs.py +426 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +389 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/work_plan.py +161 -7
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 **
|
|
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
|
|
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>` |
|
|
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.
|
|
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.
|
|
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 "
|
|
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 → **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|