@stylusnexus/work-plan 2026.6.21 → 2026.6.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -565,7 +567,7 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
565
567
 
566
568
  **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
569
 
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…**.
570
+ 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
571
 
570
572
  ## Version
571
573
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.21+4cab67a
1
+ 2026.06.22+f62c4cc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.21",
3
+ "version": "2026.6.22",
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"
@@ -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 _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
123
+ def _committed_since_from_map(last_dates, plan_date, repo_root):
124
+ """`committed_since(rel)` served from the batched date map (#391), mirroring
125
+ manifest.score_manifest's default: with no plan date, degrade to a filesystem
126
+ existence check; otherwise "committed on/after plan_date" is `last-commit-date
127
+ >= plan_date - 1 day` (the same 1-day window `path_committed_since` widens to,
128
+ so same-day Modify commits still count)."""
129
+ if plan_date is None:
130
+ return lambda rel: (Path(repo_root) / rel).exists()
131
+ cutoff = plan_date - timedelta(days=1)
132
+
133
+ def committed_since(rel):
134
+ dt = last_dates.get(rel)
135
+ return dt is not None and dt.date() >= cutoff
136
+
137
+ return committed_since
138
+
139
+
140
+ def _evaluate(doc, repo_root, today, dead_days, stall_days, last_dates=None) -> dict:
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
- score = manifest.score_manifest(decls, repo_root, pdate)
144
+ # `last_dates` is the batched {path: datetime} map precomputed in run() (#391)
145
+ # over every doc rel AND every declared path — one chunked git walk for the
146
+ # whole repo. When present, serve score_manifest's per-Modify-path
147
+ # `committed_since` (the real O(n) cost — 1434 git spawns on CritForge), the
148
+ # doc's own last-commit date, and the partial-staleness clock from it: zero
149
+ # git spawns in this loop. When absent (direct callers / tests), fall back to
150
+ # the per-path git calls so behavior is unchanged.
151
+ cs = _committed_since_from_map(last_dates, pdate, repo_root) if last_dates is not None else None
152
+ score = manifest.score_manifest(decls, repo_root, pdate, committed_since=cs)
128
153
  done, total_chk = manifest.count_checkboxes(text)
129
- last_dt = git_state.path_last_commit_date(doc.rel, repo_root)
154
+ if last_dates is not None:
155
+ last_dt = last_dates.get(doc.rel)
156
+ else:
157
+ last_dt = git_state.path_last_commit_date(doc.rel, repo_root)
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
- manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
182
+ if last_dates is not None:
183
+ ds = [last_dates[p] for p in on_disk if p in last_dates]
184
+ manifest_dt = max(ds) if ds else None
185
+ else:
186
+ manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
155
187
  if manifest_dt is None:
156
188
  stalled = True # present on disk but never committed
157
189
  else:
@@ -462,7 +494,22 @@ def run(args: list) -> int:
462
494
  docs = [d for d in docs if d.kind == type_filter]
463
495
 
464
496
  stall_days = _resolve_stall_days(flags)
465
- rows = [_evaluate(d, repo_root, today, dead_days, stall_days) for d in docs]
497
+ # Batch EVERY git date the scan needs each doc's own rel plus every declared
498
+ # path (Modify paths drive score_manifest's committed_since, the real O(n)
499
+ # cost) — into one chunked git walk (#391), instead of a subprocess per path.
500
+ # This is what hung the Plans view on large repos (CritForge: 391 docs +
501
+ # 1434 declared paths = ~1800 git spawns → ~40s, now a handful of walks).
502
+ batch_paths = set()
503
+ for d in docs:
504
+ batch_paths.add(d.rel)
505
+ try:
506
+ for dp in manifest.parse_declared_paths(_read(d.path)):
507
+ batch_paths.add(dp.path)
508
+ except OSError:
509
+ pass
510
+ last_dates = git_state.paths_last_commit_dates(sorted(batch_paths), repo_root)
511
+ rows = [_evaluate(d, repo_root, today, dead_days, stall_days, last_dates)
512
+ for d in docs]
466
513
 
467
514
  if flags.get("--llm"):
468
515
  if flags.get("--apply"):
@@ -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
 
@@ -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 = set(blocker_nums or [])
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
- lines.append(f" Blocker: #{b['number']} {reason}")
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
 
@@ -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()
@@ -25,9 +25,17 @@ class ArchiveTest(unittest.TestCase):
25
25
  return root
26
26
 
27
27
  def _run(self, root, args, mv_ok=True):
28
- # stale last-commit (well beyond DEAD_DAYS) so the absent-file plan is dead
28
+ # stale last-commit (well beyond DEAD_DAYS) so the absent-file plan is dead.
29
+ # run() now reads the batched paths_last_commit_dates map (#391); mock its
30
+ # .get to return the stale date for every doc (path_last_commit_date is the
31
+ # fallback path, mocked too).
32
+ stale = datetime(2026, 1, 1)
33
+ batched = mock.MagicMock()
34
+ batched.get.return_value = stale
29
35
  with mock.patch("commands.plan_status.git_state.path_last_commit_date",
30
- return_value=datetime(2026, 1, 1)), \
36
+ return_value=stale), \
37
+ mock.patch("commands.plan_status.git_state.paths_last_commit_dates",
38
+ return_value=batched), \
31
39
  mock.patch("commands.plan_status.Path.cwd", return_value=root), \
32
40
  mock.patch("commands.plan_status.git_state.git_mv",
33
41
  return_value=mv_ok) as mv, \