@stylusnexus/work-plan 2026.6.22 → 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 +8 -1
- package/VERSION +1 -1
- package/bin/work-plan +8 -0
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +5 -1
- package/skills/work-plan/commands/archive_track.py +67 -0
- package/skills/work-plan/commands/delete_track.py +92 -0
- package/skills/work-plan/commands/export.py +12 -3
- package/skills/work-plan/commands/hygiene.py +27 -0
- package/skills/work-plan/commands/mark_cleanup.py +78 -0
- package/skills/work-plan/commands/plan_archive.py +7 -2
- package/skills/work-plan/commands/plan_status.py +14 -7
- package/skills/work-plan/commands/plan_unarchive.py +87 -0
- package/skills/work-plan/commands/unarchive_track.py +64 -0
- package/skills/work-plan/lib/archive.py +57 -11
- package/skills/work-plan/lib/export_model.py +8 -0
- package/skills/work-plan/lib/git_state.py +22 -0
- package/skills/work-plan/lib/render.py +16 -0
- package/skills/work-plan/tests/test_archive_lib.py +108 -14
- package/skills/work-plan/tests/test_archive_track.py +100 -0
- package/skills/work-plan/tests/test_delete_track.py +108 -0
- package/skills/work-plan/tests/test_export.py +41 -0
- package/skills/work-plan/tests/test_mark_cleanup.py +88 -0
- package/skills/work-plan/tests/test_plan_archive.py +3 -1
- package/skills/work-plan/tests/test_plan_status_archive.py +5 -1
- package/skills/work-plan/tests/test_plan_status_archive_shipped.py +3 -1
- package/skills/work-plan/tests/test_plan_unarchive.py +77 -0
- package/skills/work-plan/work_plan.py +25 -0
package/README.md
CHANGED
|
@@ -314,6 +314,8 @@ Install it from either registry:
|
|
|
314
314
|
|
|
315
315
|
The extension **shells out to the `work-plan` CLI**, so install the CLI too (npm or any method above). If `work-plan` isn't on your editor's `PATH` — common when VS Code is launched from the Dock/Finder rather than a terminal — set **`workPlan.cliPath`** in Settings to an absolute launcher path (e.g. `/path/to/work-plan-toolkit/bin/work-plan`, or the npm global bin), then reload the window. Extensions auto-update from the registry.
|
|
316
316
|
|
|
317
|
+
> **Windows + WSL (and Remote-SSH / dev containers): install the CLI where the extension *runs*, not where you clicked.** When you open a folder in WSL — e.g. `code .` from a WSL shell, or **Reopen in WSL** (the window shows **`WSL: <distro>`** at the bottom-left) — VS Code runs the extension *inside WSL*, so it looks for `work-plan`, `gh`, `python3`, and `yq` on the **WSL** `PATH`. A CLI installed on **Windows-native** is invisible to it, and you'll see a **"work-plan CLI not found"** banner (or, on older builds, a misleading "Not signed in to GitHub" one) even though `gh` is signed in. Fix: open the **WSL** terminal and run `npm install -g @stylusnexus/work-plan` (plus `gh`, `python3`, `yq`) there, then **reload the window**. The same rule applies to Remote-SSH and dev containers — the CLI must live in the remote, not on your local machine. (`gh auth` is likewise per-environment: `gh auth login` inside WSL authenticates the WSL `gh`, which is what the extension reads.)
|
|
318
|
+
|
|
317
319
|
Useful settings:
|
|
318
320
|
|
|
319
321
|
| Setting | Default | What it does |
|
|
@@ -527,6 +529,10 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
527
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`. |
|
|
528
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. |
|
|
529
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). |
|
|
530
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. |
|
|
531
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. |
|
|
532
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. |
|
|
@@ -541,7 +547,8 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
541
547
|
| `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. |
|
|
542
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`. |
|
|
543
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. |
|
|
544
|
-
| `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. |
|
|
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`). |
|
|
545
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. |
|
|
546
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. |
|
|
547
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.
|
|
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.
|
|
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. |
|
|
@@ -30,7 +33,8 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
|
|
|
30
33
|
| `/work-plan duplicates [--min-similarity=0.7]` | Find likely-duplicate issues by title similarity (stdlib difflib). |
|
|
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
|
-
| `/work-plan plan-archive --repo=<key> [--draft] -- <rel>` | **Archive a shipped plan.** Moves a doc whose effective verdict is shipped into `archive/shipped/`
|
|
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
|
|
@@ -76,11 +76,16 @@ def run(args: list) -> int:
|
|
|
76
76
|
|
|
77
77
|
outcome = archive_lib.move_to_archive(rel, repo_root, "shipped")
|
|
78
78
|
if outcome is None:
|
|
79
|
-
print(f"ERROR:
|
|
79
|
+
print(f"ERROR: archive move failed for {rel}", file=sys.stderr)
|
|
80
80
|
return 1
|
|
81
81
|
if outcome == "skipped_collision":
|
|
82
82
|
_emit(as_json, rel, "skipped_collision", dest,
|
|
83
83
|
f"destination already exists: {dest} — skipped")
|
|
84
84
|
return 0
|
|
85
|
-
|
|
85
|
+
if outcome == "archived_local":
|
|
86
|
+
_emit(as_json, rel, "archived_local", dest,
|
|
87
|
+
f"✓ archived {rel} -> {dest} (moved on disk; not git-tracked)")
|
|
88
|
+
return 0
|
|
89
|
+
_emit(as_json, rel, "archived", dest,
|
|
90
|
+
f"✓ archived {rel} -> {dest} (staged rename — commit & push to share)")
|
|
86
91
|
return 0
|
|
@@ -356,11 +356,14 @@ def _archive_dead(docs, rows, repo_root, draft: bool) -> int:
|
|
|
356
356
|
moved = 0
|
|
357
357
|
for r in dead:
|
|
358
358
|
dest = reconcile_actions.archive_dest(r["rel"])
|
|
359
|
-
|
|
359
|
+
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "abandoned")
|
|
360
|
+
if outcome in ("archived", "archived_local"):
|
|
360
361
|
moved += 1
|
|
361
|
-
|
|
362
|
+
suffix = " (moved on disk; not git-tracked)" if outcome == "archived_local" else ""
|
|
363
|
+
print(f" ✓ {r['rel']}{suffix}")
|
|
362
364
|
else:
|
|
363
|
-
|
|
365
|
+
reason = "already exists at destination" if outcome == "skipped_collision" else "move failed"
|
|
366
|
+
print(f" ✗ {r['rel']} ({reason})")
|
|
364
367
|
print(f"Archived {moved}/{len(dead)}.")
|
|
365
368
|
return 0
|
|
366
369
|
|
|
@@ -379,8 +382,9 @@ def _archive_shipped(rows, repo_root, draft, as_json, include_lie_gap, yes=False
|
|
|
379
382
|
for r in targets:
|
|
380
383
|
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
|
|
381
384
|
dest = reconcile_actions.archive_dest(r["rel"], "shipped")
|
|
382
|
-
if outcome
|
|
383
|
-
archived.append({"rel": r["rel"], "dest": dest
|
|
385
|
+
if outcome in ("archived", "archived_local"):
|
|
386
|
+
archived.append({"rel": r["rel"], "dest": dest,
|
|
387
|
+
"outcome": outcome})
|
|
384
388
|
elif outcome == "skipped_collision":
|
|
385
389
|
skipped.append({"rel": r["rel"], "reason": "collision"})
|
|
386
390
|
print(json.dumps({
|
|
@@ -413,10 +417,13 @@ def _archive_shipped(rows, repo_root, draft, as_json, include_lie_gap, yes=False
|
|
|
413
417
|
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
|
|
414
418
|
if outcome == "archived":
|
|
415
419
|
archived += 1
|
|
416
|
-
print(f" ✓ {r['rel']}")
|
|
420
|
+
print(f" ✓ {r['rel']} (staged rename — commit & push to share)")
|
|
421
|
+
elif outcome == "archived_local":
|
|
422
|
+
archived += 1
|
|
423
|
+
print(f" ✓ {r['rel']} (moved on disk; not git-tracked)")
|
|
417
424
|
else:
|
|
418
425
|
skipped += 1
|
|
419
|
-
print(f" ✗ {r['rel']} ({outcome or '
|
|
426
|
+
print(f" ✗ {r['rel']} ({outcome or 'move failed'})")
|
|
420
427
|
print(f"Archived {archived}, skipped {skipped}.")
|
|
421
428
|
return 0
|
|
422
429
|
|
|
@@ -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
|