@stylusnexus/work-plan 2026.6.18 → 2026.6.21-1

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…**, multi-select several (Cmd/Ctrl/Shift-click) → **Archive Plan…** to archive the batch behind one confirm, or a repo → **Archive shipped plans…** to sweep them all.
569
+
567
570
  ## Version
568
571
 
569
572
  ```bash
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.18+64ae461
1
+ 2026.06.21+1d0cb70
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-1",
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
@@ -6,7 +6,7 @@ Manifest-less (prose) docs are flagged 👻 for the Phase 1b LLM pass.
6
6
  """
7
7
  import json
8
8
  import sys
9
- from datetime import date
9
+ from datetime import date, timedelta
10
10
  from pathlib import Path
11
11
 
12
12
  from lib import config as config_mod
@@ -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
@@ -118,13 +120,41 @@ def _declared_paths_on_disk(decls, repo_root) -> list:
118
120
  return out
119
121
 
120
122
 
121
- def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
123
+ def _committed_since_from_map(last_dates, plan_date, repo_root):
124
+ """`committed_since(rel)` served from the batched date map (#391), mirroring
125
+ manifest.score_manifest's default: with no plan date, degrade to a filesystem
126
+ existence check; otherwise "committed on/after plan_date" is `last-commit-date
127
+ >= plan_date - 1 day` (the same 1-day window `path_committed_since` widens to,
128
+ so same-day Modify commits still count)."""
129
+ if plan_date is None:
130
+ return lambda rel: (Path(repo_root) / rel).exists()
131
+ cutoff = plan_date - timedelta(days=1)
132
+
133
+ def committed_since(rel):
134
+ dt = last_dates.get(rel)
135
+ return dt is not None and dt.date() >= cutoff
136
+
137
+ return committed_since
138
+
139
+
140
+ def _evaluate(doc, repo_root, today, dead_days, stall_days, last_dates=None) -> dict:
122
141
  text = _read(doc.path)
123
142
  decls = manifest.parse_declared_paths(text)
124
143
  pdate = manifest.plan_date_from_filename(doc.path.name)
125
- score = manifest.score_manifest(decls, repo_root, pdate)
144
+ # `last_dates` is the batched {path: datetime} map precomputed in run() (#391)
145
+ # over every doc rel AND every declared path — one chunked git walk for the
146
+ # whole repo. When present, serve score_manifest's per-Modify-path
147
+ # `committed_since` (the real O(n) cost — 1434 git spawns on CritForge), the
148
+ # doc's own last-commit date, and the partial-staleness clock from it: zero
149
+ # git spawns in this loop. When absent (direct callers / tests), fall back to
150
+ # the per-path git calls so behavior is unchanged.
151
+ cs = _committed_since_from_map(last_dates, pdate, repo_root) if last_dates is not None else None
152
+ score = manifest.score_manifest(decls, repo_root, pdate, committed_since=cs)
126
153
  done, total_chk = manifest.count_checkboxes(text)
127
- last_dt = git_state.path_last_commit_date(doc.rel, repo_root)
154
+ if last_dates is not None:
155
+ last_dt = last_dates.get(doc.rel)
156
+ else:
157
+ last_dt = git_state.path_last_commit_date(doc.rel, repo_root)
128
158
  last_d = last_dt.date() if last_dt else None
129
159
  if decls and manifest.out_of_tree_ratio(decls, repo_root) >= verdict_mod.FOREIGN_RATIO:
130
160
  v = verdict_mod.Verdict(
@@ -149,7 +179,11 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
149
179
  if v.label == "partial":
150
180
  on_disk = _declared_paths_on_disk(decls, repo_root)
151
181
  if on_disk:
152
- manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
182
+ if last_dates is not None:
183
+ ds = [last_dates[p] for p in on_disk if p in last_dates]
184
+ manifest_dt = max(ds) if ds else None
185
+ else:
186
+ manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
153
187
  if manifest_dt is None:
154
188
  stalled = True # present on disk but never committed
155
189
  else:
@@ -166,6 +200,7 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
166
200
  verdict_drift = bool(baseline) and not override and baseline != v.label
167
201
  return {
168
202
  "rel": doc.rel, "kind": doc.kind,
203
+ "archived": doc.archived, "archive_kind": doc.archive_kind,
169
204
  "verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
170
205
  "files_present": score.satisfied, "files_declared": score.total,
171
206
  "checkboxes_done": done, "checkboxes_total": total_chk,
@@ -193,7 +228,7 @@ evaluate_doc = _evaluate
193
228
 
194
229
  def _render(rows, repo_root) -> None:
195
230
  print(f"# plan-status — {repo_root}\n")
196
- by = {}
231
+ by: dict = {}
197
232
  for r in rows:
198
233
  by.setdefault(r["verdict"], []).append(r)
199
234
  lie_gap = sum(1 for r in rows if r["lie_gap"])
@@ -330,6 +365,62 @@ def _archive_dead(docs, rows, repo_root, draft: bool) -> int:
330
365
  return 0
331
366
 
332
367
 
368
+ def _archive_shipped(rows, repo_root, draft, as_json, include_lie_gap, yes=False):
369
+ """Batch-archive every effective-shipped LIVE doc into archive/shipped/.
370
+ Lie-gap docs are excluded unless include_lie_gap; collisions are skipped."""
371
+ live = [r for r in rows if not r.get("archived")]
372
+ targets = reconcile_actions.shipped_rows(live, include_lie_gap=include_lie_gap)
373
+ excluded = [r for r in reconcile_actions.shipped_rows(live, include_lie_gap=True)
374
+ if r.get("lie_gap") and not include_lie_gap]
375
+
376
+ if as_json:
377
+ archived, skipped = [], []
378
+ if not draft:
379
+ for r in targets:
380
+ outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
381
+ dest = reconcile_actions.archive_dest(r["rel"], "shipped")
382
+ if outcome == "archived":
383
+ archived.append({"rel": r["rel"], "dest": dest})
384
+ elif outcome == "skipped_collision":
385
+ skipped.append({"rel": r["rel"], "reason": "collision"})
386
+ print(json.dumps({
387
+ "action": "archive_shipped",
388
+ "archived": archived,
389
+ "skipped": skipped,
390
+ "lie_gap_excluded": [{"rel": r["rel"]} for r in excluded],
391
+ }))
392
+ return 0
393
+
394
+ if not targets:
395
+ print("No shipped plans to archive.")
396
+ if excluded:
397
+ print(f"({len(excluded)} unverified lie-gap shipped — "
398
+ f"pass --include-lie-gap to include.)")
399
+ return 0
400
+ print(f"\n{'Would archive' if draft else 'Archive'} {len(targets)} shipped plan(s):")
401
+ for r in targets:
402
+ print(f" {r['rel']} -> {reconcile_actions.archive_dest(r['rel'], 'shipped')}")
403
+ if excluded:
404
+ print(f" (excluding {len(excluded)} unverified lie-gap; "
405
+ f"--include-lie-gap to include)")
406
+ if draft:
407
+ return 0
408
+ if not yes and not prompt_yes_no(f"Move {len(targets)} plan(s) to archive/shipped/? [y/N]"):
409
+ print("Skipped.")
410
+ return 0
411
+ archived = skipped = 0
412
+ for r in targets:
413
+ outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
414
+ if outcome == "archived":
415
+ archived += 1
416
+ print(f" ✓ {r['rel']}")
417
+ else:
418
+ skipped += 1
419
+ print(f" ✗ {r['rel']} ({outcome or 'git mv failed'})")
420
+ print(f"Archived {archived}, skipped {skipped}.")
421
+ return 0
422
+
423
+
333
424
  def _repo_slug(flags):
334
425
  """Resolve the org/repo GitHub slug for the --repo key (for issue creation)."""
335
426
  repo = flags.get("--repo")
@@ -386,7 +477,7 @@ def run(args: list) -> int:
386
477
  flags, _ = parse_flags(args, KNOWN)
387
478
  repo_root = _resolve_repo_root(flags)
388
479
  raw_days = flags.get("--since-days")
389
- if raw_days in (None, True):
480
+ if not isinstance(raw_days, str):
390
481
  dead_days = verdict_mod.DEAD_DAYS
391
482
  else:
392
483
  try:
@@ -396,13 +487,29 @@ def run(args: list) -> int:
396
487
  return 2
397
488
  today = date.today()
398
489
 
399
- docs = doc_discovery.discover_docs(repo_root)
490
+ docs = doc_discovery.discover_docs(
491
+ repo_root, include_archived=bool(flags.get("--include-archived")))
400
492
  type_filter = flags.get("--type")
401
493
  if type_filter and type_filter is not True:
402
494
  docs = [d for d in docs if d.kind == type_filter]
403
495
 
404
496
  stall_days = _resolve_stall_days(flags)
405
- rows = [_evaluate(d, repo_root, today, dead_days, stall_days) for d in docs]
497
+ # Batch EVERY git date the scan needs each doc's own rel plus every declared
498
+ # path (Modify paths drive score_manifest's committed_since, the real O(n)
499
+ # cost) — into one chunked git walk (#391), instead of a subprocess per path.
500
+ # This is what hung the Plans view on large repos (CritForge: 391 docs +
501
+ # 1434 declared paths = ~1800 git spawns → ~40s, now a handful of walks).
502
+ batch_paths = set()
503
+ for d in docs:
504
+ batch_paths.add(d.rel)
505
+ try:
506
+ for dp in manifest.parse_declared_paths(_read(d.path)):
507
+ batch_paths.add(dp.path)
508
+ except OSError:
509
+ pass
510
+ last_dates = git_state.paths_last_commit_dates(sorted(batch_paths), repo_root)
511
+ rows = [_evaluate(d, repo_root, today, dead_days, stall_days, last_dates)
512
+ for d in docs]
406
513
 
407
514
  if flags.get("--llm"):
408
515
  if flags.get("--apply"):
@@ -414,6 +521,13 @@ def run(args: list) -> int:
414
521
  if flags.get("--archive"):
415
522
  return _archive_dead(docs, rows, repo_root, draft=bool(flags.get("--draft")))
416
523
 
524
+ if flags.get("--archive-shipped"):
525
+ return _archive_shipped(rows, repo_root,
526
+ draft=bool(flags.get("--draft")),
527
+ as_json=bool(flags.get("--json")),
528
+ include_lie_gap=bool(flags.get("--include-lie-gap")),
529
+ yes=bool(flags.get("--yes")))
530
+
417
531
  if flags.get("--issues"):
418
532
  return _issues_for_partials(docs, rows, repo_root, _repo_slug(flags),
419
533
  draft=bool(flags.get("--draft")))
@@ -421,7 +535,14 @@ def run(args: list) -> int:
421
535
  if flags.get("--json"):
422
536
  print(json.dumps({"repo": str(repo_root), "docs": rows}, indent=2))
423
537
  return 0
424
- _render(rows, repo_root)
538
+ live_rows = [r for r in rows if not r.get("archived")]
539
+ _render(live_rows, repo_root)
540
+ n_shipped = len(reconcile_actions.shipped_rows(live_rows, include_lie_gap=True))
541
+ if n_shipped:
542
+ print(f"\n{n_shipped} shipped — archive with "
543
+ f"'plan-archive -- <rel>' or 'plan-status --archive-shipped'.")
425
544
  if flags.get("--stamp"):
426
- _stamp_docs(docs, rows, draft=bool(flags.get("--draft")))
545
+ live_docs = [d for d in docs if not d.archived]
546
+ _stamp_docs(live_docs, [r for r in rows if not r.get("archived")],
547
+ draft=bool(flags.get("--draft")))
427
548
  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
@@ -254,6 +254,92 @@ def paths_last_commit_date(rel_paths, repo_path: Path) -> Optional[datetime]:
254
254
  return None
255
255
 
256
256
 
257
+ # A `%cI` commit line ("2026-06-19T10:00:00-07:00") vs a path line, so the
258
+ # batched walk below can tell which is which without a sentinel (plan/spec doc
259
+ # paths never look like an ISO timestamp).
260
+ _ISO_DT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
261
+
262
+
263
+ def _is_inrepo_rel(p: str) -> bool:
264
+ """True if `p` is a repo-relative path that stays inside the repo — safe to
265
+ pass to `git log -- <p>`. Rejects empty, absolute (`/…`), home (`~…`),
266
+ backslash, and any `..` that escapes the root (a foreign plan's off-tree
267
+ declared path). git would exit 128 on those, poisoning the batch."""
268
+ if not p or p[0] in "/~" or "\\" in p:
269
+ return False
270
+ depth = 0
271
+ for part in p.split("/"):
272
+ if part in ("", "."):
273
+ continue
274
+ if part == "..":
275
+ depth -= 1
276
+ if depth < 0:
277
+ return False
278
+ else:
279
+ depth += 1
280
+ return True
281
+
282
+
283
+ def paths_last_commit_dates(rel_paths, repo_path: Path) -> dict:
284
+ """Most-recent commit datetime touching EACH of `rel_paths`, in a SINGLE
285
+ `git log` walk (#391) — vs one `path_last_commit_date` subprocess per path,
286
+ whose spawn overhead makes a many-doc scan O(docs) in process startups.
287
+
288
+ Returns {rel_path: datetime} for paths with at least one commit; a path
289
+ never committed is omitted. `git log` walks newest-first, so the first
290
+ commit that touches a path is its last touch. Never raises (None/bad-repo/
291
+ git-error all yield the partial-or-empty map).
292
+ """
293
+ out: dict = {}
294
+ if not rel_paths or not repo_path or not Path(repo_path).exists():
295
+ return out
296
+ # Drop off-tree paths (absolute, ~, ..-escape) BEFORE the git call: an
297
+ # out-of-repo pathspec makes `git log -- <…>` exit 128, which would poison
298
+ # the WHOLE chunk and silently lose every other path in it. Such paths can't
299
+ # have a commit in this repo anyway, so omitting them is correct (#391).
300
+ paths = [p for p in dict.fromkeys(rel_paths) if _is_inrepo_rel(p)]
301
+ if not paths:
302
+ return out
303
+ # Chunk the pathspec so a many-doc repo (1000s of declared paths) can't blow
304
+ # past the OS arg-length limit; each chunk is one `git log` walk.
305
+ for i in range(0, len(paths), _BATCH_CHUNK):
306
+ _collect_last_dates(paths[i:i + _BATCH_CHUNK], repo_path, out)
307
+ return out
308
+
309
+
310
+ _BATCH_CHUNK = 400
311
+
312
+
313
+ def _collect_last_dates(paths, repo_path, out: dict) -> None:
314
+ """One `git log --name-only` walk over `paths`; record each path's newest
315
+ commit datetime into `out`. core.quotePath=false → non-ASCII paths print raw
316
+ so they match the rel strings exactly."""
317
+ remaining = set(paths)
318
+ proc = _git(repo_path, "-c", "core.quotePath=false",
319
+ "log", "--format=%cI", "--name-only", "--", *paths)
320
+ if proc is None or proc.returncode != 0:
321
+ return
322
+ cur = None
323
+ for line in proc.stdout.splitlines():
324
+ if not line:
325
+ continue
326
+ if _ISO_DT_RE.match(line):
327
+ cur = line
328
+ continue
329
+ if cur and line in remaining:
330
+ try:
331
+ # %cI carries a numeric tz offset (often negative, e.g. -07:00).
332
+ # Parse it, then drop tzinfo for a consistent NAIVE datetime — so
333
+ # callers can compare/max() across paths (a +offset and a -offset
334
+ # mix would otherwise be aware-vs-naive). .date() is unchanged.
335
+ out[line] = datetime.fromisoformat(cur).replace(tzinfo=None)
336
+ except ValueError:
337
+ pass
338
+ remaining.discard(line)
339
+ if not remaining:
340
+ break
341
+
342
+
257
343
  def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
258
344
  """True if `rel_path` has any commit on/around `since` or later (a datetime.date).
259
345
 
@@ -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,144 @@
1
+ """Batched last-commit-date lookup (#391): one `git log` walk yields the latest
2
+ commit datetime per path, replacing N per-path subprocess spawns. Offline —
3
+ `_git` is mocked, no real git invoked."""
4
+ import io
5
+ import json as _json
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from contextlib import redirect_stdout
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from lib import git_state
17
+ from commands import plan_status
18
+
19
+
20
+ class _FakeProc:
21
+ def __init__(self, stdout, returncode=0):
22
+ self.stdout = stdout
23
+ self.returncode = returncode
24
+ self.stderr = ""
25
+
26
+
27
+ # `git log --format=%cI --name-only` over three paths, newest-first. a.md last
28
+ # touched in the newest commit; b.md too; c.md only in the older commit.
29
+ LOG = (
30
+ "2026-06-19T10:00:00-07:00\n"
31
+ "\n"
32
+ "docs/plans/a.md\n"
33
+ "docs/plans/b.md\n"
34
+ "2026-06-10T09:00:00-07:00\n"
35
+ "\n"
36
+ "docs/plans/a.md\n"
37
+ "docs/plans/c.md\n"
38
+ )
39
+
40
+
41
+ class PathsLastCommitDatesTest(unittest.TestCase):
42
+ def test_single_call_maps_each_path_to_its_latest(self):
43
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
44
+ mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)) as g:
45
+ res = git_state.paths_last_commit_dates(
46
+ ["docs/plans/a.md", "docs/plans/b.md", "docs/plans/c.md"],
47
+ Path("/repo"))
48
+ g.assert_called_once() # ONE git spawn, not three
49
+ self.assertEqual(res["docs/plans/a.md"].date().isoformat(), "2026-06-19")
50
+ self.assertEqual(res["docs/plans/b.md"].date().isoformat(), "2026-06-19")
51
+ self.assertEqual(res["docs/plans/c.md"].date().isoformat(), "2026-06-10")
52
+
53
+ def test_batched_call_uses_name_only_walk(self):
54
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
55
+ mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)) as g:
56
+ git_state.paths_last_commit_dates(["docs/plans/a.md"], Path("/repo"))
57
+ args = g.call_args.args
58
+ self.assertIn("--name-only", args)
59
+ self.assertIn("log", args)
60
+ self.assertIn("docs/plans/a.md", args) # path passed through
61
+
62
+ def test_uncommitted_path_omitted(self):
63
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
64
+ mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)):
65
+ res = git_state.paths_last_commit_dates(
66
+ ["docs/plans/a.md", "docs/plans/never.md"], Path("/repo"))
67
+ self.assertIn("docs/plans/a.md", res)
68
+ self.assertNotIn("docs/plans/never.md", res) # no commit → omitted
69
+
70
+ def test_empty_input_no_git_call(self):
71
+ with mock.patch("lib.git_state._git") as g:
72
+ self.assertEqual(git_state.paths_last_commit_dates([], Path("/repo")), {})
73
+ g.assert_not_called()
74
+
75
+ def test_git_failure_returns_empty(self):
76
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
77
+ mock.patch("lib.git_state._git", return_value=_FakeProc("", returncode=128)):
78
+ self.assertEqual(
79
+ git_state.paths_last_commit_dates(["docs/plans/a.md"], Path("/repo")), {})
80
+
81
+ def test_offtree_paths_filtered_before_git(self):
82
+ # An off-tree pathspec (../, absolute, ~) makes `git log` exit 128 and
83
+ # poison the whole chunk; they must be dropped before the call (#391).
84
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
85
+ mock.patch("lib.git_state._git", return_value=_FakeProc(LOG)) as g:
86
+ res = git_state.paths_last_commit_dates(
87
+ ["docs/plans/a.md", "../escape.md", "/abs/x.md", "~/h.md",
88
+ "a/../../b.md"], Path("/repo"))
89
+ passed = g.call_args.args
90
+ self.assertIn("docs/plans/a.md", passed)
91
+ for bad in ("../escape.md", "/abs/x.md", "~/h.md", "a/../../b.md"):
92
+ self.assertNotIn(bad, passed) # never reaches git
93
+ self.assertIn("docs/plans/a.md", res)
94
+
95
+ def test_all_offtree_no_git_call(self):
96
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
97
+ mock.patch("lib.git_state._git") as g:
98
+ self.assertEqual(
99
+ git_state.paths_last_commit_dates(["../x", "/y"], Path("/repo")), {})
100
+ g.assert_not_called()
101
+
102
+ def test_none_proc_returns_empty(self):
103
+ with mock.patch("lib.git_state.Path.exists", return_value=True), \
104
+ mock.patch("lib.git_state._git", return_value=None):
105
+ self.assertEqual(
106
+ git_state.paths_last_commit_dates(["docs/plans/a.md"], Path("/repo")), {})
107
+
108
+
109
+ class PlanStatusBatchesTest(unittest.TestCase):
110
+ """run() must use the ONE batched call, not a per-doc git spawn (#391)."""
111
+
112
+ def _repo(self, d):
113
+ root = Path(d)
114
+ (root / "docs/plans").mkdir(parents=True)
115
+ # A Modify path is what drove the per-path committed_since git spawns.
116
+ (root / "docs/plans/a.md").write_text(
117
+ "# A\n\n- Create: `src/a.ts`\n- Modify: `src/x.ts`\n")
118
+ (root / "docs/plans/b.md").write_text("# B\n\n- Create: `src/b.ts`\n")
119
+ return root
120
+
121
+ def test_run_batches_one_call_no_per_doc_spawn(self):
122
+ with tempfile.TemporaryDirectory() as d:
123
+ root = self._repo(d)
124
+ with mock.patch("commands.plan_status.Path.cwd", return_value=root), \
125
+ mock.patch("commands.plan_status.git_state.paths_last_commit_dates",
126
+ return_value={}) as batch, \
127
+ mock.patch("commands.plan_status.git_state.path_last_commit_date") as per, \
128
+ mock.patch("commands.plan_status.git_state.paths_last_commit_date") as per_multi:
129
+ buf = io.StringIO()
130
+ with redirect_stdout(buf):
131
+ rc = plan_status.run(["--json"])
132
+ self.assertEqual(rc, 0)
133
+ batch.assert_called_once() # one git walk for all docs + declared paths
134
+ per.assert_not_called() # no per-doc last-commit spawn
135
+ per_multi.assert_not_called() # no per-partial staleness spawn
136
+ # committed_since is served from the injected map (run() passes a
137
+ # closure to score_manifest), so its per-Modify-path git fallback
138
+ # never runs — that was the real O(n) cost.
139
+ docs = _json.loads(buf.getvalue())["docs"]
140
+ self.assertEqual(len(docs), 2)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ 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()
@@ -25,9 +25,17 @@ class ArchiveTest(unittest.TestCase):
25
25
  return root
26
26
 
27
27
  def _run(self, root, args, mv_ok=True):
28
- # stale last-commit (well beyond DEAD_DAYS) so the absent-file plan is dead
28
+ # stale last-commit (well beyond DEAD_DAYS) so the absent-file plan is dead.
29
+ # run() now reads the batched paths_last_commit_dates map (#391); mock its
30
+ # .get to return the stale date for every doc (path_last_commit_date is the
31
+ # fallback path, mocked too).
32
+ stale = datetime(2026, 1, 1)
33
+ batched = mock.MagicMock()
34
+ batched.get.return_value = stale
29
35
  with mock.patch("commands.plan_status.git_state.path_last_commit_date",
30
- return_value=datetime(2026, 1, 1)), \
36
+ return_value=stale), \
37
+ mock.patch("commands.plan_status.git_state.paths_last_commit_dates",
38
+ return_value=batched), \
31
39
  mock.patch("commands.plan_status.Path.cwd", return_value=root), \
32
40
  mock.patch("commands.plan_status.git_state.git_mv",
33
41
  return_value=mv_ok) as mv, \
@@ -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"])