@stylusnexus/work-plan 2026.6.13 → 2026.6.14

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.
Files changed (44) hide show
  1. package/README.md +19 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/brief.py +12 -0
  7. package/skills/work-plan/commands/close_issue.py +82 -0
  8. package/skills/work-plan/commands/export.py +70 -5
  9. package/skills/work-plan/commands/in_progress.py +110 -0
  10. package/skills/work-plan/commands/plan_ack.py +71 -0
  11. package/skills/work-plan/commands/plan_baseline.py +85 -0
  12. package/skills/work-plan/commands/plan_confirm.py +83 -0
  13. package/skills/work-plan/commands/plan_status.py +65 -1
  14. package/skills/work-plan/commands/push_track.py +156 -0
  15. package/skills/work-plan/commands/set_field.py +22 -3
  16. package/skills/work-plan/commands/where_was_i.py +30 -2
  17. package/skills/work-plan/lib/export_model.py +42 -5
  18. package/skills/work-plan/lib/git_state.py +32 -0
  19. package/skills/work-plan/lib/github_state.py +132 -4
  20. package/skills/work-plan/lib/in_progress.py +23 -0
  21. package/skills/work-plan/lib/manifest.py +18 -0
  22. package/skills/work-plan/lib/plan_fm.py +71 -0
  23. package/skills/work-plan/lib/render.py +5 -0
  24. package/skills/work-plan/lib/status_header.py +6 -2
  25. package/skills/work-plan/tests/test_auth_status.py +98 -0
  26. package/skills/work-plan/tests/test_close_issue.py +121 -0
  27. package/skills/work-plan/tests/test_export.py +161 -8
  28. package/skills/work-plan/tests/test_export_command.py +103 -0
  29. package/skills/work-plan/tests/test_git_state.py +38 -1
  30. package/skills/work-plan/tests/test_github_state.py +66 -0
  31. package/skills/work-plan/tests/test_in_progress.py +43 -0
  32. package/skills/work-plan/tests/test_in_progress_command.py +166 -0
  33. package/skills/work-plan/tests/test_list_open_issues.py +8 -3
  34. package/skills/work-plan/tests/test_manifest.py +30 -1
  35. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  36. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  37. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  38. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  39. package/skills/work-plan/tests/test_push_track.py +131 -0
  40. package/skills/work-plan/tests/test_register_in_progress.py +22 -0
  41. package/skills/work-plan/tests/test_render.py +48 -0
  42. package/skills/work-plan/tests/test_set_field.py +60 -0
  43. package/skills/work-plan/tests/test_where_was_i.py +80 -0
  44. package/skills/work-plan/work_plan.py +36 -1
package/README.md CHANGED
@@ -53,6 +53,7 @@ The five essentials you'll use 80% of the time are:
53
53
  | `/work-plan orient <track>` | Switching context. ~15-line paste-block of priority / last session / next pick / git state — drop into a fresh Claude Code terminal. |
54
54
  | `/work-plan reconcile <track> \| --all \| --repo=<key> [--draft] [--yes]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. In an `--all`/`--repo` sweep it also moves issues relabeled from one track to another in the same repo. `--draft` previews proposed ADDs/MOVEs/FLAGs; `--yes` applies without prompting. `--repo=<key>` scopes the sweep to one repo. |
55
55
  | `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Runs three steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `duplicates` (flag likely-duplicate issues). `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode. |
56
+ | `/work-plan in-progress <n> [--clear]` | Starting or stopping active work on an issue. Adds (or removes with `--clear`) the `work-plan:in-progress` label on GitHub. Repo-resolved from the issue number, or pass `--repo=<key\|slug>` to disambiguate. `brief`/`orient`/the VS Code viewer also detect in-progress automatically from a hot `feat/<n>-`/`fix/<n>-` branch. |
56
57
 
57
58
  A dozen more subcommands cover slotting new issues into tracks, closing tracks (shipped/abandoned/parked), and one-time priority-label backfill. Three capabilities worth calling out explicitly:
58
59
 
@@ -111,7 +112,9 @@ flowchart TB
111
112
 
112
113
  > **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
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
+ > **`brief` and `orient` annotate blocked issues with `⊘ blocked by #N`** read-only, surfaced from GitHub's native dependency edges, nothing is written back. Cross-repo blockers show as `owner/repo#N`; same-repo duplicates of manually-declared blockers are deduplicated.
116
+
117
+ > **GitHub access is read-only by default, with three 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 three GitHub-*mutating* actions are all opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening); `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; and `in-progress` (#271) **adds or removes** the `work-plan:in-progress` label on an issue, public-repo gated via the confirm-token flow. Nothing else touches GitHub state.
115
118
 
116
119
  ## Shared tracks
117
120
 
@@ -297,7 +300,7 @@ To install for **both** Claude Code AND Codex, run the installer twice with diff
297
300
 
298
301
  ### VS Code extension
299
302
 
300
- 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, **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, and full read/write (slot/close/edit/move/new-track/…) with a public-repo confirm modal.
303
+ 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.
301
304
 
302
305
  ![Work Plan VS Code extension — sidebar and dependency graph](https://raw.githubusercontent.com/stylusnexus/work-plan-toolkit/main/vscode/media/screenshots/dependency-graph.png)
303
306
 
@@ -490,7 +493,7 @@ The bundled `notes/` folder stays empty until you run `/work-plan init-repo <key
490
493
  ## Security & data handling
491
494
 
492
495
  - **No credentials stored.** All GitHub access goes through your existing `gh auth`. This toolkit never reads, writes, or stores GitHub tokens.
493
- - **Local-only writes.** 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`. The exceptions are the `plan-status` action flags, all confined to the repo you point it at and all opt-in: `--stamp` writes a status header into discovered plan docs; `--archive` `git mv`s dead plans into `archive/abandoned/`; `--issues` opens GitHub issues for partial plans (via `gh`). All honor `--draft` (preview, no writes) and the two mutating actions prompt for confirmation. Nothing else.
496
+ - **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; `close-issue` *closes* an issue (`gh issue close`); and `in-progress` *adds or removes* the `work-plan:in-progress` label on an issue. **Remote git push** (opt-in, public-repo gated): `plan-branch push` and `push-track` publish the shared plan branch. Nothing else.
494
497
  - **No telemetry, no network calls beyond `gh`.** All GitHub operations go through `gh` (your authenticated session); no direct HTTP requests are made.
495
498
  - **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.
496
499
  - **`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.
@@ -521,6 +524,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
521
524
  | `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. |
522
525
  | `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. |
523
526
  | `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. |
527
+ | `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. |
524
528
  | `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. |
525
529
  | `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
526
530
  | `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). |
@@ -530,6 +534,11 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
530
534
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
531
535
  | `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. |
532
536
  | `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. |
537
+ | `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. |
538
+ | `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. |
539
+ | `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. |
540
+ | `close-issue --repo=<key\|slug> [--reason=completed\|not_planned] [--comment=<text>] -- <number>` | ⚠️ A GitHub-mutating command — closes a GitHub issue via `gh issue close`. PRs merged to `dev` don't auto-close issues (GitHub auto-closes only from the default branch), so done-but-OPEN issues pile up; this closes one explicitly. `--reason` maps to GitHub's completed/not-planned; `--comment` posts a closing note. The VS Code viewer gates this behind a mandatory "Close on GitHub? — cannot be undone" modal. |
541
+ | `in-progress <n> [--clear] [--repo=<key\|slug>] [--confirm=<token>]` | ⚠️ A GitHub-mutating command — marks a tracked issue in-progress by adding the `work-plan:in-progress` label (or removes it with `--clear`). Repo-resolved from the issue number; pass `--repo` to disambiguate. The label is auto-created on first use. Public-repo gated (`--confirm=<token>`). Note: `brief`/`orient`/the VS Code viewer also derive in-progress automatically from a hot `feat/<n>-`/`fix/<n>-` branch — the label is for issues with no hot branch yet. |
533
542
 
534
543
  Run `python3 ~/.claude/skills/work-plan/work_plan.py --help` for the full list with examples.
535
544
 
@@ -541,9 +550,15 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
541
550
 
542
551
  `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.
543
552
 
553
+ **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.
554
+
544
555
  `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.
545
556
 
546
- `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").
557
+ `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.
558
+
559
+ `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.
560
+
561
+ **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.
547
562
 
548
563
  ## Version
549
564
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.13+627d944
1
+ 2026.06.14+ef58902
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.13",
3
+ "version": "2026.6.14",
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
@@ -12,7 +12,9 @@ from lib.prompts import parse_flags
12
12
  from lib.git_state import (
13
13
  parse_iso_timestamp, gap_seconds_to_label,
14
14
  branch_in_progress, commits_ahead, uncommitted_file_count, current_branch,
15
+ hot_issue_numbers,
15
16
  )
17
+ from lib.in_progress import issue_in_progress
16
18
  from lib.closure import compute_signals, is_closure_ready
17
19
  from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
18
20
  from lib.next_up import suggest_next_up
@@ -127,6 +129,7 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
127
129
 
128
130
  next_up_items = []
129
131
  next_up_closed_count = 0
132
+ hot_nums = hot_issue_numbers(local) if local else set()
130
133
  for num in next_up_nums:
131
134
  i = issues_by_num.get(num)
132
135
  if not i:
@@ -135,11 +138,20 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
135
138
  if state in ("CLOSED", "MERGED"):
136
139
  next_up_closed_count += 1
137
140
  continue
141
+ manual_blockers = set(meta.get("blockers") or [])
142
+ blocked_disp = []
143
+ for e in (i.get("blocked_by") or []):
144
+ same_repo = e.get("repo") == repo
145
+ if same_repo and e.get("number") in manual_blockers:
146
+ continue
147
+ blocked_disp.append(f"#{e['number']}" if same_repo else f"{e['repo']}#{e['number']}")
138
148
  next_up_items.append({
139
149
  "number": num, "title": i.get("title", ""),
140
150
  "priority": extract_priority(i.get("labels", [])),
141
151
  "state": state.lower() or "open",
142
152
  "milestone": short_milestone(i.get("milestone")),
153
+ "in_progress": issue_in_progress(i, hot_nums),
154
+ "blocked_by_display": blocked_disp,
143
155
  })
144
156
 
145
157
  branch_names = meta.get("github", {}).get("branches") or []
@@ -0,0 +1,82 @@
1
+ """close-issue — close a GitHub issue via `gh`, optionally with a comment (#305).
2
+
3
+ ⚠️ A GitHub-mutating command (the others: `in-progress`, and `plan-status --issues`).
4
+ Most of the toolkit 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,51 @@
1
1
  """export subcommand — emit the viewer-ready JSON read surface."""
2
2
  import json
3
- from datetime import datetime
3
+ from datetime import datetime, date
4
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
+ from lib.git_state import hot_issue_numbers
7
8
  from lib.export_model import build_export
8
9
  from lib.prompts import parse_flags
10
+ from lib import doc_discovery
11
+ from lib import verdict as verdict_mod
12
+ from commands.plan_status import evaluate_doc
13
+
14
+
15
+ def _plan_badge(track, cfg, today, dead_days, stall_days):
16
+ """Resolve a track's declared `plan:` link into an execution badge (#285).
17
+
18
+ Returns None when the track declares no plan, `{rel, resolved: false}` when
19
+ the link can't be resolved (no local clone, or the file is absent), and the
20
+ full badge — verdict/glyph/files/phases/lie_gap/stalled/override — when it
21
+ resolves. The verdict is computed by the SAME evaluator plan-status uses, so a
22
+ badge never disagrees with the Plans view. Only the declared link is trusted;
23
+ there is no name-matching fallback (#285 acceptance criteria)."""
24
+ rel = track.meta.get("plan")
25
+ if not isinstance(rel, str) or not rel.strip():
26
+ return None
27
+ rel = rel.strip()
28
+ local = resolve_local_path_for_folder(track.folder, cfg) if track.folder else None
29
+ if not local or not local.exists():
30
+ return {"rel": rel, "resolved": False}
31
+ doc_path = local / rel
32
+ if not doc_path.is_file():
33
+ return {"rel": rel, "resolved": False}
34
+ doc = doc_discovery.Doc(path=doc_path, rel=rel, kind=doc_discovery.classify_kind(rel))
35
+ row = evaluate_doc(doc, local, today, dead_days, stall_days)
36
+ return {
37
+ "rel": rel,
38
+ "resolved": True,
39
+ "verdict": row["verdict"],
40
+ "glyph": row["glyph"],
41
+ "files_present": row["files_present"],
42
+ "files_declared": row["files_declared"],
43
+ "checkboxes_done": row["checkboxes_done"],
44
+ "checkboxes_total": row["checkboxes_total"],
45
+ "lie_gap": row["lie_gap"],
46
+ "stalled": row["stalled"],
47
+ "override": row["override"],
48
+ }
9
49
 
10
50
  def run(args: list[str]) -> int:
11
51
  flags, _ = parse_flags(args, {"--json"})
@@ -36,18 +76,19 @@ def run(args: list[str]) -> int:
36
76
  issue_map = fetch_export_issues(repo_to_numbers)
37
77
 
38
78
  # Reassemble per-track lists, preserving each track's declared issue order.
39
- issues_by_track: dict[str, list] = {}
79
+ # Keyed by (repo, name) so same-named tracks in different repos don't collide.
80
+ issues_by_track: dict[tuple, list] = {}
40
81
  visibility: dict[str, object] = {}
41
82
  for t in tracks:
42
83
  nums = (t.meta.get("github", {}).get("issues")) or []
43
84
  if t.repo and nums:
44
- issues_by_track[t.name] = [
85
+ issues_by_track[(t.repo, t.name)] = [
45
86
  issue_map[(t.repo, n)]
46
87
  for n in nums
47
88
  if (t.repo, n) in issue_map
48
89
  ]
49
90
  else:
50
- issues_by_track[t.name] = []
91
+ issues_by_track[(t.repo, t.name)] = []
51
92
  if t.repo and t.repo not in visibility:
52
93
  visibility[t.repo] = repo_visibility(t.repo)
53
94
 
@@ -77,11 +118,35 @@ def run(args: list[str]) -> int:
77
118
  "visibility": visibility.get(slug),
78
119
  })
79
120
 
121
+ # Resolve each track's declared plan link into an execution badge (#285).
122
+ # Only tracks that declare `plan:` incur the per-doc git/manifest evaluation.
123
+ today = date.today()
124
+ cfg_stall = cfg.get("stall_days")
125
+ stall_days = cfg_stall if isinstance(cfg_stall, int) else verdict_mod.STALL_DAYS
126
+ plan_by_track: dict[str, dict] = {}
127
+ for t in tracks:
128
+ badge = _plan_badge(t, cfg, today, verdict_mod.DEAD_DAYS, stall_days)
129
+ if badge is not None:
130
+ plan_by_track[t.name] = badge
131
+
132
+ # Per-track branch heat, keyed (repo, name) — track names collide across repos.
133
+ hot_by_track: dict = {}
134
+ for t in tracks:
135
+ if not t.repo:
136
+ continue
137
+ local = resolve_local_path_for_folder(t.folder, cfg) if t.folder else None
138
+ if local and local.exists():
139
+ nums = hot_issue_numbers(local)
140
+ if nums:
141
+ hot_by_track[(t.repo, t.name)] = nums
142
+
80
143
  now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
81
144
  print(json.dumps(
82
145
  build_export(tracks, issues_by_track, visibility, now,
83
146
  untracked_by_repo=untracked_by_repo,
84
- config_repos=config_repos),
147
+ config_repos=config_repos,
148
+ plan_by_track=plan_by_track,
149
+ hot_by_track=hot_by_track),
85
150
  indent=2,
86
151
  ))
87
152
  return 0
@@ -0,0 +1,110 @@
1
+ """in-progress — mark/clear a tracked GitHub issue as in-progress via a label (#271).
2
+
3
+ Writes the work-plan:in-progress label through `gh` (the toolkit's 2nd mutating
4
+ command). Repo targeting is REQUIRED: issue numbers are repo-scoped, so we resolve
5
+ <n> to a unique (repo, n) from the tracked set — rejecting ambiguity — or take an
6
+ explicit --repo. Public-repo writes go behind the same confirm-token gate `set` uses.
7
+
8
+ Usage:
9
+ work_plan.py in-progress <n> [--clear] [--repo=<key|slug>] [--confirm=<token>]
10
+ """
11
+ import json
12
+ import sys
13
+
14
+ from lib.config import load_config, ConfigError, resolve_github_for_folder
15
+ from lib.tracks import discover_tracks
16
+ from lib.github_state import set_issue_in_progress
17
+ from lib.write_guard import needs_confirm, make_token, valid_token
18
+ from lib.prompts import parse_flags
19
+
20
+ KNOWN = {"--clear", "--repo", "--confirm"}
21
+
22
+
23
+ def _tracked_repos_for(number, cfg):
24
+ """Return the distinct repo slugs that list `number` in their frontmatter."""
25
+ repos = []
26
+ for t in discover_tracks(cfg):
27
+ if not t.has_frontmatter or not t.repo:
28
+ continue
29
+ if number in ((t.meta.get("github", {}) or {}).get("issues") or []):
30
+ if t.repo not in repos:
31
+ repos.append(t.repo)
32
+ return repos
33
+
34
+
35
+ def _resolve_repo(number, repo_flag, cfg):
36
+ """Resolve a unique github slug for `number`.
37
+
38
+ With --repo: a slug (owner/name) is used directly; a config key is resolved.
39
+ The resolved slug is then validated: if the issue IS tracked somewhere and
40
+ the slug is NOT among those tracked repos, the call is rejected to guard
41
+ against typos labelling the wrong repo. If the issue is tracked nowhere,
42
+ --repo is the only targeting option and is accepted as explicit intent.
43
+ Without --repo: search tracked frontmatter for the distinct repos listing
44
+ `number`. Returns (slug, None) on success, or (None, error_message).
45
+ """
46
+ if isinstance(repo_flag, str) and repo_flag:
47
+ slug = repo_flag if "/" in repo_flag else resolve_github_for_folder(repo_flag, cfg)
48
+ if not slug:
49
+ return (None, f"could not resolve a github slug for --repo={repo_flag!r}.")
50
+ tracked = _tracked_repos_for(number, cfg)
51
+ if tracked and slug not in tracked:
52
+ return (None,
53
+ f"issue #{number} is tracked in {tracked}, not {slug!r} — "
54
+ f"refusing to label the wrong repo "
55
+ f"(drop --repo to use the tracked one, or slot it into a track "
56
+ f"in {slug} first).")
57
+ return (slug, None)
58
+ repos = _tracked_repos_for(number, cfg)
59
+ if not repos:
60
+ return (None, f"issue #{number} is not in any tracked repo — pass --repo=<key|slug>.")
61
+ if len(repos) > 1:
62
+ return (None, f"issue #{number} is ambiguous across repos {repos} — "
63
+ f"pass --repo=<slug> to disambiguate.")
64
+ return (repos[0], None)
65
+
66
+
67
+ def run(args: list) -> int:
68
+ flags, positional = parse_flags(args, KNOWN)
69
+ if not positional:
70
+ print("usage: work_plan.py in-progress <n> [--clear] [--repo=<key|slug>]",
71
+ file=sys.stderr)
72
+ return 2
73
+ try:
74
+ number = int(positional[0])
75
+ except (TypeError, ValueError):
76
+ print(f"ERROR: issue number must be an integer (got {positional[0]!r}).",
77
+ file=sys.stderr)
78
+ return 2
79
+ clear = bool(flags.get("--clear"))
80
+ repo_flag = flags.get("--repo")
81
+ try:
82
+ cfg = load_config()
83
+ except ConfigError as e:
84
+ print(f"ERROR: {e}", file=sys.stderr)
85
+ return 1
86
+
87
+ slug, problem = _resolve_repo(number, repo_flag, cfg)
88
+ if not slug:
89
+ print(f"ERROR: {problem}", file=sys.stderr)
90
+ return 1
91
+
92
+ confirm = flags.get("--confirm")
93
+ if needs_confirm(slug, cfg) and not (
94
+ isinstance(confirm, str) and valid_token(confirm, slug, str(number))
95
+ ):
96
+ print(json.dumps({
97
+ "needs_confirm": True,
98
+ "reason": f"{slug} is PUBLIC (or visibility unknown); the in-progress "
99
+ f"label will be written there.",
100
+ "token": make_token(slug, str(number)),
101
+ }))
102
+ return 0
103
+
104
+ ok, message = set_issue_in_progress(slug, number, clear=clear)
105
+ if not ok:
106
+ print(f"ERROR: failed to update {slug}#{number}: {message}", file=sys.stderr)
107
+ return 1
108
+ verb = "cleared in-progress on" if clear else "marked in-progress"
109
+ print(f"✓ {verb} {slug}#{number}.")
110
+ return 0
@@ -0,0 +1,71 @@
1
+ """plan-ack — persist an acknowledgment into a plan/spec doc's YAML frontmatter
2
+ (#286 slice 1).
3
+
4
+ The VS Code viewer's default "Acknowledge (stop flagging)" persists in
5
+ per-machine `workspaceState` — ephemeral and unshared. This command writes a
6
+ durable `acknowledged: true` into the doc's **frontmatter only** (never the body,
7
+ manifest, checkboxes, or status banner), so the acknowledgment is committed with
8
+ the repo and shared with teammates. `plan-status` reads it back and demotes the
9
+ doc the same way a local ack does.
10
+
11
+ Usage:
12
+ work_plan.py plan-ack --repo=<key> [--confirm=<token>] -- <rel>
13
+ work_plan.py plan-ack --repo=<key> --clear [--confirm=<token>] -- <rel>
14
+
15
+ `<rel>` is the repo-relative POSIX path of the plan doc (as emitted by
16
+ `plan-status --json`); it is validated to resolve to a real file inside the repo.
17
+ """
18
+ import sys
19
+
20
+ from lib import config as config_mod
21
+ from lib import plan_fm
22
+ from lib.prompts import parse_flags
23
+
24
+ KNOWN = {"--repo", "--clear", "--confirm"}
25
+
26
+
27
+ def run(args: list) -> int:
28
+ flags, positional = parse_flags(args, KNOWN)
29
+
30
+ repo = flags.get("--repo")
31
+ if not repo or repo is True:
32
+ print("ERROR: --repo=<key> is required.", file=sys.stderr)
33
+ return 2
34
+ if not positional:
35
+ print("usage: work_plan.py plan-ack --repo=<key> [--clear] -- <rel>",
36
+ file=sys.stderr)
37
+ return 2
38
+ rel = positional[0]
39
+ clear = bool(flags.get("--clear"))
40
+
41
+ try:
42
+ cfg = config_mod.load_config()
43
+ except config_mod.ConfigError as e:
44
+ print(f"ERROR: {e}", file=sys.stderr)
45
+ return 1
46
+
47
+ local = config_mod.resolve_local_path_for_folder(repo, cfg)
48
+ if not local or not local.exists():
49
+ print(f"repo '{repo}' has no resolvable local path in config", file=sys.stderr)
50
+ return 2
51
+
52
+ doc_path = plan_fm.resolve_doc_path(local, rel)
53
+ if doc_path is None:
54
+ print(f"ERROR: '{rel}' is not a file inside {local}", file=sys.stderr)
55
+ return 1
56
+
57
+ slug = config_mod.resolve_github_for_folder(repo, cfg)
58
+ action = "clearing the acknowledgment on" if clear else f"acknowledging '{rel}' via"
59
+ if not plan_fm.public_repo_gate(slug, rel, cfg, flags.get("--confirm"), action):
60
+ return 0
61
+
62
+ if clear:
63
+ if not plan_fm.set_key(doc_path, "acknowledged", None):
64
+ print(f"✓ {rel} was not acknowledged in frontmatter (nothing to clear).")
65
+ return 0
66
+ print(f"✓ cleared acknowledgment on {rel} (frontmatter only).")
67
+ return 0
68
+
69
+ plan_fm.set_key(doc_path, "acknowledged", True)
70
+ print(f"✓ {rel} acknowledged — wrote acknowledged:true to frontmatter only.")
71
+ return 0