@stylusnexus/work-plan 2026.6.11 → 2026.6.13-2
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 +38 -6
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +3 -0
- package/skills/work-plan/commands/auth_status.py +35 -0
- package/skills/work-plan/commands/close_issue.py +82 -0
- package/skills/work-plan/commands/export.py +72 -3
- package/skills/work-plan/commands/group.py +5 -1
- package/skills/work-plan/commands/init_repo.py +84 -14
- package/skills/work-plan/commands/list_open_issues.py +52 -0
- package/skills/work-plan/commands/new_track.py +8 -2
- package/skills/work-plan/commands/plan_ack.py +71 -0
- package/skills/work-plan/commands/plan_baseline.py +85 -0
- package/skills/work-plan/commands/plan_branch.py +314 -0
- package/skills/work-plan/commands/plan_confirm.py +83 -0
- package/skills/work-plan/commands/plan_status.py +140 -9
- package/skills/work-plan/commands/push_track.py +156 -0
- package/skills/work-plan/commands/reconcile.py +49 -34
- package/skills/work-plan/commands/refresh_md.py +49 -1
- package/skills/work-plan/commands/remove_repo.py +69 -0
- package/skills/work-plan/commands/set_field.py +22 -3
- package/skills/work-plan/lib/export_model.py +27 -4
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/github_state.py +63 -0
- package/skills/work-plan/lib/manifest.py +28 -0
- package/skills/work-plan/lib/plan_fm.py +71 -0
- package/skills/work-plan/lib/plan_worktree.py +288 -0
- package/skills/work-plan/lib/status_header.py +6 -2
- package/skills/work-plan/lib/tracks.py +6 -2
- package/skills/work-plan/lib/verdict.py +1 -0
- package/skills/work-plan/tests/test_auth_status.py +98 -0
- package/skills/work-plan/tests/test_close_issue.py +121 -0
- package/skills/work-plan/tests/test_export.py +65 -0
- package/skills/work-plan/tests/test_export_command.py +95 -0
- package/skills/work-plan/tests/test_init_repo.py +100 -1
- package/skills/work-plan/tests/test_list_open_issues.py +83 -0
- package/skills/work-plan/tests/test_manifest.py +30 -1
- package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
- package/skills/work-plan/tests/test_plan_ack.py +104 -0
- package/skills/work-plan/tests/test_plan_baseline.py +86 -0
- package/skills/work-plan/tests/test_plan_branch.py +279 -0
- package/skills/work-plan/tests/test_plan_confirm.py +109 -0
- package/skills/work-plan/tests/test_plan_status_override.py +145 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
- package/skills/work-plan/tests/test_plan_worktree.py +378 -0
- package/skills/work-plan/tests/test_push_track.py +131 -0
- package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
- package/skills/work-plan/tests/test_refresh_md.py +75 -0
- package/skills/work-plan/tests/test_remove_repo.py +77 -0
- package/skills/work-plan/tests/test_set_field.py +60 -0
- package/skills/work-plan/work_plan.py +125 -6
package/README.md
CHANGED
|
@@ -111,7 +111,7 @@ flowchart TB
|
|
|
111
111
|
|
|
112
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
|
-
> **GitHub access is read-only
|
|
114
|
+
> **GitHub access is read-only by default, with two explicit, opt-in write actions.** Issue *data* always comes from read-only `gh` calls (`gh issue list`, `gh issue view`), and every routine write (frontmatter, status table, session log) goes to your local markdown files only. The two GitHub-*mutating* actions are both opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening), and `close-issue` (#305) **closes** an issue via `gh issue close` — for the common case where a PR merged to `dev` left its issue OPEN (GitHub auto-closes only from the default branch), with the VS Code viewer firing a mandatory "Close on GitHub? — cannot be undone" modal on every close. Nothing else touches GitHub state.
|
|
115
115
|
|
|
116
116
|
## Shared tracks
|
|
117
117
|
|
|
@@ -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
|
|
@@ -281,7 +297,7 @@ To install for **both** Claude Code AND Codex, run the installer twice with diff
|
|
|
281
297
|
|
|
282
298
|
### VS Code extension
|
|
283
299
|
|
|
284
|
-
The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks), a Mermaid dependency graph (with focus toggle and repo-scoped full map), the Untracked bucket, cross-track dependency chips, per-issue move buttons, and full read/write (slot/close/edit/move/new-track/…) with a public-repo confirm modal.
|
|
300
|
+
The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks, with per-track open/closed counts and an **activity-bar badge** for blocked/open status), a Mermaid dependency graph (with focus toggle and repo-scoped full map), per-track detail with an **open/closed progress bar** and a one-click **Plan** link, the Untracked bucket, cross-track dependency chips, per-issue move/close buttons, **keyword issue search** (`%wildcard%` substitution, results in a dedicated tab), the daily-driver **Brief / Re-orient / Handoff** commands, an inline **active lens + sort** indicator under the view title, a **Plans view** with confirm-gated frontmatter writes (verdict / acknowledge / drift-baseline) and a fast-fail GitHub-auth banner, and full read/write (slot/close/edit/move/new-track/push-track/…) with a public-repo confirm modal.
|
|
285
301
|
|
|
286
302
|

|
|
287
303
|
|
|
@@ -474,7 +490,7 @@ The bundled `notes/` folder stays empty until you run `/work-plan init-repo <key
|
|
|
474
490
|
## Security & data handling
|
|
475
491
|
|
|
476
492
|
- **No credentials stored.** All GitHub access goes through your existing `gh auth`. This toolkit never reads, writes, or stores GitHub tokens.
|
|
477
|
-
- **
|
|
493
|
+
- **Writes are local by default; every remote/GitHub write is opt-in and gated.** The skill writes to `~/.claude/skills/work-plan/`, `~/.claude/skills/repo-activity-summary/`, `~/.claude/commands/work-plan.md`, `~/.claude/work-plan/config.yml`, and your `notes_root`. Repo-confined writes: the `plan-status` action flags (`--stamp` writes a status header into discovered plan docs; `--archive` `git mv`s dead plans into `archive/abandoned/`; all honor `--draft` and prompt), and the frontmatter-only plan writers `plan-confirm` (`verdict_override`), `plan-ack` (`acknowledged`), `plan-baseline` (`verdict_baseline`) — each writes one key into a plan doc's **YAML frontmatter only** (never its body/checkboxes/manifest), public-repo gated. **GitHub-mutating** (opt-in, gated): `plan-status --issues` *creates* an issue per partial plan, and `close-issue` *closes* an issue (`gh issue close`). **Remote git push** (opt-in, public-repo gated): `plan-branch push` and `push-track` publish the shared plan branch. Nothing else.
|
|
478
494
|
- **No telemetry, no network calls beyond `gh`.** All GitHub operations go through `gh` (your authenticated session); no direct HTTP requests are made.
|
|
479
495
|
- **AI subcommands (`group`, `suggest-priorities`) send issue titles to Claude** via Claude Code's existing integration. Body content, code, and PR contents are NOT sent. If your repo is private and you're cautious about what reaches the model, skip these subcommands.
|
|
480
496
|
- **`init-repo` writes to your config via `yq -i`.** Inputs are JSON-encoded before being passed to `yq`, so a maliciously crafted `--github=` value can't break out of the YAML edit.
|
|
@@ -495,15 +511,18 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
495
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. |
|
|
496
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). |
|
|
497
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. |
|
|
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. |
|
|
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. If the live fetch comes back incomplete (GitHub timeout/permission error, or a frontmatter issue that no longer resolves), that track is **skipped and left untouched** rather than rewriting valid rows as `(not fetched)`, and the command exits nonzero so sweeps can flag the degraded run. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
|
|
499
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. |
|
|
500
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. |
|
|
501
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. |
|
|
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. |
|
|
518
|
+
| `init-repo <key> --github=<slug> [--local=<path>] [--update [--clear-local]]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required for an add; `--local` is optional. `--update` on an existing key changes its local/github; `--update --clear-local` forgets the saved local path (keeps github + other fields). `--clear-local` and `--local` are mutually exclusive. |
|
|
519
|
+
| `remove-repo <key>` | Unregister a repo: delete its block from your config. **Config-only** — the notes folder, any tracks, and the local clone are left untouched (a notes folder or tracks that referenced it are now orphaned and can be removed by hand). Completes the add/update/remove trio with `init-repo`. |
|
|
503
520
|
| `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
521
|
| `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. |
|
|
505
522
|
| `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
523
|
| `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. |
|
|
524
|
+
| `push-track <track\|track@repo> [--repo=<key>] [--no-push] [--confirm=<token>]` | **Promote a private track to the shared tier and publish it** (#306). Moves the track's `.md` from `notes_root` into the repo's `.work-plan/` (on its `plan_branch`, via a worktree), removes the private copy so it isn't duplicated, commits to the plan branch, and pushes — unless `--no-push`. Tier is derived from location, so this is a file move, not a frontmatter edit. Requires a local clone + a configured `plan_branch` (else hints `plan-branch init`). Pushing to a **public** repo makes the track world-visible → confirm-token gated. |
|
|
525
|
+
| `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. |
|
|
507
526
|
| `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
|
|
508
527
|
| `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). |
|
|
509
528
|
| `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). |
|
|
@@ -512,6 +531,9 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
512
531
|
| `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
|
|
513
532
|
| `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. |
|
|
514
533
|
| `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. |
|
|
534
|
+
| `plan-confirm --repo=<key> --verdict=shipped\|partial\|dead [--clear] [--confirm=<token>] -- <rel>` | Affirm a **human** verdict on ONE plan/spec doc by writing `verdict_override` into its YAML **frontmatter only** (never the body, checkboxes, manifest, or status banner). `plan-status` then pins that verdict over the mechanical one and silences the "shipped but boxes unchecked" lie-gap. Use when a genuinely-shipped plan is flagged only because its phase checkboxes were never ticked. `<rel>` is the repo-relative doc path. Public-repo gated (`--confirm=<token>`); `--clear` removes the override. |
|
|
535
|
+
| `plan-ack --repo=<key> [--clear] [--confirm=<token>] -- <rel>` | Persist a **durable acknowledgment** into ONE plan/spec doc's YAML **frontmatter only** (`acknowledged: true`) — a "stop flagging this" that's committed with the repo and shared with teammates, unlike the VS Code viewer's per-machine `workspaceState` ack. `plan-status` reads it back (emits `acknowledged`) and demotes the doc. `<rel>` is the repo-relative doc path. Public-repo gated (`--confirm=<token>`); `--clear` removes it. |
|
|
536
|
+
| `plan-baseline --repo=<key> [--clear] [--confirm=<token>] -- <rel>` | Stamp the **current computed verdict** into ONE plan/spec doc's YAML **frontmatter only** (`verdict_baseline`) as a drift tripwire. `plan-status` then flags **drift** (emits `verdict_drift`) when the live verdict later diverges from the baseline — catching a once-shipped plan that silently **regressed** (its declared files were deleted/moved), the third "started, then drifted off" signal beyond stalled + lie-gap. The value is computed authoritatively (not taken from the caller); a human `verdict_override` suppresses drift. Public-repo gated; `--clear` removes it. |
|
|
515
537
|
|
|
516
538
|
Run `python3 ~/.claude/skills/work-plan/work_plan.py --help` for the full list with examples.
|
|
517
539
|
|
|
@@ -521,7 +543,17 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
|
|
|
521
543
|
|
|
522
544
|
`needs_confirm` fails **closed** — unknown visibility prompts too. An all-private team can opt out of the *unknown-visibility* case (e.g. when a `gh` lookup flakes) by setting `assume_private_when_unknown: true` in `~/.claude/work-plan/config.yml`; **public repos always prompt regardless.**
|
|
523
545
|
|
|
524
|
-
`export --json` is the viewer's read surface (schema 1): every frontmatter'd track plus an additive `untracked` list of open issues that no track references, per repo.
|
|
546
|
+
`export --json` is the viewer's read surface (schema 1): every frontmatter'd track plus an additive `untracked` list of open issues that no track references, per repo. It also emits a top-level `repos` list — every configured repo (`folder`/`repo`/`local`/`has_local`/`visibility`), tracked or not — so the viewer can show a freshly registered repo in the sidebar independent of whether it has any tracks yet.
|
|
547
|
+
|
|
548
|
+
**Track ↔ plan link (#285).** A track can name its plan/spec doc with a `plan: <repo-relative-path>` field in its frontmatter — set it with `set <track> plan=docs/superpowers/plans/<file>.md` (empty `plan=` clears it; a path that doesn't resolve in the repo checkout is saved with an advisory warning). `export --json` then resolves that link into a per-track `plan` object — `{rel, resolved, verdict, glyph, files_present/declared, checkboxes_done/total, lie_gap, stalled, override}` — computed by the **same evaluator `plan-status` uses**, so the badge never disagrees with the Plans view. An unresolvable link (no local clone, or the file is absent) emits `{rel, resolved:false}`. Only the *declared* link is trusted — there is no filename-to-slug guessing (which false-matches and misses). The viewer surfaces this as a one-click **Plan** affordance on the track detail panel.
|
|
549
|
+
|
|
550
|
+
`list-open-issues --repo=<owner/name> [--exclude=<csv>]` is a second viewer read surface: it emits a repo's **open** issues as JSON (`{repo, issues:[…]}`, the same per-issue shape as `export`). The extension's **Slot** command uses it to offer a pick-list instead of a typed number; `--exclude` drops the track's current issues so they don't reappear. Unlike `export`'s `untracked` (open issues in *no* track), this includes issues tracked by *other* tracks — they're valid slot targets. Read-only.
|
|
551
|
+
|
|
552
|
+
`auth-status [--json]` is the viewer's **auth probe**: it runs `gh auth status` and emits `{gh_present, authenticated, user, error}` (exit `0` authenticated / `1` gh present but not signed in / `2` gh not found). Because every GitHub read goes through `gh` and the fetch helpers return empty rather than erroring, an unauthenticated session would otherwise look like an empty-but-working one. The extension calls this at activation (and after every refresh) to **fast-fail with a clear "Not signed in to GitHub" banner + a Sign in path** instead of rendering a misleadingly empty tree — and distinguishes "not signed in" (`gh auth login`) from "gh not installed" (a different fix). Read-only.
|
|
553
|
+
|
|
554
|
+
`plan-status --json` is the viewer's **Plans view** read surface: alongside each doc's verdict it now also emits `manifest_last_touched` (the most recent commit date across the plan's declared files), `stalled`, `lie_gap`, `unchecked_items`, and `stall_days`. The staleness window honors `stall_days:` in `~/.claude/work-plan/config.yml` and a `--stall-days=<n>` flag (precedence: flag → config → default 14). The viewer consumes these to flag plans whose declared-file build has gone cold — a `partial` plan with no recent commit on its manifest ("stalled") — and plans scored shipped whose own phase checkboxes are mostly unticked ("lie-gap"). It also emits `override` (the human `verdict_override`, `shipped`/`partial`/`dead` or `null`, set via `plan-confirm` — when present the CLI pins the verdict to it and forces `lie_gap` false), `acknowledged` (the durable frontmatter ack set via `plan-ack`), and `verdict_baseline` + `verdict_drift` (the drift tripwire set via `plan-baseline` — `verdict_drift` is true when the live verdict no longer matches the stamped baseline, suppressed under an override). It also emits `offtree_paths`: declared manifest paths that resolve **outside** the repo (absolute, `~`, `..`-escape, junk `/`) — a read-only flag for a typo or misfiled plan that would otherwise silently drag the file score down (the 🧳 foreign verdict only fires when *most* paths are off-tree; this surfaces the sub-threshold ones too). Never auto-fixed — surfacing only.
|
|
555
|
+
|
|
556
|
+
**The Plans view can write to plan-doc frontmatter — and only frontmatter.** Right-click a plan in the viewer → **Confirm Verdict…** / **Clear Confirmation** (writes `verdict_override`), **Acknowledge & Save to Doc** / **Clear Saved Acknowledgment** (writes `acknowledged`), or **Stamp Baseline — Watch for Drift** / **Clear Baseline** (writes `verdict_baseline`) drives the matching CLI write behind a mandatory modal that names the exact file and states the write touches only the doc's YAML frontmatter — never its prose body, checkboxes, or declared-file manifest. These are the only viewer-initiated writes to a plan doc, each touches one frontmatter key and nothing else, and on a public repo each additionally passes through the public-repo confirm modal above. The default **Acknowledge (stop flagging)** remains per-machine and writes nothing to git; **Acknowledge & Save to Doc** is the opt-in durable, shared variant. Everything else the Plans view does (scan, local acknowledge) remains read-only on git.
|
|
525
557
|
|
|
526
558
|
## Version
|
|
527
559
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.13+22f59a8
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.13-2",
|
|
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"
|
|
@@ -29,6 +29,9 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
|
|
|
29
29
|
| `/work-plan reconcile <track> \| --all [--draft]` | Sync track frontmatter with GitHub labels (read-only on GitHub). Default label is `track/<slug>`; override per-track via `github.labels` in frontmatter. Add `--draft` to preview proposed ADDs/FLAGs without prompting or writing. |
|
|
30
30
|
| `/work-plan duplicates [--min-similarity=0.7]` | Find likely-duplicate issues by title similarity (stdlib difflib). |
|
|
31
31
|
| `/work-plan plan-status [--repo=<key>] [--stamp [--draft]] [--type=plan\|spec]` | **Doc/plan liveness.** "Which of my plan/spec docs actually shipped, half-shipped, or died?" Correlates each plan's declared file-manifest (Create/Modify/Test paths) against git + filesystem — not the unreliable checkboxes. Reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. Read-only by default; `--stamp` writes an idempotent status header into each doc (`--draft` previews, writes nothing). Natural-language triggers: "what's done vs unfinished in `<repo>`", "stamp the plan statuses", "which plans are stale/dead". |
|
|
32
|
+
| `/work-plan plan-confirm --repo=<key> --verdict=shipped\|partial\|dead [--clear] -- <rel>` | **Affirm a human verdict** on one plan doc by writing `verdict_override` into its **frontmatter only** (never body/checkboxes/manifest). `plan-status` then pins that verdict and silences the "shipped but boxes unchecked" lie-gap. Use when a genuinely-shipped plan is flagged red only because nobody ticked its phase checkboxes — confirm it instead of hand-ticking boxes. Public-repo gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`). Natural-language triggers: "that plan really did ship, stop flagging it", "mark `<plan>` as shipped/dead". |
|
|
33
|
+
| `/work-plan plan-ack --repo=<key> [--clear] -- <rel>` | **Durably acknowledge** one plan doc by writing `acknowledged: true` into its **frontmatter only** — a "stop flagging this" that's committed + shared (vs the viewer's per-machine ack). `plan-status` reads it back and demotes the doc. Public-repo gated. Natural-language triggers: "stop flagging this plan for everyone", "acknowledge `<plan>` for good". |
|
|
34
|
+
| `/work-plan plan-baseline --repo=<key> [--clear] -- <rel>` | **Stamp a drift baseline** on one plan doc by writing its current computed verdict to `verdict_baseline` in **frontmatter only**. `plan-status` then flags **drift** when the live verdict diverges — catching a once-shipped plan that silently regressed (files deleted/moved). Distinct from `plan-confirm` (human pin); an override suppresses drift. Public-repo gated. Natural-language triggers: "watch this plan for regressions", "alert me if `<plan>` stops being shipped". |
|
|
32
35
|
|
|
33
36
|
## How to invoke
|
|
34
37
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""auth-status — report whether `gh` is installed and authenticated.
|
|
2
|
+
|
|
3
|
+
The toolkit's every GitHub read/write goes through `gh`, and the fetch helpers
|
|
4
|
+
deliberately never raise (they return empty on failure). That makes an
|
|
5
|
+
unauthenticated session look like an empty-but-working one. This command is the
|
|
6
|
+
explicit probe the VS Code extension calls at activation so it can fast-fail with
|
|
7
|
+
a clear indicator + a sign-in path instead of rendering a misleadingly empty tree.
|
|
8
|
+
|
|
9
|
+
Read-only; never mutates anything. Exit code mirrors auth state so a shell caller
|
|
10
|
+
can gate on it: 0 = authenticated, 1 = gh present but not logged in, 2 = gh not
|
|
11
|
+
found.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from lib import github_state
|
|
16
|
+
from lib.prompts import parse_flags
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(args: list) -> int:
|
|
20
|
+
flags, _ = parse_flags(args, {"--json"})
|
|
21
|
+
status = github_state.gh_auth_status()
|
|
22
|
+
|
|
23
|
+
if flags.get("--json"):
|
|
24
|
+
print(json.dumps(status))
|
|
25
|
+
elif status["authenticated"]:
|
|
26
|
+
who = f" as {status['user']}" if status.get("user") else ""
|
|
27
|
+
print(f"✓ Authenticated to GitHub{who}.")
|
|
28
|
+
elif not status["gh_present"]:
|
|
29
|
+
print("✗ GitHub CLI (gh) not found on PATH. Install it: https://cli.github.com")
|
|
30
|
+
else:
|
|
31
|
+
print("✗ Not logged in to GitHub. Run: gh auth login")
|
|
32
|
+
|
|
33
|
+
if status["authenticated"]:
|
|
34
|
+
return 0
|
|
35
|
+
return 2 if not status["gh_present"] else 1
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""close-issue — close a GitHub issue via `gh`, optionally with a comment (#305).
|
|
2
|
+
|
|
3
|
+
⚠️ This is the toolkit's FIRST and ONLY GitHub-mutating command. Everything else
|
|
4
|
+
is read-only on GitHub. PRs merged to `dev` don't auto-close issues (GitHub only
|
|
5
|
+
auto-closes from the default branch, `main`), so done-but-OPEN issues pile up;
|
|
6
|
+
this closes one explicitly.
|
|
7
|
+
|
|
8
|
+
The gate is the caller's: the VS Code viewer shows a mandatory "Close on GitHub?"
|
|
9
|
+
modal before every close. There is no needs_confirm token here — unlike the plan
|
|
10
|
+
frontmatter writers, closing doesn't leak private content to a public repo (the
|
|
11
|
+
issue already lives there), so the unconditional UI modal is the right guard
|
|
12
|
+
rather than the public-only token dance.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
work_plan.py close-issue --repo=<key|slug> [--reason=completed|not_planned] [--comment=<text>] -- <number>
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from lib import config as config_mod
|
|
21
|
+
from lib import github_state
|
|
22
|
+
from lib.prompts import parse_flags
|
|
23
|
+
|
|
24
|
+
VALID_REASONS = {"completed", "not_planned"}
|
|
25
|
+
KNOWN = {"--repo", "--reason", "--comment", "--json"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_slug(repo: str, cfg: dict):
|
|
29
|
+
"""A `--repo` value may be a github slug (owner/name) or a config folder key.
|
|
30
|
+
A slug is used directly; a key is resolved to its slug via config."""
|
|
31
|
+
if "/" in repo:
|
|
32
|
+
return repo
|
|
33
|
+
return config_mod.resolve_github_for_folder(repo, cfg)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(args: list) -> int:
|
|
37
|
+
flags, positional = parse_flags(args, KNOWN)
|
|
38
|
+
|
|
39
|
+
repo = flags.get("--repo")
|
|
40
|
+
if not repo or repo is True:
|
|
41
|
+
print("ERROR: --repo=<key|slug> is required.", file=sys.stderr)
|
|
42
|
+
return 2
|
|
43
|
+
if not positional:
|
|
44
|
+
print("usage: work_plan.py close-issue --repo=<key|slug> "
|
|
45
|
+
"[--reason=completed|not_planned] [--comment=<text>] -- <number>",
|
|
46
|
+
file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
try:
|
|
49
|
+
number = int(positional[0])
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
print(f"ERROR: issue number must be an integer (got {positional[0]!r}).",
|
|
52
|
+
file=sys.stderr)
|
|
53
|
+
return 2
|
|
54
|
+
|
|
55
|
+
reason = flags.get("--reason")
|
|
56
|
+
if reason is True or (reason is not None and reason not in VALID_REASONS):
|
|
57
|
+
print("ERROR: --reason must be 'completed' or 'not_planned'.", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
comment = flags.get("--comment")
|
|
60
|
+
comment = comment if isinstance(comment, str) else None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
cfg = config_mod.load_config()
|
|
64
|
+
except config_mod.ConfigError as e:
|
|
65
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
slug = _resolve_slug(repo, cfg)
|
|
69
|
+
if not slug:
|
|
70
|
+
print(f"ERROR: could not resolve a github slug for --repo={repo!r}.", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
ok, message = github_state.close_issue(slug, number, reason=reason, comment=comment)
|
|
74
|
+
if not ok:
|
|
75
|
+
print(f"ERROR: failed to close {slug}#{number}: {message}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
suffix = " with comment" if comment else ""
|
|
79
|
+
print(json.dumps({"closed": number, "repo": slug, "reason": reason or "completed"})
|
|
80
|
+
if flags.get("--json")
|
|
81
|
+
else f"✓ closed {slug}#{number}{suffix}.")
|
|
82
|
+
return 0
|
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
"""export subcommand — emit the viewer-ready JSON read surface."""
|
|
2
2
|
import json
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
from lib.config import load_config, ConfigError
|
|
3
|
+
from datetime import datetime, date
|
|
4
|
+
from lib.config import load_config, ConfigError, resolve_local_path_for_folder
|
|
5
5
|
from lib.tracks import discover_tracks
|
|
6
6
|
from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
|
|
7
7
|
from lib.export_model import build_export
|
|
8
8
|
from lib.prompts import parse_flags
|
|
9
|
+
from lib import doc_discovery
|
|
10
|
+
from lib import verdict as verdict_mod
|
|
11
|
+
from commands.plan_status import evaluate_doc
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _plan_badge(track, cfg, today, dead_days, stall_days):
|
|
15
|
+
"""Resolve a track's declared `plan:` link into an execution badge (#285).
|
|
16
|
+
|
|
17
|
+
Returns None when the track declares no plan, `{rel, resolved: false}` when
|
|
18
|
+
the link can't be resolved (no local clone, or the file is absent), and the
|
|
19
|
+
full badge — verdict/glyph/files/phases/lie_gap/stalled/override — when it
|
|
20
|
+
resolves. The verdict is computed by the SAME evaluator plan-status uses, so a
|
|
21
|
+
badge never disagrees with the Plans view. Only the declared link is trusted;
|
|
22
|
+
there is no name-matching fallback (#285 acceptance criteria)."""
|
|
23
|
+
rel = track.meta.get("plan")
|
|
24
|
+
if not isinstance(rel, str) or not rel.strip():
|
|
25
|
+
return None
|
|
26
|
+
rel = rel.strip()
|
|
27
|
+
local = resolve_local_path_for_folder(track.folder, cfg) if track.folder else None
|
|
28
|
+
if not local or not local.exists():
|
|
29
|
+
return {"rel": rel, "resolved": False}
|
|
30
|
+
doc_path = local / rel
|
|
31
|
+
if not doc_path.is_file():
|
|
32
|
+
return {"rel": rel, "resolved": False}
|
|
33
|
+
doc = doc_discovery.Doc(path=doc_path, rel=rel, kind=doc_discovery.classify_kind(rel))
|
|
34
|
+
row = evaluate_doc(doc, local, today, dead_days, stall_days)
|
|
35
|
+
return {
|
|
36
|
+
"rel": rel,
|
|
37
|
+
"resolved": True,
|
|
38
|
+
"verdict": row["verdict"],
|
|
39
|
+
"glyph": row["glyph"],
|
|
40
|
+
"files_present": row["files_present"],
|
|
41
|
+
"files_declared": row["files_declared"],
|
|
42
|
+
"checkboxes_done": row["checkboxes_done"],
|
|
43
|
+
"checkboxes_total": row["checkboxes_total"],
|
|
44
|
+
"lie_gap": row["lie_gap"],
|
|
45
|
+
"stalled": row["stalled"],
|
|
46
|
+
"override": row["override"],
|
|
47
|
+
}
|
|
9
48
|
|
|
10
49
|
def run(args: list[str]) -> int:
|
|
11
50
|
flags, _ = parse_flags(args, {"--json"})
|
|
@@ -60,10 +99,40 @@ def run(args: list[str]) -> int:
|
|
|
60
99
|
open_rows = fetch_open_issues(repo)
|
|
61
100
|
untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
|
|
62
101
|
|
|
102
|
+
# Every CONFIGURED repo, regardless of whether any track references it (#288).
|
|
103
|
+
# Lets the viewer show a registered-but-empty repo so the user can start
|
|
104
|
+
# adding tracks to it. visibility is filled here for repos no track covered.
|
|
105
|
+
config_repos = []
|
|
106
|
+
for folder, block in (cfg.get("repos") or {}).items():
|
|
107
|
+
slug = block.get("github") if isinstance(block, dict) else None
|
|
108
|
+
local = resolve_local_path_for_folder(folder, cfg)
|
|
109
|
+
if slug and slug not in visibility:
|
|
110
|
+
visibility[slug] = repo_visibility(slug)
|
|
111
|
+
config_repos.append({
|
|
112
|
+
"folder": folder,
|
|
113
|
+
"repo": slug,
|
|
114
|
+
"local": str(local) if local else None,
|
|
115
|
+
"has_local": bool(local and local.exists()),
|
|
116
|
+
"visibility": visibility.get(slug),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
# Resolve each track's declared plan link into an execution badge (#285).
|
|
120
|
+
# Only tracks that declare `plan:` incur the per-doc git/manifest evaluation.
|
|
121
|
+
today = date.today()
|
|
122
|
+
cfg_stall = cfg.get("stall_days")
|
|
123
|
+
stall_days = cfg_stall if isinstance(cfg_stall, int) else verdict_mod.STALL_DAYS
|
|
124
|
+
plan_by_track: dict[str, dict] = {}
|
|
125
|
+
for t in tracks:
|
|
126
|
+
badge = _plan_badge(t, cfg, today, verdict_mod.DEAD_DAYS, stall_days)
|
|
127
|
+
if badge is not None:
|
|
128
|
+
plan_by_track[t.name] = badge
|
|
129
|
+
|
|
63
130
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
64
131
|
print(json.dumps(
|
|
65
132
|
build_export(tracks, issues_by_track, visibility, now,
|
|
66
|
-
untracked_by_repo=untracked_by_repo
|
|
133
|
+
untracked_by_repo=untracked_by_repo,
|
|
134
|
+
config_repos=config_repos,
|
|
135
|
+
plan_by_track=plan_by_track),
|
|
67
136
|
indent=2,
|
|
68
137
|
))
|
|
69
138
|
return 0
|
|
@@ -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
|
|
@@ -50,10 +50,56 @@ def _report_shared_tracks(local_path: "Path | None") -> None:
|
|
|
50
50
|
)
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _update_existing(key: str, github: str, local: "str | None", clear_local: bool = False) -> int:
|
|
54
|
+
"""Update an already-registered repo's local (and github if it differs).
|
|
55
|
+
|
|
56
|
+
Does NOT recreate the notes folder / archive dirs — they already exist.
|
|
57
|
+
Uses the same env()-via-opaque-block yq pattern as a fresh add, setting only
|
|
58
|
+
the fields that change so other keys in the block are preserved.
|
|
59
|
+
|
|
60
|
+
clear_local sets `.repos.<key>.local = null` (forget a stale checkout path)
|
|
61
|
+
while keeping github + every other field. Mutually exclusive with `local`
|
|
62
|
+
(enforced in run() before this is called).
|
|
63
|
+
"""
|
|
64
|
+
updates = {}
|
|
65
|
+
if clear_local:
|
|
66
|
+
# JSON null → YAML null; the * merge overwrites local with null, leaving
|
|
67
|
+
# github + other keys intact (same opaque-env discipline as below).
|
|
68
|
+
updates["local"] = None
|
|
69
|
+
elif local:
|
|
70
|
+
updates["local"] = local
|
|
71
|
+
if github:
|
|
72
|
+
updates["github"] = github
|
|
73
|
+
if not updates:
|
|
74
|
+
print(f"ℹ Nothing to update for repo '{key}' (no --local, --clear-local, or --github given).")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
# `key` is validated against ^[a-z][a-z0-9-]*$ in run() before this is called,
|
|
78
|
+
# so it's safe in the yq path. Field values travel as an OPAQUE env value via
|
|
79
|
+
# env() (parsed as JSON), never interpolated — uniform with the add path.
|
|
80
|
+
env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
|
|
81
|
+
yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
|
|
82
|
+
try:
|
|
83
|
+
subprocess.run(
|
|
84
|
+
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
85
|
+
check=True, capture_output=True, text=True, env=env,
|
|
86
|
+
)
|
|
87
|
+
except subprocess.CalledProcessError as e:
|
|
88
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
89
|
+
return 1
|
|
90
|
+
if clear_local:
|
|
91
|
+
print(f"✓ Cleared local path for '{key}'")
|
|
92
|
+
elif local:
|
|
93
|
+
print(f"✓ Updated repo '{key}' local path → {local}")
|
|
94
|
+
else:
|
|
95
|
+
print(f"✓ Updated repo '{key}' in {DEFAULT_CONFIG_PATH}")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
53
99
|
def run(args: list[str]) -> int:
|
|
54
|
-
flags, positional = parse_flags(args, {"--github", "--local"})
|
|
100
|
+
flags, positional = parse_flags(args, {"--github", "--local", "--update", "--clear-local"})
|
|
55
101
|
if not positional:
|
|
56
|
-
print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>]")
|
|
102
|
+
print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>] [--update [--clear-local]]")
|
|
57
103
|
return 2
|
|
58
104
|
|
|
59
105
|
key = positional[0]
|
|
@@ -61,13 +107,23 @@ def run(args: list[str]) -> int:
|
|
|
61
107
|
print(f"ERROR: '{key}' is not a valid key. Use lowercase letters, digits, hyphens; must start with a letter.")
|
|
62
108
|
return 2
|
|
63
109
|
|
|
64
|
-
|
|
110
|
+
clear_local = bool(flags.get("--clear-local"))
|
|
111
|
+
local = flags.get("--local") or None
|
|
112
|
+
|
|
113
|
+
# --clear-local forgets the saved local path; pairing it with --local (which
|
|
114
|
+
# SETS a path) is contradictory.
|
|
115
|
+
if clear_local and local:
|
|
116
|
+
print("ERROR: --clear-local and --local are mutually exclusive.")
|
|
117
|
+
return 2
|
|
118
|
+
|
|
119
|
+
# --github is required for a fresh add / a github change, but --clear-local is
|
|
120
|
+
# a field-only edit on an existing block, so we don't force it there.
|
|
65
121
|
github = flags.get("--github")
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
122
|
+
if github and "/" not in github:
|
|
123
|
+
print("ERROR: github slug must be in the form 'org/repo'.")
|
|
124
|
+
return 2
|
|
125
|
+
if not github and not clear_local:
|
|
126
|
+
print("ERROR: --github is required (e.g. --github=org/repo).")
|
|
71
127
|
return 2
|
|
72
128
|
|
|
73
129
|
try:
|
|
@@ -77,19 +133,33 @@ def run(args: list[str]) -> int:
|
|
|
77
133
|
print("\nRun ./install.sh from the toolkit root to seed your config first.")
|
|
78
134
|
return 1
|
|
79
135
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
print("Edit it manually, or pick a different key.")
|
|
83
|
-
return 1
|
|
136
|
+
update = bool(flags.get("--update"))
|
|
137
|
+
existing = cfg.get("repos", {})
|
|
84
138
|
|
|
85
|
-
# --local is
|
|
86
|
-
|
|
139
|
+
# --clear-local is an update-only operation on an existing key.
|
|
140
|
+
if clear_local:
|
|
141
|
+
if not update:
|
|
142
|
+
print("ERROR: --clear-local requires --update (it edits an existing repo).")
|
|
143
|
+
return 2
|
|
144
|
+
if key not in existing:
|
|
145
|
+
print(f"ERROR: repo '{key}' not found in {DEFAULT_CONFIG_PATH} — nothing to clear.")
|
|
146
|
+
return 1
|
|
147
|
+
return _update_existing(key, github or "", None, clear_local=True)
|
|
148
|
+
|
|
149
|
+
# --local is optional; if absent, skip (no prompt). Validate it exists.
|
|
87
150
|
local_path = None
|
|
88
151
|
if local:
|
|
89
152
|
local_path = Path(local).expanduser()
|
|
90
153
|
if not local_path.exists():
|
|
91
154
|
print(f"WARN: {local_path} does not exist. Saving anyway — fix later if wrong.")
|
|
92
155
|
|
|
156
|
+
if key in existing:
|
|
157
|
+
if not update:
|
|
158
|
+
print(f"ERROR: repo '{key}' already exists in {DEFAULT_CONFIG_PATH}.")
|
|
159
|
+
print("Pass --update to change its local path, or pick a different key.")
|
|
160
|
+
return 1
|
|
161
|
+
return _update_existing(key, github, local)
|
|
162
|
+
|
|
93
163
|
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
94
164
|
if not notes_root.exists():
|
|
95
165
|
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""list-open-issues subcommand — emit a repo's open issues as JSON.
|
|
2
|
+
|
|
3
|
+
A read surface for the VS Code viewer's Slot command (#282): Slot adds an issue
|
|
4
|
+
that is typically NOT already in the track, so the per-track export can't supply
|
|
5
|
+
the candidate list. This fetches the repo's open issues live via `gh` and emits
|
|
6
|
+
them in the same `Issue` shape the export uses, so the viewer can offer a
|
|
7
|
+
pick-list instead of a free-typed number.
|
|
8
|
+
|
|
9
|
+
Read-only. Never writes anything. The viewer passes the track's current issue
|
|
10
|
+
numbers via --exclude so already-slotted issues are filtered out here.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from lib.github_state import fetch_open_issues
|
|
15
|
+
from lib.export_model import normalize_issue
|
|
16
|
+
from lib.prompts import parse_flags
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(args: list[str]) -> int:
|
|
20
|
+
flags, _ = parse_flags(args, {"--repo", "--exclude"})
|
|
21
|
+
|
|
22
|
+
repo = flags.get("--repo")
|
|
23
|
+
if not repo or repo is True:
|
|
24
|
+
print(json.dumps({"error": "list-open-issues requires --repo=<owner/name>"}))
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
exclude = _parse_exclude(flags.get("--exclude"))
|
|
28
|
+
|
|
29
|
+
# fetch_open_issues validates the slug and returns [] on any error/bad repo,
|
|
30
|
+
# so a malformed --repo yields an empty list rather than raising.
|
|
31
|
+
rows = fetch_open_issues(repo)
|
|
32
|
+
issues = [
|
|
33
|
+
normalize_issue(r) for r in rows
|
|
34
|
+
if r.get("number") not in exclude
|
|
35
|
+
]
|
|
36
|
+
print(json.dumps({"repo": repo, "issues": issues}, indent=2))
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_exclude(raw) -> set:
|
|
41
|
+
"""Parse the --exclude CSV (e.g. "87,91,103") into a set of ints.
|
|
42
|
+
|
|
43
|
+
Tolerates blanks and non-numeric tokens (skipped), so a stray trailing
|
|
44
|
+
comma or empty value never errors. `True` (bare --exclude) → empty set."""
|
|
45
|
+
if not raw or raw is True:
|
|
46
|
+
return set()
|
|
47
|
+
out = set()
|
|
48
|
+
for tok in str(raw).split(","):
|
|
49
|
+
tok = tok.strip()
|
|
50
|
+
if tok.isdigit():
|
|
51
|
+
out.add(int(tok))
|
|
52
|
+
return out
|
|
@@ -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:
|