@stylusnexus/work-plan 2026.6.10 → 2026.6.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -7
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +6 -4
- package/skills/work-plan/commands/canonicalize.py +7 -92
- package/skills/work-plan/commands/handoff.py +15 -6
- package/skills/work-plan/commands/init.py +13 -3
- package/skills/work-plan/commands/init_repo.py +8 -2
- package/skills/work-plan/commands/new_track.py +7 -0
- package/skills/work-plan/commands/notes_vcs.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +106 -37
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/lib/config.py +11 -0
- package/skills/work-plan/lib/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +46 -13
- package/skills/work-plan/lib/notes_vcs.py +276 -0
- package/skills/work-plan/lib/prompts.py +12 -1
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +9 -4
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_github_state.py +3 -3
- package/skills/work-plan/tests/test_init_repo.py +12 -7
- package/skills/work-plan/tests/test_new_track.py +7 -7
- package/skills/work-plan/tests/test_notes_vcs.py +426 -0
- package/skills/work-plan/tests/test_notes_vcs_command.py +312 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- package/skills/work-plan/tests/test_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +2 -2
- package/skills/work-plan/tests/test_tracks.py +4 -4
- package/skills/work-plan/work_plan.py +97 -17
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ flowchart TB
|
|
|
109
109
|
- For tracks where you don't want to bother curating at all, set `next_up_auto: true` in the track's frontmatter — `brief` will then derive the list live each invocation, ignoring whatever's stored.
|
|
110
110
|
- **Weekly** → `hygiene` runs `refresh-md --all` + `reconcile --all` + `duplicates` in sequence to keep status icons, GitHub labels, and dedup state honest.
|
|
111
111
|
|
|
112
|
-
> **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md <track>` (or **
|
|
112
|
+
> **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md <track>` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly.
|
|
113
113
|
|
|
114
114
|
> **GitHub access is read-only.** The toolkit never writes to GitHub. All issue data comes from read-only `gh` CLI calls (`gh issue list`, `gh issue view`). Every write (frontmatter, status table, session log) goes to your local markdown files only.
|
|
115
115
|
|
|
@@ -148,8 +148,8 @@ The CLI never auto-pushes. When you create or update a shared track, it prints a
|
|
|
148
148
|
**Multi-repo disambiguation:** if the same track slug exists in two repos, qualify with `@<repo>` or `--repo=<key>`:
|
|
149
149
|
|
|
150
150
|
```bash
|
|
151
|
-
/work-plan slot 4234 auth-flow@
|
|
152
|
-
/work-plan close auth-flow --repo=
|
|
151
|
+
/work-plan slot 4234 auth-flow@myproject
|
|
152
|
+
/work-plan close auth-flow --repo=myproject
|
|
153
153
|
```
|
|
154
154
|
|
|
155
155
|
## Plan & doc liveness (`plan-status`)
|
|
@@ -448,8 +448,12 @@ repos:
|
|
|
448
448
|
myproject:
|
|
449
449
|
github: your-org/myproject
|
|
450
450
|
local: /path/to/local/checkout # optional, enables in-progress detection
|
|
451
|
+
notes_vcs:
|
|
452
|
+
auto_commit: true # opt-in local history for notes_root (set by `notes-vcs init`)
|
|
451
453
|
```
|
|
452
454
|
|
|
455
|
+
`notes_vcs.auto_commit` is added by `/work-plan notes-vcs init` (or `enable`) — when on, every track-mutating command commits `notes_root` so private-tier edits are undoable. Absent → off.
|
|
456
|
+
|
|
453
457
|
### Where your config lives
|
|
454
458
|
|
|
455
459
|
The active config the skill reads is **`~/.claude/work-plan/config.yml`** — created by `install.sh` (or `install.ps1`) on first run. There's no template file in the repo to confuse with the runtime config; install just writes the right two lines directly.
|
|
@@ -487,24 +491,26 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
487
491
|
| Subcommand | What it does |
|
|
488
492
|
|---|---|
|
|
489
493
|
| `brief [--repo=<key>]` | Multi-track snapshot of all active tracks across configured repos. `--repo=<key>` filters to one project (matches the folder name under `notes_root` or the `org/repo` GitHub slug; archived-reopen callouts are also scoped). |
|
|
490
|
-
| `handoff <track> [--auto-next \| --set-next 1,2,3]` | Wrap up a work block. Writes a `### Session — <ts>` entry. `--auto-next` suggests a priority-sorted top-3 from open issues (interactive: apply / edit / skip). `--set-next 1,2,3` is the explicit form
|
|
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>` |
|
|
498
|
+
| `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
|
|
495
499
|
| `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `duplicates`. With `--repo=<key>`, steps 1 and 2 scope to that repo and the global `duplicates` step is skipped. |
|
|
496
500
|
| `list [--all] [--sort=recent\|priority]` | List active tracks (or all including parked/archived). `--sort=recent` orders by `last_touched` (most recent first); `--sort=priority` orders by `launch_priority` (P0→P3) with recency as tiebreaker. Default keeps discovery order. |
|
|
497
501
|
| `init <path> [--priority=P0..P3] [--milestone=<m>]` | Add frontmatter to a brand-new track .md file (the file must already exist). Pass `--priority=`/`--milestone=` to skip the prompts. |
|
|
498
502
|
| `init-repo <key> --github=<slug> [--local=<path>]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required; `--local` is optional. |
|
|
499
503
|
| `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
|
|
504
|
+
| `rename-track <old-slug \| old@repo> <new-slug> [--repo=<key>] [--fix-refs] [--commit]` | Rename an active track's slug: moves its `.md` file and updates the frontmatter `track` field + `last_touched`. Validates `<new-slug>` like `new-track` and rejects a name already taken in the same repo/tier. For shared tracks, `--commit` stages + commits the move (otherwise it prints a "commit to share" hint). `--fix-refs` rewrites sibling tracks' `depends_on` that reference the old slug; without it they're just warned about. Archived tracks are immutable. Public-repo gated. |
|
|
500
505
|
| `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
|
|
506
|
+
| `notes-vcs <init\|enable\|disable\|status\|undo> [<sha>] [--no-enable] [--json]` | Opt-in **local** version control for the private `notes_root` tier — history/undo for tracks on your machine, never pushed. `init` git-inits `notes_root` as a personal repo (baseline commit of existing tracks) and turns on auto-commit (`--no-enable` skips that). For safety it **refuses** a `notes_root` that already has a git remote or is a repo work-plan didn't create. When on, every track-mutating command (`slot`/`group`/`handoff`/`close`/`set`/…) commits **only the files it changed** (pre-existing uncommitted edits are left alone) as an undoable commit. The shared tier is unaffected — it's versioned by its own repo. `status` shows whether `notes_root` is a repo, whether auto-commit is on, and the last commit (`--json` for the machine shape the VS Code viewer polls). `undo [<sha>]` reverts a commit (default HEAD) — reverses the last edit. |
|
|
501
507
|
| `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
|
|
502
508
|
| `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
|
|
503
509
|
| `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
|
|
504
510
|
| `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
|
|
505
|
-
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews ADDs/MOVEs/FLAGs without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
511
|
+
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews the label drift (ADDs/MOVEs/FLAGs) without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
506
512
|
| `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
|
|
507
|
-
| `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). |
|
|
513
|
+
| `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). The table carries a `Milestone` column and is ordered active-milestone-first — issues in the track's `milestone_alignment` milestone, then other milestones grouped (blank divider row between groups), then no-milestone last — so "what's next" sits above "someday" (#101). It's one table (not per-milestone sub-tables) so `refresh-md` re-derives it cleanly. |
|
|
508
514
|
| `plan-status [--repo=<key>] [--json] [--stamp [--draft]] [--llm [--apply]] [--archive \| --issues] [--draft]` | Reach a verdict on every plan/spec doc in a repo by correlating its declared file-manifest against git + the filesystem: ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less / 🧳 foreign. Read-only by default. `--stamp` writes an idempotent status header into each doc (`--draft` previews); `--llm` runs a two-step AI verdict on prose/ambiguous docs; `--archive` moves dead plans to `archive/abandoned/` and `--issues` opens issues for partial plans (both gated, both honor `--draft`); `--json` for machine output. |
|
|
509
515
|
|
|
510
516
|
Run `python3 ~/.claude/skills/work-plan/work_plan.py --help` for the full list with examples.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
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.
|
|
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 "
|
|
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
|
|
@@ -97,8 +99,8 @@ Track files live in one of two places:
|
|
|
97
99
|
|
|
98
100
|
**Disambiguation when the same track slug exists in two repos:**
|
|
99
101
|
```
|
|
100
|
-
/work-plan slot 4234 auth-flow@
|
|
101
|
-
/work-plan close auth-flow --repo=
|
|
102
|
+
/work-plan slot 4234 auth-flow@myproject # @repo qualifier
|
|
103
|
+
/work-plan close auth-flow --repo=myproject # --repo=<key> flag
|
|
102
104
|
```
|
|
103
105
|
|
|
104
106
|
Both forms work on: `slot`, `close`, `handoff`, `canonicalize`, `refresh-md`, `reconcile`, `set`.
|
|
@@ -8,9 +8,11 @@ Use --all to canonicalize every active track that doesn't yet have one.
|
|
|
8
8
|
"""
|
|
9
9
|
from lib.config import load_config, ConfigError
|
|
10
10
|
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
11
|
-
from lib.github_state import fetch_issues
|
|
11
|
+
from lib.github_state import fetch_issues
|
|
12
12
|
from lib.frontmatter import write_file
|
|
13
|
-
from lib.status_table import
|
|
13
|
+
from lib.status_table import (
|
|
14
|
+
find_canonical_status_tables, render_canonical_table, insert_canonical_block,
|
|
15
|
+
)
|
|
14
16
|
from lib.prompts import parse_flags
|
|
15
17
|
|
|
16
18
|
|
|
@@ -75,10 +77,11 @@ def run(args: list[str]) -> int:
|
|
|
75
77
|
issues = fetch_issues(track.repo, issue_nums)
|
|
76
78
|
issues_by_num = {i["number"]: i for i in issues}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
table_md = render_canonical_table(
|
|
81
|
+
issue_nums, issues_by_num,
|
|
80
82
|
milestone_alignment=track.meta.get("milestone_alignment"),
|
|
81
83
|
)
|
|
84
|
+
new_body = insert_canonical_block(track.body, table_md, replace=force)
|
|
82
85
|
write_file(track.path, track.meta, new_body)
|
|
83
86
|
print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
|
|
84
87
|
any_changes = True
|
|
@@ -86,91 +89,3 @@ def run(args: list[str]) -> int:
|
|
|
86
89
|
if not any_changes:
|
|
87
90
|
print("Nothing to do.")
|
|
88
91
|
return 0
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _insert_canonical_table(body: str, issue_nums: list[int],
|
|
92
|
-
issues_by_num: dict, replace: bool = False,
|
|
93
|
-
milestone_alignment=None) -> str:
|
|
94
|
-
"""Insert (or replace) a canonical table at the top of the body."""
|
|
95
|
-
table_md = _render_canonical_table(issue_nums, issues_by_num, milestone_alignment)
|
|
96
|
-
|
|
97
|
-
if replace:
|
|
98
|
-
# Strip existing canonical block (marker + heading + table + separator)
|
|
99
|
-
body = _strip_existing_canonical(body)
|
|
100
|
-
|
|
101
|
-
# Prepend table after any leading whitespace
|
|
102
|
-
body_stripped = body.lstrip("\n")
|
|
103
|
-
leading_whitespace = body[: len(body) - len(body_stripped)]
|
|
104
|
-
return leading_whitespace + table_md + "\n---\n\n" + body_stripped
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _render_canonical_table(issue_nums: list[int], issues_by_num: dict,
|
|
108
|
-
milestone_alignment=None) -> str:
|
|
109
|
-
lines = [
|
|
110
|
-
"## Issues (canonical)",
|
|
111
|
-
"",
|
|
112
|
-
f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
|
|
113
|
-
"",
|
|
114
|
-
]
|
|
115
|
-
|
|
116
|
-
# Build a normalized issue list with compact milestone strings.
|
|
117
|
-
from lib.github_state import short_milestone
|
|
118
|
-
norm_issues = []
|
|
119
|
-
for num in sorted(issue_nums):
|
|
120
|
-
gh = issues_by_num.get(num, {})
|
|
121
|
-
ms = short_milestone(gh.get("milestone")) or None
|
|
122
|
-
norm_issues.append({"number": num, "milestone": ms, "_gh": gh})
|
|
123
|
-
|
|
124
|
-
from lib.export_model import group_issues_by_milestone
|
|
125
|
-
groups = group_issues_by_milestone(norm_issues, milestone_alignment)
|
|
126
|
-
|
|
127
|
-
if len(groups) <= 1:
|
|
128
|
-
# Single milestone group (or all null) — render flat, same as before.
|
|
129
|
-
lines.append("| # | Title | Assignee | Status |")
|
|
130
|
-
lines.append("|---|---|---|---|")
|
|
131
|
-
for num in sorted(issue_nums):
|
|
132
|
-
i = issues_by_num.get(num, {})
|
|
133
|
-
lines.append(render_issue_row(
|
|
134
|
-
num, i.get("title", "(not fetched)"),
|
|
135
|
-
format_assignees(i), state_to_status_label(i.get("state")),
|
|
136
|
-
))
|
|
137
|
-
lines.append("")
|
|
138
|
-
return "\n".join(lines)
|
|
139
|
-
|
|
140
|
-
# Multiple milestone groups — render with section headings.
|
|
141
|
-
for label, issues in groups:
|
|
142
|
-
if label:
|
|
143
|
-
heading = f"{label} ({len(issues)})"
|
|
144
|
-
else:
|
|
145
|
-
heading = f"No milestone ({len(issues)})"
|
|
146
|
-
lines.append(f"### {heading}")
|
|
147
|
-
lines.append("")
|
|
148
|
-
lines.append("| # | Title | Assignee | Status |")
|
|
149
|
-
lines.append("|---|---|---|---|")
|
|
150
|
-
for norm in issues:
|
|
151
|
-
num = norm["number"]
|
|
152
|
-
i = norm["_gh"]
|
|
153
|
-
lines.append(render_issue_row(
|
|
154
|
-
num, i.get("title", "(not fetched)"),
|
|
155
|
-
format_assignees(i), state_to_status_label(i.get("state")),
|
|
156
|
-
))
|
|
157
|
-
lines.append("")
|
|
158
|
-
return "\n".join(lines)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _strip_existing_canonical(body: str) -> str:
|
|
162
|
-
"""Remove an existing canonical-table block from the top of the body."""
|
|
163
|
-
if CANONICAL_MARKER not in body:
|
|
164
|
-
return body
|
|
165
|
-
# Find the start of the heading "## Issues (canonical)" if present, else the marker
|
|
166
|
-
heading_idx = body.find("## Issues (canonical)")
|
|
167
|
-
marker_idx = body.find(CANONICAL_MARKER)
|
|
168
|
-
start = heading_idx if 0 <= heading_idx < marker_idx else marker_idx
|
|
169
|
-
# Find end: the next "---\n" separator after the marker
|
|
170
|
-
sep_idx = body.find("\n---\n", marker_idx)
|
|
171
|
-
if sep_idx == -1:
|
|
172
|
-
# No separator — strip just the marker line
|
|
173
|
-
end = body.find("\n", marker_idx) + 1
|
|
174
|
-
else:
|
|
175
|
-
end = sep_idx + len("\n---\n")
|
|
176
|
-
return body[:start] + body[end:].lstrip("\n")
|
|
@@ -19,6 +19,7 @@ from lib.session_log import append_session_log, SESSION_LOG_HEADER
|
|
|
19
19
|
from lib.git_state import (
|
|
20
20
|
has_uncommitted, current_branch, parse_iso_timestamp,
|
|
21
21
|
gap_seconds_to_label, uncommitted_file_count, commits_ahead,
|
|
22
|
+
is_safe_ref, GIT_TIMEOUT,
|
|
22
23
|
)
|
|
23
24
|
from lib.github_state import fetch_issues, state_to_status_label, extract_priority, short_milestone
|
|
24
25
|
from lib.status_table import update_row_status, sync_missing_rows, find_canonical_status_tables, ISSUE_NUM_RE
|
|
@@ -514,12 +515,20 @@ def _recent_commits(track, since_dt) -> list[dict]:
|
|
|
514
515
|
|
|
515
516
|
if branches:
|
|
516
517
|
for b in branches:
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
518
|
+
# A branch name from frontmatter is passed as a positional rev; a
|
|
519
|
+
# dash-led value (e.g. `--output=/path`) would be read by git as an
|
|
520
|
+
# option → arbitrary-file write. Reject before use (#192).
|
|
521
|
+
if not is_safe_ref(str(b)):
|
|
522
|
+
continue
|
|
523
|
+
try:
|
|
524
|
+
proc = subprocess.run(
|
|
525
|
+
["git", "-C", str(track.local_path), "log", b,
|
|
526
|
+
f"--since={since_iso}",
|
|
527
|
+
"--pretty=format:%H|%s|%cI"],
|
|
528
|
+
capture_output=True, text=True, timeout=GIT_TIMEOUT,
|
|
529
|
+
)
|
|
530
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
531
|
+
continue
|
|
523
532
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
524
533
|
continue
|
|
525
534
|
for line in proc.stdout.strip().split("\n"):
|
|
@@ -67,12 +67,22 @@ def run(args: list[str]) -> int:
|
|
|
67
67
|
return 1
|
|
68
68
|
folder = None
|
|
69
69
|
else:
|
|
70
|
-
|
|
70
|
+
# Containment guard (#195): a non-shared target MUST live under
|
|
71
|
+
# notes_root. Without this, `init /etc/anything` (any user-writable
|
|
72
|
+
# file with no frontmatter) would get frontmatter prepended via
|
|
73
|
+
# write_file, clobbering it. `path` is already resolved; resolve
|
|
74
|
+
# notes_root too so the comparison is symlink/relative-safe.
|
|
75
|
+
notes_root = Path(cfg["notes_root"]).expanduser().resolve()
|
|
71
76
|
try:
|
|
72
77
|
rel = path.relative_to(notes_root)
|
|
73
|
-
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
74
78
|
except ValueError:
|
|
75
|
-
|
|
79
|
+
print(
|
|
80
|
+
f"ERROR: {path} is not inside notes_root ({notes_root}) or a"
|
|
81
|
+
" registered .work-plan/ directory — refusing to write"
|
|
82
|
+
" frontmatter outside the tracked tree."
|
|
83
|
+
)
|
|
84
|
+
return 1
|
|
85
|
+
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
76
86
|
repo = resolve_github_for_folder(folder, cfg) if folder else None
|
|
77
87
|
|
|
78
88
|
issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Non-interactive: --github is required; --local is optional (no prompts).
|
|
4
4
|
"""
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
import subprocess
|
|
8
9
|
from pathlib import Path
|
|
@@ -113,11 +114,16 @@ def run(args: list[str]) -> int:
|
|
|
113
114
|
if local:
|
|
114
115
|
repo_block["local"] = local
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
# `key` is validated against ^[a-z][a-z0-9-]*$ above, so it's safe in the yq
|
|
118
|
+
# path. The repo block is passed as an OPAQUE env value via env() (parsed as
|
|
119
|
+
# YAML/JSON) rather than interpolated into the expression — uniform with the
|
|
120
|
+
# strenv() hardening in set-notes-root (#196).
|
|
121
|
+
env = {**os.environ, "WP_REPO_BLOCK": json.dumps(repo_block)}
|
|
122
|
+
yq_expr = f".repos.{key} = env(WP_REPO_BLOCK)"
|
|
117
123
|
try:
|
|
118
124
|
subprocess.run(
|
|
119
125
|
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
120
|
-
check=True, capture_output=True, text=True,
|
|
126
|
+
check=True, capture_output=True, text=True, env=env,
|
|
121
127
|
)
|
|
122
128
|
except subprocess.CalledProcessError as e:
|
|
123
129
|
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
@@ -103,6 +103,13 @@ def run(args: list[str]) -> int:
|
|
|
103
103
|
elif "/" in repo_arg:
|
|
104
104
|
github = repo_arg
|
|
105
105
|
folder = repo_arg.rsplit("/", 1)[-1]
|
|
106
|
+
# Validate the derived folder segment (#195). `rsplit` caps traversal at
|
|
107
|
+
# one segment, but a slug like `x/..` yields folder=".." → the track
|
|
108
|
+
# would be written one level ABOVE notes_root. A real GitHub repo name
|
|
109
|
+
# matches [A-Za-z0-9._-]+ and is never "." / ".." — reject anything else.
|
|
110
|
+
if folder in ("", ".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", folder):
|
|
111
|
+
print(f"ERROR: cannot derive a safe notes folder from '{repo_arg}'.")
|
|
112
|
+
return 2
|
|
106
113
|
else:
|
|
107
114
|
print(
|
|
108
115
|
f"ERROR: unknown repo '{repo_arg}' — pass a configured key"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""notes-vcs subcommand — opt-in local version control for notes_root (#103).
|
|
2
|
+
|
|
3
|
+
Actions:
|
|
4
|
+
init git-init notes_root as a personal, never-pushed repo (initial commit
|
|
5
|
+
of existing tracks), then enable auto-commit unless --no-enable.
|
|
6
|
+
enable turn on auto-commit after track-mutating commands.
|
|
7
|
+
disable turn it off (history is kept; just stop adding new commits).
|
|
8
|
+
status report whether notes_root is under git, whether auto-commit is on,
|
|
9
|
+
and the last commit — plus a nudge to `init` when it isn't a repo.
|
|
10
|
+
Add --json for the machine-readable shape the VS Code viewer polls.
|
|
11
|
+
undo revert a commit in notes_root (default HEAD). The git side of the
|
|
12
|
+
viewer's Undo button (#224); on the CLI it reverses the last edit.
|
|
13
|
+
|
|
14
|
+
Config writes use the same opaque-env `yq strenv` pattern as set-notes-root
|
|
15
|
+
(#191): the value is never interpolated into the yq expression.
|
|
16
|
+
|
|
17
|
+
Usage: notes-vcs <init|enable|disable|status|undo> [<sha>] [--no-enable] [--json]
|
|
18
|
+
"""
|
|
19
|
+
import json as _json
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from lib.config import (
|
|
25
|
+
load_config, ConfigError, DEFAULT_CONFIG_PATH, notes_vcs_auto_commit,
|
|
26
|
+
)
|
|
27
|
+
from lib.prompts import parse_flags
|
|
28
|
+
from lib import notes_vcs
|
|
29
|
+
|
|
30
|
+
_ACTIONS = ("init", "enable", "disable", "status", "undo")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _set_auto_commit(value: bool) -> bool:
|
|
34
|
+
"""Persist notes_vcs.auto_commit=<value> into config.yml via yq. The bool
|
|
35
|
+
travels as an opaque env value (strenv), never interpolated into the
|
|
36
|
+
expression. Returns True on success."""
|
|
37
|
+
env = {**os.environ, "WP_VCS": "true" if value else "false"}
|
|
38
|
+
try:
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["yq", "-i", ".notes_vcs.auto_commit = (strenv(WP_VCS) == \"true\")",
|
|
41
|
+
str(DEFAULT_CONFIG_PATH)],
|
|
42
|
+
check=True, capture_output=True, text=True, env=env,
|
|
43
|
+
)
|
|
44
|
+
return True
|
|
45
|
+
except subprocess.CalledProcessError as e:
|
|
46
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _status_dict(notes_root: Path, cfg: dict) -> dict:
|
|
51
|
+
"""Machine-readable state for the VS Code viewer (#224). last_commit_sha is
|
|
52
|
+
the handle the viewer diffs across a write to decide whether to offer Undo."""
|
|
53
|
+
is_root = notes_vcs.is_git_root(notes_root)
|
|
54
|
+
return {
|
|
55
|
+
"notes_root": str(notes_root),
|
|
56
|
+
"under_git": notes_vcs.is_under_git(notes_root),
|
|
57
|
+
"is_root": is_root,
|
|
58
|
+
"auto_commit": notes_vcs_auto_commit(cfg),
|
|
59
|
+
"last_commit_sha": notes_vcs.last_commit_sha(notes_root) if is_root else None,
|
|
60
|
+
"head_parent_sha": notes_vcs.head_parent_sha(notes_root) if is_root else None,
|
|
61
|
+
"last_commit_subject": notes_vcs.last_commit_summary(notes_root) if is_root else None,
|
|
62
|
+
"dirty": notes_vcs.has_changes(notes_root) if is_root else False,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _print_status(notes_root: Path, cfg: dict) -> None:
|
|
67
|
+
on = notes_vcs_auto_commit(cfg)
|
|
68
|
+
print(f"notes_root: {notes_root}")
|
|
69
|
+
if notes_vcs.is_git_root(notes_root):
|
|
70
|
+
print("git: ✓ local repo (notes_root is the git root)")
|
|
71
|
+
last = notes_vcs.last_commit_summary(notes_root)
|
|
72
|
+
print(f"last commit: {last}" if last else "last commit: (none yet)")
|
|
73
|
+
if notes_vcs.has_changes(notes_root):
|
|
74
|
+
print("working tree: uncommitted changes present")
|
|
75
|
+
elif notes_vcs.is_under_git(notes_root):
|
|
76
|
+
print("git: ⚠ inside another git repo (NOT its root) — "
|
|
77
|
+
"auto-commit is disabled here; move notes_root to its own folder.")
|
|
78
|
+
else:
|
|
79
|
+
print("git: ✗ not a repo — run `work-plan notes-vcs init` to add local history")
|
|
80
|
+
print(f"auto-commit: {'on' if on else 'off'}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run(args: list[str]) -> int:
|
|
84
|
+
flags, positional = parse_flags(args, {"--no-enable", "--json"})
|
|
85
|
+
|
|
86
|
+
action = positional[0] if positional else "status"
|
|
87
|
+
if action not in _ACTIONS:
|
|
88
|
+
print(f"usage: work_plan.py notes-vcs <{'|'.join(_ACTIONS)}> "
|
|
89
|
+
"[<sha>] [--no-enable] [--json]")
|
|
90
|
+
return 2
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
cfg = load_config()
|
|
94
|
+
except ConfigError as e:
|
|
95
|
+
print(f"ERROR: {e}")
|
|
96
|
+
return 1
|
|
97
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
98
|
+
|
|
99
|
+
if action == "status":
|
|
100
|
+
if "--json" in flags:
|
|
101
|
+
print(_json.dumps(_status_dict(notes_root, cfg)))
|
|
102
|
+
else:
|
|
103
|
+
_print_status(notes_root, cfg)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
if action == "undo":
|
|
107
|
+
if not notes_vcs.is_git_root(notes_root):
|
|
108
|
+
print(f"ERROR: {notes_root} is not a git repo — nothing to undo.")
|
|
109
|
+
return 1
|
|
110
|
+
# Same boundary as init/auto-commit: never rewrite a repo we don't own
|
|
111
|
+
# or one with a remote (it could be an unrelated project clone).
|
|
112
|
+
if notes_vcs.has_remotes(notes_root) or not notes_vcs.is_owned(notes_root):
|
|
113
|
+
print(f"ERROR: {notes_root} is not a work-plan local-history repo "
|
|
114
|
+
"(it has a git remote, or wasn't created by work-plan). "
|
|
115
|
+
"Refusing to revert — undo only operates on personal history.")
|
|
116
|
+
return 1
|
|
117
|
+
sha = positional[1] if len(positional) > 1 else None
|
|
118
|
+
new_sha = notes_vcs.revert(notes_root, sha)
|
|
119
|
+
if not new_sha:
|
|
120
|
+
print(f"ERROR: failed to revert {sha or 'HEAD'} in {notes_root} "
|
|
121
|
+
"(nothing to revert, or a conflict — resolve it manually).")
|
|
122
|
+
return 1
|
|
123
|
+
print(f"✓ Reverted {sha or 'HEAD'} — new commit {new_sha}.")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
if action == "init":
|
|
127
|
+
if notes_vcs.is_under_git(notes_root) and not notes_vcs.is_git_root(notes_root):
|
|
128
|
+
print(f"ERROR: {notes_root} is inside another git repo but is not its "
|
|
129
|
+
"root. Auto-commit would stage unrelated files there. Move "
|
|
130
|
+
"notes_root to its own folder first (see set-notes-root).")
|
|
131
|
+
return 1
|
|
132
|
+
if notes_vcs.is_git_root(notes_root):
|
|
133
|
+
# Don't adopt an existing repo we didn't create, or one with a
|
|
134
|
+
# remote — private notes must never be pushable.
|
|
135
|
+
if notes_vcs.has_remotes(notes_root):
|
|
136
|
+
print(f"ERROR: {notes_root} already has a git remote. Local "
|
|
137
|
+
"history must stay personal and never-pushed — point "
|
|
138
|
+
"notes_root at a folder with no remote, or remove the "
|
|
139
|
+
"remote first.")
|
|
140
|
+
return 1
|
|
141
|
+
if not notes_vcs.is_owned(notes_root):
|
|
142
|
+
print(f"ERROR: {notes_root} is an existing git repo not created "
|
|
143
|
+
"by work-plan. Refusing to adopt it (it may hold unrelated "
|
|
144
|
+
"history). Point notes_root at a fresh folder for local "
|
|
145
|
+
"history.")
|
|
146
|
+
return 1
|
|
147
|
+
if not notes_vcs.init_repo(notes_root):
|
|
148
|
+
print(f"ERROR: failed to git-init {notes_root}. Is git installed?")
|
|
149
|
+
return 1
|
|
150
|
+
print(f"✓ Initialized local history at {notes_root} (personal repo — no remote).")
|
|
151
|
+
if "--no-enable" in flags:
|
|
152
|
+
print(" auto-commit left off — run `notes-vcs enable` to turn it on.")
|
|
153
|
+
return 0
|
|
154
|
+
if not _set_auto_commit(True):
|
|
155
|
+
return 1
|
|
156
|
+
print("✓ auto-commit enabled — track edits now create undoable commits.")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
if action == "enable":
|
|
160
|
+
if not notes_vcs.is_git_root(notes_root):
|
|
161
|
+
print(f"WARN: {notes_root} is not a git repo yet. Run "
|
|
162
|
+
"`work-plan notes-vcs init` first, or commits will be skipped.")
|
|
163
|
+
if not _set_auto_commit(True):
|
|
164
|
+
return 1
|
|
165
|
+
print("✓ auto-commit enabled.")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
# action == "disable"
|
|
169
|
+
if not _set_auto_commit(False):
|
|
170
|
+
return 1
|
|
171
|
+
print("✓ auto-commit disabled (existing history kept).")
|
|
172
|
+
return 0
|