@stylusnexus/work-plan 2026.6.18 → 2026.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -537,8 +537,9 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
537
537
  | `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews the label drift (ADDs/MOVEs/FLAGs) without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
538
538
  | `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
539
539
  | `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. |
540
- | `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. |
540
+ | `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`. |
541
541
  | `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. |
542
+ | `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. |
542
543
  | `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. |
543
544
  | `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. |
544
545
  | `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. |
@@ -564,6 +565,8 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
564
565
 
565
566
  **The Plans view can write to plan-doc frontmatter — and only frontmatter.** Right-click a plan in the viewer → **Confirm Verdict…** / **Clear Confirmation** (writes `verdict_override`), **Acknowledge & Save to Doc** / **Clear Saved Acknowledgment** (writes `acknowledged`), or **Stamp Baseline — Watch for Drift** / **Clear Baseline** (writes `verdict_baseline`) drives the matching CLI write behind a mandatory modal that names the exact file and states the write touches only the doc's YAML frontmatter — never its prose body, checkboxes, or declared-file manifest. These are the only viewer-initiated writes to a plan doc, each touches one frontmatter key and nothing else, and on a public repo each additionally passes through the public-repo confirm modal above. The default **Acknowledge (stop flagging)** remains per-machine and writes nothing to git; **Acknowledge & Save to Doc** is the opt-in durable, shared variant. Everything else the Plans view does (scan, local acknowledge) remains read-only on git.
566
567
 
568
+ Archived shipped plans (`plan-archive` / `plan-status --archive-shipped`) move into `archive/shipped/` and drop out of the live verdict buckets; the viewer surfaces them in a collapsed **"Archived (N)"** node per repo. Right-click a shipped plan → **Archive Plan…**, or a repo → **Archive shipped plans…**.
569
+
567
570
  ## Version
568
571
 
569
572
  ```bash
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.18+64ae461
1
+ 2026.06.21+4cab67a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.18",
3
+ "version": "2026.6.21",
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"
@@ -30,6 +30,7 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
30
30
  | `/work-plan duplicates [--min-similarity=0.7]` | Find likely-duplicate issues by title similarity (stdlib difflib). |
31
31
  | `/work-plan plan-status [--repo=<key>] [--stamp [--draft]] [--type=plan\|spec]` | **Doc/plan liveness.** "Which of my plan/spec docs actually shipped, half-shipped, or died?" Correlates each plan's declared file-manifest (Create/Modify/Test paths) against git + filesystem — not the unreliable checkboxes. Reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. Read-only by default; `--stamp` writes an idempotent status header into each doc (`--draft` previews, writes nothing). Natural-language triggers: "what's done vs unfinished in `<repo>`", "stamp the plan statuses", "which plans are stale/dead". |
32
32
  | `/work-plan plan-confirm --repo=<key> --verdict=shipped\|partial\|dead [--clear] -- <rel>` | **Affirm a human verdict** on one plan doc by writing `verdict_override` into its **frontmatter only** (never body/checkboxes/manifest). `plan-status` then pins that verdict and silences the "shipped but boxes unchecked" lie-gap. Use when a genuinely-shipped plan is flagged red only because nobody ticked its phase checkboxes — confirm it instead of hand-ticking boxes. Public-repo gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`). Natural-language triggers: "that plan really did ship, stop flagging it", "mark `<plan>` as shipped/dead". |
33
+ | `/work-plan plan-archive --repo=<key> [--draft] -- <rel>` | **Archive a shipped plan.** Moves a doc whose effective verdict is shipped into `archive/shipped/` (`git mv`). Refuses non-shipped; skips collisions. Natural-language triggers: "archive this finished plan", "file away the shipped plans". Bulk: `plan-status --archive-shipped`. |
33
34
  | `/work-plan plan-ack --repo=<key> [--clear] -- <rel>` | **Durably acknowledge** one plan doc by writing `acknowledged: true` into its **frontmatter only** — a "stop flagging this" that's committed + shared (vs the viewer's per-machine ack). `plan-status` reads it back and demotes the doc. Public-repo gated. Natural-language triggers: "stop flagging this plan for everyone", "acknowledge `<plan>` for good". |
34
35
  | `/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". |
35
36
 
@@ -100,6 +100,7 @@ Rules:
100
100
  def run(args: list[str]) -> int:
101
101
  apply_mode = "--apply" in args
102
102
  heuristic = "--heuristic" in args
103
+ json_mode = "--json" in args
103
104
  repo_arg = next((a for a in args if a.startswith("--repo=")), None)
104
105
 
105
106
  limit = 100
@@ -153,7 +154,14 @@ def run(args: list[str]) -> int:
153
154
  and t.meta.get("status") in ("active", "in-progress", "blocked")
154
155
  ]
155
156
  if not active_tracks:
156
- print(f"No active tracks found for {repo}. Run /work-plan group first.")
157
+ # --json callers (the VS Code viewer) parse stdout as JSON, so this
158
+ # informational early-exit must stay machine-readable rather than a bare
159
+ # human line (which crashed Suggest Tracks with a JSON parse error).
160
+ if json_mode:
161
+ print(json.dumps({"repo": repo, "folder": folder,
162
+ "tracks": [], "untracked": [], "note": "no_active_tracks"}))
163
+ else:
164
+ print(f"No active tracks found for {repo}. Run /work-plan group first.")
157
165
  return 0
158
166
 
159
167
  # Build per-repo set of already-tracked issue numbers
@@ -168,7 +176,11 @@ def run(args: list[str]) -> int:
168
176
  untracked = [i for i in open_issues if i.get("number") not in tracked_nums]
169
177
 
170
178
  if not untracked:
171
- print(f"No untracked issues found for {repo} — full coverage!")
179
+ if json_mode:
180
+ print(json.dumps({"repo": repo, "folder": folder,
181
+ "tracks": [], "untracked": [], "note": "full_coverage"}))
182
+ else:
183
+ print(f"No untracked issues found for {repo} — full coverage!")
172
184
  return 0
173
185
 
174
186
  batch_id = _make_batch_id(repo)
@@ -0,0 +1,86 @@
1
+ """plan-archive — move ONE plan/spec doc whose effective verdict is `shipped`
2
+ into archive/shipped/ (history-preserving git mv).
3
+
4
+ Eligibility uses the same `_evaluate` machinery as plan-status, so an
5
+ override-confirmed or lie-gap shipped doc qualifies (its effective verdict is
6
+ shipped). Non-shipped docs are refused (no move). A name collision in the
7
+ archive dir is skipped, never overwritten.
8
+
9
+ Interactivity: behind a y/N prompt by default (terminal use). `--yes` skips the
10
+ prompt for non-interactive callers (the VS Code viewer) — required because
11
+ lib/prompts.py defaults to "no" on non-TTY stdin. `--json` emits a single
12
+ JSON-only object (no prompts/human lines) so the viewer can parse the outcome.
13
+
14
+ Usage:
15
+ work_plan.py plan-archive --repo=<key> [--draft] [--yes] [--json] -- <rel>
16
+ """
17
+ import json
18
+ import sys
19
+ from datetime import date
20
+
21
+ from commands import plan_status
22
+ from lib import archive as archive_lib
23
+ from lib import doc_discovery, reconcile_actions, verdict as verdict_mod
24
+ from lib.prompts import parse_flags, prompt_yes_no
25
+
26
+ KNOWN = {"--repo", "--draft", "--yes", "--json"}
27
+
28
+
29
+ def _emit(as_json: bool, rel: str, outcome: str, dest, human: str) -> None:
30
+ if as_json:
31
+ print(json.dumps({"action": "archive", "rel": rel,
32
+ "outcome": outcome, "dest": dest}))
33
+ else:
34
+ print(human)
35
+
36
+
37
+ def run(args: list) -> int:
38
+ flags, positional = parse_flags(args, KNOWN)
39
+ if not flags.get("--repo") or flags.get("--repo") is True:
40
+ print("ERROR: --repo=<key> is required.", file=sys.stderr)
41
+ return 2
42
+ if not positional:
43
+ print("usage: work_plan.py plan-archive --repo=<key> [--draft] [--yes] "
44
+ "[--json] -- <rel>", file=sys.stderr)
45
+ return 2
46
+ rel = positional[0]
47
+ as_json = bool(flags.get("--json"))
48
+
49
+ repo_root = plan_status._resolve_repo_root(flags)
50
+ docs = doc_discovery.discover_docs(repo_root)
51
+ doc = next((d for d in docs if d.rel == rel), None)
52
+ if doc is None:
53
+ print(f"ERROR: '{rel}' is not a discovered plan doc inside {repo_root}",
54
+ file=sys.stderr)
55
+ return 1
56
+
57
+ today = date.today()
58
+ stall_days = plan_status._resolve_stall_days(flags)
59
+ row = plan_status._evaluate(doc, repo_root, today, verdict_mod.DEAD_DAYS, stall_days)
60
+
61
+ dest = reconcile_actions.archive_dest(rel, "shipped")
62
+ if row["verdict"] != "shipped":
63
+ _emit(as_json, rel, "refused_not_shipped", None,
64
+ f"✗ {rel} is '{row['verdict']}', not shipped — not archived.")
65
+ return 0
66
+
67
+ if flags.get("--draft"):
68
+ # Human preview only; the viewer never combines --draft with --json.
69
+ print(f"Would archive {rel} -> {dest}")
70
+ return 0
71
+
72
+ if not flags.get("--yes"):
73
+ if not prompt_yes_no(f"Archive {rel} -> {dest}? [y/N]"):
74
+ print("Skipped.")
75
+ return 0
76
+
77
+ outcome = archive_lib.move_to_archive(rel, repo_root, "shipped")
78
+ if outcome is None:
79
+ print(f"ERROR: git mv failed for {rel}", file=sys.stderr)
80
+ return 1
81
+ if outcome == "skipped_collision":
82
+ _emit(as_json, rel, "skipped_collision", dest,
83
+ f"destination already exists: {dest} — skipped")
84
+ return 0
85
+ _emit(as_json, rel, "archived", dest, f"✓ archived {rel} -> {dest}")
86
+ return 0
@@ -16,11 +16,13 @@ from lib import verdict as verdict_mod
16
16
  from lib import status_header
17
17
  from lib import llm_evidence
18
18
  from lib import reconcile_actions
19
+ from lib import archive as archive_lib
19
20
  from lib.scratch import cache_dir
20
21
  from lib.prompts import parse_flags, prompt_yes_no
21
22
 
22
23
  KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
23
- "--llm", "--apply", "--archive", "--issues", "--stall-days"}
24
+ "--llm", "--apply", "--archive", "--issues", "--stall-days",
25
+ "--include-archived", "--archive-shipped", "--include-lie-gap", "--yes"}
24
26
  _ORDER = ["shipped", "partial", "dead", "foreign", "manifest-less"]
25
27
 
26
28
  # Human verdict-override (#286): a reviewer affirms the verdict in the doc's
@@ -166,6 +168,7 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
166
168
  verdict_drift = bool(baseline) and not override and baseline != v.label
167
169
  return {
168
170
  "rel": doc.rel, "kind": doc.kind,
171
+ "archived": doc.archived, "archive_kind": doc.archive_kind,
169
172
  "verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
170
173
  "files_present": score.satisfied, "files_declared": score.total,
171
174
  "checkboxes_done": done, "checkboxes_total": total_chk,
@@ -193,7 +196,7 @@ evaluate_doc = _evaluate
193
196
 
194
197
  def _render(rows, repo_root) -> None:
195
198
  print(f"# plan-status — {repo_root}\n")
196
- by = {}
199
+ by: dict = {}
197
200
  for r in rows:
198
201
  by.setdefault(r["verdict"], []).append(r)
199
202
  lie_gap = sum(1 for r in rows if r["lie_gap"])
@@ -330,6 +333,62 @@ def _archive_dead(docs, rows, repo_root, draft: bool) -> int:
330
333
  return 0
331
334
 
332
335
 
336
+ def _archive_shipped(rows, repo_root, draft, as_json, include_lie_gap, yes=False):
337
+ """Batch-archive every effective-shipped LIVE doc into archive/shipped/.
338
+ Lie-gap docs are excluded unless include_lie_gap; collisions are skipped."""
339
+ live = [r for r in rows if not r.get("archived")]
340
+ targets = reconcile_actions.shipped_rows(live, include_lie_gap=include_lie_gap)
341
+ excluded = [r for r in reconcile_actions.shipped_rows(live, include_lie_gap=True)
342
+ if r.get("lie_gap") and not include_lie_gap]
343
+
344
+ if as_json:
345
+ archived, skipped = [], []
346
+ if not draft:
347
+ for r in targets:
348
+ outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
349
+ dest = reconcile_actions.archive_dest(r["rel"], "shipped")
350
+ if outcome == "archived":
351
+ archived.append({"rel": r["rel"], "dest": dest})
352
+ elif outcome == "skipped_collision":
353
+ skipped.append({"rel": r["rel"], "reason": "collision"})
354
+ print(json.dumps({
355
+ "action": "archive_shipped",
356
+ "archived": archived,
357
+ "skipped": skipped,
358
+ "lie_gap_excluded": [{"rel": r["rel"]} for r in excluded],
359
+ }))
360
+ return 0
361
+
362
+ if not targets:
363
+ print("No shipped plans to archive.")
364
+ if excluded:
365
+ print(f"({len(excluded)} unverified lie-gap shipped — "
366
+ f"pass --include-lie-gap to include.)")
367
+ return 0
368
+ print(f"\n{'Would archive' if draft else 'Archive'} {len(targets)} shipped plan(s):")
369
+ for r in targets:
370
+ print(f" {r['rel']} -> {reconcile_actions.archive_dest(r['rel'], 'shipped')}")
371
+ if excluded:
372
+ print(f" (excluding {len(excluded)} unverified lie-gap; "
373
+ f"--include-lie-gap to include)")
374
+ if draft:
375
+ return 0
376
+ if not yes and not prompt_yes_no(f"Move {len(targets)} plan(s) to archive/shipped/? [y/N]"):
377
+ print("Skipped.")
378
+ return 0
379
+ archived = skipped = 0
380
+ for r in targets:
381
+ outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
382
+ if outcome == "archived":
383
+ archived += 1
384
+ print(f" ✓ {r['rel']}")
385
+ else:
386
+ skipped += 1
387
+ print(f" ✗ {r['rel']} ({outcome or 'git mv failed'})")
388
+ print(f"Archived {archived}, skipped {skipped}.")
389
+ return 0
390
+
391
+
333
392
  def _repo_slug(flags):
334
393
  """Resolve the org/repo GitHub slug for the --repo key (for issue creation)."""
335
394
  repo = flags.get("--repo")
@@ -386,7 +445,7 @@ def run(args: list) -> int:
386
445
  flags, _ = parse_flags(args, KNOWN)
387
446
  repo_root = _resolve_repo_root(flags)
388
447
  raw_days = flags.get("--since-days")
389
- if raw_days in (None, True):
448
+ if not isinstance(raw_days, str):
390
449
  dead_days = verdict_mod.DEAD_DAYS
391
450
  else:
392
451
  try:
@@ -396,7 +455,8 @@ def run(args: list) -> int:
396
455
  return 2
397
456
  today = date.today()
398
457
 
399
- docs = doc_discovery.discover_docs(repo_root)
458
+ docs = doc_discovery.discover_docs(
459
+ repo_root, include_archived=bool(flags.get("--include-archived")))
400
460
  type_filter = flags.get("--type")
401
461
  if type_filter and type_filter is not True:
402
462
  docs = [d for d in docs if d.kind == type_filter]
@@ -414,6 +474,13 @@ def run(args: list) -> int:
414
474
  if flags.get("--archive"):
415
475
  return _archive_dead(docs, rows, repo_root, draft=bool(flags.get("--draft")))
416
476
 
477
+ if flags.get("--archive-shipped"):
478
+ return _archive_shipped(rows, repo_root,
479
+ draft=bool(flags.get("--draft")),
480
+ as_json=bool(flags.get("--json")),
481
+ include_lie_gap=bool(flags.get("--include-lie-gap")),
482
+ yes=bool(flags.get("--yes")))
483
+
417
484
  if flags.get("--issues"):
418
485
  return _issues_for_partials(docs, rows, repo_root, _repo_slug(flags),
419
486
  draft=bool(flags.get("--draft")))
@@ -421,7 +488,14 @@ def run(args: list) -> int:
421
488
  if flags.get("--json"):
422
489
  print(json.dumps({"repo": str(repo_root), "docs": rows}, indent=2))
423
490
  return 0
424
- _render(rows, repo_root)
491
+ live_rows = [r for r in rows if not r.get("archived")]
492
+ _render(live_rows, repo_root)
493
+ n_shipped = len(reconcile_actions.shipped_rows(live_rows, include_lie_gap=True))
494
+ if n_shipped:
495
+ print(f"\n{n_shipped} shipped — archive with "
496
+ f"'plan-archive -- <rel>' or 'plan-status --archive-shipped'.")
425
497
  if flags.get("--stamp"):
426
- _stamp_docs(docs, rows, draft=bool(flags.get("--draft")))
498
+ live_docs = [d for d in docs if not d.archived]
499
+ _stamp_docs(live_docs, [r for r in rows if not r.get("archived")],
500
+ draft=bool(flags.get("--draft")))
427
501
  return 0
@@ -0,0 +1,21 @@
1
+ """The archive move primitive: collision-checked, history-preserving `git mv`
2
+ of a doc into archive/<kind>/. Eligibility (is-this-shipped) is the caller's
3
+ job — this only moves files."""
4
+ from pathlib import Path
5
+
6
+ from lib import git_state
7
+ from lib import reconcile_actions
8
+
9
+
10
+ def move_to_archive(rel: str, repo_root, kind: str):
11
+ """git-mv `rel` into archive/<kind>/. Returns:
12
+ "archived" on a clean move,
13
+ "skipped_collision" if the destination already exists (never overwrite),
14
+ None if git_mv failed for any other reason (hard error).
15
+ """
16
+ dest = reconcile_actions.archive_dest(rel, kind)
17
+ if (Path(repo_root) / dest).exists():
18
+ return "skipped_collision"
19
+ if git_state.git_mv(rel, dest, repo_root):
20
+ return "archived"
21
+ return None
@@ -9,12 +9,16 @@ DEFAULT_GLOBS = [
9
9
  "docs/plans/*.md",
10
10
  ]
11
11
 
12
+ ARCHIVE_SUBDIRS = [("archive/shipped", "shipped"), ("archive/abandoned", "abandoned")]
13
+
12
14
 
13
15
  @dataclass
14
16
  class Doc:
15
17
  path: Path # absolute
16
18
  rel: str # repo-relative POSIX-style
17
19
  kind: str # "plan" | "spec" | "adhoc"
20
+ archived: bool = False
21
+ archive_kind: Optional[str] = None # "shipped" | "abandoned" | None
18
22
 
19
23
 
20
24
  def classify_kind(rel: str) -> str:
@@ -26,7 +30,8 @@ def classify_kind(rel: str) -> str:
26
30
  return "adhoc"
27
31
 
28
32
 
29
- def discover_docs(repo_root: Path, globs: Optional[list] = None) -> list:
33
+ def discover_docs(repo_root: Path, globs: Optional[list] = None,
34
+ include_archived: bool = False) -> list:
30
35
  globs = globs or DEFAULT_GLOBS
31
36
  repo_root = Path(repo_root)
32
37
  out = []
@@ -38,4 +43,15 @@ def discover_docs(repo_root: Path, globs: Optional[list] = None) -> list:
38
43
  seen.add(p)
39
44
  rel = p.relative_to(repo_root).as_posix()
40
45
  out.append(Doc(path=p, rel=rel, kind=classify_kind(rel)))
46
+ if include_archived:
47
+ for g in globs:
48
+ parent = g.rsplit("/", 1)[0] # "docs/plans/*.md" -> "docs/plans"
49
+ for sub, kind in ARCHIVE_SUBDIRS:
50
+ for p in sorted(repo_root.glob(f"{parent}/{sub}/*.md")):
51
+ if not p.is_file() or p in seen:
52
+ continue
53
+ seen.add(p)
54
+ rel = p.relative_to(repo_root).as_posix()
55
+ out.append(Doc(path=p, rel=rel, kind=classify_kind(rel),
56
+ archived=True, archive_kind=kind))
41
57
  return out
@@ -198,8 +198,8 @@ def _normalize_gql_node(node) -> Optional[dict]:
198
198
  deps_truncated."""
199
199
  if not node:
200
200
  return None
201
- labels = [{"name": l.get("name")} for l in
202
- ((node.get("labels") or {}).get("nodes") or []) if l.get("name")]
201
+ labels = [{"name": lbl.get("name")} for lbl in
202
+ ((node.get("labels") or {}).get("nodes") or []) if lbl.get("name")]
203
203
  assignees = [{"login": a.get("login")} for a in
204
204
  ((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
205
205
  ms = node.get("milestone")
@@ -378,7 +378,7 @@ def fetch_open_issues(repo: str, limit: int = 1000) -> list[dict]:
378
378
  return []
379
379
 
380
380
 
381
- def fetch_recent_issues(repo: str, since_iso: str, extra_labels: list[str] = None) -> list[dict]:
381
+ def fetch_recent_issues(repo: str, since_iso: str, extra_labels: Optional[list[str]] = None) -> list[dict]:
382
382
  """Fetch issues created since `since_iso` (date YYYY-MM-DD)."""
383
383
  if not _valid_repo(repo):
384
384
  return []
@@ -434,7 +434,7 @@ def repo_visibility(repo: str) -> Optional[str]:
434
434
 
435
435
 
436
436
  def extract_priority(labels: list[dict]) -> str:
437
- label_names = {l["name"] for l in labels}
437
+ label_names = {lbl["name"] for lbl in labels}
438
438
  for p in PRIORITY_LABELS:
439
439
  if p in label_names:
440
440
  return p.split("/")[1]
@@ -31,7 +31,7 @@ def strip_range(p: str) -> str:
31
31
 
32
32
  def parse_declared_paths(text: str) -> list:
33
33
  """Extract declared file paths. First kind seen per path wins (dedup)."""
34
- seen = {} # path -> kind
34
+ seen: dict[str, str] = {} # path -> kind
35
35
  for kind, raw in PATH_RE.findall(text):
36
36
  p = strip_range(raw)
37
37
  if "/" not in p: # skip bare tokens / commands
@@ -141,7 +141,8 @@ def score_manifest(
141
141
  offline testing; defaults wire to the filesystem and git.
142
142
  """
143
143
  if exists is None:
144
- exists = lambda rel: (Path(repo_root) / rel).exists()
144
+ def exists(rel):
145
+ return (Path(repo_root) / rel).exists()
145
146
  if committed_since is None:
146
147
  from lib import git_state
147
148
  # Deliberate degradation: with no plan date we can't ask "committed since
@@ -181,7 +182,8 @@ def unsatisfied_paths(
181
182
  Same satisfaction rule and injectable predicates as `score_manifest`.
182
183
  """
183
184
  if exists is None:
184
- exists = lambda rel: (Path(repo_root) / rel).exists()
185
+ def exists(rel):
186
+ return (Path(repo_root) / rel).exists()
185
187
  if committed_since is None:
186
188
  from lib import git_state
187
189
  committed_since = (
@@ -12,10 +12,24 @@ def partial_rows(rows: list) -> list:
12
12
  return [r for r in rows if r["verdict"] == "partial"]
13
13
 
14
14
 
15
- def archive_dest(rel: str) -> str:
16
- """docs/.../plans/x.md -> docs/.../plans/archive/abandoned/x.md"""
15
+ def shipped_rows(rows: list, include_lie_gap: bool = False) -> list:
16
+ """Rows whose EFFECTIVE verdict is shipped. Lie-gap shipped rows are
17
+ excluded unless include_lie_gap=True (so an unverified plan can't be swept
18
+ invisibly inside a batch)."""
19
+ out = []
20
+ for r in rows:
21
+ if r["verdict"] != "shipped":
22
+ continue
23
+ if r.get("lie_gap") and not include_lie_gap:
24
+ continue
25
+ out.append(r)
26
+ return out
27
+
28
+
29
+ def archive_dest(rel: str, kind: str = "abandoned") -> str:
30
+ """docs/.../plans/x.md -> docs/.../plans/archive/<kind>/x.md"""
17
31
  p = PurePosixPath(rel)
18
- return str(p.parent / "archive" / "abandoned" / p.name)
32
+ return str(p.parent / "archive" / kind / p.name)
19
33
 
20
34
 
21
35
  def issue_for(doc, row, unsatisfied) -> tuple:
@@ -0,0 +1,90 @@
1
+ """Pure archive helpers: destination path + shipped-row selection (offline)."""
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ from lib import reconcile_actions as ra
10
+
11
+
12
+ class ArchiveDestTest(unittest.TestCase):
13
+ def test_default_kind_is_abandoned(self):
14
+ self.assertEqual(
15
+ ra.archive_dest("docs/plans/x.md"),
16
+ "docs/plans/archive/abandoned/x.md",
17
+ )
18
+
19
+ def test_shipped_kind(self):
20
+ self.assertEqual(
21
+ ra.archive_dest("docs/plans/x.md", kind="shipped"),
22
+ "docs/plans/archive/shipped/x.md",
23
+ )
24
+
25
+ def test_nested_dir_preserved(self):
26
+ self.assertEqual(
27
+ ra.archive_dest("docs/superpowers/specs/y-design.md", kind="shipped"),
28
+ "docs/superpowers/specs/archive/shipped/y-design.md",
29
+ )
30
+
31
+
32
+ class ShippedRowsTest(unittest.TestCase):
33
+ ROWS = [
34
+ {"rel": "a.md", "verdict": "shipped", "lie_gap": False},
35
+ {"rel": "b.md", "verdict": "shipped", "lie_gap": True}, # lie-gap
36
+ {"rel": "c.md", "verdict": "partial", "lie_gap": False},
37
+ {"rel": "d.md", "verdict": "dead", "lie_gap": False},
38
+ ]
39
+
40
+ def test_excludes_lie_gap_by_default(self):
41
+ rels = [r["rel"] for r in ra.shipped_rows(self.ROWS)]
42
+ self.assertEqual(rels, ["a.md"])
43
+
44
+ def test_includes_lie_gap_when_opted_in(self):
45
+ rels = [r["rel"] for r in ra.shipped_rows(self.ROWS, include_lie_gap=True)]
46
+ self.assertEqual(rels, ["a.md", "b.md"])
47
+
48
+
49
+ import tempfile
50
+ from unittest import mock
51
+ from lib import archive as archive_lib
52
+
53
+
54
+ class MoveToArchiveTest(unittest.TestCase):
55
+ def _repo(self, d):
56
+ root = Path(d)
57
+ (root / "docs/plans").mkdir(parents=True)
58
+ (root / "docs/plans/x.md").write_text("# x")
59
+ return root
60
+
61
+ def test_archived_calls_git_mv_to_shipped_dir(self):
62
+ with tempfile.TemporaryDirectory() as d:
63
+ root = self._repo(d)
64
+ with mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
65
+ outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
66
+ self.assertEqual(outcome, "archived")
67
+ mv.assert_called_once_with(
68
+ "docs/plans/x.md", "docs/plans/archive/shipped/x.md", root)
69
+
70
+ def test_collision_skips_without_calling_git_mv(self):
71
+ with tempfile.TemporaryDirectory() as d:
72
+ root = self._repo(d)
73
+ dest = root / "docs/plans/archive/shipped"
74
+ dest.mkdir(parents=True)
75
+ (dest / "x.md").write_text("# already here")
76
+ with mock.patch("lib.archive.git_state.git_mv") as mv:
77
+ outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
78
+ self.assertEqual(outcome, "skipped_collision")
79
+ mv.assert_not_called()
80
+
81
+ def test_git_mv_failure_returns_none(self):
82
+ with tempfile.TemporaryDirectory() as d:
83
+ root = self._repo(d)
84
+ with mock.patch("lib.archive.git_state.git_mv", return_value=False):
85
+ outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
86
+ self.assertIsNone(outcome)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ unittest.main()
@@ -399,6 +399,40 @@ class AutoTriageHeuristicTest(unittest.TestCase):
399
399
  self.assertEqual(sug["track"], "auth-flow")
400
400
 
401
401
 
402
+ class AutoTriageJsonEarlyExitTest(unittest.TestCase):
403
+ """--json informational early-exits must stay parseable JSON, not human text
404
+ (the bug that crashed Suggest Tracks with 'could not parse auto-triage JSON')."""
405
+
406
+ def test_no_active_tracks_emits_json_note(self):
407
+ cfg = _make_cfg()
408
+ # A parked track → no ACTIVE tracks for the repo.
409
+ parked = _make_track("old", "org/myrepo", [1], status="parked")
410
+ rc, out, _ = _drive_prepare(["--json"], cfg=cfg, tracks=[parked],
411
+ open_issues=_open_issues(2, 3))
412
+ self.assertEqual(rc, 0)
413
+ data = json.loads(out.strip()) # MUST parse — was a bare human line
414
+ self.assertEqual(data["note"], "no_active_tracks")
415
+ self.assertEqual(data["tracks"], [])
416
+
417
+ def test_no_untracked_emits_json_note(self):
418
+ cfg = _make_cfg()
419
+ tracks = [_make_track("auth-flow", "org/myrepo", [1, 2])]
420
+ rc, out, _ = _drive_prepare(["--json"], cfg=cfg, tracks=tracks,
421
+ open_issues=_open_issues(1, 2)) # all tracked
422
+ self.assertEqual(rc, 0)
423
+ data = json.loads(out.strip())
424
+ self.assertEqual(data["note"], "full_coverage")
425
+ self.assertEqual(data["untracked"], [])
426
+
427
+ def test_no_active_tracks_human_text_without_json(self):
428
+ cfg = _make_cfg()
429
+ parked = _make_track("old", "org/myrepo", [1], status="parked")
430
+ rc, out, _ = _drive_prepare([], cfg=cfg, tracks=[parked],
431
+ open_issues=_open_issues(2, 3))
432
+ self.assertEqual(rc, 0)
433
+ self.assertIn("group", out) # the human guidance still shows
434
+
435
+
402
436
  class AutoTriageV2AnswersTest(unittest.TestCase):
403
437
  """v2 abstain-first answers schema (#241) + back-compat with v1."""
404
438
 
@@ -0,0 +1,47 @@
1
+ """discover_docs include_archived pass: archived docs are excluded by default
2
+ and included + tagged when opted in (offline, filesystem only)."""
3
+ import sys
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(SKILL_ROOT))
10
+
11
+ from lib import doc_discovery
12
+
13
+
14
+ def _repo(d):
15
+ root = Path(d)
16
+ (root / "docs/plans").mkdir(parents=True)
17
+ (root / "docs/plans/live.md").write_text("# live")
18
+ (root / "docs/plans/archive/shipped").mkdir(parents=True)
19
+ (root / "docs/plans/archive/shipped/done.md").write_text("# done")
20
+ (root / "docs/plans/archive/abandoned").mkdir(parents=True)
21
+ (root / "docs/plans/archive/abandoned/dead.md").write_text("# dead")
22
+ return root
23
+
24
+
25
+ class DiscoverArchivedTest(unittest.TestCase):
26
+ def test_default_excludes_archived(self):
27
+ with tempfile.TemporaryDirectory() as d:
28
+ docs = doc_discovery.discover_docs(_repo(d))
29
+ rels = sorted(x.rel for x in docs)
30
+ self.assertEqual(rels, ["docs/plans/live.md"])
31
+
32
+ def test_include_archived_tags_shipped_and_abandoned(self):
33
+ with tempfile.TemporaryDirectory() as d:
34
+ docs = doc_discovery.discover_docs(_repo(d), include_archived=True)
35
+ by_rel = {x.rel: x for x in docs}
36
+ self.assertIn("docs/plans/archive/shipped/done.md", by_rel)
37
+ self.assertIn("docs/plans/archive/abandoned/dead.md", by_rel)
38
+ self.assertTrue(by_rel["docs/plans/archive/shipped/done.md"].archived)
39
+ self.assertEqual(
40
+ by_rel["docs/plans/archive/shipped/done.md"].archive_kind, "shipped")
41
+ self.assertEqual(
42
+ by_rel["docs/plans/archive/abandoned/dead.md"].archive_kind, "abandoned")
43
+ self.assertFalse(by_rel["docs/plans/live.md"].archived)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ unittest.main()
@@ -0,0 +1,96 @@
1
+ """plan-archive: per-doc archive of a shipped plan. Offline — git_mv mocked,
2
+ repo resolution via Path.cwd patch (no config file)."""
3
+ import io
4
+ import json
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from contextlib import redirect_stdout
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import plan_archive
16
+
17
+ SHIPPED_BODY = "# P\n\n**Files:**\n- Create: `src/new.ts`\n- [x] Step 1\n"
18
+ PARTIAL_BODY = "# P\n\n**Files:**\n- Create: `src/missing.ts`\n- [ ] Step 1\n"
19
+
20
+
21
+ def _repo(d, body, present=True):
22
+ root = Path(d)
23
+ (root / "docs/plans").mkdir(parents=True)
24
+ (root / "docs/plans/p.md").write_text(body)
25
+ if present:
26
+ (root / "src").mkdir()
27
+ (root / "src/new.ts").write_text("export const x = 1")
28
+ return root
29
+
30
+
31
+ def _run(root, args):
32
+ with mock.patch("commands.plan_archive.plan_status.git_state.path_last_commit_date",
33
+ return_value=None), \
34
+ mock.patch("commands.plan_archive.plan_status._resolve_repo_root",
35
+ return_value=root):
36
+ buf = io.StringIO()
37
+ with redirect_stdout(buf):
38
+ rc = plan_archive.run(args)
39
+ return rc, buf.getvalue()
40
+
41
+
42
+ class PlanArchiveTest(unittest.TestCase):
43
+ def test_archives_shipped_with_yes_json(self):
44
+ with tempfile.TemporaryDirectory() as d:
45
+ root = _repo(d, SHIPPED_BODY)
46
+ with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv",
47
+ return_value=True) as mv:
48
+ rc, out = _run(root, ["--repo=x", "--yes", "--json",
49
+ "--", "docs/plans/p.md"])
50
+ self.assertEqual(rc, 0)
51
+ payload = json.loads(out)
52
+ self.assertEqual(payload["outcome"], "archived")
53
+ self.assertEqual(payload["dest"], "docs/plans/archive/shipped/p.md")
54
+ mv.assert_called_once()
55
+
56
+ def test_refuses_non_shipped(self):
57
+ with tempfile.TemporaryDirectory() as d:
58
+ root = _repo(d, PARTIAL_BODY, present=False)
59
+ with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv") as mv:
60
+ rc, out = _run(root, ["--repo=x", "--yes", "--json",
61
+ "--", "docs/plans/p.md"])
62
+ self.assertEqual(rc, 0)
63
+ self.assertEqual(json.loads(out)["outcome"], "refused_not_shipped")
64
+ mv.assert_not_called()
65
+
66
+ def test_collision_skips(self):
67
+ with tempfile.TemporaryDirectory() as d:
68
+ root = _repo(d, SHIPPED_BODY)
69
+ dest = root / "docs/plans/archive/shipped"
70
+ dest.mkdir(parents=True)
71
+ (dest / "p.md").write_text("# existing")
72
+ with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv") as mv:
73
+ rc, out = _run(root, ["--repo=x", "--yes", "--json",
74
+ "--", "docs/plans/p.md"])
75
+ self.assertEqual(rc, 0)
76
+ self.assertEqual(json.loads(out)["outcome"], "skipped_collision")
77
+ mv.assert_not_called()
78
+
79
+ def test_non_tty_without_yes_declines(self):
80
+ with tempfile.TemporaryDirectory() as d:
81
+ root = _repo(d, SHIPPED_BODY)
82
+ with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv") as mv:
83
+ rc, out = _run(root, ["--repo=x", "--", "docs/plans/p.md"])
84
+ self.assertEqual(rc, 0)
85
+ mv.assert_not_called() # prompt defaults to "no" on non-TTY
86
+
87
+ def test_missing_rel_errors(self):
88
+ with tempfile.TemporaryDirectory() as d:
89
+ root = _repo(d, SHIPPED_BODY)
90
+ rc, _ = _run(root, ["--repo=x", "--yes", "--json",
91
+ "--", "docs/plans/nope.md"])
92
+ self.assertEqual(rc, 1)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ unittest.main()
@@ -0,0 +1,127 @@
1
+ """plan-status --include-archived emits archived docs (tagged) in --json and
2
+ keeps them out of the live human render; --archive-shipped batch-archives."""
3
+ import io
4
+ import json
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from contextlib import redirect_stdout
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import plan_status
16
+
17
+ SHIPPED = "# P\n\n**Files:**\n- Create: `src/a.ts`\n- [x] Step 1\n"
18
+
19
+
20
+ def _repo(d):
21
+ root = Path(d)
22
+ (root / "docs/plans").mkdir(parents=True)
23
+ (root / "docs/plans/live.md").write_text(SHIPPED)
24
+ (root / "src").mkdir()
25
+ (root / "src/a.ts").write_text("export const a = 1")
26
+ (root / "docs/plans/archive/shipped").mkdir(parents=True)
27
+ (root / "docs/plans/archive/shipped/old.md").write_text(SHIPPED)
28
+ return root
29
+
30
+
31
+ def _run(root, args):
32
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
33
+ return_value=None), \
34
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
35
+ buf = io.StringIO()
36
+ with redirect_stdout(buf):
37
+ rc = plan_status.run(args)
38
+ return rc, buf.getvalue()
39
+
40
+
41
+ class IncludeArchivedTest(unittest.TestCase):
42
+ def test_json_without_flag_omits_archived(self):
43
+ with tempfile.TemporaryDirectory() as d:
44
+ rc, out = _run(_repo(d), ["--json"])
45
+ rels = [r["rel"] for r in json.loads(out)["docs"]]
46
+ self.assertEqual(rels, ["docs/plans/live.md"])
47
+
48
+ def test_json_with_flag_includes_tagged_archived(self):
49
+ with tempfile.TemporaryDirectory() as d:
50
+ rc, out = _run(_repo(d), ["--json", "--include-archived"])
51
+ by_rel = {r["rel"]: r for r in json.loads(out)["docs"]}
52
+ self.assertIn("docs/plans/archive/shipped/old.md", by_rel)
53
+ arch = by_rel["docs/plans/archive/shipped/old.md"]
54
+ self.assertTrue(arch["archived"])
55
+ self.assertEqual(arch["archive_kind"], "shipped")
56
+ self.assertFalse(by_rel["docs/plans/live.md"]["archived"])
57
+
58
+
59
+ LIEGAP = "# L\n\n**Files:**\n- Create: `src/b.ts`\n- [ ] Step 1\n- [ ] Step 2\n"
60
+
61
+
62
+ def _repo_batch(d):
63
+ root = Path(d)
64
+ (root / "docs/plans").mkdir(parents=True)
65
+ (root / "docs/plans/clean.md").write_text(SHIPPED)
66
+ (root / "docs/plans/liegap.md").write_text(LIEGAP)
67
+ (root / "src").mkdir()
68
+ (root / "src/a.ts").write_text("export const a = 1") # clean shipped
69
+ (root / "src/b.ts").write_text("export const b = 1") # liegap shipped (0/2 boxes)
70
+ return root
71
+
72
+
73
+ class ArchiveShippedBatchTest(unittest.TestCase):
74
+ def test_batch_json_archives_clean_excludes_liegap(self):
75
+ with tempfile.TemporaryDirectory() as d:
76
+ root = _repo_batch(d)
77
+ with mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
78
+ return_value=True):
79
+ rc, out = _run(root, ["--archive-shipped", "--yes", "--json"])
80
+ self.assertEqual(rc, 0)
81
+ payload = json.loads(out)
82
+ self.assertEqual(payload["action"], "archive_shipped")
83
+ self.assertEqual([a["rel"] for a in payload["archived"]],
84
+ ["docs/plans/clean.md"])
85
+ self.assertEqual([x["rel"] for x in payload["lie_gap_excluded"]],
86
+ ["docs/plans/liegap.md"])
87
+
88
+ def test_batch_include_lie_gap_archives_both(self):
89
+ with tempfile.TemporaryDirectory() as d:
90
+ root = _repo_batch(d)
91
+ with mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
92
+ return_value=True):
93
+ rc, out = _run(root, ["--archive-shipped", "--include-lie-gap",
94
+ "--yes", "--json"])
95
+ archived = sorted(a["rel"] for a in json.loads(out)["archived"])
96
+ self.assertEqual(archived,
97
+ ["docs/plans/clean.md", "docs/plans/liegap.md"])
98
+
99
+ def test_footer_hint_lists_shipped_count(self):
100
+ with tempfile.TemporaryDirectory() as d:
101
+ rc, out = _run(_repo(d), []) # human render
102
+ self.assertIn("1 shipped", out)
103
+ self.assertIn("plan-archive", out)
104
+
105
+ def test_terminal_yes_skips_prompt_and_archives(self):
106
+ # Non-JSON terminal path: --yes must skip the y/N prompt (which defaults
107
+ # to "no" on non-TTY stdin) and archive, mirroring per-doc plan-archive.
108
+ with tempfile.TemporaryDirectory() as d:
109
+ root = _repo_batch(d)
110
+ with mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
111
+ return_value=True) as mv:
112
+ rc, out = _run(root, ["--archive-shipped", "--yes"])
113
+ self.assertEqual(rc, 0)
114
+ mv.assert_called_once() # clean.md archived, not skipped
115
+ self.assertIn("Archived 1", out)
116
+
117
+ def test_terminal_without_yes_declines_on_non_tty(self):
118
+ with tempfile.TemporaryDirectory() as d:
119
+ root = _repo_batch(d)
120
+ with mock.patch("commands.plan_status.archive_lib.git_state.git_mv") as mv:
121
+ rc, out = _run(root, ["--archive-shipped"])
122
+ self.assertEqual(rc, 0)
123
+ mv.assert_not_called() # prompt defaults to no
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()
@@ -78,6 +78,8 @@ class _Doc:
78
78
  d.rel = rel
79
79
  d.kind = kind
80
80
  d.path = _FakePath(name)
81
+ d.archived = False
82
+ d.archive_kind = None
81
83
  return d
82
84
 
83
85
 
@@ -53,6 +53,7 @@ SUBCOMMANDS = {
53
53
  "plan-status": "commands.plan_status",
54
54
  "--plan-status": "commands.plan_status", # flag-style alias
55
55
  "plan-confirm": "commands.plan_confirm",
56
+ "plan-archive": "commands.plan_archive",
56
57
  "plan-ack": "commands.plan_ack",
57
58
  "plan-baseline": "commands.plan_baseline",
58
59
  "close-issue": "commands.close_issue",
@@ -193,6 +194,10 @@ DESCRIPTIONS = [
193
194
  "Affirm a human verdict on ONE plan/spec doc by writing `verdict_override` into its YAML frontmatter — FRONTMATTER-ONLY (never the body, manifest, checkboxes, or status banner) (#286). plan-status then pins that verdict over the mechanical one and silences the 'shipped but boxes unchecked' lie-gap. Use when a plan genuinely shipped but its phase checkboxes were never ticked, so the red lie-gap X is a false alarm. `<rel>` is the repo-relative doc path from `plan-status --json`. On a PUBLIC repo it prints a confirm heads-up + token and exits (re-run with --confirm=<token>) — the VS Code viewer surfaces this as a modal. --clear removes the override.",
194
195
  "When the Plans view flags a genuinely-done plan with a lie-gap (red X) only because nobody ticked its checkboxes — confirm it instead of hand-ticking 24 boxes.",
195
196
  "/work-plan plan-confirm --repo=myproject --verdict=shipped -- docs/superpowers/plans/2026-03-16-idea-mode-ui.md"),
197
+ ("plan-archive", "--repo=<key> [--draft] [--yes] [--json] -- <rel>",
198
+ "Archive ONE plan/spec doc whose effective verdict is shipped: history-preserving `git mv` into archive/shipped/. Refuses non-shipped docs; skips (never overwrites) a name collision. --draft previews; --yes skips the prompt for non-interactive callers (the VS Code viewer); --json emits a single {action,rel,outcome,dest} object. `<rel>` is the repo-relative doc path from `plan-status --json`.",
199
+ "When a shipped plan clutters the active plans view and you want to move it to archive/shipped/ in one step — with full git history preserved.",
200
+ "/work-plan plan-archive --repo=myproject -- docs/superpowers/plans/2026-03-16-idea-mode-ui.md"),
196
201
  ("plan-ack", "--repo=<key> [--clear] [--confirm=<token>] -- <rel>",
197
202
  "Persist an acknowledgment into ONE plan/spec doc's YAML **frontmatter only** (`acknowledged: true`) — never the body/manifest/checkboxes/banner (#286). Unlike the VS Code viewer's default ack (per-machine, ephemeral `workspaceState`), this is durable + shared: it's committed with the repo, and `plan-status` reads it back to demote the doc. `<rel>` is the repo-relative doc path. Public-repo gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`). `--clear` removes it.",
198
203
  "When you want a 'stop flagging this plan' that sticks across machines and teammates, not just on your laptop.",
@@ -274,9 +279,9 @@ def _print_help() -> int:
274
279
  print(" Backfill priorities → /work-plan suggest-priorities --repo=myproject")
275
280
  print()
276
281
  print("=" * 80)
277
- print(f"Config: ~/.claude/work-plan/config.yml (or ~/.agents/work-plan/config.yml on Codex)")
278
- print(f"Docs: See the toolkit README for full setup, requirements, and platform-specific install.")
279
- print(f"Meta: --help / -h · --version / -v")
282
+ print("Config: ~/.claude/work-plan/config.yml (or ~/.agents/work-plan/config.yml on Codex)")
283
+ print("Docs: See the toolkit README for full setup, requirements, and platform-specific install.")
284
+ print("Meta: --help / -h · --version / -v")
280
285
  return 0
281
286
 
282
287
 
@@ -291,7 +296,7 @@ def main(argv: list[str]) -> int:
291
296
  return 0
292
297
  if sub not in SUBCOMMANDS:
293
298
  print(f"unknown subcommand '{sub}'", file=sys.stderr)
294
- print(f"Run 'python3 work_plan.py --help' for usage.", file=sys.stderr)
299
+ print("Run 'python3 work_plan.py --help' for usage.", file=sys.stderr)
295
300
  return 2
296
301
  try:
297
302
  module = __import__(SUBCOMMANDS[sub], fromlist=["run"])