@stylusnexus/work-plan 2026.6.24 → 2026.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -529,6 +529,10 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
529
529
  | `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`. |
530
530
  | `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. |
531
531
  | `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. |
532
+ | `mark-cleanup <track> [--reason=<text>] [--clear] [--confirm] [--repo=<key>]` | Flag a track as a cleanup candidate — writes `cleanup_candidate: true` (+ optional `cleanup_reason`) into its frontmatter, a reversible earmark that `hygiene` surfaces (🧹) so stale tracks aren't lost. `--clear` removes it. Non-destructive; public-repo gated (#329). |
533
+ | `archive-track <track> [--confirm] [--repo=<key>]` | Set a track aside **reversibly**: move its `.md` into `archive/parked/` so it drops out of the active views but is kept (distinct from `close`, which is terminal). Git-aware — a shared track archives as a staged `git mv` (commit & push to share), a private one as a filesystem move. Never touches GitHub (#328). |
534
+ | `unarchive-track <track> [--confirm] [--repo=<key>]` | Restore an archived track (parked/shipped/abandoned) back into the active set — the inverse of `archive-track` and of `close`'s move (#328). |
535
+ | `delete-track <track> [--confirm] [--repo=<key>]` | **DESTRUCTIVE:** remove a track's local `.md`. **Never touches GitHub** — the issues it referenced outlive the track. Private tier: an undoable notes-vcs commit; shared tier: a staged `git rm` to commit & push (recoverable from git history until then). Prefer `archive-track` for a reversible set-aside. The VS Code viewer adds a hard modal + type-to-confirm on shared tracks (#330). |
532
536
  | `set-next-up <track> (--preset=<name> \| --order=a,b,c \| --clear \| --auto=on\|off) [--repo=<key>] [--confirm=<token>]` | Configure the ranking preset and/or auto-derivation flag for a track's next_up list. `--preset` sets a named preset: `flow` (default — milestone → dependency → priority → recency), `priority-driven` (priority first, ignores milestone bias, for backlogs with no milestones), `backlog` (oldest issues first — surfaces stalled work), or `custom` (requires `--order`). `--order=a,b,c` sets a custom comma-separated criterion list (`milestone`, `dependency`, `priority`, `recency`, `aging`). `--clear` reverts to the global `next_up_default` config or the default `flow`. `--auto=on` activates `next_up_auto` so brief/orient/export auto-derive the next-up list live from the ranking preset (ignoring the curated list); `--auto=off` clears it to revert to the curated list. `--auto` can be used standalone or combined with `--preset`/`--order`/`--clear`. Writes `next_up_order` and/or `next_up_auto` into the track's frontmatter; does NOT touch the `next_up` issue-list. Global default: add `next_up_default: <preset>` to `~/.claude/work-plan/config.yml`. Public-repo gated. |
533
537
  | `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. |
534
538
  | `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. |
@@ -544,6 +548,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
544
548
  | `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. Add `--archive-shipped` to batch-archive every clean shipped doc (lie-gap excluded unless `--include-lie-gap`); `--include-archived` also lists archived docs (tagged `archived`/`archive_kind`) in `--json`. |
545
549
  | `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. |
546
550
  | `plan-archive --repo=<key> [--draft] [--yes] [--json] -- <rel>` | Archive ONE plan/spec doc whose effective verdict is **shipped**: history-preserving `git mv` into `archive/shipped/`. Refuses non-shipped docs; skips (never overwrites) a name collision. `--draft` previews; `--yes` skips the prompt for non-interactive callers (the VS Code viewer); `--json` emits a single `{action,rel,outcome,dest}` object. The `outcome` distinguishes a **tracked** doc (`archived` — a staged history-preserving `git mv`; commit & push to share it) from a **gitignored/untracked** doc (`archived_local` — a plain filesystem move, not git-tracked), so archive never silently fails on an untracked path (#399). |
551
+ | `plan-unarchive --repo=<key> [--draft] [--yes] [--json] -- <rel>` | Restore ONE archived plan/spec doc back **out** of `archive/<kind>/` to its live location — the inverse of `plan-archive` (#388). Refuses (never overwrites) a collision with a live doc of the same name. Git-aware staged/local move; `--draft` previews; `--yes`/`--json` for non-interactive callers. `<rel>` is the archived doc's path (from `plan-status --json --include-archived`). |
547
552
  | `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. |
548
553
  | `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. |
549
554
  | `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. |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.24+b047035
1
+ 2026.07.03+7d98e1e
package/bin/work-plan CHANGED
@@ -34,6 +34,14 @@ _wp_missing=""
34
34
  _wp_have python3 || _wp_missing="$_wp_missing python3"
35
35
  case "${1:-}" in
36
36
  --version|-v|--help|-h|"") ;;
37
+ auth-status)
38
+ # The VS Code extension's activation probe. It shells out to `gh` only,
39
+ # never yq. Gating it behind yq means a missing yq makes the probe exit 1
40
+ # with empty stdout, which the extension can't tell apart from "not logged
41
+ # in" — so it shows "Not signed in to GitHub" and traps the user in a
42
+ # sign-in loop that can never satisfy a yq dependency. Require gh alone.
43
+ _wp_have gh || _wp_missing="$_wp_missing gh"
44
+ ;;
37
45
  *)
38
46
  _wp_have yq || _wp_missing="$_wp_missing yq"
39
47
  _wp_have gh || _wp_missing="$_wp_missing gh"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.24",
3
+ "version": "2026.7.3",
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"
@@ -18,6 +18,9 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
18
18
  | `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Three steps in sequence: ① `refresh-md --all` — pull live GitHub state into every active track's status table (same as "Sync Issue States from GitHub" but for all tracks); ② `reconcile --all` — sync track frontmatter membership against GitHub labels; ③ `duplicates` — flag likely-duplicate issues for consolidation. Run once a week to keep status icons, labels, and dedup state honest. `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode (it needs a single explicit repo to be unambiguous). |
19
19
  | `/work-plan slot <issue-num> [track]` | A new GitHub issue should belong to a track. If the issue is already listed in another active track's frontmatter, you'll be prompted to move it (remove from source) instead of duplicating. |
20
20
  | `/work-plan close [track]` | Track is done (shipped) / paused (parked) / won't ship (abandoned). |
21
+ | `/work-plan mark-cleanup <track> [--reason=<text>] [--clear]` | Earmark a track for cleanup — a reversible `cleanup_candidate` flag `hygiene` surfaces. Triggers: "mark this track for cleanup / retirement". Non-destructive. |
22
+ | `/work-plan archive-track <track>` / `unarchive-track <track>` | **Set a track aside reversibly** into `archive/parked/` (out of active views, kept), or restore it. Distinct from `close` (terminal). Triggers: "archive / park / set aside this track", "bring back that archived track". |
23
+ | `/work-plan delete-track <track>` | **DESTRUCTIVE** — remove a track's `.md`. **Never touches GitHub issues** (they outlive the track). Private: undoable notes-vcs commit; shared: staged `git rm`. Triggers: "delete / remove this track for good". Prefer archive-track if reversible is fine. |
21
24
  | `/work-plan refresh-md <track> \| --all \| --repo=<key>` | **Pull live GitHub state into a track's status table.** Run this after closing or merging issues — it re-fetches each issue's open/closed state from GitHub and rewrites the status cells in the track body, which refreshes the dependency graph and `next_up` display. `--all` sweeps every active track; `--repo=<key>` scopes to one repo. In VS Code: right-click a track → **Sync Issue States from GitHub**. |
22
25
  | `/work-plan list [--all]` | List active tracks (or all including parked/archived). |
23
26
  | `/work-plan init <path>` | Add frontmatter to a new track .md file. |
@@ -31,6 +34,7 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
31
34
  | `/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
35
  | `/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
36
  | `/work-plan plan-archive --repo=<key> [--draft] -- <rel>` | **Archive a shipped plan.** Moves a doc whose effective verdict is shipped into `archive/shipped/` — a staged `git mv` for a tracked doc (commit & push to share), or a plain filesystem move for a gitignored/untracked one (#399). Refuses non-shipped; skips collisions. Natural-language triggers: "archive this finished plan", "file away the shipped plans". Bulk: `plan-status --archive-shipped`. |
37
+ | `/work-plan plan-unarchive --repo=<key> -- <rel>` | **Restore an archived plan** back to the live set — the inverse of `plan-archive` (#388). Moves a doc out of `archive/<kind>/`; refuses a collision with a live doc. Triggers: "un-archive / restore this plan", "I archived that plan by mistake". In VS Code: right-click a doc in the 📦 Archived folder → **Restore to Live**. |
34
38
  | `/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". |
35
39
  | `/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". |
36
40
 
@@ -0,0 +1,67 @@
1
+ """archive-track — set a track aside (reversibly) by moving its .md into
2
+ archive/parked/. Distinct from `close` (terminal shipped/parked/abandoned): an
3
+ archived track is out of the active rotation but kept, and `unarchive-track`
4
+ restores it. Reuses the #399 git-aware move: a shared (git-tracked) track
5
+ archives as a staged `git mv`, a private one as a filesystem move — honest
6
+ messaging either way. Never touches GitHub.
7
+ """
8
+ import json
9
+ from lib.config import load_config, ConfigError
10
+ from lib.tracks import (discover_tracks, find_track_by_name, parse_track_repo_arg,
11
+ AmbiguousTrackError)
12
+ from lib.write_guard import needs_confirm, make_token, valid_token
13
+ from lib.prompts import parse_flags
14
+ from lib.archive import move_to_archive
15
+
16
+
17
+ def run(args: list[str]) -> int:
18
+ flags, positional = parse_flags(args, {"--confirm", "--repo"})
19
+ if not positional:
20
+ print("usage: work_plan.py archive-track <track | track@repo> [--repo=<key>] [--confirm=<token>]")
21
+ return 2
22
+
23
+ name_from_arg, repo_from_arg = parse_track_repo_arg(positional[0])
24
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
25
+ repo_qualifier = repo_from_arg or repo_flag
26
+
27
+ try:
28
+ cfg = load_config()
29
+ except ConfigError as e:
30
+ print(f"ERROR: {e}")
31
+ return 1
32
+ try:
33
+ track = find_track_by_name(name_from_arg, discover_tracks(cfg), repo=repo_qualifier)
34
+ except AmbiguousTrackError as e:
35
+ print(str(e))
36
+ return 1
37
+ if not track:
38
+ print(f"No track matching {name_from_arg!r}.")
39
+ return 1
40
+
41
+ confirm = flags.get("--confirm")
42
+ if track.repo and needs_confirm(track.repo, cfg) and not (
43
+ isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)):
44
+ print(json.dumps({"needs_confirm": True,
45
+ "reason": f"{track.repo} is PUBLIC (or visibility unknown); archiving '{track.name}' will be written there.",
46
+ "token": make_token(track.repo, track.name)}))
47
+ return 0
48
+
49
+ # Archive within the track's own directory: base = the doc's dir, rel = its
50
+ # name. `git -C <base>` resolves the enclosing repo, so is_tracked/git_mv work
51
+ # whether the tier root is notes_root (private) or a repo clone (shared).
52
+ base = track.path.parent
53
+ outcome = move_to_archive(track.path.name, base, "parked")
54
+ if outcome is None:
55
+ print(f"ERROR: could not archive {track.name!r} (move failed).")
56
+ return 1
57
+ if outcome == "skipped_collision":
58
+ print(f"Not archived — a file already exists at archive/parked/{track.path.name}.")
59
+ return 0
60
+ if outcome == "archived":
61
+ print(f"✓ '{track.name}' archived → archive/parked/ (staged rename)")
62
+ if getattr(track, "tier", None) == "shared":
63
+ print(" ↑ shared track — commit & push to share this archive with teammates.")
64
+ else: # archived_local
65
+ print(f"✓ '{track.name}' archived → archive/parked/ (moved on disk; not git-tracked)")
66
+ print(" Restore with: work-plan unarchive-track " + track.name)
67
+ return 0
@@ -0,0 +1,92 @@
1
+ """delete-track — remove a track's markdown file. DESTRUCTIVE but bounded:
2
+
3
+ * It removes ONLY the track's local .md. It NEVER touches GitHub — the issues
4
+ the track referenced outlive it (deleting a track must not close or delete
5
+ any issue). This module deliberately imports nothing that mutates GitHub.
6
+ * Private tier (notes_root, an owned no-remote git root): the deletion is
7
+ staged via `git rm`, and the dispatcher's notes-vcs auto-commit turns it into
8
+ an undoable commit.
9
+ * Shared tier (a repo clone with a remote): the deletion is staged via
10
+ `git rm` and left for the user to commit & push — recoverable from git
11
+ history until they do.
12
+ * An untracked track file: a plain filesystem unlink.
13
+
14
+ Public/shared repos require the --confirm token (the VS Code modal supplies it,
15
+ plus a stronger type-to-confirm on shared-tier deletes).
16
+ """
17
+ import json
18
+ from lib.config import load_config, ConfigError
19
+ from lib.tracks import (discover_tracks, discover_archived_tracks,
20
+ find_track_by_name, parse_track_repo_arg, AmbiguousTrackError)
21
+ from lib.write_guard import needs_confirm, make_token, valid_token
22
+ from lib.prompts import parse_flags
23
+ from lib import git_state
24
+
25
+
26
+ def run(args: list[str]) -> int:
27
+ flags, positional = parse_flags(args, {"--confirm", "--repo"})
28
+ if not positional:
29
+ print("usage: work_plan.py delete-track <track | track@repo> [--repo=<key>] [--confirm=<token>]")
30
+ return 2
31
+
32
+ name_from_arg, repo_from_arg = parse_track_repo_arg(positional[0])
33
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
34
+ repo_qualifier = repo_from_arg or repo_flag
35
+
36
+ try:
37
+ cfg = load_config()
38
+ except ConfigError as e:
39
+ print(f"ERROR: {e}")
40
+ return 1
41
+
42
+ # A track may be deleted whether active or archived — search both tiers.
43
+ candidates = discover_tracks(cfg) + discover_archived_tracks(cfg)
44
+ try:
45
+ track = find_track_by_name(name_from_arg, candidates, repo=repo_qualifier)
46
+ except AmbiguousTrackError as e:
47
+ print(str(e))
48
+ return 1
49
+ if not track:
50
+ print(f"No track matching {name_from_arg!r}.")
51
+ return 1
52
+
53
+ confirm = flags.get("--confirm")
54
+ if track.repo and needs_confirm(track.repo, cfg) and not (
55
+ isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)):
56
+ print(json.dumps({"needs_confirm": True,
57
+ "reason": f"{track.repo} is PUBLIC (or visibility unknown); deleting '{track.name}' will remove its file there.",
58
+ "token": make_token(track.repo, track.name)}))
59
+ return 0
60
+
61
+ base = track.path.parent
62
+ rel = track.path.name
63
+ # `staged` records whether the deletion went through git (recoverable) or a
64
+ # bare filesystem unlink (PERMANENT). Recoverability messaging keys on THIS,
65
+ # not on tier — a private track is only recoverable when notes_root is under
66
+ # version control (notes-vcs, which is opt-in and OFF by default).
67
+ if git_state.is_tracked(rel, base):
68
+ if not git_state.git_rm(rel, base):
69
+ print(f"ERROR: could not delete {track.name!r} (git rm failed — "
70
+ "commit or discard any unsaved edits to it first).")
71
+ return 1
72
+ staged = True
73
+ else:
74
+ try:
75
+ track.path.unlink()
76
+ except OSError as e:
77
+ print(f"ERROR: could not delete {track.name!r} ({e}).")
78
+ return 1
79
+ staged = False
80
+
81
+ print(f"✓ deleted track {track.name!r} ({rel})")
82
+ print(" GitHub issues are untouched — only the track's local .md was removed.")
83
+ if not staged:
84
+ print(" ⚠ PERMANENT — this file isn't under version control (notes-vcs is "
85
+ "off), so the deletion CANNOT be undone. Enable it with "
86
+ "`work-plan notes-vcs init` for undoable deletes going forward.")
87
+ elif getattr(track, "tier", None) == "shared":
88
+ print(" ↑ shared track — the deletion is staged; commit & push to remove it "
89
+ "for teammates (recoverable from git history until you do).")
90
+ else:
91
+ print(" Recoverable via notes-vcs undo (the deletion is an undoable commit).")
92
+ return 0
@@ -2,7 +2,7 @@
2
2
  import json
3
3
  from datetime import datetime, date
4
4
  from lib.config import load_config, ConfigError, resolve_local_path_for_folder
5
- from lib.tracks import discover_tracks, find_tier_duplicates, issue_refs
5
+ from lib.tracks import discover_tracks, discover_archived_tracks, find_tier_duplicates, issue_refs
6
6
  from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
7
7
  from lib.git_state import hot_issue_numbers
8
8
  from lib.export_model import build_export
@@ -48,15 +48,24 @@ def _plan_badge(track, cfg, today, dead_days, stall_days):
48
48
  }
49
49
 
50
50
  def run(args: list[str]) -> int:
51
- flags, _ = parse_flags(args, {"--json"})
51
+ flags, _ = parse_flags(args, {"--json", "--include-archived"})
52
52
  if not flags.get("--json"):
53
- print("usage: work-plan export --json"); return 2
53
+ print("usage: work-plan export --json [--include-archived]"); return 2
54
54
  try:
55
55
  cfg = load_config()
56
56
  except ConfigError as e:
57
57
  print(json.dumps({"error": str(e)})); return 1
58
58
  tracks = [t for t in discover_tracks(cfg) if t.has_frontmatter]
59
59
 
60
+ # --include-archived (#328): append archived-tier tracks, tagged so the
61
+ # viewer can render them greyed under a "Show archived" toggle. In-memory
62
+ # meta flag only (never written back). Excluded by default.
63
+ if flags.get("--include-archived"):
64
+ for t in discover_archived_tracks(cfg):
65
+ if t.has_frontmatter:
66
+ t.meta["archived"] = True
67
+ tracks.append(t)
68
+
60
69
  # Build repo_to_numbers: {repo: [number, ...]} deduped per repo, first-seen order.
61
70
  repo_to_numbers: dict[str, list[int]] = {}
62
71
  for t in tracks:
@@ -25,9 +25,30 @@ Pass --timeout=N to set the gh subprocess timeout for the duplicates step
25
25
  from commands import refresh_md, reconcile, duplicates, dedupe_tiers
26
26
  from lib.config import load_config, ConfigError
27
27
  from lib.prompts import parse_flags
28
+ from lib.render import render_cleanup_callout
29
+ from lib.tracks import discover_tracks
28
30
  import time
29
31
 
30
32
 
33
+ def _print_cleanup_callout() -> None:
34
+ """Print the "marked for cleanup" section if any active track is earmarked
35
+ via `mark-cleanup` (#328). Best-effort: a config/discovery failure is
36
+ swallowed so it never derails the hygiene run."""
37
+ try:
38
+ cfg = load_config()
39
+ candidates = [
40
+ (t.name, t.meta.get("cleanup_reason"))
41
+ for t in discover_tracks(cfg)
42
+ if t.meta.get("cleanup_candidate")
43
+ ]
44
+ except Exception:
45
+ return
46
+ block = render_cleanup_callout(candidates)
47
+ if block:
48
+ print()
49
+ print(block)
50
+
51
+
31
52
  def _resolve_repo_folder(repo_key: str, cfg: dict):
32
53
  """Translate hygiene's --repo arg (folder key OR org/repo slug) to a config
33
54
  folder key, which is what duplicates expects. Returns None if unresolvable.
@@ -100,6 +121,12 @@ def run(args: list[str]) -> int:
100
121
  print(f"\n⚠ dedupe-tiers exited with code {rc}; continuing.")
101
122
  print(f" (step 3/4 done in {time.time() - t_dt:.1f}s)")
102
123
 
124
+ # Cleanup earmarks (#328): surface tracks flagged via `mark-cleanup` so they
125
+ # don't get forgotten. Printed after the maintenance steps and before the
126
+ # (optional) duplicates scan, so it always shows regardless of the --repo /
127
+ # multi-repo early returns below.
128
+ _print_cleanup_callout()
129
+
103
130
  if skip_dups:
104
131
  print()
105
132
  print("(skipping duplicates per --no-duplicates)")
@@ -0,0 +1,78 @@
1
+ """mark-cleanup subcommand — flag (or unflag) a track as a cleanup candidate.
2
+
3
+ Writes a lightweight `cleanup_candidate: true` marker (plus an optional
4
+ `cleanup_reason`) into a track's frontmatter, so hygiene can surface tracks the
5
+ user has earmarked for retirement/consolidation. `--clear` removes both keys.
6
+
7
+ Same guarded-write shape as `set`: a PUBLIC-repo track requires a --confirm
8
+ token round-trip; private-tier tracks write straight through. The notes_root
9
+ auto-commit (opt-in local VCS) is handled centrally by the dispatcher.
10
+
11
+ Usage:
12
+ mark-cleanup <track | track@repo> [--repo=<key>] [--clear]
13
+ [--reason=<text>] [--confirm=<token>]
14
+ """
15
+ import json
16
+ from lib.config import load_config, ConfigError
17
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
18
+ from lib.frontmatter import write_file
19
+ from lib.write_guard import needs_confirm, make_token, valid_token
20
+ from lib.prompts import parse_flags
21
+
22
+
23
+ def run(args: list[str]) -> int:
24
+ flags, positional = parse_flags(args, {"--repo", "--clear", "--reason", "--confirm"})
25
+ if len(positional) < 1:
26
+ print("usage: work_plan.py mark-cleanup <track | track@repo> [--repo=<key>] "
27
+ "[--clear] [--reason=<text>] [--confirm=<token>]")
28
+ return 2
29
+
30
+ track_arg = positional[0]
31
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
32
+ name = name_from_arg
33
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
34
+ repo_qualifier = repo_from_arg or repo_flag
35
+
36
+ clear = flags.get("--clear", False)
37
+ reason = flags.get("--reason")
38
+ reason = reason if isinstance(reason, str) else None
39
+
40
+ try:
41
+ cfg = load_config()
42
+ except ConfigError as e:
43
+ print(f"ERROR: {e}")
44
+ return 1
45
+
46
+ try:
47
+ track = find_track_by_name(name, discover_tracks(cfg), repo=repo_qualifier)
48
+ except AmbiguousTrackError as e:
49
+ print(str(e))
50
+ return 1
51
+ if not track:
52
+ print(f"No track matching {name!r}.")
53
+ return 1
54
+
55
+ # Public-repo confirm gate (the extension surfaces this as a modal).
56
+ confirm = flags.get("--confirm")
57
+ if track.repo and needs_confirm(track.repo, cfg) and not (isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)):
58
+ print(json.dumps({"needs_confirm": True,
59
+ "reason": f"{track.repo} is PUBLIC (or visibility unknown); edit will be written there.",
60
+ "token": make_token(track.repo, track.name)}))
61
+ return 0
62
+
63
+ if clear:
64
+ track.meta.pop("cleanup_candidate", None)
65
+ track.meta.pop("cleanup_reason", None)
66
+ write_file(track.path, track.meta, track.body)
67
+ print(f"✓ cleared cleanup mark on {track.name!r}")
68
+ return 0
69
+
70
+ track.meta["cleanup_candidate"] = True
71
+ if reason:
72
+ track.meta["cleanup_reason"] = reason
73
+ write_file(track.path, track.meta, track.body)
74
+ if reason:
75
+ print(f"✓ {track.name!r} marked for cleanup — {reason}")
76
+ else:
77
+ print(f"✓ {track.name!r} marked for cleanup")
78
+ return 0
@@ -0,0 +1,87 @@
1
+ """plan-unarchive — restore ONE archived plan/spec doc back out of
2
+ `archive/<kind>/` to its live location (#388). The inverse of plan-archive;
3
+ reuses the git-aware `restore_from_archive` primitive. A name collision with a
4
+ live doc is refused, never overwritten.
5
+
6
+ Interactivity mirrors plan-archive: behind a y/N prompt by default, `--yes` for
7
+ non-interactive callers (the VS Code viewer), `--json` for a single parseable
8
+ outcome object.
9
+
10
+ Usage:
11
+ work_plan.py plan-unarchive --repo=<key> [--draft] [--yes] [--json] -- <rel>
12
+ """
13
+ import json
14
+ import sys
15
+ from pathlib import Path, PurePosixPath
16
+
17
+ from commands import plan_status
18
+ from lib import archive as archive_lib
19
+ from lib.prompts import parse_flags, prompt_yes_no
20
+
21
+ KNOWN = {"--repo", "--draft", "--yes", "--json"}
22
+
23
+
24
+ def _restore_dest(rel: str):
25
+ """The live path an archived `rel` restores to (strip archive/<kind>/), or
26
+ None when `rel` isn't under archive/<kind>/."""
27
+ p = PurePosixPath(rel)
28
+ if p.parent.parent.name != "archive":
29
+ return None
30
+ return str(p.parent.parent.parent / p.name)
31
+
32
+
33
+ def _emit(as_json: bool, rel: str, outcome: str, dest, human: str) -> None:
34
+ if as_json:
35
+ print(json.dumps({"action": "unarchive", "rel": rel,
36
+ "outcome": outcome, "dest": dest}))
37
+ else:
38
+ print(human)
39
+
40
+
41
+ def run(args: list) -> int:
42
+ flags, positional = parse_flags(args, KNOWN)
43
+ if not flags.get("--repo") or flags.get("--repo") is True:
44
+ print("ERROR: --repo=<key> is required.", file=sys.stderr)
45
+ return 2
46
+ if not positional:
47
+ print("usage: work_plan.py plan-unarchive --repo=<key> [--draft] [--yes] "
48
+ "[--json] -- <rel>", file=sys.stderr)
49
+ return 2
50
+ rel = positional[0]
51
+ as_json = bool(flags.get("--json"))
52
+ repo_root = plan_status._resolve_repo_root(flags)
53
+
54
+ dest = _restore_dest(rel)
55
+ if dest is None:
56
+ print(f"ERROR: '{rel}' is not under archive/<kind>/ — nothing to restore.",
57
+ file=sys.stderr)
58
+ return 1
59
+ if not (Path(repo_root) / rel).is_file():
60
+ print(f"ERROR: archived doc '{rel}' not found under {repo_root}",
61
+ file=sys.stderr)
62
+ return 1
63
+
64
+ if flags.get("--draft"):
65
+ print(f"Would restore {rel} -> {dest}")
66
+ return 0
67
+
68
+ if not flags.get("--yes"):
69
+ if not prompt_yes_no(f"Restore {rel} -> {dest}? [y/N]"):
70
+ print("Skipped.")
71
+ return 0
72
+
73
+ outcome = archive_lib.restore_from_archive(rel, repo_root)
74
+ if outcome is None:
75
+ print(f"ERROR: restore failed for {rel}", file=sys.stderr)
76
+ return 1
77
+ if outcome == "skipped_collision":
78
+ _emit(as_json, rel, "skipped_collision", dest,
79
+ f"a live doc already exists at {dest} — skipped")
80
+ return 0
81
+ if outcome == "restored_local":
82
+ _emit(as_json, rel, "restored_local", dest,
83
+ f"✓ restored {rel} -> {dest} (moved on disk; not git-tracked)")
84
+ return 0
85
+ _emit(as_json, rel, "restored", dest,
86
+ f"✓ restored {rel} -> {dest} (staged rename — commit & push to share)")
87
+ return 0
@@ -0,0 +1,64 @@
1
+ """unarchive-track — restore an archived track (parked / shipped / abandoned)
2
+ back into the active set. The inverse of archive-track (and of `close`'s move).
3
+ Reuses the #399 git-aware restore primitive. Never touches GitHub.
4
+ """
5
+ import json
6
+ from lib.config import load_config, ConfigError
7
+ from lib.tracks import (discover_archived_tracks, find_track_by_name,
8
+ parse_track_repo_arg, AmbiguousTrackError)
9
+ from lib.write_guard import needs_confirm, make_token, valid_token
10
+ from lib.prompts import parse_flags
11
+ from lib.archive import restore_from_archive
12
+
13
+
14
+ def run(args: list[str]) -> int:
15
+ flags, positional = parse_flags(args, {"--confirm", "--repo"})
16
+ if not positional:
17
+ print("usage: work_plan.py unarchive-track <track | track@repo> [--repo=<key>] [--confirm=<token>]")
18
+ return 2
19
+
20
+ name_from_arg, repo_from_arg = parse_track_repo_arg(positional[0])
21
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
22
+ repo_qualifier = repo_from_arg or repo_flag
23
+
24
+ try:
25
+ cfg = load_config()
26
+ except ConfigError as e:
27
+ print(f"ERROR: {e}")
28
+ return 1
29
+ try:
30
+ track = find_track_by_name(name_from_arg, discover_archived_tracks(cfg), repo=repo_qualifier)
31
+ except AmbiguousTrackError as e:
32
+ print(str(e))
33
+ return 1
34
+ if not track:
35
+ print(f"No archived track matching {name_from_arg!r}.")
36
+ return 1
37
+
38
+ confirm = flags.get("--confirm")
39
+ if track.repo and needs_confirm(track.repo, cfg) and not (
40
+ isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)):
41
+ print(json.dumps({"needs_confirm": True,
42
+ "reason": f"{track.repo} is PUBLIC (or visibility unknown); restoring '{track.name}' will be written there.",
43
+ "token": make_token(track.repo, track.name)}))
44
+ return 0
45
+
46
+ # The archived path is `.../<kind-parent>/archive/<kind>/<name>`. Restore
47
+ # relative to the directory ABOVE the `archive/` segment so the doc lands
48
+ # back where it started. base = 3 levels up (strip archive/<kind>/<name>).
49
+ base = track.path.parent.parent.parent
50
+ rel = track.path.relative_to(base).as_posix()
51
+ outcome = restore_from_archive(rel, base)
52
+ if outcome is None:
53
+ print(f"ERROR: could not restore {track.name!r} (not under archive/<kind>/, or move failed).")
54
+ return 1
55
+ if outcome == "skipped_collision":
56
+ print(f"Not restored — an active track already exists at {track.path.name}.")
57
+ return 0
58
+ if outcome == "restored":
59
+ print(f"✓ '{track.name}' restored to the active set (staged rename)")
60
+ if getattr(track, "tier", None) == "shared":
61
+ print(" ↑ shared track — commit & push to share the restore with teammates.")
62
+ else: # restored_local
63
+ print(f"✓ '{track.name}' restored to the active set (moved on disk; not git-tracked)")
64
+ return 0
@@ -4,7 +4,7 @@ archive/<kind>/. For tracked files the move is a history-preserving `git mv`
4
4
  filesystem move. Eligibility (is-this-shipped) is the caller's job — this
5
5
  only moves files."""
6
6
  import shutil
7
- from pathlib import Path
7
+ from pathlib import Path, PurePosixPath
8
8
 
9
9
  from lib import git_state
10
10
  from lib import reconcile_actions
@@ -34,3 +34,34 @@ def move_to_archive(rel: str, repo_root, kind: str):
34
34
  return "archived_local"
35
35
  except OSError:
36
36
  return None
37
+
38
+
39
+ def restore_from_archive(rel: str, repo_root):
40
+ """Inverse of move_to_archive: move an archived doc back OUT of
41
+ `.../archive/<kind>/<name>` to its original directory (`.../name`). Returns:
42
+ "restored" tracked file: history-preserving `git mv` (staged),
43
+ "restored_local" untracked/gitignored file: plain filesystem move,
44
+ "skipped_collision" a live doc already exists at the destination,
45
+ None `rel` isn't under archive/<kind>/, or a hard move failure.
46
+ Shared by unarchive-track (#328) and plan-unarchive (#388).
47
+ """
48
+ p = PurePosixPath(rel)
49
+ # Expect .../archive/<kind>/<name>; the grandparent dir must be "archive".
50
+ if p.parent.parent.name != "archive":
51
+ return None
52
+ dest = str(p.parent.parent.parent / p.name)
53
+ if (Path(repo_root) / dest).exists():
54
+ return "skipped_collision"
55
+ if git_state.is_tracked(rel, Path(repo_root)):
56
+ if git_state.git_mv(rel, dest, repo_root):
57
+ return "restored"
58
+ return None
59
+ # Untracked / gitignored — plain filesystem move.
60
+ src_path = Path(repo_root) / rel
61
+ dst_path = Path(repo_root) / dest
62
+ try:
63
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
64
+ shutil.move(str(src_path), str(dst_path))
65
+ return "restored_local"
66
+ except OSError:
67
+ return None
@@ -139,6 +139,14 @@ def build_export(tracks, issues_by_track, visibility, now: str,
139
139
  "folder": getattr(t, "folder", None),
140
140
  "tier": getattr(t, "tier", "private") or "private",
141
141
  "status": t.meta.get("status"),
142
+ # True for a track in the archive tier (#328), surfaced only when
143
+ # export ran with --include-archived; the viewer greys these.
144
+ "archived": bool(t.meta.get("archived")),
145
+ # Cleanup earmark (#328): a lightweight "candidate for retirement"
146
+ # flag set via `mark-cleanup`, with an optional free-text reason
147
+ # (null when unset). Surfaced by the viewer + hygiene callout.
148
+ "cleanup_candidate": bool(t.meta.get("cleanup_candidate")),
149
+ "cleanup_reason": t.meta.get("cleanup_reason"),
142
150
  "launch_priority": t.meta.get("launch_priority"),
143
151
  "milestone_alignment": milestone_alignment,
144
152
  "visibility": visibility.get(t.repo),
@@ -378,3 +378,13 @@ def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
378
378
  (Path(repo_path) / dst_rel).parent.mkdir(parents=True, exist_ok=True)
379
379
  proc = _git(repo_path, "mv", src_rel, dst_rel)
380
380
  return proc is not None and proc.returncode == 0
381
+
382
+
383
+ def git_rm(rel_path: str, repo_path: Path) -> bool:
384
+ """git-rm `rel_path` (repo-relative) — stage its deletion. Returns True on
385
+ success. The removal stays in the index (undoable from git history) until
386
+ the caller commits; #330 delete-track uses it for tracked tracks."""
387
+ if not repo_path or not Path(repo_path).exists():
388
+ return False
389
+ proc = _git(repo_path, "rm", rel_path)
390
+ return proc is not None and proc.returncode == 0
@@ -90,3 +90,19 @@ def render_track_row(t: dict) -> str:
90
90
  def render_archived_reopen(repo: str, slug: str, issue: dict) -> str:
91
91
  return (f"⚠ archive/{slug}.md (shipped) — new issue #{issue['number']} "
92
92
  f"matches this slug. Re-open or slot into a different track?")
93
+
94
+
95
+ def render_cleanup_callout(candidates: list) -> str:
96
+ """Render the hygiene "marked for cleanup" section, or "" if none.
97
+
98
+ `candidates` is a list of (name, reason) tuples; reason may be None/empty.
99
+ Returns a multi-line block (header + one line per track) or an empty string
100
+ when nothing is marked, so the caller can skip printing entirely.
101
+ """
102
+ if not candidates:
103
+ return ""
104
+ lines = ["🧹 Marked for cleanup:"]
105
+ for name, reason in candidates:
106
+ suffix = f" — {reason}" if reason else ""
107
+ lines.append(f" {name}{suffix}")
108
+ return "\n".join(lines)
@@ -128,5 +128,57 @@ class MoveToArchiveTest(unittest.TestCase):
128
128
  mv.assert_not_called()
129
129
 
130
130
 
131
+ class RestoreFromArchiveTest(unittest.TestCase):
132
+ def _archived_repo(self, d):
133
+ root = Path(d)
134
+ (root / "docs/plans/archive/shipped").mkdir(parents=True)
135
+ (root / "docs/plans/archive/shipped/x.md").write_text("# x")
136
+ return root
137
+
138
+ def test_tracked_restore_calls_git_mv_to_original_dir(self):
139
+ """Tracked archived file → staged git mv back out → 'restored'."""
140
+ with tempfile.TemporaryDirectory() as d:
141
+ root = self._archived_repo(d)
142
+ with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
143
+ mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
144
+ outcome = archive_lib.restore_from_archive(
145
+ "docs/plans/archive/shipped/x.md", root)
146
+ self.assertEqual(outcome, "restored")
147
+ mv.assert_called_once_with(
148
+ "docs/plans/archive/shipped/x.md", "docs/plans/x.md", root)
149
+
150
+ def test_untracked_restore_local_via_filesystem(self):
151
+ """Untracked archived file → shutil.move back → 'restored_local'; moves on disk."""
152
+ with tempfile.TemporaryDirectory() as d:
153
+ root = self._archived_repo(d)
154
+ with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
155
+ mock.patch("lib.archive.git_state.git_mv") as mv:
156
+ outcome = archive_lib.restore_from_archive(
157
+ "docs/plans/archive/shipped/x.md", root)
158
+ self.assertEqual(outcome, "restored_local")
159
+ mv.assert_not_called()
160
+ self.assertFalse((root / "docs/plans/archive/shipped/x.md").exists())
161
+ self.assertTrue((root / "docs/plans/x.md").exists())
162
+
163
+ def test_collision_refuses_when_live_doc_exists(self):
164
+ """A live doc already at the destination → 'skipped_collision', no move."""
165
+ with tempfile.TemporaryDirectory() as d:
166
+ root = self._archived_repo(d)
167
+ (root / "docs/plans/x.md").write_text("# live already")
168
+ with mock.patch("lib.archive.git_state.is_tracked") as is_tracked, \
169
+ mock.patch("lib.archive.git_state.git_mv") as mv:
170
+ outcome = archive_lib.restore_from_archive(
171
+ "docs/plans/archive/shipped/x.md", root)
172
+ self.assertEqual(outcome, "skipped_collision")
173
+ is_tracked.assert_not_called()
174
+ mv.assert_not_called()
175
+
176
+ def test_not_under_archive_returns_none(self):
177
+ """A path not under archive/<kind>/ → None (nothing to restore)."""
178
+ with tempfile.TemporaryDirectory() as d:
179
+ root = Path(d)
180
+ self.assertIsNone(archive_lib.restore_from_archive("docs/plans/x.md", root))
181
+
182
+
131
183
  if __name__ == "__main__":
132
184
  unittest.main()
@@ -0,0 +1,100 @@
1
+ """archive-track / unarchive-track commands (offline; move primitives mocked)."""
2
+ import io
3
+ import sys
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from unittest.mock import patch
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ sys.path.insert(0, str(SKILL_ROOT))
12
+
13
+ from commands import archive_track, unarchive_track
14
+ from lib.write_guard import make_token
15
+
16
+
17
+ def _t(name="ph", repo="o/r", path="/tmp/notes/proj/ph.md", tier="private"):
18
+ return SimpleNamespace(name=name, repo=repo, path=Path(path), tier=tier,
19
+ folder="proj", has_frontmatter=True, meta={"status": "active"})
20
+
21
+
22
+ class ArchiveTrackTest(unittest.TestCase):
23
+ def _drive(self, args, track=None, outcome="archived", vis="PRIVATE"):
24
+ track = track or _t()
25
+ with patch("commands.archive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
26
+ patch("commands.archive_track.discover_tracks", return_value=[track]), \
27
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
28
+ patch("commands.archive_track.move_to_archive", return_value=outcome) as mv:
29
+ buf = io.StringIO()
30
+ with redirect_stdout(buf):
31
+ rc = archive_track.run(args)
32
+ return rc, mv, buf.getvalue()
33
+
34
+ def test_archives_private_track(self):
35
+ rc, mv, out = self._drive(["ph"])
36
+ self.assertEqual(rc, 0)
37
+ mv.assert_called_once_with("ph.md", Path("/tmp/notes/proj"), "parked")
38
+ self.assertIn("archived", out)
39
+
40
+ def test_shared_track_prints_push_hint(self):
41
+ rc, mv, out = self._drive(["ph"], track=_t(tier="shared"))
42
+ self.assertEqual(rc, 0)
43
+ self.assertIn("commit & push", out)
44
+
45
+ def test_collision_is_reported_not_errored(self):
46
+ rc, mv, out = self._drive(["ph"], outcome="skipped_collision")
47
+ self.assertEqual(rc, 0)
48
+ self.assertIn("already exists", out)
49
+
50
+ def test_hard_failure_returns_1(self):
51
+ rc, mv, out = self._drive(["ph"], outcome=None)
52
+ self.assertEqual(rc, 1)
53
+
54
+ def test_public_blocks_without_confirm(self):
55
+ rc, mv, out = self._drive(["ph"], vis="PUBLIC")
56
+ self.assertEqual(rc, 0)
57
+ mv.assert_not_called()
58
+ self.assertIn("needs_confirm", out)
59
+
60
+ def test_public_with_confirm_archives(self):
61
+ tok = make_token("o/r", "ph")
62
+ rc, mv, out = self._drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
63
+ self.assertEqual(rc, 0)
64
+ mv.assert_called_once()
65
+
66
+
67
+ class UnarchiveTrackTest(unittest.TestCase):
68
+ def _drive(self, args, track=None, outcome="restored", vis="PRIVATE"):
69
+ track = track or _t(path="/tmp/notes/proj/archive/parked/ph.md")
70
+ with patch("commands.unarchive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
71
+ patch("commands.unarchive_track.discover_archived_tracks", return_value=[track]), \
72
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
73
+ patch("commands.unarchive_track.restore_from_archive", return_value=outcome) as rs:
74
+ buf = io.StringIO()
75
+ with redirect_stdout(buf):
76
+ rc = unarchive_track.run(args)
77
+ return rc, rs, buf.getvalue()
78
+
79
+ def test_restores_from_archive_with_correct_rel_and_base(self):
80
+ rc, rs, out = self._drive(["ph"])
81
+ self.assertEqual(rc, 0)
82
+ rs.assert_called_once_with("archive/parked/ph.md", Path("/tmp/notes/proj"))
83
+ self.assertIn("restored", out)
84
+
85
+ def test_collision_reported(self):
86
+ rc, rs, out = self._drive(["ph"], outcome="skipped_collision")
87
+ self.assertEqual(rc, 0)
88
+ self.assertIn("already exists", out)
89
+
90
+ def test_not_found_returns_1(self):
91
+ with patch("commands.unarchive_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
92
+ patch("commands.unarchive_track.discover_archived_tracks", return_value=[]):
93
+ buf = io.StringIO()
94
+ with redirect_stdout(buf):
95
+ rc = unarchive_track.run(["nope"])
96
+ self.assertEqual(rc, 1)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ unittest.main()
@@ -0,0 +1,108 @@
1
+ """delete-track (offline; git mocked). Verifies the destructive-but-bounded
2
+ behavior AND the never-touch-GitHub guarantee."""
3
+ import io
4
+ import sys
5
+ import unittest
6
+ from contextlib import redirect_stdout
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from unittest.mock import patch
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import delete_track
15
+ from lib.write_guard import make_token
16
+
17
+
18
+ def _t(name="ph", repo="o/r", path="/tmp/notes/proj/ph.md", tier="private"):
19
+ return SimpleNamespace(name=name, repo=repo, path=Path(path), tier=tier,
20
+ folder="proj", has_frontmatter=True, meta={"status": "active"})
21
+
22
+
23
+ def _drive(args, track=None, vis="PRIVATE", tracked=True, git_rm_ok=True,
24
+ archived=None):
25
+ track = track or _t()
26
+ with patch("commands.delete_track.load_config", return_value={"notes_root": "/tmp/notes"}), \
27
+ patch("commands.delete_track.discover_tracks", return_value=[track]), \
28
+ patch("commands.delete_track.discover_archived_tracks", return_value=archived or []), \
29
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
30
+ patch("commands.delete_track.git_state.is_tracked", return_value=tracked), \
31
+ patch("commands.delete_track.git_state.git_rm", return_value=git_rm_ok) as grm, \
32
+ patch("pathlib.Path.unlink") as unlink:
33
+ buf = io.StringIO()
34
+ with redirect_stdout(buf):
35
+ rc = delete_track.run(args)
36
+ return rc, grm, unlink, buf.getvalue()
37
+
38
+
39
+ class DeleteTrackTest(unittest.TestCase):
40
+ def test_private_tracked_git_rm_and_undo_message(self):
41
+ rc, grm, unlink, out = _drive(["ph"])
42
+ self.assertEqual(rc, 0)
43
+ grm.assert_called_once_with("ph.md", Path("/tmp/notes/proj"))
44
+ unlink.assert_not_called()
45
+ self.assertIn("deleted track", out)
46
+ self.assertIn("GitHub issues are untouched", out)
47
+ self.assertIn("Recoverable via notes-vcs", out)
48
+
49
+ def test_shared_tracked_prints_commit_push(self):
50
+ rc, grm, unlink, out = _drive(["ph"], track=_t(tier="shared"))
51
+ self.assertEqual(rc, 0)
52
+ grm.assert_called_once()
53
+ self.assertIn("commit & push", out)
54
+
55
+ def test_untracked_uses_filesystem_unlink_and_warns_permanent(self):
56
+ # notes-vcs off (untracked) → unlink → the message must say PERMANENT,
57
+ # NOT promise notes-vcs undo (the review's borderline-critical finding).
58
+ rc, grm, unlink, out = _drive(["ph"], tracked=False)
59
+ self.assertEqual(rc, 0)
60
+ grm.assert_not_called()
61
+ unlink.assert_called_once()
62
+ self.assertIn("PERMANENT", out)
63
+ self.assertNotIn("Recoverable via notes-vcs", out)
64
+
65
+ def test_private_tracked_message_is_recoverable_not_permanent(self):
66
+ # notes-vcs on (tracked) → git rm → recoverable, and NOT flagged permanent.
67
+ rc, grm, unlink, out = _drive(["ph"], tracked=True)
68
+ self.assertEqual(rc, 0)
69
+ self.assertIn("Recoverable via notes-vcs", out)
70
+ self.assertNotIn("PERMANENT", out)
71
+
72
+ def test_git_rm_failure_returns_1(self):
73
+ rc, grm, unlink, out = _drive(["ph"], git_rm_ok=False)
74
+ self.assertEqual(rc, 1)
75
+
76
+ def test_public_blocks_without_confirm(self):
77
+ rc, grm, unlink, out = _drive(["ph"], vis="PUBLIC")
78
+ self.assertEqual(rc, 0)
79
+ grm.assert_not_called()
80
+ unlink.assert_not_called()
81
+ self.assertIn("needs_confirm", out)
82
+
83
+ def test_public_with_confirm_deletes(self):
84
+ tok = make_token("o/r", "ph")
85
+ rc, grm, unlink, out = _drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
86
+ self.assertEqual(rc, 0)
87
+ grm.assert_called_once()
88
+
89
+ def test_not_found_returns_1(self):
90
+ with patch("commands.delete_track.load_config", return_value={"notes_root": "/tmp"}), \
91
+ patch("commands.delete_track.discover_tracks", return_value=[]), \
92
+ patch("commands.delete_track.discover_archived_tracks", return_value=[]):
93
+ buf = io.StringIO()
94
+ with redirect_stdout(buf):
95
+ rc = delete_track.run(["nope"])
96
+ self.assertEqual(rc, 1)
97
+
98
+ def test_module_never_touches_github(self):
99
+ """Guardrail: delete-track must not import a GitHub-mutating helper.
100
+ The issues outlive the track (#330)."""
101
+ src = (SKILL_ROOT / "commands" / "delete_track.py").read_text()
102
+ self.assertNotIn("github_state", src)
103
+ self.assertNotIn("gh issue", src)
104
+ self.assertNotIn("subprocess", src)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ unittest.main()
@@ -38,8 +38,18 @@ class BuildExportTest(unittest.TestCase):
38
38
  # Phase 2: next_up_preset must be present in every track
39
39
  self.assertIn("next_up_preset", t)
40
40
  self.assertEqual(t["next_up_preset"], "flow") # default when no next_up_order in meta
41
+ # archived defaults false (active tracks); #328.
42
+ self.assertFalse(t["archived"])
41
43
  json.dumps(out) # must be serializable
42
44
 
45
+ def test_archived_flag_emitted_when_meta_set(self):
46
+ """A track tagged archived (the --include-archived union sets meta) exports
47
+ archived:true so the viewer can grey it (#328)."""
48
+ tr = _track("old", "o/r", [1])
49
+ tr.meta["archived"] = True
50
+ out = build_export([tr], {("o/r", "old"): []}, {"o/r": "PRIVATE"}, now="t")
51
+ self.assertTrue(out["tracks"][0]["archived"])
52
+
43
53
  def test_path_is_null_when_track_has_no_path(self):
44
54
  """A track object without a `path` attribute exports path=None, so the
45
55
  viewer disables its open-file affordance instead of erroring (#211)."""
@@ -381,6 +391,37 @@ class BuildExportPlanTest(unittest.TestCase):
381
391
  self.assertEqual(out["tracks"][0]["plan"], {"rel": "docs/plans/p.md", "resolved": False})
382
392
 
383
393
 
394
+ class BuildExportCleanupCandidateTest(unittest.TestCase):
395
+ """cleanup_candidate / cleanup_reason surface in the export JSON (#328)."""
396
+
397
+ def test_cleanup_absent_defaults(self):
398
+ """A track with no cleanup marker → candidate False, reason None."""
399
+ tracks = [_track("alpha", "o/r", [1])]
400
+ out = build_export(tracks, {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
401
+ tr = out["tracks"][0]
402
+ self.assertFalse(tr["cleanup_candidate"])
403
+ self.assertIsNone(tr["cleanup_reason"])
404
+
405
+ def test_cleanup_candidate_and_reason_exported(self):
406
+ """cleanup_candidate: true + cleanup_reason → both surface in export."""
407
+ t = _track("alpha", "o/r", [1])
408
+ t.meta["cleanup_candidate"] = True
409
+ t.meta["cleanup_reason"] = "superseded by v2"
410
+ out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
411
+ tr = out["tracks"][0]
412
+ self.assertTrue(tr["cleanup_candidate"])
413
+ self.assertEqual(tr["cleanup_reason"], "superseded by v2")
414
+
415
+ def test_cleanup_candidate_without_reason(self):
416
+ """Marked but no reason → candidate True, reason None."""
417
+ t = _track("alpha", "o/r", [1])
418
+ t.meta["cleanup_candidate"] = True
419
+ out = build_export([t], {("o/r", "alpha"): []}, {"o/r": "PRIVATE"}, now="t")
420
+ tr = out["tracks"][0]
421
+ self.assertTrue(tr["cleanup_candidate"])
422
+ self.assertIsNone(tr["cleanup_reason"])
423
+
424
+
384
425
  class BuildExportDependsOnTest(unittest.TestCase):
385
426
  """Tests that depends_on is surfaced in the export JSON (#102)."""
386
427
 
@@ -0,0 +1,88 @@
1
+ # tests/test_mark_cleanup.py
2
+ import io, sys, unittest
3
+ from contextlib import redirect_stdout
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+ from unittest.mock import patch
7
+ SKILL_ROOT = Path(__file__).resolve().parents[1]; sys.path.insert(0, str(SKILL_ROOT))
8
+ from commands import mark_cleanup
9
+ from lib.write_guard import make_token
10
+
11
+
12
+ def _t(name="ph", repo="o/r", meta=None):
13
+ return SimpleNamespace(name=name, repo=repo, path=Path(f"/tmp/{name}.md"),
14
+ has_frontmatter=True,
15
+ meta=meta if meta is not None else {"status": "active", "github": {"repo": repo}},
16
+ body="# b")
17
+
18
+
19
+ def _drive(args, track=None, vis="PRIVATE"):
20
+ track = track or _t()
21
+ with patch("commands.mark_cleanup.load_config", return_value={"notes_root": "/tmp"}), \
22
+ patch("commands.mark_cleanup.discover_tracks", return_value=[track]), \
23
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
24
+ patch("commands.mark_cleanup.write_file") as mw:
25
+ buf = io.StringIO()
26
+ with redirect_stdout(buf):
27
+ rc = mark_cleanup.run(args)
28
+ return rc, mw, buf.getvalue()
29
+
30
+
31
+ class MarkCleanupTest(unittest.TestCase):
32
+ def test_marks_private_track(self):
33
+ rc, mw, out = _drive(["ph"])
34
+ self.assertEqual(rc, 0)
35
+ mw.assert_called_once()
36
+ self.assertIs(mw.call_args[0][1]["cleanup_candidate"], True)
37
+ self.assertIn("marked for cleanup", out)
38
+
39
+ def test_marks_with_reason(self):
40
+ rc, mw, out = _drive(["ph", "--reason=superseded by v2"])
41
+ self.assertEqual(rc, 0)
42
+ mw.assert_called_once()
43
+ written = mw.call_args[0][1]
44
+ self.assertIs(written["cleanup_candidate"], True)
45
+ self.assertEqual(written["cleanup_reason"], "superseded by v2")
46
+ self.assertIn("superseded by v2", out)
47
+
48
+ def test_clear_removes_both_keys(self):
49
+ t = _t(meta={"status": "active", "github": {"repo": "o/r"},
50
+ "cleanup_candidate": True, "cleanup_reason": "old"})
51
+ rc, mw, out = _drive(["ph", "--clear"], track=t)
52
+ self.assertEqual(rc, 0)
53
+ mw.assert_called_once()
54
+ written = mw.call_args[0][1]
55
+ self.assertNotIn("cleanup_candidate", written)
56
+ self.assertNotIn("cleanup_reason", written)
57
+ self.assertIn("cleared", out)
58
+
59
+ def test_clear_when_only_candidate_present(self):
60
+ # --clear must not error when only one of the two keys exists.
61
+ t = _t(meta={"status": "active", "github": {"repo": "o/r"},
62
+ "cleanup_candidate": True})
63
+ rc, mw, out = _drive(["ph", "--clear"], track=t)
64
+ self.assertEqual(rc, 0)
65
+ mw.assert_called_once()
66
+ self.assertNotIn("cleanup_candidate", mw.call_args[0][1])
67
+
68
+ def test_public_blocks_without_confirm(self):
69
+ rc, mw, out = _drive(["ph"], vis="PUBLIC")
70
+ self.assertEqual(rc, 0)
71
+ mw.assert_not_called()
72
+ self.assertIn("needs_confirm", out)
73
+
74
+ def test_public_with_valid_confirm_writes(self):
75
+ tok = make_token("o/r", "ph")
76
+ rc, mw, out = _drive(["ph", f"--confirm={tok}"], vis="PUBLIC")
77
+ self.assertEqual(rc, 0)
78
+ mw.assert_called_once()
79
+ self.assertIs(mw.call_args[0][1]["cleanup_candidate"], True)
80
+
81
+ def test_no_track_arg_is_usage_error(self):
82
+ rc, mw, out = _drive([])
83
+ self.assertEqual(rc, 2)
84
+ mw.assert_not_called()
85
+
86
+
87
+ if __name__ == "__main__":
88
+ unittest.main()
@@ -0,0 +1,77 @@
1
+ """plan-unarchive: restore an archived plan doc (offline; restore primitive
2
+ + repo-root resolution mocked)."""
3
+ import io
4
+ import json
5
+ import sys
6
+ import unittest
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from commands import plan_unarchive
15
+
16
+ ARCHIVED = "docs/plans/archive/shipped/p.md"
17
+
18
+
19
+ def _drive(args, outcome="restored", is_file=True):
20
+ with mock.patch("commands.plan_unarchive.plan_status._resolve_repo_root",
21
+ return_value=Path("/repo")), \
22
+ mock.patch("pathlib.Path.is_file", return_value=is_file), \
23
+ mock.patch("commands.plan_unarchive.archive_lib.restore_from_archive",
24
+ return_value=outcome) as rs:
25
+ buf = io.StringIO()
26
+ with redirect_stdout(buf):
27
+ rc = plan_unarchive.run(args)
28
+ return rc, rs, buf.getvalue()
29
+
30
+
31
+ class PlanUnarchiveTest(unittest.TestCase):
32
+ def test_restores_and_emits_json(self):
33
+ rc, rs, out = _drive(["--repo=r", "--yes", "--json", "--", ARCHIVED])
34
+ self.assertEqual(rc, 0)
35
+ rs.assert_called_once_with(ARCHIVED, Path("/repo"))
36
+ obj = json.loads(out)
37
+ self.assertEqual(obj["action"], "unarchive")
38
+ self.assertEqual(obj["outcome"], "restored")
39
+ # dest strips archive/shipped/ back to the live location.
40
+ self.assertEqual(obj["dest"], "docs/plans/p.md")
41
+
42
+ def test_collision_refused(self):
43
+ rc, rs, out = _drive(["--repo=r", "--yes", "--json", "--", ARCHIVED],
44
+ outcome="skipped_collision")
45
+ self.assertEqual(rc, 0)
46
+ self.assertEqual(json.loads(out)["outcome"], "skipped_collision")
47
+
48
+ def test_restored_local(self):
49
+ rc, rs, out = _drive(["--repo=r", "--yes", "--json", "--", ARCHIVED],
50
+ outcome="restored_local")
51
+ self.assertEqual(json.loads(out)["outcome"], "restored_local")
52
+
53
+ def test_draft_previews_without_moving(self):
54
+ rc, rs, out = _drive(["--repo=r", "--draft", "--", ARCHIVED])
55
+ self.assertEqual(rc, 0)
56
+ rs.assert_not_called()
57
+ self.assertIn("Would restore", out)
58
+
59
+ def test_not_under_archive_errors(self):
60
+ rc, rs, out = _drive(["--repo=r", "--yes", "--", "docs/plans/p.md"])
61
+ self.assertEqual(rc, 1)
62
+ rs.assert_not_called()
63
+
64
+ def test_missing_file_errors(self):
65
+ rc, rs, out = _drive(["--repo=r", "--yes", "--", ARCHIVED], is_file=False)
66
+ self.assertEqual(rc, 1)
67
+ rs.assert_not_called()
68
+
69
+ def test_repo_required(self):
70
+ buf = io.StringIO()
71
+ with redirect_stdout(buf):
72
+ rc = plan_unarchive.run(["--yes", "--", ARCHIVED])
73
+ self.assertEqual(rc, 2)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ unittest.main()
@@ -54,6 +54,7 @@ SUBCOMMANDS = {
54
54
  "--plan-status": "commands.plan_status", # flag-style alias
55
55
  "plan-confirm": "commands.plan_confirm",
56
56
  "plan-archive": "commands.plan_archive",
57
+ "plan-unarchive": "commands.plan_unarchive",
57
58
  "plan-ack": "commands.plan_ack",
58
59
  "plan-baseline": "commands.plan_baseline",
59
60
  "close-issue": "commands.close_issue",
@@ -66,6 +67,10 @@ SUBCOMMANDS = {
66
67
  "set-next-up": "commands.set_next_up",
67
68
  "new-track": "commands.new_track",
68
69
  "rename-track": "commands.rename_track",
70
+ "mark-cleanup": "commands.mark_cleanup",
71
+ "archive-track": "commands.archive_track",
72
+ "unarchive-track": "commands.unarchive_track",
73
+ "delete-track": "commands.delete_track",
69
74
  "set-notes-root": "commands.set_notes_root",
70
75
  "notes-vcs": "commands.notes_vcs",
71
76
  "plan-branch": "commands.plan_branch",
@@ -186,6 +191,22 @@ DESCRIPTIONS = [
186
191
  "Rename an active track's slug: moves the .md file, updates the frontmatter `track` field + last_touched. Resolve <old-slug> with track@repo or --repo when ambiguous. 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 (else prints a 'commit to share' hint). --fix-refs rewrites sibling tracks' depends_on that reference the old slug (otherwise they're just warned about). Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token>.",
187
192
  "When a project pivots, a track name turns out misleading, or a slug needs norming — instead of hand-editing the file + frontmatter. Archived tracks are immutable (not renamable).",
188
193
  "/work-plan rename-track old-project-name new-project-name"),
194
+ ("mark-cleanup", "<track | track@repo> [--repo=<key>] [--clear] [--reason=<text>] [--confirm=<token>]",
195
+ "Flag a track as a cleanup candidate by writing `cleanup_candidate: true` (and an optional `cleanup_reason`) into its frontmatter — a lightweight earmark that `hygiene` surfaces so stale/consolidatable tracks don't get lost. --clear removes both keys. Gates on public repos — prints {needs_confirm, token} and exits cleanly; re-run with --confirm=<token>.",
196
+ "When you notice a track is stale, superseded, or ready to retire but aren't closing it yet — mark it so the next hygiene pass reminds you.",
197
+ "/work-plan mark-cleanup old-experiment --reason='superseded by v2'"),
198
+ ("archive-track", "<track | track@repo> [--repo=<key>] [--confirm=<token>]",
199
+ "Set a track aside REVERSIBLY: move its .md into archive/parked/ so it drops out of the active views but is kept for reference (distinct from `close`, which is terminal). Git-aware — a shared track archives as a staged `git mv` (commit & push to share), a private one as a filesystem move. Never touches GitHub. Gates on public repos. Restore with unarchive-track.",
200
+ "When a track is dormant but you don't want to close it — park it out of the active rotation.",
201
+ "/work-plan archive-track spike-idea"),
202
+ ("unarchive-track", "<track | track@repo> [--repo=<key>] [--confirm=<token>]",
203
+ "Restore an archived track (parked/shipped/abandoned) back into the active set — the inverse of archive-track and of `close`'s move. Git-aware staged/local move; never touches GitHub. Gates on public repos.",
204
+ "When an archived track becomes relevant again — pull it back into the active views.",
205
+ "/work-plan unarchive-track spike-idea"),
206
+ ("delete-track", "<track | track@repo> [--repo=<key>] [--confirm=<token>]",
207
+ "DESTRUCTIVE: remove a track's local .md file. NEVER touches GitHub — the issues it referenced outlive the track. Private tier: the deletion is an undoable notes-vcs commit. Shared tier: a staged `git rm` you commit & push (recoverable from git history until then). Gates on public repos (the VS Code viewer adds a hard modal + type-to-confirm for shared tracks). Prefer archive-track for a reversible set-aside.",
208
+ "When a track is genuinely dead and you want it gone (not just archived) — its GitHub issues are unaffected.",
209
+ "/work-plan delete-track abandoned-spike"),
189
210
  ("plan-status", "[--repo=<key>] [--json] [--stamp [--draft]] [--llm [--apply]] [--archive | --issues] [--draft] [--since-days=N] [--type=plan|spec]",
190
211
  "Reach a verdict on every plan/spec doc in a repo by correlating each plan's declared file-manifest (Create/Modify/Test paths) against the filesystem + git — not the unreliable checkboxes. Read-only: reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. --json for machine output. Add --stamp to write each verdict into its doc as an idempotent status header (--draft previews without writing). Add --llm for a two-step AI pass that judges prose/ambiguous docs (writes a prompt; you save JSON to the cache; re-run with --llm --apply). --archive moves dead plans to archive/abandoned/ (gated); --issues opens a GitHub issue per partial plan listing its unsatisfied files (gated). Both honor --draft.",
191
212
  "When you point at a repo and need to know what's actually done vs. half-done vs. dead among accumulated plans. Run from inside the repo, or use --repo=<key> for a configured one.",
@@ -198,6 +219,10 @@ DESCRIPTIONS = [
198
219
  "Archive ONE plan/spec doc whose effective verdict is shipped: history-preserving `git mv` into archive/shipped/. Refuses non-shipped docs; skips (never overwrites) a name collision. --draft previews; --yes skips the prompt for non-interactive callers (the VS Code viewer); --json emits a single {action,rel,outcome,dest} object. `<rel>` is the repo-relative doc path from `plan-status --json`.",
199
220
  "When a shipped plan clutters the active plans view and you want to move it to archive/shipped/ in one step — with full git history preserved.",
200
221
  "/work-plan plan-archive --repo=myproject -- docs/superpowers/plans/2026-03-16-idea-mode-ui.md"),
222
+ ("plan-unarchive", "--repo=<key> [--draft] [--yes] [--json] -- <rel>",
223
+ "Restore ONE archived plan/spec doc back OUT of archive/<kind>/ to its live location — the inverse of plan-archive (#388). Refuses (never overwrites) a collision with a live doc of the same name. Git-aware staged/local move. --draft previews; --yes / --json for non-interactive callers. `<rel>` is the archived doc's repo-relative path (from `plan-status --json --include-archived`).",
224
+ "When a plan was archived by mistake, or a shipped plan needs more work — pull it back into the live plans set without a hand-run git mv.",
225
+ "/work-plan plan-unarchive --repo=myproject -- docs/superpowers/plans/archive/shipped/2026-03-16-idea-mode-ui.md"),
201
226
  ("plan-ack", "--repo=<key> [--clear] [--confirm=<token>] -- <rel>",
202
227
  "Persist an acknowledgment into ONE plan/spec doc's YAML **frontmatter only** (`acknowledged: true`) — never the body/manifest/checkboxes/banner (#286). Unlike the VS Code viewer's default ack (per-machine, ephemeral `workspaceState`), this is durable + shared: it's committed with the repo, and `plan-status` reads it back to demote the doc. `<rel>` is the repo-relative doc path. Public-repo gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`). `--clear` removes it.",
203
228
  "When you want a 'stop flagging this plan' that sticks across machines and teammates, not just on your laptop.",