@stylusnexus/work-plan 2026.6.21 → 2026.6.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +1 -1
- package/skills/work-plan/commands/plan_archive.py +7 -2
- package/skills/work-plan/commands/plan_status.py +67 -13
- package/skills/work-plan/lib/archive.py +25 -10
- package/skills/work-plan/lib/blockers.py +40 -0
- package/skills/work-plan/lib/git_state.py +98 -0
- package/skills/work-plan/lib/next_up.py +5 -1
- package/skills/work-plan/lib/render.py +5 -1
- package/skills/work-plan/tests/test_archive_lib.py +57 -15
- package/skills/work-plan/tests/test_blockers.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 +3 -1
- package/skills/work-plan/tests/test_plan_status_archive.py +15 -3
- package/skills/work-plan/tests/test_plan_status_archive_shipped.py +3 -1
package/README.md
CHANGED
|
@@ -115,6 +115,8 @@ flowchart TB
|
|
|
115
115
|
|
|
116
116
|
> **`brief` and `orient` annotate blocked issues with `⊘ blocked by #N`** — read-only, surfaced from GitHub's native dependency edges, nothing is written back. Cross-repo blockers show as `owner/repo#N`; same-repo duplicates of manually-declared blockers are deduplicated.
|
|
117
117
|
|
|
118
|
+
> **A manual `blockers:` entry can be a free-text note, not just an issue number.** Normally a blocker is an issue number (`- 5550` or `- "#5550"`), which the VS Code dependency graph draws as a `blocks` edge. But it can also be prose (e.g. `- "gated on the cost go/no-go verdict"`) for when the blocker isn't a single issue. Free-text blockers still mark the track blocked and render as prose in `brief`/`orient` and the detail panel — but they don't draw a graph edge, so prefer an issue number when there is one.
|
|
119
|
+
|
|
118
120
|
> **GitHub access is read-only by default, with three explicit, opt-in write actions.** Issue *data* always comes from read-only `gh` calls (`gh issue list`, `gh issue view`), and every routine write (frontmatter, status table, session log) goes to your local markdown files only. The three GitHub-*mutating* actions are all opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening); `close-issue` (#305) **closes** an issue via `gh issue close` — for the common case where a PR merged to `dev` left its issue OPEN (GitHub auto-closes only from the default branch), with the VS Code viewer firing a mandatory "Close on GitHub? — cannot be undone" modal on every close; and `in-progress` (#271) **adds or removes** the `work-plan:in-progress` label on an issue, public-repo gated via the confirm-token flow. Nothing else touches GitHub state.
|
|
119
121
|
|
|
120
122
|
## Shared tracks
|
|
@@ -312,6 +314,8 @@ Install it from either registry:
|
|
|
312
314
|
|
|
313
315
|
The extension **shells out to the `work-plan` CLI**, so install the CLI too (npm or any method above). If `work-plan` isn't on your editor's `PATH` — common when VS Code is launched from the Dock/Finder rather than a terminal — set **`workPlan.cliPath`** in Settings to an absolute launcher path (e.g. `/path/to/work-plan-toolkit/bin/work-plan`, or the npm global bin), then reload the window. Extensions auto-update from the registry.
|
|
314
316
|
|
|
317
|
+
> **Windows + WSL (and Remote-SSH / dev containers): install the CLI where the extension *runs*, not where you clicked.** When you open a folder in WSL — e.g. `code .` from a WSL shell, or **Reopen in WSL** (the window shows **`WSL: <distro>`** at the bottom-left) — VS Code runs the extension *inside WSL*, so it looks for `work-plan`, `gh`, `python3`, and `yq` on the **WSL** `PATH`. A CLI installed on **Windows-native** is invisible to it, and you'll see a **"work-plan CLI not found"** banner (or, on older builds, a misleading "Not signed in to GitHub" one) even though `gh` is signed in. Fix: open the **WSL** terminal and run `npm install -g @stylusnexus/work-plan` (plus `gh`, `python3`, `yq`) there, then **reload the window**. The same rule applies to Remote-SSH and dev containers — the CLI must live in the remote, not on your local machine. (`gh auth` is likewise per-environment: `gh auth login` inside WSL authenticates the WSL `gh`, which is what the extension reads.)
|
|
318
|
+
|
|
315
319
|
Useful settings:
|
|
316
320
|
|
|
317
321
|
| Setting | Default | What it does |
|
|
@@ -539,7 +543,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
539
543
|
| `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
544
|
| `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
545
|
| `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. |
|
|
546
|
+
| `plan-archive --repo=<key> [--draft] [--yes] [--json] -- <rel>` | Archive ONE plan/spec doc whose effective verdict is **shipped**: history-preserving `git mv` into `archive/shipped/`. Refuses non-shipped docs; skips (never overwrites) a name collision. `--draft` previews; `--yes` skips the prompt for non-interactive callers (the VS Code viewer); `--json` emits a single `{action,rel,outcome,dest}` object. The `outcome` distinguishes a **tracked** doc (`archived` — a staged history-preserving `git mv`; commit & push to share it) from a **gitignored/untracked** doc (`archived_local` — a plain filesystem move, not git-tracked), so archive never silently fails on an untracked path (#399). |
|
|
543
547
|
| `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. |
|
|
544
548
|
| `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. |
|
|
545
549
|
| `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. |
|
|
@@ -565,7 +569,7 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
|
|
|
565
569
|
|
|
566
570
|
**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.
|
|
567
571
|
|
|
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
|
|
572
|
+
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
573
|
|
|
570
574
|
## Version
|
|
571
575
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.24+b047035
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.24",
|
|
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,7 +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/`
|
|
33
|
+
| `/work-plan plan-archive --repo=<key> [--draft] -- <rel>` | **Archive a shipped plan.** Moves a doc whose effective verdict is shipped into `archive/shipped/` — a staged `git mv` for a tracked doc (commit & push to share), or a plain filesystem move for a gitignored/untracked one (#399). Refuses non-shipped; skips collisions. Natural-language triggers: "archive this finished plan", "file away the shipped plans". Bulk: `plan-status --archive-shipped`. |
|
|
34
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". |
|
|
35
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". |
|
|
36
36
|
|
|
@@ -76,11 +76,16 @@ def run(args: list) -> int:
|
|
|
76
76
|
|
|
77
77
|
outcome = archive_lib.move_to_archive(rel, repo_root, "shipped")
|
|
78
78
|
if outcome is None:
|
|
79
|
-
print(f"ERROR:
|
|
79
|
+
print(f"ERROR: archive move failed for {rel}", file=sys.stderr)
|
|
80
80
|
return 1
|
|
81
81
|
if outcome == "skipped_collision":
|
|
82
82
|
_emit(as_json, rel, "skipped_collision", dest,
|
|
83
83
|
f"destination already exists: {dest} — skipped")
|
|
84
84
|
return 0
|
|
85
|
-
|
|
85
|
+
if outcome == "archived_local":
|
|
86
|
+
_emit(as_json, rel, "archived_local", dest,
|
|
87
|
+
f"✓ archived {rel} -> {dest} (moved on disk; not git-tracked)")
|
|
88
|
+
return 0
|
|
89
|
+
_emit(as_json, rel, "archived", dest,
|
|
90
|
+
f"✓ archived {rel} -> {dest} (staged rename — commit & push to share)")
|
|
86
91
|
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
|
|
@@ -120,13 +120,41 @@ def _declared_paths_on_disk(decls, repo_root) -> list:
|
|
|
120
120
|
return out
|
|
121
121
|
|
|
122
122
|
|
|
123
|
-
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:
|
|
124
141
|
text = _read(doc.path)
|
|
125
142
|
decls = manifest.parse_declared_paths(text)
|
|
126
143
|
pdate = manifest.plan_date_from_filename(doc.path.name)
|
|
127
|
-
|
|
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)
|
|
128
153
|
done, total_chk = manifest.count_checkboxes(text)
|
|
129
|
-
|
|
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)
|
|
130
158
|
last_d = last_dt.date() if last_dt else None
|
|
131
159
|
if decls and manifest.out_of_tree_ratio(decls, repo_root) >= verdict_mod.FOREIGN_RATIO:
|
|
132
160
|
v = verdict_mod.Verdict(
|
|
@@ -151,7 +179,11 @@ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
|
|
|
151
179
|
if v.label == "partial":
|
|
152
180
|
on_disk = _declared_paths_on_disk(decls, repo_root)
|
|
153
181
|
if on_disk:
|
|
154
|
-
|
|
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)
|
|
155
187
|
if manifest_dt is None:
|
|
156
188
|
stalled = True # present on disk but never committed
|
|
157
189
|
else:
|
|
@@ -324,11 +356,14 @@ def _archive_dead(docs, rows, repo_root, draft: bool) -> int:
|
|
|
324
356
|
moved = 0
|
|
325
357
|
for r in dead:
|
|
326
358
|
dest = reconcile_actions.archive_dest(r["rel"])
|
|
327
|
-
|
|
359
|
+
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "abandoned")
|
|
360
|
+
if outcome in ("archived", "archived_local"):
|
|
328
361
|
moved += 1
|
|
329
|
-
|
|
362
|
+
suffix = " (moved on disk; not git-tracked)" if outcome == "archived_local" else ""
|
|
363
|
+
print(f" ✓ {r['rel']}{suffix}")
|
|
330
364
|
else:
|
|
331
|
-
|
|
365
|
+
reason = "already exists at destination" if outcome == "skipped_collision" else "move failed"
|
|
366
|
+
print(f" ✗ {r['rel']} ({reason})")
|
|
332
367
|
print(f"Archived {moved}/{len(dead)}.")
|
|
333
368
|
return 0
|
|
334
369
|
|
|
@@ -347,8 +382,9 @@ def _archive_shipped(rows, repo_root, draft, as_json, include_lie_gap, yes=False
|
|
|
347
382
|
for r in targets:
|
|
348
383
|
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
|
|
349
384
|
dest = reconcile_actions.archive_dest(r["rel"], "shipped")
|
|
350
|
-
if outcome
|
|
351
|
-
archived.append({"rel": r["rel"], "dest": dest
|
|
385
|
+
if outcome in ("archived", "archived_local"):
|
|
386
|
+
archived.append({"rel": r["rel"], "dest": dest,
|
|
387
|
+
"outcome": outcome})
|
|
352
388
|
elif outcome == "skipped_collision":
|
|
353
389
|
skipped.append({"rel": r["rel"], "reason": "collision"})
|
|
354
390
|
print(json.dumps({
|
|
@@ -381,10 +417,13 @@ def _archive_shipped(rows, repo_root, draft, as_json, include_lie_gap, yes=False
|
|
|
381
417
|
outcome = archive_lib.move_to_archive(r["rel"], repo_root, "shipped")
|
|
382
418
|
if outcome == "archived":
|
|
383
419
|
archived += 1
|
|
384
|
-
print(f" ✓ {r['rel']}")
|
|
420
|
+
print(f" ✓ {r['rel']} (staged rename — commit & push to share)")
|
|
421
|
+
elif outcome == "archived_local":
|
|
422
|
+
archived += 1
|
|
423
|
+
print(f" ✓ {r['rel']} (moved on disk; not git-tracked)")
|
|
385
424
|
else:
|
|
386
425
|
skipped += 1
|
|
387
|
-
print(f" ✗ {r['rel']} ({outcome or '
|
|
426
|
+
print(f" ✗ {r['rel']} ({outcome or 'move failed'})")
|
|
388
427
|
print(f"Archived {archived}, skipped {skipped}.")
|
|
389
428
|
return 0
|
|
390
429
|
|
|
@@ -462,7 +501,22 @@ def run(args: list) -> int:
|
|
|
462
501
|
docs = [d for d in docs if d.kind == type_filter]
|
|
463
502
|
|
|
464
503
|
stall_days = _resolve_stall_days(flags)
|
|
465
|
-
|
|
504
|
+
# Batch EVERY git date the scan needs — each doc's own rel plus every declared
|
|
505
|
+
# path (Modify paths drive score_manifest's committed_since, the real O(n)
|
|
506
|
+
# cost) — into one chunked git walk (#391), instead of a subprocess per path.
|
|
507
|
+
# This is what hung the Plans view on large repos (CritForge: 391 docs +
|
|
508
|
+
# 1434 declared paths = ~1800 git spawns → ~40s, now a handful of walks).
|
|
509
|
+
batch_paths = set()
|
|
510
|
+
for d in docs:
|
|
511
|
+
batch_paths.add(d.rel)
|
|
512
|
+
try:
|
|
513
|
+
for dp in manifest.parse_declared_paths(_read(d.path)):
|
|
514
|
+
batch_paths.add(dp.path)
|
|
515
|
+
except OSError:
|
|
516
|
+
pass
|
|
517
|
+
last_dates = git_state.paths_last_commit_dates(sorted(batch_paths), repo_root)
|
|
518
|
+
rows = [_evaluate(d, repo_root, today, dead_days, stall_days, last_dates)
|
|
519
|
+
for d in docs]
|
|
466
520
|
|
|
467
521
|
if flags.get("--llm"):
|
|
468
522
|
if flags.get("--apply"):
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
"""The archive move primitive: collision-checked
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
"""The archive move primitive: collision-checked move of a doc into
|
|
2
|
+
archive/<kind>/. For tracked files the move is a history-preserving `git mv`
|
|
3
|
+
(staged rename); for untracked/gitignored files it falls back to a plain
|
|
4
|
+
filesystem move. Eligibility (is-this-shipped) is the caller's job — this
|
|
5
|
+
only moves files."""
|
|
6
|
+
import shutil
|
|
4
7
|
from pathlib import Path
|
|
5
8
|
|
|
6
9
|
from lib import git_state
|
|
@@ -8,14 +11,26 @@ from lib import reconcile_actions
|
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
def move_to_archive(rel: str, repo_root, kind: str):
|
|
11
|
-
"""
|
|
12
|
-
"archived"
|
|
13
|
-
"
|
|
14
|
-
|
|
14
|
+
"""Move `rel` into archive/<kind>/. Returns:
|
|
15
|
+
"archived" tracked file: history-preserving `git mv` (staged rename),
|
|
16
|
+
"archived_local" untracked/gitignored file: plain filesystem move,
|
|
17
|
+
"skipped_collision" destination already exists (never overwrite),
|
|
18
|
+
None hard failure (git mv failed on a tracked file, or OSError
|
|
19
|
+
on an untracked file).
|
|
15
20
|
"""
|
|
16
21
|
dest = reconcile_actions.archive_dest(rel, kind)
|
|
17
22
|
if (Path(repo_root) / dest).exists():
|
|
18
23
|
return "skipped_collision"
|
|
19
|
-
if git_state.
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
if git_state.is_tracked(rel, Path(repo_root)):
|
|
25
|
+
if git_state.git_mv(rel, dest, repo_root):
|
|
26
|
+
return "archived"
|
|
27
|
+
return None
|
|
28
|
+
# Untracked / gitignored — plain filesystem move.
|
|
29
|
+
src_path = Path(repo_root) / rel
|
|
30
|
+
dst_path = Path(repo_root) / dest
|
|
31
|
+
try:
|
|
32
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
shutil.move(str(src_path), str(dst_path))
|
|
34
|
+
return "archived_local"
|
|
35
|
+
except OSError:
|
|
36
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Track blocker normalization — the Python sibling of the viewer's blockerIssue().
|
|
2
|
+
|
|
3
|
+
A `blockers:` frontmatter entry is usually an issue number, but it may also be a
|
|
4
|
+
free-text note (e.g. "gated on the cost go/no-go verdict, needs #5548 telemetry").
|
|
5
|
+
Consumers that render a blocker with a leading `#` must funnel it through
|
|
6
|
+
`blocker_display` first, or a prose blocker prints as `#<sentence>`; the next-up
|
|
7
|
+
gate must funnel it through `blocker_issue`, or a string-form ref ("5550") never
|
|
8
|
+
matches the integer issue number it should exclude.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_ISSUE_REF = re.compile(r"^\s*#?(\d+)\s*$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def blocker_issue(value):
|
|
17
|
+
"""The issue number a blocker refers to, or None for free-text.
|
|
18
|
+
|
|
19
|
+
Mirrors the viewer's `blockerIssue`: a bare int, "5550", or "#5550" resolves
|
|
20
|
+
to the number; anything else (prose, even when it embeds a `#5550`) is
|
|
21
|
+
free-text. A leading-zero string like "007" is treated as free-text rather
|
|
22
|
+
than silently coerced to 7. `bool` is rejected (it is an int subclass).
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(value, bool):
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(value, int):
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
m = _ISSUE_REF.match(value)
|
|
30
|
+
if m is not None:
|
|
31
|
+
digits = m.group(1)
|
|
32
|
+
if str(int(digits)) == digits: # reject leading-zero "007"
|
|
33
|
+
return int(digits)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def blocker_display(value):
|
|
38
|
+
"""How to show a blocker: `#N` for an issue ref, the prose itself otherwise."""
|
|
39
|
+
n = blocker_issue(value)
|
|
40
|
+
return f"#{n}" if n is not None else str(value)
|
|
@@ -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
|
|
|
@@ -272,6 +358,18 @@ def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
|
|
|
272
358
|
return proc is not None and proc.returncode == 0 and bool(proc.stdout.strip())
|
|
273
359
|
|
|
274
360
|
|
|
361
|
+
def is_tracked(rel_path: str, repo_path: Path) -> bool:
|
|
362
|
+
"""True iff `rel_path` is tracked by git (git ls-files --error-unmatch).
|
|
363
|
+
|
|
364
|
+
False for untracked/gitignored paths AND when repo_path isn't a git repo
|
|
365
|
+
or doesn't exist. Never raises.
|
|
366
|
+
"""
|
|
367
|
+
if not repo_path or not Path(repo_path).exists():
|
|
368
|
+
return False
|
|
369
|
+
proc = _git(repo_path, "ls-files", "--error-unmatch", rel_path)
|
|
370
|
+
return proc is not None and proc.returncode == 0
|
|
371
|
+
|
|
372
|
+
|
|
275
373
|
def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
|
|
276
374
|
"""git-mv `src_rel` -> `dst_rel` (both repo-relative), creating the dest
|
|
277
375
|
directory first. Returns True on success. History-preserving."""
|
|
@@ -40,6 +40,7 @@ from __future__ import annotations
|
|
|
40
40
|
from datetime import datetime
|
|
41
41
|
from typing import Iterable, Optional
|
|
42
42
|
|
|
43
|
+
from lib.blockers import blocker_issue
|
|
43
44
|
from lib.github_state import extract_priority, short_milestone
|
|
44
45
|
|
|
45
46
|
PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
@@ -151,7 +152,10 @@ def suggest_next_up(
|
|
|
151
152
|
List of issue numbers, highest-ranked first. Empty if nothing
|
|
152
153
|
qualifies (e.g., everything closed or blocked).
|
|
153
154
|
"""
|
|
154
|
-
blockers
|
|
155
|
+
# Normalize blockers to issue numbers: a string-form ref ("5550"/"#5550")
|
|
156
|
+
# must still exclude issue 5550, and a free-text blocker resolves to None
|
|
157
|
+
# (dropped) so it harmlessly gates nothing.
|
|
158
|
+
blockers = {n for n in (blocker_issue(b) for b in (blocker_nums or [])) if n is not None}
|
|
155
159
|
in_progress = set(in_progress_nums or [])
|
|
156
160
|
# Resolve order: None → default preset; unknown names in list → skipped.
|
|
157
161
|
effective_order = order if order is not None else PRESETS[DEFAULT_PRESET]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Compose terminal output strings."""
|
|
2
2
|
|
|
3
|
+
from lib.blockers import blocker_display
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
def time_aware_framing(gap_seconds: int, current_hour: int, handoff_today: bool = True) -> str:
|
|
5
7
|
"""Adapt framing to gap-since-last-activity + hour."""
|
|
@@ -66,7 +68,9 @@ def render_track_row(t: dict) -> str:
|
|
|
66
68
|
if t["blockers"]:
|
|
67
69
|
for b in t["blockers"]:
|
|
68
70
|
reason = b.get("reason", "manually flagged")
|
|
69
|
-
|
|
71
|
+
# blocker_display: `#N` for an issue ref, the prose verbatim for a
|
|
72
|
+
# free-text blocker (a raw `#{number}` would print `#<sentence>`).
|
|
73
|
+
lines.append(f" Blocker: {blocker_display(b['number'])} — {reason}")
|
|
70
74
|
else:
|
|
71
75
|
lines.append(" Blockers: none")
|
|
72
76
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Pure archive helpers: destination path + shipped-row selection (offline)."""
|
|
2
2
|
import sys
|
|
3
|
+
import tempfile
|
|
3
4
|
import unittest
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
from unittest import mock
|
|
5
7
|
|
|
6
8
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
7
9
|
sys.path.insert(0, str(SKILL_ROOT))
|
|
8
10
|
|
|
9
11
|
from lib import reconcile_actions as ra
|
|
12
|
+
from lib import archive as archive_lib
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
class ArchiveDestTest(unittest.TestCase):
|
|
@@ -46,11 +49,6 @@ class ShippedRowsTest(unittest.TestCase):
|
|
|
46
49
|
self.assertEqual(rels, ["a.md", "b.md"])
|
|
47
50
|
|
|
48
51
|
|
|
49
|
-
import tempfile
|
|
50
|
-
from unittest import mock
|
|
51
|
-
from lib import archive as archive_lib
|
|
52
|
-
|
|
53
|
-
|
|
54
52
|
class MoveToArchiveTest(unittest.TestCase):
|
|
55
53
|
def _repo(self, d):
|
|
56
54
|
root = Path(d)
|
|
@@ -58,33 +56,77 @@ class MoveToArchiveTest(unittest.TestCase):
|
|
|
58
56
|
(root / "docs/plans/x.md").write_text("# x")
|
|
59
57
|
return root
|
|
60
58
|
|
|
61
|
-
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# tracked path: branches through git_mv
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def test_tracked_archived_calls_git_mv(self):
|
|
64
|
+
"""Tracked file: is_tracked=True -> git_mv called -> 'archived'."""
|
|
62
65
|
with tempfile.TemporaryDirectory() as d:
|
|
63
66
|
root = self._repo(d)
|
|
64
|
-
with mock.patch("lib.archive.git_state.
|
|
67
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
|
|
68
|
+
mock.patch("lib.archive.git_state.git_mv", return_value=True) as mv:
|
|
65
69
|
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
66
70
|
self.assertEqual(outcome, "archived")
|
|
67
71
|
mv.assert_called_once_with(
|
|
68
72
|
"docs/plans/x.md", "docs/plans/archive/shipped/x.md", root)
|
|
69
73
|
|
|
70
|
-
def
|
|
74
|
+
def test_tracked_git_mv_failure_returns_none(self):
|
|
75
|
+
"""Tracked file where git_mv fails -> None (hard error)."""
|
|
71
76
|
with tempfile.TemporaryDirectory() as d:
|
|
72
77
|
root = self._repo(d)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(dest / "x.md").write_text("# already here")
|
|
76
|
-
with mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
78
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
|
|
79
|
+
mock.patch("lib.archive.git_state.git_mv", return_value=False):
|
|
77
80
|
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
78
|
-
self.
|
|
81
|
+
self.assertIsNone(outcome)
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# untracked/gitignored path: plain filesystem move
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def test_untracked_archived_local_via_filesystem(self):
|
|
88
|
+
"""Untracked file: is_tracked=False -> shutil.move called -> 'archived_local'.
|
|
89
|
+
|
|
90
|
+
The file must actually move on disk (src gone, dest exists)."""
|
|
91
|
+
with tempfile.TemporaryDirectory() as d:
|
|
92
|
+
root = self._repo(d)
|
|
93
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
|
|
94
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
95
|
+
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
96
|
+
self.assertEqual(outcome, "archived_local")
|
|
79
97
|
mv.assert_not_called()
|
|
98
|
+
# Source is gone; dest exists.
|
|
99
|
+
self.assertFalse((root / "docs/plans/x.md").exists())
|
|
100
|
+
self.assertTrue((root / "docs/plans/archive/shipped/x.md").exists())
|
|
80
101
|
|
|
81
|
-
def
|
|
102
|
+
def test_untracked_oserror_returns_none(self):
|
|
103
|
+
"""Untracked file where shutil.move raises OSError -> None."""
|
|
82
104
|
with tempfile.TemporaryDirectory() as d:
|
|
83
105
|
root = self._repo(d)
|
|
84
|
-
with mock.patch("lib.archive.git_state.
|
|
106
|
+
with mock.patch("lib.archive.git_state.is_tracked", return_value=False), \
|
|
107
|
+
mock.patch("lib.archive.shutil.move",
|
|
108
|
+
side_effect=OSError("permission denied")):
|
|
85
109
|
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
86
110
|
self.assertIsNone(outcome)
|
|
87
111
|
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# collision: checked before tracking branch, git_mv never called
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def test_collision_skips_without_any_move(self):
|
|
117
|
+
"""Destination already exists -> 'skipped_collision', no move attempted."""
|
|
118
|
+
with tempfile.TemporaryDirectory() as d:
|
|
119
|
+
root = self._repo(d)
|
|
120
|
+
dest = root / "docs/plans/archive/shipped"
|
|
121
|
+
dest.mkdir(parents=True)
|
|
122
|
+
(dest / "x.md").write_text("# already here")
|
|
123
|
+
with mock.patch("lib.archive.git_state.is_tracked") as is_tracked, \
|
|
124
|
+
mock.patch("lib.archive.git_state.git_mv") as mv:
|
|
125
|
+
outcome = archive_lib.move_to_archive("docs/plans/x.md", root, "shipped")
|
|
126
|
+
self.assertEqual(outcome, "skipped_collision")
|
|
127
|
+
is_tracked.assert_not_called()
|
|
128
|
+
mv.assert_not_called()
|
|
129
|
+
|
|
88
130
|
|
|
89
131
|
if __name__ == "__main__":
|
|
90
132
|
unittest.main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Tests for lib.blockers — blocker_issue / blocker_display normalization."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
from lib.blockers import blocker_issue, blocker_display
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestBlockerIssue(unittest.TestCase):
|
|
9
|
+
def test_bare_int_is_its_own_ref(self):
|
|
10
|
+
self.assertEqual(blocker_issue(5550), 5550)
|
|
11
|
+
|
|
12
|
+
def test_pure_id_strings_resolve(self):
|
|
13
|
+
self.assertEqual(blocker_issue("5550"), 5550)
|
|
14
|
+
self.assertEqual(blocker_issue("#5550"), 5550)
|
|
15
|
+
self.assertEqual(blocker_issue(" #5550 "), 5550)
|
|
16
|
+
|
|
17
|
+
def test_prose_is_free_text_even_with_embedded_ref(self):
|
|
18
|
+
# Must NOT extract 5550 — it's an active next_up item being described.
|
|
19
|
+
self.assertIsNone(
|
|
20
|
+
blocker_issue("#5550 selective routing is gated on the verdict, needs #5548")
|
|
21
|
+
)
|
|
22
|
+
self.assertIsNone(blocker_issue("waiting on design review"))
|
|
23
|
+
|
|
24
|
+
def test_leading_zero_is_free_text(self):
|
|
25
|
+
# "007" must not silently become issue 7.
|
|
26
|
+
self.assertIsNone(blocker_issue("007"))
|
|
27
|
+
|
|
28
|
+
def test_bool_and_empty_are_free_text(self):
|
|
29
|
+
self.assertIsNone(blocker_issue(True))
|
|
30
|
+
self.assertIsNone(blocker_issue(""))
|
|
31
|
+
self.assertIsNone(blocker_issue("#"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestBlockerDisplay(unittest.TestCase):
|
|
35
|
+
def test_issue_ref_gets_hash_prefix(self):
|
|
36
|
+
self.assertEqual(blocker_display(5550), "#5550")
|
|
37
|
+
self.assertEqual(blocker_display("#5550"), "#5550")
|
|
38
|
+
self.assertEqual(blocker_display("5550"), "#5550")
|
|
39
|
+
|
|
40
|
+
def test_free_text_shown_verbatim_without_hash(self):
|
|
41
|
+
prose = "gated on the cost go/no-go verdict"
|
|
42
|
+
self.assertEqual(blocker_display(prose), prose)
|
|
43
|
+
self.assertNotIn("#", blocker_display(prose))
|
|
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()
|
|
@@ -43,7 +43,9 @@ class PlanArchiveTest(unittest.TestCase):
|
|
|
43
43
|
def test_archives_shipped_with_yes_json(self):
|
|
44
44
|
with tempfile.TemporaryDirectory() as d:
|
|
45
45
|
root = _repo(d, SHIPPED_BODY)
|
|
46
|
-
with mock.patch("commands.plan_archive.archive_lib.git_state.
|
|
46
|
+
with mock.patch("commands.plan_archive.archive_lib.git_state.is_tracked",
|
|
47
|
+
return_value=True), \
|
|
48
|
+
mock.patch("commands.plan_archive.archive_lib.git_state.git_mv",
|
|
47
49
|
return_value=True) as mv:
|
|
48
50
|
rc, out = _run(root, ["--repo=x", "--yes", "--json",
|
|
49
51
|
"--", "docs/plans/p.md"])
|
|
@@ -25,11 +25,23 @@ 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
|
+
#
|
|
33
|
+
# _archive_dead now routes through archive_lib.move_to_archive, so patch
|
|
34
|
+
# is_tracked + git_mv on lib.archive.git_state (not plan_status.git_state).
|
|
35
|
+
stale = datetime(2026, 1, 1)
|
|
36
|
+
batched = mock.MagicMock()
|
|
37
|
+
batched.get.return_value = stale
|
|
29
38
|
with mock.patch("commands.plan_status.git_state.path_last_commit_date",
|
|
30
|
-
return_value=
|
|
39
|
+
return_value=stale), \
|
|
40
|
+
mock.patch("commands.plan_status.git_state.paths_last_commit_dates",
|
|
41
|
+
return_value=batched), \
|
|
31
42
|
mock.patch("commands.plan_status.Path.cwd", return_value=root), \
|
|
32
|
-
mock.patch("
|
|
43
|
+
mock.patch("lib.archive.git_state.is_tracked", return_value=True), \
|
|
44
|
+
mock.patch("lib.archive.git_state.git_mv",
|
|
33
45
|
return_value=mv_ok) as mv, \
|
|
34
46
|
mock.patch("commands.plan_status.prompt_yes_no", return_value=True):
|
|
35
47
|
buf = io.StringIO()
|
|
@@ -107,7 +107,9 @@ class ArchiveShippedBatchTest(unittest.TestCase):
|
|
|
107
107
|
# to "no" on non-TTY stdin) and archive, mirroring per-doc plan-archive.
|
|
108
108
|
with tempfile.TemporaryDirectory() as d:
|
|
109
109
|
root = _repo_batch(d)
|
|
110
|
-
with mock.patch("commands.plan_status.archive_lib.git_state.
|
|
110
|
+
with mock.patch("commands.plan_status.archive_lib.git_state.is_tracked",
|
|
111
|
+
return_value=True), \
|
|
112
|
+
mock.patch("commands.plan_status.archive_lib.git_state.git_mv",
|
|
111
113
|
return_value=True) as mv:
|
|
112
114
|
rc, out = _run(root, ["--archive-shipped", "--yes"])
|
|
113
115
|
self.assertEqual(rc, 0)
|