@stylusnexus/work-plan 2026.6.24 → 2026.7.10
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 +6 -0
- package/VERSION +1 -1
- package/bin/work-plan +8 -0
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +5 -0
- package/skills/work-plan/commands/archive_track.py +67 -0
- package/skills/work-plan/commands/brief.py +16 -1
- 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_unarchive.py +87 -0
- package/skills/work-plan/commands/unarchive_track.py +64 -0
- package/skills/work-plan/lib/archive.py +32 -1
- package/skills/work-plan/lib/export_model.py +8 -0
- package/skills/work-plan/lib/git_state.py +10 -0
- package/skills/work-plan/lib/render.py +16 -0
- package/skills/work-plan/tests/test_archive_lib.py +52 -0
- package/skills/work-plan/tests/test_archive_track.py +100 -0
- package/skills/work-plan/tests/test_brief_numeric_refs.py +36 -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_unarchive.py +77 -0
- package/skills/work-plan/work_plan.py +25 -0
package/README.md
CHANGED
|
@@ -503,6 +503,7 @@ The bundled `notes/` folder stays empty until you run `/work-plan init-repo <key
|
|
|
503
503
|
- **AI subcommands (`group`, `suggest-priorities`) send issue titles to Claude** via Claude Code's existing integration. Body content, code, and PR contents are NOT sent. If your repo is private and you're cautious about what reaches the model, skip these subcommands.
|
|
504
504
|
- **`init-repo` writes to your config via `yq -i`.** Inputs are JSON-encoded before being passed to `yq`, so a maliciously crafted `--github=` value can't break out of the YAML edit.
|
|
505
505
|
- **`install.sh` / `install.ps1` only touch user-owned dirs.** No `sudo`, no system-wide changes, no privilege escalation.
|
|
506
|
+
- **Least-privilege tool access.** The `work-plan` and `repo-activity-summary` skills declare `allowed-tools` frontmatter, so Claude Code grants them a scoped allowlist — `work-plan` gets `Bash(work-plan:*)`, `Bash(python3:*)`, and `Write` (the two-step AI subcommands' JSON cache); `repo-activity-summary` gets `Bash(gh:*)` — rather than unrestricted shell.
|
|
506
507
|
|
|
507
508
|
For vulnerability reporting, threat model, and past advisories, see [SECURITY.md](./SECURITY.md).
|
|
508
509
|
|
|
@@ -529,6 +530,10 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
529
530
|
| `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
531
|
| `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
532
|
| `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. |
|
|
533
|
+
| `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). |
|
|
534
|
+
| `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). |
|
|
535
|
+
| `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). |
|
|
536
|
+
| `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
537
|
| `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
538
|
| `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
539
|
| `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 +549,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
544
549
|
| `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
550
|
| `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
551
|
| `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). |
|
|
552
|
+
| `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
553
|
| `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
554
|
| `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
555
|
| `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.10+92f8eef
|
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.10",
|
|
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"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: work-plan
|
|
3
3
|
description: Use when starting or ending a work session across many GitHub issues, switching between parallel agent sessions on different workstreams, re-orienting on what to do next, sweeping for stale tracking state, or bootstrapping a new repo into a daily-planning system.
|
|
4
4
|
argument-hint: "[brief|handoff|orient|reconcile|hygiene|--help]"
|
|
5
|
+
allowed-tools: Bash(work-plan:*), Bash(python3:*), Write
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Work Plan
|
|
@@ -18,6 +19,9 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
|
|
|
18
19
|
| `/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
20
|
| `/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
21
|
| `/work-plan close [track]` | Track is done (shipped) / paused (parked) / won't ship (abandoned). |
|
|
22
|
+
| `/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. |
|
|
23
|
+
| `/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". |
|
|
24
|
+
| `/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
25
|
| `/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
26
|
| `/work-plan list [--all]` | List active tracks (or all including parked/archived). |
|
|
23
27
|
| `/work-plan init <path>` | Add frontmatter to a new track .md file. |
|
|
@@ -31,6 +35,7 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
|
|
|
31
35
|
| `/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
36
|
| `/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
37
|
| `/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`. |
|
|
38
|
+
| `/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
39
|
| `/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
40
|
| `/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
41
|
|
|
@@ -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
|
|
@@ -24,6 +24,20 @@ from lib.drift import detect_drift
|
|
|
24
24
|
from lib.render import time_aware_framing, render_track_row, render_archived_reopen
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _numeric_refs(*ref_lists) -> list:
|
|
28
|
+
"""Union of the given ref lists, keeping only int issue numbers, sorted.
|
|
29
|
+
|
|
30
|
+
`next_up` legitimately holds non-issue string tokens (epic/track names like
|
|
31
|
+
`golden-path-v2`) alongside issue numbers. Those aren't fetchable GitHub
|
|
32
|
+
issues, and `sorted()` can't order a set that mixes str and int (#417), so
|
|
33
|
+
string tokens are dropped from the fetch set.
|
|
34
|
+
"""
|
|
35
|
+
refs: set = set()
|
|
36
|
+
for lst in ref_lists:
|
|
37
|
+
refs |= set(lst or [])
|
|
38
|
+
return sorted(n for n in refs if isinstance(n, int))
|
|
39
|
+
|
|
40
|
+
|
|
27
41
|
def run(args: list[str]) -> int:
|
|
28
42
|
flags, _ = parse_flags(args, {"--repo"})
|
|
29
43
|
repo_arg = flags.get("--repo")
|
|
@@ -137,7 +151,8 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
|
|
|
137
151
|
stored_next_up = meta.get("next_up") or []
|
|
138
152
|
# Fetch state for stored next_up issues even if they're not in github.issues,
|
|
139
153
|
# so stale closed entries surface as a clear signal rather than vanishing.
|
|
140
|
-
|
|
154
|
+
# Only numeric refs are fetchable; string tokens in next_up are dropped (#417).
|
|
155
|
+
fetch_nums = _numeric_refs(issue_nums, stored_next_up)
|
|
141
156
|
issues = fetch_issues(repo, fetch_nums) if (repo and fetch_nums) else []
|
|
142
157
|
issues_by_num = {i["number"]: i for i in issues}
|
|
143
158
|
|
|
@@ -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
|