@caddis/cli 0.4.52 → 0.4.53

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.
Files changed (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -32,13 +32,25 @@ DESIGN (locked, see the plan doc — do not re-litigate here)
32
32
  whose document is gone are only counted, never deleted. Only a file git both ignores and does not track qualifies. Relays,
33
33
  to-dos, symlinks and anything outside `.caddis/` are never touched; lane worktrees older than
34
34
  7 days are only reported. It lists by default; `--prune --apply` deletes.
35
+ - Tidy is part of finishing (caddis-next Phase 13): `--check` also fails on a plan whose Tracker
36
+ is all done but whose status is still active, because the finishing PR should have closed it.
37
+ `--check --commit-range` checks only the files `git diff` says the range changed, so problems
38
+ already in a repo never block an unrelated PR. It first refuses when any of those files has an
39
+ uncommitted change (the check would read a file that is not what the PR ships), and a range git
40
+ cannot read prints only that error, never an unfiltered list.
41
+ - `--only <plan> [<plan> ...]` limits a run to those top-level plans: nothing else is moved or
42
+ stamped. `/implement` closes the plan it finished with it, so another session's finished work
43
+ is never swept into this PR.
35
44
 
36
45
  Usage:
37
46
  python scripts/caddis_tidy.py # report only (default = dry-run)
38
47
  python scripts/caddis_tidy.py --apply # actually move terminal-status artifacts
48
+ python scripts/caddis_tidy.py --apply --only .caddis/plans/<plan>.md # close this plan only
39
49
  python scripts/caddis_tidy.py --check # conformance check; exit 1 on a violation
40
50
  python scripts/caddis_tidy.py --repo-root <p> # scan a different repo (default: cwd)
41
51
  python scripts/caddis_tidy.py --commit-range origin/main..HEAD # also read parking trailers
52
+ python scripts/caddis_tidy.py --check --commit-range origin/main..HEAD # check only the files
53
+ # this range changed (what /ship-pr runs)
42
54
  python scripts/caddis_tidy.py --prune # list old local state that could be deleted
43
55
  python scripts/caddis_tidy.py --prune --apply # delete it (the user's choice, never automatic)
44
56
  """
@@ -83,17 +95,14 @@ TERMINAL_STATUSES = {"done", "superseded", "shipped", "implemented"}
83
95
  LEGACY_FRONTMATTER_EXEMPT = {
84
96
  "prompts/caddis-rename-and-publish.md",
85
97
  "prompts/db-diagram-fix-er-arrows-theme-formatting.md",
86
- "prompts/docket-phase-2c-ux-correctness.md",
87
- "prompts/docket-phase-3-f12-worktree.md",
88
- "prompts/docket-reaudit-f12-and-pipeline-runner.md",
89
98
  "prompts/driver-remaining-toolchain-work.md",
90
99
  "prompts/fable-inspect-claudster.md",
91
- "prompts/fable-inspect-docket.md",
92
- "prompts/fable-verify-docket-reaudit.md",
93
100
  }
94
101
 
95
102
  # A Markdown table rule such as `|---|:---:|`.
96
103
  _TABLE_RULE_RE = re.compile(r"^\|(\s*:?-+:?\s*\|)+\s*$")
104
+ # A plan's phase heading, `### Phase 3 ...` or `### Phase 3b ...`; group 1 is the phase id.
105
+ _PHASE_HEADING_RE = re.compile(r"^###\s+Phase\s+(\d+[A-Za-z]?)(?![A-Za-z0-9])", re.M)
97
106
  # Commit trailers that close or annotate a parking-lot item. Keys compare case-insensitively,
98
107
  # as git's own trailer keys do.
99
108
  _PARKED_TRAILERS = {"resolves-parked": "resolves", "partly-resolves-parked": "partly"}
@@ -105,7 +114,7 @@ _KINDS = ("plans", "prompts", "parking-lot")
105
114
  # ── parking-lot: the ONE register of future work ────────────────────────────────────────────
106
115
  # Before this landed, future work lived in nine places at once (measured 2026-08-14 in this repo):
107
116
  # individual parking-lot files, a 90 KB `future-work-register.md`, parked plans, relay.md's
108
- # "things owed", KB notes, `.caddis/plans/backlog/`, the comms register, docket, and the Hub's
117
+ # "things owed", KB notes, `.caddis/plans/backlog/`, the comms register, an external task board, and the Hub's
109
118
  # carried-open lists. Nothing was wrong with any single one; the DEFECT was that a reader had to
110
119
  # know all nine to answer "what is left to do?". Nobody did, so items were re-raised and dropped.
111
120
  #
@@ -165,9 +174,75 @@ def tracker_complete(text: str) -> bool:
165
174
  seen.add(number)
166
175
  if not caddis_todo._is_done(cells[status_at]):
167
176
  return False
177
+ # A `### Phase N` heading with no row for N is a phase the Tracker forgot, not a finished one.
178
+ # Ids compare as written: a `3b` heading needs a `3b` row, and a row is a whole number, so a
179
+ # plan with a lettered phase is never closed by tidy (a look costs less than a hidden plan).
180
+ rows = {str(n) for n in seen}
181
+ if any(m.group(1).lower() not in rows for m in _PHASE_HEADING_RE.finditer(text)):
182
+ return False
168
183
  return bool(seen)
169
184
 
170
185
 
186
+ # A cell that STARTS with `dropped:` and a reason excuses an open row. `not dropped: x` does not.
187
+ _DROPPED_CELL_RE = re.compile(r"^dropped:\s*\S", re.IGNORECASE)
188
+ _PARKING_REF_RE = re.compile(r"\.caddis/parking-lot/(?:done/)?([A-Za-z0-9][A-Za-z0-9._-]*\.md)")
189
+ # Same phase-id rule as caddis_roadmap: `2`, `2a`, `Phase 2`, `P2`.
190
+ _ROW_PHASE_RE = re.compile(r"^\|\s*(?:phase\s+|p)?(\d+[a-z]?)\b", re.IGNORECASE)
191
+ _UNESCAPED_PIPE_RE = re.compile(r"(?<!\\)\|")
192
+
193
+
194
+ def _row_cells(line: str) -> list[str]:
195
+ """Stripped cells of one table line, split on unescaped pipes only (as caddis_rows.split_cells)."""
196
+ parts = _UNESCAPED_PIPE_RE.split(line.strip())
197
+ if parts and parts[0] == "":
198
+ parts.pop(0)
199
+ if parts and parts[-1] == "":
200
+ parts.pop(-1)
201
+ return [part.strip() for part in parts]
202
+
203
+
204
+ def open_tracker_rows(path: Path) -> list[tuple[str, str]]:
205
+ """Return `(phase_id, row_line)` for every open phase row in `path`'s `## Tracker` table.
206
+
207
+ No Tracker, or a Tracker with no Status column, gives [].
208
+ """
209
+ try:
210
+ text = path.read_text(encoding="utf-8", errors="ignore")
211
+ except OSError:
212
+ return []
213
+ table = caddis_todo.tracker_table(text)
214
+ if table is None:
215
+ return []
216
+ columns, lines = table
217
+ if "status" not in columns:
218
+ return []
219
+ status_at = columns.index("status")
220
+ out: list[tuple[str, str]] = []
221
+ for line in lines:
222
+ match = _ROW_PHASE_RE.match(line.strip())
223
+ if match is None or _TABLE_RULE_RE.match(line.strip()):
224
+ continue
225
+ cells = _row_cells(line)
226
+ if len(cells) <= status_at:
227
+ continue
228
+ if not caddis_todo._is_done(cells[status_at]):
229
+ out.append((match.group(1), line))
230
+ return out
231
+
232
+
233
+ def _row_excused(repo_root: Path, row_line: str) -> bool:
234
+ """True when one cell starts with `dropped: <reason>`, or a cell names a parking-lot file that
235
+ exists (open, or already moved to `.caddis/parking-lot/done/`)."""
236
+ lot = repo_root / ".caddis" / PARKING_LOT_DIR
237
+ for cell in _row_cells(row_line):
238
+ if _DROPPED_CELL_RE.match(cell):
239
+ return True
240
+ for name in _PARKING_REF_RE.findall(cell):
241
+ if (lot / name).is_file() or (lot / "done" / name).is_file():
242
+ return True
243
+ return False
244
+
245
+
171
246
  def _set_header(text: str, fields: dict[str, str]) -> str | None:
172
247
  """`text` with these top-level header keys set, every other byte kept. None when there is no
173
248
  header the shared reader accepts, or when the reader would not read the new values back."""
@@ -276,12 +351,35 @@ class Report:
276
351
  # useless to the agent that has to fix it - it needs to know WHICH rule and WHAT the legal
277
352
  # values are, in the failure text, without opening another document.
278
353
  violations: list[tuple[Path, str]] = field(default_factory=list)
354
+ # --check only: plans whose Tracker is all done while `status:` is still active, with that
355
+ # status. The finishing PR should have run --apply; these fail --check.
356
+ tracker_done_open: list[tuple[Path, str]] = field(default_factory=list)
357
+ # --commit-range only: repo-relative posix paths the range changed.
358
+ in_range: set[str] | None = None
359
+ # --only only: named plans that did not move, with the reason.
360
+ not_moved: list[tuple[Path, str]] = field(default_factory=list)
361
+ # --apply only: files left alone because git shows uncommitted changes in them (another
362
+ # session's edit in a shared checkout). Neither moved nor rewritten; not an error.
363
+ dirty_skipped: list[Path] = field(default_factory=list)
364
+ # --apply only: git status could not be read in a git repo, so nothing was moved or rewritten.
365
+ dirty_check_failed: str | None = None
366
+ # Terminal plans (`done`/`shipped`/`implemented`) held at the top level because `## Tracker`
367
+ # still has unexcused open rows: (plan_path, [phase_ids]).
368
+ open_rows_held: list[tuple[Path, list[str]]] = field(default_factory=list)
369
+ # True when the run stopped before reading any artifact (an unreadable range, dirty files in
370
+ # the range, a bad --only path). Only `errors` means anything then.
371
+ refused: bool = False
279
372
 
280
373
  @property
281
374
  def blocked(self) -> bool:
282
375
  """True when --apply must not (or did not) finish: a collision or an error."""
283
376
  return bool(self.collisions or self.errors)
284
377
 
378
+ def refuse(self, message: str) -> "Report":
379
+ self.errors.append(message)
380
+ self.refused = True
381
+ return self
382
+
285
383
  def check_violations(self) -> int:
286
384
  return len(self.check_flagged)
287
385
 
@@ -317,6 +415,29 @@ def _collides(src: Path) -> bool:
317
415
  return (done_dir.exists() and not done_dir.is_dir()) or dst.exists() or dst.is_symlink()
318
416
 
319
417
 
418
+ def _dirty_paths(repo_root: Path) -> set[str] | None:
419
+ """Repo-relative posix paths with uncommitted changes to tracked files (modified, staged, renamed,
420
+ deleted), from `git status --porcelain -z`. Untracked files are not listed: tidy moves them with a
421
+ plain rename that stages nothing. None when git status cannot be read."""
422
+ result = _git_run(repo_root, "status", "--porcelain", "-z", "--untracked-files=no")
423
+ if result is None or result.returncode != 0:
424
+ return None
425
+ dirty: set[str] = set()
426
+ entries = result.stdout.decode("utf-8", "replace").split("\0")
427
+ i = 0
428
+ while i < len(entries):
429
+ entry = entries[i]
430
+ i += 1
431
+ if len(entry) < 4:
432
+ continue
433
+ dirty.add(entry[3:])
434
+ if entry[0] in "RC" or entry[1] in "RC": # a rename or copy names its source in the next field
435
+ if i < len(entries) and entries[i]:
436
+ dirty.add(entries[i])
437
+ i += 1
438
+ return dirty
439
+
440
+
320
441
  def _move_one(repo_root: Path, src: Path, report: Report) -> None:
321
442
  """One named `git mv` into the sibling `done/`; a plain rename when git cannot (no repo, or
322
443
  an untracked file). Only called once every destination is known to be free."""
@@ -382,10 +503,7 @@ def parked_resolutions(root: Path, commit_range: str) -> list[Resolution]:
382
503
  """Every `Resolves-Parked:` / `Partly-Resolves-Parked:` trailer in `commit_range`, oldest
383
504
  commit first. Reads git only; changes nothing. Raises ValueError when git cannot read the
384
505
  range, so the caller can refuse the whole run instead of acting on half an answer."""
385
- # `A..B` only: a bare ref would read the whole history, and `A...B` adds the base's own
386
- # commits, neither of which is the change being tidied.
387
- if commit_range.startswith("-") or commit_range.count("..") != 1 or "..." in commit_range:
388
- raise ValueError(f"not a commit range (use BASE..HEAD): {commit_range!r}")
506
+ _check_range_ok(commit_range)
389
507
  try:
390
508
  done = subprocess.run(
391
509
  ["git", "log", "--reverse", "--date=short", f"--abbrev={_SHA_ABBREV}",
@@ -468,6 +586,101 @@ def _plan_parked(root: Path, commit_range: str, report: Report,
468
586
  report.noted.append(res.path)
469
587
 
470
588
 
589
+ def _check_range_ok(commit_range: str) -> None:
590
+ """`A..B` only: a bare ref would read the whole history, and `A...B` adds the base's own
591
+ commits, neither of which is the change being tidied. Raises ValueError otherwise."""
592
+ if commit_range.startswith("-") or commit_range.count("..") != 1 or "..." in commit_range:
593
+ raise ValueError(f"not a commit range (use BASE..HEAD): {commit_range!r}")
594
+ base, head = commit_range.split("..")
595
+ if not base.strip() or not head.strip():
596
+ # git reads an empty side as HEAD, so `..HEAD` is an empty range that passes every check.
597
+ raise ValueError("commit range has an empty side (name both, e.g. origin/main..HEAD): "
598
+ f"{commit_range!r}")
599
+
600
+
601
+ def changed_paths(root: Path, commit_range: str) -> set[str]:
602
+ """Repo-root-relative posix paths the branch `B` changed since it left `A`, for `A..B`.
603
+
604
+ Read as `git diff A...B` (from the merge base), so a branch that is behind its base does not
605
+ count the base's newer files as its own. Raises ValueError when git cannot read the range."""
606
+ _check_range_ok(commit_range)
607
+ base, head = commit_range.split("..")
608
+ try:
609
+ done = subprocess.run(
610
+ ["git", "diff", "--name-only", "--no-renames", "--relative", "-z",
611
+ f"{base}...{head}", "--"],
612
+ cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
613
+ timeout=30,
614
+ )
615
+ except (OSError, subprocess.SubprocessError) as exc:
616
+ raise ValueError(f"cannot read commit range {commit_range}: {exc}") from exc
617
+ if done.returncode != 0:
618
+ why = next((ln.strip() for ln in done.stderr.splitlines() if ln.strip()), "git diff failed")
619
+ raise ValueError(f"cannot read commit range {commit_range}: {why}")
620
+ return {name for name in done.stdout.split("\0") if name}
621
+
622
+
623
+ def _rev(root: Path, ref: str) -> str | None:
624
+ """The commit `ref` names, or None when git cannot resolve it."""
625
+ try:
626
+ done = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
627
+ cwd=str(root), capture_output=True, text=True, timeout=30)
628
+ except (OSError, subprocess.SubprocessError):
629
+ return None
630
+ return done.stdout.strip() or None if done.returncode == 0 else None
631
+
632
+
633
+ def dirty_paths(root: Path, paths: set[str]) -> list[str]:
634
+ """The subset of `paths` with an uncommitted change (staged, unstaged or untracked), as
635
+ `git status --porcelain -- <paths>` reports them. Raises ValueError when git cannot say."""
636
+ names = sorted(paths)
637
+ dirty: set[str] = set()
638
+ for at in range(0, len(names), 200): # chunks keep the command line short on Windows
639
+ try:
640
+ done = subprocess.run(
641
+ ["git", "--literal-pathspecs", "status", "--porcelain", "-z", "--no-renames",
642
+ "--untracked-files=all", "--", *names[at:at + 200]],
643
+ cwd=str(root), capture_output=True, text=True, encoding="utf-8",
644
+ errors="replace", timeout=60,
645
+ )
646
+ except (OSError, subprocess.SubprocessError) as exc:
647
+ raise ValueError(f"cannot read git status: {exc}") from exc
648
+ if done.returncode != 0:
649
+ raise ValueError(f"cannot read git status: {done.stderr.strip()}")
650
+ dirty.update(entry[3:] for entry in done.stdout.split("\0") if len(entry) > 3)
651
+ return sorted(dirty)
652
+
653
+
654
+ def _only_targets(root: Path, only: list[str]) -> tuple[set[Path], list[str]]:
655
+ """The resolved `--only` plans, and a message for each name that is not a top-level plan."""
656
+ plans = (root / ".caddis" / "plans").resolve()
657
+ targets: set[Path] = set()
658
+ problems: list[str] = []
659
+ for name in only:
660
+ path = Path(name)
661
+ path = (path if path.is_absolute() else root / path).resolve()
662
+ if (path.parent != plans or path.suffix != ".md" or path.name.lower() == "readme.md"
663
+ or not path.is_file()):
664
+ problems.append(f"--only {name}: not a top-level plan file in .caddis/plans/")
665
+ else:
666
+ targets.add(path)
667
+ return targets, problems
668
+
669
+
670
+ def _limit_check_to(root: Path, report: Report, touched: set[str]) -> None:
671
+ """Drop every --check finding whose file the commit range did not change."""
672
+ def keep(path: Path) -> bool:
673
+ try:
674
+ return path.relative_to(root).as_posix() in touched
675
+ except ValueError:
676
+ return False
677
+
678
+ report.check_flagged = [p for p in report.check_flagged if keep(p)]
679
+ report.violations = [(p, m) for p, m in report.violations if keep(p)]
680
+ report.tracker_done_open = [(p, s) for p, s in report.tracker_done_open if keep(p)]
681
+ report.open_rows_held = [(p, ids) for p, ids in report.open_rows_held if keep(p)]
682
+
683
+
471
684
  def _is_legacy_exempt(repo_root: Path, path: Path) -> bool:
472
685
  try:
473
686
  rel = path.relative_to(repo_root / ".caddis").as_posix()
@@ -517,18 +730,47 @@ def _check_parking_lot_item(item: Item, report: Report) -> None:
517
730
 
518
731
 
519
732
  def tidy(repo_root: Path, apply: bool = False, check: bool = False,
520
- kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None) -> Report:
733
+ kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None,
734
+ only: list[str] | None = None) -> Report:
521
735
  """Plan every change first, then make all of them or none.
522
736
 
523
737
  Nothing is written until the whole set is known and every destination is free. One existing
524
- `done/<name>` (or an unreadable commit range) leaves every file exactly as it was.
738
+ `done/<name>` (or an unreadable commit range) leaves every file exactly as it was. `only`
739
+ names the plans this run may touch; nothing else is moved or stamped.
525
740
  """
526
741
  report = Report()
742
+ if commit_range is not None:
743
+ try:
744
+ report.in_range = changed_paths(repo_root, commit_range)
745
+ except ValueError as exc:
746
+ return report.refuse(str(exc))
747
+ if check:
748
+ head = commit_range.split("..")[1]
749
+ if _rev(repo_root, head) != _rev(repo_root, "HEAD"):
750
+ return report.refuse(f"--check reads the working tree, so {commit_range} must "
751
+ "end at HEAD; check out that branch first")
752
+ try:
753
+ dirty = dirty_paths(repo_root, report.in_range) if report.in_range else []
754
+ except ValueError as exc:
755
+ return report.refuse(str(exc))
756
+ if dirty:
757
+ return report.refuse("uncommitted changes in files this range touched: "
758
+ f"{', '.join(dirty)}; commit them first")
759
+ only_set: set[Path] | None = None
760
+ if only is not None:
761
+ only_set, problems = _only_targets(repo_root, only)
762
+ if problems or not only_set:
763
+ report.errors.extend(problems or ["--only names no plan"])
764
+ report.refused = True
765
+ return report
766
+ kinds = ("plans",) # --only names plans: prompts and parking-lot items stay put
527
767
  moves: list[Path] = []
528
768
  rewrites: dict[Path, str] = {} # applied before the moves, so a failed move leaves a
529
769
  # finished status at the top level, where the next run moves it by status alone
530
770
  for kind in kinds:
531
771
  items = scan(repo_root / ".caddis" / kind)
772
+ if only_set is not None:
773
+ items = [item for item in items if item.path.resolve() in only_set]
532
774
  for item in items:
533
775
  if kind == PARKING_LOT_DIR:
534
776
  _check_parking_lot_item(item, report)
@@ -539,6 +781,20 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
539
781
  continue
540
782
  cls = classify_status(item.status)
541
783
  if cls == "terminal":
784
+ if (kind == "plans"
785
+ and (item.status or "").strip().lower() in {"done", "shipped", "implemented"}):
786
+ open_ids = [
787
+ pid for pid, row_line in open_tracker_rows(item.path)
788
+ if not _row_excused(repo_root, row_line)
789
+ ]
790
+ if open_ids:
791
+ report.open_rows_held.append((item.path, open_ids))
792
+ if check:
793
+ report._flag(
794
+ item.path,
795
+ f"held (open Tracker rows): rows {', '.join(open_ids)}",
796
+ )
797
+ continue
542
798
  moves.append(item.path)
543
799
  continue
544
800
  if cls == "none":
@@ -551,6 +807,10 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
551
807
  report.stale_suspects.append(item.path)
552
808
  continue
553
809
  # active status
810
+ if item.phases_all_done and kind == "plans" and check:
811
+ report.tracker_done_open.append((item.path, str(item.status).strip()))
812
+ if item.path not in report.check_flagged:
813
+ report.check_flagged.append(item.path)
554
814
  if item.phases_all_done and kind == "plans":
555
815
  text = _read_exact(item.path)
556
816
  new = None if text is None else _set_header(text, {"status": "done"})
@@ -563,6 +823,20 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
563
823
  report.stale_suspects.append(item.path)
564
824
  if commit_range is not None and PARKING_LOT_DIR in kinds:
565
825
  _plan_parked(repo_root, commit_range, report, moves, rewrites)
826
+ if check and report.in_range is not None:
827
+ _limit_check_to(repo_root, report, report.in_range)
828
+ if only_set is not None:
829
+ planned = {src.resolve() for src in moves}
830
+ # A plan held for open Tracker rows is already reported (exit 2); it is not a failed request.
831
+ held = {p.resolve() for p, _ in report.open_rows_held}
832
+ for item in items: # kinds is ("plans",) here, so `items` is the named plans
833
+ if item.path.resolve() not in planned and item.path.resolve() not in held:
834
+ why = ("no frontmatter status" if classify_status(item.status) == "none" else
835
+ f"status {item.status} and the Tracker is not all done")
836
+ report.not_moved.append((item.path, why))
837
+ seen = {item.path.resolve() for item in items}
838
+ for path in sorted(only_set - seen):
839
+ report.not_moved.append((path, "not a plan this run reads"))
566
840
 
567
841
  report.collisions.extend(src for src in moves if _collides(src))
568
842
  if not apply:
@@ -573,6 +847,28 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
573
847
  # Past this point only an I/O failure can stop the run. Every step is safe to repeat: a
574
848
  # written status or stamp that did not move yet is moved by status on the next run, and a
575
849
  # note already present is not appended again.
850
+ # Never move, stage or rewrite a file with uncommitted changes to tracked content: in a shared checkout
851
+ # that is another session's edit, and a `git mv` stages it for whoever commits next (2026-10-15,
852
+ # serve-sight). A status that tidy itself wrote is such a change too, so that plan moves on a later run,
853
+ # after the status write is committed.
854
+ if _is_git_repo(repo_root):
855
+ dirty = _dirty_paths(repo_root)
856
+ if dirty is None:
857
+ report.dirty_check_failed = "git status could not be read"
858
+ return report
859
+
860
+ def _is_dirty(path: Path) -> bool:
861
+ try:
862
+ rel_path = path.relative_to(repo_root).as_posix()
863
+ except ValueError:
864
+ return True # cannot place it in the repo: fail closed, leave it alone
865
+ return rel_path in dirty
866
+
867
+ for path in sorted({*moves, *rewrites}, key=str):
868
+ if _is_dirty(path) and path not in report.dirty_skipped:
869
+ report.dirty_skipped.append(path)
870
+ moves = [src for src in moves if src not in report.dirty_skipped]
871
+ rewrites = {path: text for path, text in rewrites.items() if path not in report.dirty_skipped}
576
872
  try:
577
873
  for path, text in rewrites.items():
578
874
  atomic_write(path, text)
@@ -834,20 +1130,39 @@ def main(argv: list[str] | None = None) -> int:
834
1130
  ap.add_argument("--repo-root", default=".", help="repo root containing .caddis/ (default: cwd)")
835
1131
  ap.add_argument("--apply", action="store_true", help="actually move files (default: dry-run report)")
836
1132
  ap.add_argument("--check", action="store_true",
837
- help="conformance check - exit 1 if any top-level artifact lacks a known status")
1133
+ help="conformance check - exit 1 if any top-level artifact lacks a known "
1134
+ "status, or a plan's Tracker is all done while its status is active")
838
1135
  ap.add_argument("--commit-range", default=None,
839
1136
  help="also read Resolves-Parked / Partly-Resolves-Parked trailers in these "
840
- "commits only, e.g. origin/main..HEAD (default: read no trailers)")
1137
+ "commits only, e.g. origin/main..HEAD (default: read no trailers); "
1138
+ "with --check, check only the files this range changed")
1139
+ ap.add_argument("--only", nargs="+", default=None, metavar="PLAN",
1140
+ help="touch only these top-level plans (.caddis/plans/<name>.md); nothing "
1141
+ "else is moved or stamped")
841
1142
  ap.add_argument("--prune", action="store_true",
842
1143
  help="list old local state under .caddis/ (with --apply: delete it)")
843
1144
  args = ap.parse_args(argv)
1145
+ # A status or path the console cannot encode prints as `?`, never a crash (a cp1252 pipe).
1146
+ for stream in (sys.stdout, sys.stderr):
1147
+ try:
1148
+ stream.reconfigure(errors="replace")
1149
+ except (AttributeError, ValueError, OSError):
1150
+ pass
844
1151
 
845
1152
  repo_root = Path(args.repo_root).resolve()
846
1153
  if args.prune:
847
- if args.check or args.commit_range:
848
- ap.error("--prune runs alone: not with --check or --commit-range")
1154
+ if args.check or args.commit_range or args.only:
1155
+ ap.error("--prune runs alone: not with --check, --commit-range or --only")
849
1156
  return _print_prune(repo_root, prune(repo_root, apply=args.apply), args.apply)
850
- report = tidy(repo_root, apply=args.apply, check=args.check, commit_range=args.commit_range)
1157
+ if args.only and args.check:
1158
+ ap.error("--only is for --apply (or its dry run), not --check")
1159
+ report = tidy(repo_root, apply=args.apply, check=args.check, commit_range=args.commit_range,
1160
+ only=args.only)
1161
+ if report.refused:
1162
+ # Nothing was read, so nothing else is true enough to print: no unfiltered list.
1163
+ for msg in report.errors:
1164
+ print(f"ERROR: {msg}")
1165
+ return 1
851
1166
 
852
1167
  def rel(p: Path) -> str:
853
1168
  return p.relative_to(repo_root).as_posix()
@@ -867,10 +1182,18 @@ def main(argv: list[str] | None = None) -> int:
867
1182
  label = "moved" if args.apply else "would move"
868
1183
  for p in moved_or_would:
869
1184
  print(f" {label}: {rel(p)}")
1185
+ for p, rows in report.open_rows_held:
1186
+ print(f" held (open Tracker rows): {rel(p)}: rows {', '.join(rows)}")
1187
+ for p in report.dirty_skipped:
1188
+ print(f" skipped (uncommitted changes): {rel(p)}")
1189
+ if report.dirty_check_failed:
1190
+ print(f" skipped all moves: {report.dirty_check_failed}")
870
1191
  for name, why in report.parked_refused:
871
1192
  print(f" REFUSED trailer `{name}`: {why}")
872
1193
  for p in report.collisions:
873
1194
  print(f" COLLISION (done/ already has this name): {rel(p)}")
1195
+ for p, why in report.not_moved:
1196
+ print(f" NOT MOVED (--only): {rel(p)}: {why}")
874
1197
  if report.blocked:
875
1198
  print(" BLOCKED: " + ("nothing was changed." if args.apply else
876
1199
  "--apply would change nothing.")
@@ -878,8 +1201,19 @@ def main(argv: list[str] | None = None) -> int:
878
1201
  for p in report.stale_suspects:
879
1202
  print(f" stale-suspect (all phases done, status still active): {rel(p)}")
880
1203
  if args.check:
881
- exempt = [p for p in report.frontmatter_less if p not in report.check_flagged]
882
- detailed = {p for p, _ in report.violations}
1204
+ # By the list itself: after a --commit-range filter, "not flagged" no longer means legacy.
1205
+ exempt = [p for p in report.frontmatter_less if _is_legacy_exempt(repo_root, p)]
1206
+ detailed = {p for p, _ in report.violations} | {p for p, _ in report.tracker_done_open}
1207
+ if report.in_range is not None:
1208
+ # Out of range is not legacy: list only the grandfathered files this range touched.
1209
+ exempt = [p for p in exempt if rel(p) in report.in_range]
1210
+ for p, status in report.tracker_done_open:
1211
+ if _collides(p):
1212
+ print(f" CHECK VIOLATION: plan {rel(p)}: tracker done, but done/{p.name} "
1213
+ "already exists (rename one of them)")
1214
+ else:
1215
+ print(f" CHECK VIOLATION: plan {rel(p)}: tracker done, status {status}"
1216
+ f" (run caddis_tidy.py --apply --only {rel(p)} and commit the move)")
883
1217
  for p, msg in report.violations:
884
1218
  print(f" CHECK VIOLATION: {rel(p)}: {msg}")
885
1219
  for p in report.check_flagged:
@@ -889,10 +1223,13 @@ def main(argv: list[str] | None = None) -> int:
889
1223
  print(f" (legacy, grandfathered): {rel(p)}")
890
1224
 
891
1225
  # A collision is reported, not fatal: /handoff and /implement surface it and carry on.
892
- if report.errors:
1226
+ # A named plan that did not move is a failed request, and with --only a collision is one too.
1227
+ if report.errors or report.not_moved or (args.only and report.collisions):
893
1228
  return 1
894
1229
  if args.check:
895
1230
  return 1 if report.check_violations() else 0
1231
+ if report.open_rows_held:
1232
+ return 2
896
1233
  return 0
897
1234
 
898
1235
 
@@ -308,12 +308,67 @@ def _plan_rows(plan: Path | None) -> list[dict]:
308
308
  return rows
309
309
 
310
310
 
311
+ def _split_cells(line: str) -> list[str]:
312
+ r"""Stripped cells of one table line, split on unescaped pipes only (`a \| b` is one cell)."""
313
+ parts = re.split(r"(?<!\\)\|", line.strip())
314
+ if parts and parts[0] == "":
315
+ parts.pop(0)
316
+ if parts and parts[-1] == "":
317
+ parts.pop(-1)
318
+ return [part.strip() for part in parts]
319
+
320
+
321
+ def _open_work_rows(root: Path) -> list[dict]:
322
+ path = _artifact(root) / "open-work.md"
323
+ if path.is_symlink() or not path.is_file():
324
+ return []
325
+ text = path.read_text(encoding="utf-8", errors="replace")
326
+ header, body = split_document(text)
327
+ if header.get("type") != "open-work":
328
+ return []
329
+ rows: list[dict] = []
330
+ columns: list[str] | None = None
331
+ id_idx = status_idx = title_idx = 0
332
+ for raw_line in body.splitlines():
333
+ line = raw_line.strip()
334
+ if not line.startswith("|"):
335
+ columns = None
336
+ continue
337
+ cells = _split_cells(line) # unescaped pipes only, as caddis_rows.split_cells
338
+ if not cells or all(c and set(c) <= set("-: ") and "-" in c for c in cells):
339
+ continue
340
+ if columns is None:
341
+ lowered = [c.lower() for c in cells]
342
+ if "id" in lowered and "status" in lowered:
343
+ columns = lowered
344
+ id_idx = columns.index("id")
345
+ status_idx = columns.index("status")
346
+ title_idx = next(
347
+ (i for i, col in enumerate(columns) if col in {"task", "title", "item"}),
348
+ id_idx + 1,
349
+ )
350
+ continue
351
+ if len(cells) != len(columns):
352
+ continue
353
+ status = cells[status_idx].strip().lower()
354
+ first = status.split(" ", 1)[0] if status else ""
355
+ if _is_done(status) or first in {"not-needed", "✓", "✔"}:
356
+ continue
357
+ row_id = cells[id_idx].replace("**", "").replace("__", "").replace("`", "").strip()
358
+ if not row_id:
359
+ continue
360
+ title = cells[title_idx] if title_idx < len(cells) else ""
361
+ rows.append({"id": row_id, "kind": "open-work", "title": title})
362
+ return rows
363
+
364
+
311
365
  def open_work(root: Path, workstream: str | None = None) -> list[dict]:
312
366
  rows = _plan_rows(_active_plan(root, workstream))
313
367
  for name in (None, workstream) if workstream and workstream != "inbox" else (None,):
314
368
  rows.extend({"id": item["id"], "kind": "todo", "title": item["title"],
315
369
  **{key: item[key] for key in ("resolves", "partial") if key in item}}
316
370
  for item in list_items(root, name))
371
+ rows.extend(_open_work_rows(root))
317
372
  return rows
318
373
 
319
374