@stylusnexus/work-plan 2026.6.19 → 2026.6.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +80 -6
- package/skills/work-plan/lib/archive.py +21 -0
- package/skills/work-plan/lib/doc_discovery.py +17 -1
- 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_plan_archive.py +96 -0
- 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…**, or a repo → **Archive shipped plans…**.
|
|
569
|
+
|
|
567
570
|
## Version
|
|
568
571
|
|
|
569
572
|
```bash
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.21+4cab67a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.21",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -30,6 +30,7 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
|
|
|
30
30
|
| `/work-plan duplicates [--min-similarity=0.7]` | Find likely-duplicate issues by title similarity (stdlib difflib). |
|
|
31
31
|
| `/work-plan plan-status [--repo=<key>] [--stamp [--draft]] [--type=plan\|spec]` | **Doc/plan liveness.** "Which of my plan/spec docs actually shipped, half-shipped, or died?" Correlates each plan's declared file-manifest (Create/Modify/Test paths) against git + filesystem — not the unreliable checkboxes. Reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. Read-only by default; `--stamp` writes an idempotent status header into each doc (`--draft` previews, writes nothing). Natural-language triggers: "what's done vs unfinished in `<repo>`", "stamp the plan statuses", "which plans are stale/dead". |
|
|
32
32
|
| `/work-plan plan-confirm --repo=<key> --verdict=shipped\|partial\|dead [--clear] -- <rel>` | **Affirm a human verdict** on one plan doc by writing `verdict_override` into its **frontmatter only** (never body/checkboxes/manifest). `plan-status` then pins that verdict and silences the "shipped but boxes unchecked" lie-gap. Use when a genuinely-shipped plan is flagged red only because nobody ticked its phase checkboxes — confirm it instead of hand-ticking boxes. Public-repo gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`). Natural-language triggers: "that plan really did ship, stop flagging it", "mark `<plan>` as shipped/dead". |
|
|
33
|
+
| `/work-plan plan-archive --repo=<key> [--draft] -- <rel>` | **Archive a shipped plan.** Moves a doc whose effective verdict is shipped into `archive/shipped/` (`git mv`). Refuses non-shipped; skips collisions. Natural-language triggers: "archive this finished plan", "file away the shipped plans". Bulk: `plan-status --archive-shipped`. |
|
|
33
34
|
| `/work-plan plan-ack --repo=<key> [--clear] -- <rel>` | **Durably acknowledge** one plan doc by writing `acknowledged: true` into its **frontmatter only** — a "stop flagging this" that's committed + shared (vs the viewer's per-machine ack). `plan-status` reads it back and demotes the doc. Public-repo gated. Natural-language triggers: "stop flagging this plan for everyone", "acknowledge `<plan>` for good". |
|
|
34
35
|
| `/work-plan plan-baseline --repo=<key> [--clear] -- <rel>` | **Stamp a drift baseline** on one plan doc by writing its current computed verdict to `verdict_baseline` in **frontmatter only**. `plan-status` then flags **drift** when the live verdict diverges — catching a once-shipped plan that silently regressed (files deleted/moved). Distinct from `plan-confirm` (human pin); an override suppresses drift. Public-repo gated. Natural-language triggers: "watch this plan for regressions", "alert me if `<plan>` stops being shipped". |
|
|
35
36
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""plan-archive — move ONE plan/spec doc whose effective verdict is `shipped`
|
|
2
|
+
into archive/shipped/ (history-preserving git mv).
|
|
3
|
+
|
|
4
|
+
Eligibility uses the same `_evaluate` machinery as plan-status, so an
|
|
5
|
+
override-confirmed or lie-gap shipped doc qualifies (its effective verdict is
|
|
6
|
+
shipped). Non-shipped docs are refused (no move). A name collision in the
|
|
7
|
+
archive dir is skipped, never overwritten.
|
|
8
|
+
|
|
9
|
+
Interactivity: behind a y/N prompt by default (terminal use). `--yes` skips the
|
|
10
|
+
prompt for non-interactive callers (the VS Code viewer) — required because
|
|
11
|
+
lib/prompts.py defaults to "no" on non-TTY stdin. `--json` emits a single
|
|
12
|
+
JSON-only object (no prompts/human lines) so the viewer can parse the outcome.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
work_plan.py plan-archive --repo=<key> [--draft] [--yes] [--json] -- <rel>
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import date
|
|
20
|
+
|
|
21
|
+
from commands import plan_status
|
|
22
|
+
from lib import archive as archive_lib
|
|
23
|
+
from lib import doc_discovery, reconcile_actions, verdict as verdict_mod
|
|
24
|
+
from lib.prompts import parse_flags, prompt_yes_no
|
|
25
|
+
|
|
26
|
+
KNOWN = {"--repo", "--draft", "--yes", "--json"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _emit(as_json: bool, rel: str, outcome: str, dest, human: str) -> None:
|
|
30
|
+
if as_json:
|
|
31
|
+
print(json.dumps({"action": "archive", "rel": rel,
|
|
32
|
+
"outcome": outcome, "dest": dest}))
|
|
33
|
+
else:
|
|
34
|
+
print(human)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(args: list) -> int:
|
|
38
|
+
flags, positional = parse_flags(args, KNOWN)
|
|
39
|
+
if not flags.get("--repo") or flags.get("--repo") is True:
|
|
40
|
+
print("ERROR: --repo=<key> is required.", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
if not positional:
|
|
43
|
+
print("usage: work_plan.py plan-archive --repo=<key> [--draft] [--yes] "
|
|
44
|
+
"[--json] -- <rel>", file=sys.stderr)
|
|
45
|
+
return 2
|
|
46
|
+
rel = positional[0]
|
|
47
|
+
as_json = bool(flags.get("--json"))
|
|
48
|
+
|
|
49
|
+
repo_root = plan_status._resolve_repo_root(flags)
|
|
50
|
+
docs = doc_discovery.discover_docs(repo_root)
|
|
51
|
+
doc = next((d for d in docs if d.rel == rel), None)
|
|
52
|
+
if doc is None:
|
|
53
|
+
print(f"ERROR: '{rel}' is not a discovered plan doc inside {repo_root}",
|
|
54
|
+
file=sys.stderr)
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
today = date.today()
|
|
58
|
+
stall_days = plan_status._resolve_stall_days(flags)
|
|
59
|
+
row = plan_status._evaluate(doc, repo_root, today, verdict_mod.DEAD_DAYS, stall_days)
|
|
60
|
+
|
|
61
|
+
dest = reconcile_actions.archive_dest(rel, "shipped")
|
|
62
|
+
if row["verdict"] != "shipped":
|
|
63
|
+
_emit(as_json, rel, "refused_not_shipped", None,
|
|
64
|
+
f"✗ {rel} is '{row['verdict']}', not shipped — not archived.")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
if flags.get("--draft"):
|
|
68
|
+
# Human preview only; the viewer never combines --draft with --json.
|
|
69
|
+
print(f"Would archive {rel} -> {dest}")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
if not flags.get("--yes"):
|
|
73
|
+
if not prompt_yes_no(f"Archive {rel} -> {dest}? [y/N]"):
|
|
74
|
+
print("Skipped.")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
outcome = archive_lib.move_to_archive(rel, repo_root, "shipped")
|
|
78
|
+
if outcome is None:
|
|
79
|
+
print(f"ERROR: git mv failed for {rel}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
if outcome == "skipped_collision":
|
|
82
|
+
_emit(as_json, rel, "skipped_collision", dest,
|
|
83
|
+
f"destination already exists: {dest} — skipped")
|
|
84
|
+
return 0
|
|
85
|
+
_emit(as_json, rel, "archived", dest, f"✓ archived {rel} -> {dest}")
|
|
86
|
+
return 0
|
|
@@ -16,11 +16,13 @@ from lib import verdict as verdict_mod
|
|
|
16
16
|
from lib import status_header
|
|
17
17
|
from lib import llm_evidence
|
|
18
18
|
from lib import reconcile_actions
|
|
19
|
+
from lib import archive as archive_lib
|
|
19
20
|
from lib.scratch import cache_dir
|
|
20
21
|
from lib.prompts import parse_flags, prompt_yes_no
|
|
21
22
|
|
|
22
23
|
KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
|
|
23
|
-
"--llm", "--apply", "--archive", "--issues", "--stall-days"
|
|
24
|
+
"--llm", "--apply", "--archive", "--issues", "--stall-days",
|
|
25
|
+
"--include-archived", "--archive-shipped", "--include-lie-gap", "--yes"}
|
|
24
26
|
_ORDER = ["shipped", "partial", "dead", "foreign", "manifest-less"]
|
|
25
27
|
|
|
26
28
|
# Human verdict-override (#286): a reviewer affirms the verdict in the doc's
|
|
@@ -166,6 +168,7 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
|
|
|
166
168
|
verdict_drift = bool(baseline) and not override and baseline != v.label
|
|
167
169
|
return {
|
|
168
170
|
"rel": doc.rel, "kind": doc.kind,
|
|
171
|
+
"archived": doc.archived, "archive_kind": doc.archive_kind,
|
|
169
172
|
"verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
|
|
170
173
|
"files_present": score.satisfied, "files_declared": score.total,
|
|
171
174
|
"checkboxes_done": done, "checkboxes_total": total_chk,
|
|
@@ -193,7 +196,7 @@ evaluate_doc = _evaluate
|
|
|
193
196
|
|
|
194
197
|
def _render(rows, repo_root) -> None:
|
|
195
198
|
print(f"# plan-status — {repo_root}\n")
|
|
196
|
-
by = {}
|
|
199
|
+
by: dict = {}
|
|
197
200
|
for r in rows:
|
|
198
201
|
by.setdefault(r["verdict"], []).append(r)
|
|
199
202
|
lie_gap = sum(1 for r in rows if r["lie_gap"])
|
|
@@ -330,6 +333,62 @@ def _archive_dead(docs, rows, repo_root, draft: bool) -> int:
|
|
|
330
333
|
return 0
|
|
331
334
|
|
|
332
335
|
|
|
336
|
+
def _archive_shipped(rows, repo_root, draft, as_json, include_lie_gap, yes=False):
|
|
337
|
+
"""Batch-archive every effective-shipped LIVE doc into archive/shipped/.
|
|
338
|
+
Lie-gap docs are excluded unless include_lie_gap; collisions are skipped."""
|
|
339
|
+
live = [r for r in rows if not r.get("archived")]
|
|
340
|
+
targets = reconcile_actions.shipped_rows(live, include_lie_gap=include_lie_gap)
|
|
341
|
+
excluded = [r for r in reconcile_actions.shipped_rows(live, include_lie_gap=True)
|
|
342
|
+
if r.get("lie_gap") and not include_lie_gap]
|
|
343
|
+
|
|
344
|
+
if as_json:
|
|
345
|
+
archived, skipped = [], []
|
|
346
|
+
if not draft:
|
|
347
|
+
for r in targets:
|
|
348
|
+
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
|
|
349
|
+
dest = reconcile_actions.archive_dest(r["rel"], "shipped")
|
|
350
|
+
if outcome == "archived":
|
|
351
|
+
archived.append({"rel": r["rel"], "dest": dest})
|
|
352
|
+
elif outcome == "skipped_collision":
|
|
353
|
+
skipped.append({"rel": r["rel"], "reason": "collision"})
|
|
354
|
+
print(json.dumps({
|
|
355
|
+
"action": "archive_shipped",
|
|
356
|
+
"archived": archived,
|
|
357
|
+
"skipped": skipped,
|
|
358
|
+
"lie_gap_excluded": [{"rel": r["rel"]} for r in excluded],
|
|
359
|
+
}))
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
if not targets:
|
|
363
|
+
print("No shipped plans to archive.")
|
|
364
|
+
if excluded:
|
|
365
|
+
print(f"({len(excluded)} unverified lie-gap shipped — "
|
|
366
|
+
f"pass --include-lie-gap to include.)")
|
|
367
|
+
return 0
|
|
368
|
+
print(f"\n{'Would archive' if draft else 'Archive'} {len(targets)} shipped plan(s):")
|
|
369
|
+
for r in targets:
|
|
370
|
+
print(f" {r['rel']} -> {reconcile_actions.archive_dest(r['rel'], 'shipped')}")
|
|
371
|
+
if excluded:
|
|
372
|
+
print(f" (excluding {len(excluded)} unverified lie-gap; "
|
|
373
|
+
f"--include-lie-gap to include)")
|
|
374
|
+
if draft:
|
|
375
|
+
return 0
|
|
376
|
+
if not yes and not prompt_yes_no(f"Move {len(targets)} plan(s) to archive/shipped/? [y/N]"):
|
|
377
|
+
print("Skipped.")
|
|
378
|
+
return 0
|
|
379
|
+
archived = skipped = 0
|
|
380
|
+
for r in targets:
|
|
381
|
+
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
|
|
382
|
+
if outcome == "archived":
|
|
383
|
+
archived += 1
|
|
384
|
+
print(f" ✓ {r['rel']}")
|
|
385
|
+
else:
|
|
386
|
+
skipped += 1
|
|
387
|
+
print(f" ✗ {r['rel']} ({outcome or 'git mv failed'})")
|
|
388
|
+
print(f"Archived {archived}, skipped {skipped}.")
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
|
|
333
392
|
def _repo_slug(flags):
|
|
334
393
|
"""Resolve the org/repo GitHub slug for the --repo key (for issue creation)."""
|
|
335
394
|
repo = flags.get("--repo")
|
|
@@ -386,7 +445,7 @@ def run(args: list) -> int:
|
|
|
386
445
|
flags, _ = parse_flags(args, KNOWN)
|
|
387
446
|
repo_root = _resolve_repo_root(flags)
|
|
388
447
|
raw_days = flags.get("--since-days")
|
|
389
|
-
if
|
|
448
|
+
if not isinstance(raw_days, str):
|
|
390
449
|
dead_days = verdict_mod.DEAD_DAYS
|
|
391
450
|
else:
|
|
392
451
|
try:
|
|
@@ -396,7 +455,8 @@ def run(args: list) -> int:
|
|
|
396
455
|
return 2
|
|
397
456
|
today = date.today()
|
|
398
457
|
|
|
399
|
-
docs = doc_discovery.discover_docs(
|
|
458
|
+
docs = doc_discovery.discover_docs(
|
|
459
|
+
repo_root, include_archived=bool(flags.get("--include-archived")))
|
|
400
460
|
type_filter = flags.get("--type")
|
|
401
461
|
if type_filter and type_filter is not True:
|
|
402
462
|
docs = [d for d in docs if d.kind == type_filter]
|
|
@@ -414,6 +474,13 @@ def run(args: list) -> int:
|
|
|
414
474
|
if flags.get("--archive"):
|
|
415
475
|
return _archive_dead(docs, rows, repo_root, draft=bool(flags.get("--draft")))
|
|
416
476
|
|
|
477
|
+
if flags.get("--archive-shipped"):
|
|
478
|
+
return _archive_shipped(rows, repo_root,
|
|
479
|
+
draft=bool(flags.get("--draft")),
|
|
480
|
+
as_json=bool(flags.get("--json")),
|
|
481
|
+
include_lie_gap=bool(flags.get("--include-lie-gap")),
|
|
482
|
+
yes=bool(flags.get("--yes")))
|
|
483
|
+
|
|
417
484
|
if flags.get("--issues"):
|
|
418
485
|
return _issues_for_partials(docs, rows, repo_root, _repo_slug(flags),
|
|
419
486
|
draft=bool(flags.get("--draft")))
|
|
@@ -421,7 +488,14 @@ def run(args: list) -> int:
|
|
|
421
488
|
if flags.get("--json"):
|
|
422
489
|
print(json.dumps({"repo": str(repo_root), "docs": rows}, indent=2))
|
|
423
490
|
return 0
|
|
424
|
-
|
|
491
|
+
live_rows = [r for r in rows if not r.get("archived")]
|
|
492
|
+
_render(live_rows, repo_root)
|
|
493
|
+
n_shipped = len(reconcile_actions.shipped_rows(live_rows, include_lie_gap=True))
|
|
494
|
+
if n_shipped:
|
|
495
|
+
print(f"\n{n_shipped} shipped — archive with "
|
|
496
|
+
f"'plan-archive -- <rel>' or 'plan-status --archive-shipped'.")
|
|
425
497
|
if flags.get("--stamp"):
|
|
426
|
-
|
|
498
|
+
live_docs = [d for d in docs if not d.archived]
|
|
499
|
+
_stamp_docs(live_docs, [r for r in rows if not r.get("archived")],
|
|
500
|
+
draft=bool(flags.get("--draft")))
|
|
427
501
|
return 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The archive move primitive: collision-checked, history-preserving `git mv`
|
|
2
|
+
of a doc into archive/<kind>/. Eligibility (is-this-shipped) is the caller's
|
|
3
|
+
job — this only moves files."""
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lib import git_state
|
|
7
|
+
from lib import reconcile_actions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def move_to_archive(rel: str, repo_root, kind: str):
|
|
11
|
+
"""git-mv `rel` into archive/<kind>/. Returns:
|
|
12
|
+
"archived" on a clean move,
|
|
13
|
+
"skipped_collision" if the destination already exists (never overwrite),
|
|
14
|
+
None if git_mv failed for any other reason (hard error).
|
|
15
|
+
"""
|
|
16
|
+
dest = reconcile_actions.archive_dest(rel, kind)
|
|
17
|
+
if (Path(repo_root) / dest).exists():
|
|
18
|
+
return "skipped_collision"
|
|
19
|
+
if git_state.git_mv(rel, dest, repo_root):
|
|
20
|
+
return "archived"
|
|
21
|
+
return None
|
|
@@ -9,12 +9,16 @@ DEFAULT_GLOBS = [
|
|
|
9
9
|
"docs/plans/*.md",
|
|
10
10
|
]
|
|
11
11
|
|
|
12
|
+
ARCHIVE_SUBDIRS = [("archive/shipped", "shipped"), ("archive/abandoned", "abandoned")]
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
@dataclass
|
|
14
16
|
class Doc:
|
|
15
17
|
path: Path # absolute
|
|
16
18
|
rel: str # repo-relative POSIX-style
|
|
17
19
|
kind: str # "plan" | "spec" | "adhoc"
|
|
20
|
+
archived: bool = False
|
|
21
|
+
archive_kind: Optional[str] = None # "shipped" | "abandoned" | None
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
def classify_kind(rel: str) -> str:
|
|
@@ -26,7 +30,8 @@ def classify_kind(rel: str) -> str:
|
|
|
26
30
|
return "adhoc"
|
|
27
31
|
|
|
28
32
|
|
|
29
|
-
def discover_docs(repo_root: Path, globs: Optional[list] = None
|
|
33
|
+
def discover_docs(repo_root: Path, globs: Optional[list] = None,
|
|
34
|
+
include_archived: bool = False) -> list:
|
|
30
35
|
globs = globs or DEFAULT_GLOBS
|
|
31
36
|
repo_root = Path(repo_root)
|
|
32
37
|
out = []
|
|
@@ -38,4 +43,15 @@ def discover_docs(repo_root: Path, globs: Optional[list] = None) -> list:
|
|
|
38
43
|
seen.add(p)
|
|
39
44
|
rel = p.relative_to(repo_root).as_posix()
|
|
40
45
|
out.append(Doc(path=p, rel=rel, kind=classify_kind(rel)))
|
|
46
|
+
if include_archived:
|
|
47
|
+
for g in globs:
|
|
48
|
+
parent = g.rsplit("/", 1)[0] # "docs/plans/*.md" -> "docs/plans"
|
|
49
|
+
for sub, kind in ARCHIVE_SUBDIRS:
|
|
50
|
+
for p in sorted(repo_root.glob(f"{parent}/{sub}/*.md")):
|
|
51
|
+
if not p.is_file() or p in seen:
|
|
52
|
+
continue
|
|
53
|
+
seen.add(p)
|
|
54
|
+
rel = p.relative_to(repo_root).as_posix()
|
|
55
|
+
out.append(Doc(path=p, rel=rel, kind=classify_kind(rel),
|
|
56
|
+
archived=True, archive_kind=kind))
|
|
41
57
|
return out
|
|
@@ -198,8 +198,8 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
198
198
|
deps_truncated."""
|
|
199
199
|
if not node:
|
|
200
200
|
return None
|
|
201
|
-
labels = [{"name":
|
|
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,96 @@
|
|
|
1
|
+
"""plan-archive: per-doc archive of a shipped plan. Offline — git_mv mocked,
|
|
2
|
+
repo resolution via Path.cwd patch (no config file)."""
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from contextlib import redirect_stdout
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from commands import plan_archive
|
|
16
|
+
|
|
17
|
+
SHIPPED_BODY = "# P\n\n**Files:**\n- Create: `src/new.ts`\n- [x] Step 1\n"
|
|
18
|
+
PARTIAL_BODY = "# P\n\n**Files:**\n- Create: `src/missing.ts`\n- [ ] Step 1\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _repo(d, body, present=True):
|
|
22
|
+
root = Path(d)
|
|
23
|
+
(root / "docs/plans").mkdir(parents=True)
|
|
24
|
+
(root / "docs/plans/p.md").write_text(body)
|
|
25
|
+
if present:
|
|
26
|
+
(root / "src").mkdir()
|
|
27
|
+
(root / "src/new.ts").write_text("export const x = 1")
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(root, args):
|
|
32
|
+
with mock.patch("commands.plan_archive.plan_status.git_state.path_last_commit_date",
|
|
33
|
+
return_value=None), \
|
|
34
|
+
mock.patch("commands.plan_archive.plan_status._resolve_repo_root",
|
|
35
|
+
return_value=root):
|
|
36
|
+
buf = io.StringIO()
|
|
37
|
+
with redirect_stdout(buf):
|
|
38
|
+
rc = plan_archive.run(args)
|
|
39
|
+
return rc, buf.getvalue()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PlanArchiveTest(unittest.TestCase):
|
|
43
|
+
def test_archives_shipped_with_yes_json(self):
|
|
44
|
+
with tempfile.TemporaryDirectory() as d:
|
|
45
|
+
root = _repo(d, SHIPPED_BODY)
|
|
46
|
+
with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv",
|
|
47
|
+
return_value=True) as mv:
|
|
48
|
+
rc, out = _run(root, ["--repo=x", "--yes", "--json",
|
|
49
|
+
"--", "docs/plans/p.md"])
|
|
50
|
+
self.assertEqual(rc, 0)
|
|
51
|
+
payload = json.loads(out)
|
|
52
|
+
self.assertEqual(payload["outcome"], "archived")
|
|
53
|
+
self.assertEqual(payload["dest"], "docs/plans/archive/shipped/p.md")
|
|
54
|
+
mv.assert_called_once()
|
|
55
|
+
|
|
56
|
+
def test_refuses_non_shipped(self):
|
|
57
|
+
with tempfile.TemporaryDirectory() as d:
|
|
58
|
+
root = _repo(d, PARTIAL_BODY, present=False)
|
|
59
|
+
with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv") as mv:
|
|
60
|
+
rc, out = _run(root, ["--repo=x", "--yes", "--json",
|
|
61
|
+
"--", "docs/plans/p.md"])
|
|
62
|
+
self.assertEqual(rc, 0)
|
|
63
|
+
self.assertEqual(json.loads(out)["outcome"], "refused_not_shipped")
|
|
64
|
+
mv.assert_not_called()
|
|
65
|
+
|
|
66
|
+
def test_collision_skips(self):
|
|
67
|
+
with tempfile.TemporaryDirectory() as d:
|
|
68
|
+
root = _repo(d, SHIPPED_BODY)
|
|
69
|
+
dest = root / "docs/plans/archive/shipped"
|
|
70
|
+
dest.mkdir(parents=True)
|
|
71
|
+
(dest / "p.md").write_text("# existing")
|
|
72
|
+
with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv") as mv:
|
|
73
|
+
rc, out = _run(root, ["--repo=x", "--yes", "--json",
|
|
74
|
+
"--", "docs/plans/p.md"])
|
|
75
|
+
self.assertEqual(rc, 0)
|
|
76
|
+
self.assertEqual(json.loads(out)["outcome"], "skipped_collision")
|
|
77
|
+
mv.assert_not_called()
|
|
78
|
+
|
|
79
|
+
def test_non_tty_without_yes_declines(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as d:
|
|
81
|
+
root = _repo(d, SHIPPED_BODY)
|
|
82
|
+
with mock.patch("commands.plan_archive.archive_lib.git_state.git_mv") as mv:
|
|
83
|
+
rc, out = _run(root, ["--repo=x", "--", "docs/plans/p.md"])
|
|
84
|
+
self.assertEqual(rc, 0)
|
|
85
|
+
mv.assert_not_called() # prompt defaults to "no" on non-TTY
|
|
86
|
+
|
|
87
|
+
def test_missing_rel_errors(self):
|
|
88
|
+
with tempfile.TemporaryDirectory() as d:
|
|
89
|
+
root = _repo(d, SHIPPED_BODY)
|
|
90
|
+
rc, _ = _run(root, ["--repo=x", "--yes", "--json",
|
|
91
|
+
"--", "docs/plans/nope.md"])
|
|
92
|
+
self.assertEqual(rc, 1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
unittest.main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""plan-status --include-archived emits archived docs (tagged) in --json and
|
|
2
|
+
keeps them out of the live human render; --archive-shipped batch-archives."""
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from contextlib import redirect_stdout
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
14
|
+
|
|
15
|
+
from commands import plan_status
|
|
16
|
+
|
|
17
|
+
SHIPPED = "# P\n\n**Files:**\n- Create: `src/a.ts`\n- [x] Step 1\n"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _repo(d):
|
|
21
|
+
root = Path(d)
|
|
22
|
+
(root / "docs/plans").mkdir(parents=True)
|
|
23
|
+
(root / "docs/plans/live.md").write_text(SHIPPED)
|
|
24
|
+
(root / "src").mkdir()
|
|
25
|
+
(root / "src/a.ts").write_text("export const a = 1")
|
|
26
|
+
(root / "docs/plans/archive/shipped").mkdir(parents=True)
|
|
27
|
+
(root / "docs/plans/archive/shipped/old.md").write_text(SHIPPED)
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run(root, args):
|
|
32
|
+
with mock.patch("commands.plan_status.git_state.path_last_commit_date",
|
|
33
|
+
return_value=None), \
|
|
34
|
+
mock.patch("commands.plan_status.Path.cwd", return_value=root):
|
|
35
|
+
buf = io.StringIO()
|
|
36
|
+
with redirect_stdout(buf):
|
|
37
|
+
rc = plan_status.run(args)
|
|
38
|
+
return rc, buf.getvalue()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IncludeArchivedTest(unittest.TestCase):
|
|
42
|
+
def test_json_without_flag_omits_archived(self):
|
|
43
|
+
with tempfile.TemporaryDirectory() as d:
|
|
44
|
+
rc, out = _run(_repo(d), ["--json"])
|
|
45
|
+
rels = [r["rel"] for r in json.loads(out)["docs"]]
|
|
46
|
+
self.assertEqual(rels, ["docs/plans/live.md"])
|
|
47
|
+
|
|
48
|
+
def test_json_with_flag_includes_tagged_archived(self):
|
|
49
|
+
with tempfile.TemporaryDirectory() as d:
|
|
50
|
+
rc, out = _run(_repo(d), ["--json", "--include-archived"])
|
|
51
|
+
by_rel = {r["rel"]: r for r in json.loads(out)["docs"]}
|
|
52
|
+
self.assertIn("docs/plans/archive/shipped/old.md", by_rel)
|
|
53
|
+
arch = by_rel["docs/plans/archive/shipped/old.md"]
|
|
54
|
+
self.assertTrue(arch["archived"])
|
|
55
|
+
self.assertEqual(arch["archive_kind"], "shipped")
|
|
56
|
+
self.assertFalse(by_rel["docs/plans/live.md"]["archived"])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
LIEGAP = "# L\n\n**Files:**\n- Create: `src/b.ts`\n- [ ] Step 1\n- [ ] Step 2\n"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _repo_batch(d):
|
|
63
|
+
root = Path(d)
|
|
64
|
+
(root / "docs/plans").mkdir(parents=True)
|
|
65
|
+
(root / "docs/plans/clean.md").write_text(SHIPPED)
|
|
66
|
+
(root / "docs/plans/liegap.md").write_text(LIEGAP)
|
|
67
|
+
(root / "src").mkdir()
|
|
68
|
+
(root / "src/a.ts").write_text("export const a = 1") # clean shipped
|
|
69
|
+
(root / "src/b.ts").write_text("export const b = 1") # liegap shipped (0/2 boxes)
|
|
70
|
+
return root
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ArchiveShippedBatchTest(unittest.TestCase):
|
|
74
|
+
def test_batch_json_archives_clean_excludes_liegap(self):
|
|
75
|
+
with tempfile.TemporaryDirectory() as d:
|
|
76
|
+
root = _repo_batch(d)
|
|
77
|
+
with mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
|
|
78
|
+
return_value=True):
|
|
79
|
+
rc, out = _run(root, ["--archive-shipped", "--yes", "--json"])
|
|
80
|
+
self.assertEqual(rc, 0)
|
|
81
|
+
payload = json.loads(out)
|
|
82
|
+
self.assertEqual(payload["action"], "archive_shipped")
|
|
83
|
+
self.assertEqual([a["rel"] for a in payload["archived"]],
|
|
84
|
+
["docs/plans/clean.md"])
|
|
85
|
+
self.assertEqual([x["rel"] for x in payload["lie_gap_excluded"]],
|
|
86
|
+
["docs/plans/liegap.md"])
|
|
87
|
+
|
|
88
|
+
def test_batch_include_lie_gap_archives_both(self):
|
|
89
|
+
with tempfile.TemporaryDirectory() as d:
|
|
90
|
+
root = _repo_batch(d)
|
|
91
|
+
with mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
|
|
92
|
+
return_value=True):
|
|
93
|
+
rc, out = _run(root, ["--archive-shipped", "--include-lie-gap",
|
|
94
|
+
"--yes", "--json"])
|
|
95
|
+
archived = sorted(a["rel"] for a in json.loads(out)["archived"])
|
|
96
|
+
self.assertEqual(archived,
|
|
97
|
+
["docs/plans/clean.md", "docs/plans/liegap.md"])
|
|
98
|
+
|
|
99
|
+
def test_footer_hint_lists_shipped_count(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as d:
|
|
101
|
+
rc, out = _run(_repo(d), []) # human render
|
|
102
|
+
self.assertIn("1 shipped", out)
|
|
103
|
+
self.assertIn("plan-archive", out)
|
|
104
|
+
|
|
105
|
+
def test_terminal_yes_skips_prompt_and_archives(self):
|
|
106
|
+
# Non-JSON terminal path: --yes must skip the y/N prompt (which defaults
|
|
107
|
+
# to "no" on non-TTY stdin) and archive, mirroring per-doc plan-archive.
|
|
108
|
+
with tempfile.TemporaryDirectory() as d:
|
|
109
|
+
root = _repo_batch(d)
|
|
110
|
+
with mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
|
|
111
|
+
return_value=True) as mv:
|
|
112
|
+
rc, out = _run(root, ["--archive-shipped", "--yes"])
|
|
113
|
+
self.assertEqual(rc, 0)
|
|
114
|
+
mv.assert_called_once() # clean.md archived, not skipped
|
|
115
|
+
self.assertIn("Archived 1", out)
|
|
116
|
+
|
|
117
|
+
def test_terminal_without_yes_declines_on_non_tty(self):
|
|
118
|
+
with tempfile.TemporaryDirectory() as d:
|
|
119
|
+
root = _repo_batch(d)
|
|
120
|
+
with mock.patch("commands.plan_status.archive_lib.git_state.git_mv") as mv:
|
|
121
|
+
rc, out = _run(root, ["--archive-shipped"])
|
|
122
|
+
self.assertEqual(rc, 0)
|
|
123
|
+
mv.assert_not_called() # prompt defaults to no
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
unittest.main()
|
|
@@ -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"])
|