@stylusnexus/work-plan 2026.6.19 → 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 +4 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +1 -0
- package/skills/work-plan/commands/plan_archive.py +86 -0
- package/skills/work-plan/commands/plan_status.py +133 -12
- package/skills/work-plan/lib/archive.py +21 -0
- package/skills/work-plan/lib/doc_discovery.py +17 -1
- package/skills/work-plan/lib/git_state.py +86 -0
- package/skills/work-plan/lib/github_state.py +4 -4
- package/skills/work-plan/lib/manifest.py +5 -3
- package/skills/work-plan/lib/reconcile_actions.py +17 -3
- package/skills/work-plan/tests/test_archive_lib.py +90 -0
- package/skills/work-plan/tests/test_discover_archived.py +47 -0
- package/skills/work-plan/tests/test_paths_last_commit_dates.py +144 -0
- package/skills/work-plan/tests/test_plan_archive.py +96 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +10 -2
- package/skills/work-plan/tests/test_plan_status_archive_shipped.py +127 -0
- package/skills/work-plan/tests/test_plan_status_stalled.py +2 -0
- package/skills/work-plan/work_plan.py +9 -4
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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":
|
|
202
|
-
((node.get("labels") or {}).get("nodes") or []) if
|
|
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 = {
|
|
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
|
|
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
|
|
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
|
|
16
|
-
"""
|
|
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" /
|
|
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()
|
|
@@ -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=
|
|
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()
|
|
@@ -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(
|
|
278
|
-
print(
|
|
279
|
-
print(
|
|
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(
|
|
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"])
|