@caddis/cli 0.4.33 → 0.4.35

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 (166) hide show
  1. package/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +185 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
  7. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
  9. package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
  11. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
  12. package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
  13. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
  14. package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
  15. package/bundles/antigravity-plugin/session_end_agy.py +16 -1
  16. package/bundles/antigravity-plugin/skills/_registry.md +34 -32
  17. package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
  18. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
  19. package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
  21. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
  22. package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
  23. package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
  24. package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
  25. package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
  26. package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
  28. package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
  29. package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
  30. package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
  31. package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
  33. package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
  34. package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +5 -0
  37. package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
  39. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
  40. package/bundles/antigravity-plugin/skills/implement/SKILL.md +21 -0
  41. package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
  42. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
  43. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +15 -0
  47. package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
  49. package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
  50. package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
  51. package/bundles/antigravity-plugin/skills/ship/SKILL.md +19 -4
  52. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -0
  53. package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
  54. package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
  55. package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
  56. package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
  57. package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
  58. package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
  59. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
  60. package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
  61. package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
  62. package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
  64. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  65. package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
  66. package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
  68. package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
  69. package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
  70. package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
  71. package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
  72. package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
  73. package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
  74. package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
  76. package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
  77. package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
  78. package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
  79. package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
  80. package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
  81. package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
  82. package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
  83. package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
  84. package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
  85. package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
  86. package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
  87. package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
  88. package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
  89. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
  90. package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
  91. package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
  92. package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
  93. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
  94. package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
  95. package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
  96. package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
  97. package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
  98. package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
  99. package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
  100. package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
  101. package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
  102. package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
  103. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  104. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
  105. package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
  106. package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
  107. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
  108. package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
  109. package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
  110. package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
  111. package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
  112. package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
  113. package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
  114. package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
  115. package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
  116. package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
  117. package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
  118. package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
  119. package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
  120. package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
  121. package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
  122. package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
  123. package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
  124. package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
  125. package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
  126. package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
  127. package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
  128. package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
  129. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  130. package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
  131. package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
  132. package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
  133. package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
  134. package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
  135. package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
  136. package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
  137. package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
  138. package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
  139. package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
  140. package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
  141. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
  142. package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
  143. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
  144. package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
  145. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
  146. package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
  147. package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
  148. package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
  149. package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
  150. package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
  151. package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
  152. package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
  153. package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
  154. package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
  155. package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
  156. package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
  157. package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
  158. package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
  159. package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
  160. package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
  161. package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
  162. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
  163. package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
  164. package/bundles/codex/AGENTS.md +5 -1
  165. package/bundles/manifest.json +3 -3
  166. package/package.json +1 -1
@@ -19,22 +19,39 @@ DESIGN (locked, see the plan doc — do not re-litigate here)
19
19
  - Dry-run by default; --apply moves; --check validates conformance (frontmatter-less or an
20
20
  unknown status on a top-level artifact) and exits 1 on a violation, for a future CI/pre-push
21
21
  gate. Legacy frontmatter-less prompts are left alone by the mover, only flagged by --check.
22
- - NEVER auto-flips `status:` (that's the implementing session's judgment call) and NEVER
23
- overwrites an existing `done/<name>` — a collision is reported and skipped, not clobbered.
22
+ - A plan's `## Tracker` decides when it is done (R2 Phase 10): when every phase row is `done`,
23
+ tidy sets that plan's `status: done` and moves it. Nothing else sets it. Phase heading
24
+ emoji do not count; they drifted from the Tracker in both directions.
25
+ - A `Resolves-Parked: <file>` trailer in the commits of `--commit-range` moves that parking-lot
26
+ item to `done/` with `resolved_by: <short sha>`; `Partly-Resolves-Parked:` only appends a dated
27
+ note. Unknown or out-of-directory names are reported, never created or moved.
28
+ - NEVER overwrites an existing `done/<name>`: if any destination exists, the run changes nothing
29
+ at all (no move, no status line, no note) and reports the collision.
30
+ - `--prune` (R2 Phase 11) deletes old LOCAL state on fixed ages, with no settings: session-state
31
+ files older than 7 days, and the two logs rotated above 5 MB into one `.1` copy. `.lock` files
32
+ whose document is gone are only counted, never deleted. Only a file git both ignores and does not track qualifies. Relays,
33
+ to-dos, symlinks and anything outside `.caddis/` are never touched; lane worktrees older than
34
+ 7 days are only reported. It lists by default; `--prune --apply` deletes.
24
35
 
25
36
  Usage:
26
37
  python scripts/caddis_tidy.py # report only (default = dry-run)
27
38
  python scripts/caddis_tidy.py --apply # actually move terminal-status artifacts
28
39
  python scripts/caddis_tidy.py --check # conformance check; exit 1 on a violation
29
40
  python scripts/caddis_tidy.py --repo-root <p> # scan a different repo (default: cwd)
41
+ python scripts/caddis_tidy.py --commit-range origin/main..HEAD # also read parking trailers
42
+ python scripts/caddis_tidy.py --prune # list old local state that could be deleted
43
+ python scripts/caddis_tidy.py --prune --apply # delete it (the user's choice, never automatic)
30
44
  """
31
45
 
32
46
  from __future__ import annotations
33
47
 
34
48
  import argparse
49
+ import os
35
50
  import re
51
+ import stat
36
52
  import subprocess
37
53
  import sys
54
+ import time
38
55
  from dataclasses import dataclass, field
39
56
  from pathlib import Path
40
57
 
@@ -46,6 +63,16 @@ except ModuleNotFoundError as exc:
46
63
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
47
64
  import caddis_frontmatter
48
65
 
66
+ try:
67
+ import caddis_todo
68
+ except ModuleNotFoundError as exc:
69
+ if exc.name != "caddis_todo":
70
+ raise
71
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
72
+ import caddis_todo
73
+ # caddis_todo has put its own dependencies on the path by now; this one ships beside it.
74
+ from caddis_file_lock import atomic_write # noqa: E402
75
+
49
76
  TERMINAL_STATUSES = {"done", "superseded", "shipped", "implemented"}
50
77
 
51
78
  # Legacy prompts written before the frontmatter convention existed (2026-07-23). They were
@@ -65,9 +92,13 @@ LEGACY_FRONTMATTER_EXEMPT = {
65
92
  "prompts/fable-verify-docket-reaudit.md",
66
93
  }
67
94
 
68
- _PHASE_HEADING_RE = re.compile(r"^#{2,4}\s*Phase\s+.+$", re.MULTILINE)
69
- _DONE_MARKERS = ("✅",)
70
- _NOT_DONE_MARKERS = ("⏳", "🔨", "🔲")
95
+ # A Markdown table rule such as `|---|:---:|`.
96
+ _TABLE_RULE_RE = re.compile(r"^\|(\s*:?-+:?\s*\|)+\s*$")
97
+ # Commit trailers that close or annotate a parking-lot item. Keys compare case-insensitively,
98
+ # as git's own trailer keys do.
99
+ _PARKED_TRAILERS = {"resolves-parked": "resolves", "partly-resolves-parked": "partly"}
100
+ # Fixed so `resolved_by:` does not depend on the machine's `core.abbrev`.
101
+ _SHA_ABBREV = "12"
71
102
 
72
103
  _KINDS = ("plans", "prompts", "parking-lot")
73
104
 
@@ -106,16 +137,70 @@ def _parse_frontmatter(text: str) -> dict:
106
137
  return caddis_frontmatter.parse(text)
107
138
 
108
139
 
109
- def _phases_all_done(text: str) -> bool:
110
- headings = _PHASE_HEADING_RE.findall(text)
111
- if not headings:
140
+ def tracker_complete(text: str) -> bool:
141
+ """True only when the `## Tracker` heading line has phase rows, all unique, all done.
142
+
143
+ The table is read by caddis_todo.tracker_table, the same reader the open-work view uses, and
144
+ a status cell is judged by the same `_is_done`. Stricter here on purpose: that view skips a
145
+ row it cannot read, while a row this cannot read BLOCKS completion. Wrongly moving a live plan
146
+ hides it; wrongly keeping a finished one costs a look.
147
+ """
148
+ table = caddis_todo.tracker_table(text)
149
+ if table is None:
112
150
  return False
113
- for h in headings:
114
- if any(m in h for m in _NOT_DONE_MARKERS):
115
- return False
116
- if not any(m in h for m in _DONE_MARKERS):
151
+ columns, lines = table
152
+ status_at = columns.index("status")
153
+ seen: set[int] = set()
154
+ for line in lines:
155
+ row = line.strip()
156
+ if not row.startswith("|") or _TABLE_RULE_RE.match(row):
157
+ continue
158
+ match = caddis_todo._PHASE.match(line)
159
+ cells = [cell.strip() for cell in line.strip("|").split("|")]
160
+ if match is None or len(cells) != len(columns):
161
+ return False # malformed
162
+ number = int(match.group(1))
163
+ if number in seen:
164
+ return False # two rows for one phase: which one is true is not knowable
165
+ seen.add(number)
166
+ if not caddis_todo._is_done(cells[status_at]):
117
167
  return False
118
- return True
168
+ return bool(seen)
169
+
170
+
171
+ def _set_header(text: str, fields: dict[str, str]) -> str | None:
172
+ """`text` with these top-level header keys set, every other byte kept. None when there is no
173
+ header the shared reader accepts, or when the reader would not read the new values back."""
174
+ bom = "\ufeff" if text.startswith("\ufeff") else ""
175
+ lines = text[len(bom):].splitlines(keepends=True)
176
+ if not lines or lines[0].rstrip("\r\n") != "---":
177
+ return None
178
+ end = next((i for i in range(1, len(lines)) if lines[i].rstrip("\r\n") == "---"), None)
179
+ if end is None:
180
+ return None
181
+ eol = "\r\n" if lines[0].endswith("\r\n") else "\n"
182
+ for key, value in fields.items():
183
+ at = [i for i in range(1, end)
184
+ if lines[i][:1].strip() and lines[i].partition(":")[0] == key]
185
+ for i in at:
186
+ lines[i] = f"{key}: {value}{eol}"
187
+ if not at:
188
+ lines.insert(end, f"{key}: {value}{eol}")
189
+ end += 1
190
+ out = bom + "".join(lines)
191
+ header = caddis_frontmatter.parse(out)
192
+ if any(header.get(key) != value for key, value in fields.items()):
193
+ return None
194
+ return out
195
+
196
+
197
+ def _read_exact(path: Path) -> str | None:
198
+ """The file's text with its line endings untouched, or None if it is not clean UTF-8.
199
+ A rewrite must never drop bytes the way a lenient read would."""
200
+ try:
201
+ return path.read_bytes().decode("utf-8")
202
+ except (OSError, UnicodeDecodeError):
203
+ return None
119
204
 
120
205
 
121
206
  def classify_status(status: str | None, kind: str = "plans") -> str:
@@ -160,7 +245,7 @@ def scan(dir_path: Path) -> list[Item]:
160
245
  path=f,
161
246
  status=fm.get("status"),
162
247
  frontmatter=fm,
163
- phases_all_done=_phases_all_done(text),
248
+ phases_all_done=tracker_complete(text),
164
249
  size=len(text.encode("utf-8")),
165
250
  ))
166
251
  return items
@@ -174,6 +259,16 @@ class Report:
174
259
  frontmatter_less: list[Path] = field(default_factory=list)
175
260
  unknown_status: list[Path] = field(default_factory=list)
176
261
  collisions: list[Path] = field(default_factory=list)
262
+ # Plans whose Tracker is complete: their `status:` is (or would be) set to `done`.
263
+ completed: list[Path] = field(default_factory=list)
264
+ # Parking-lot items a `Resolves-Parked:` trailer closes, with the short sha that closed them.
265
+ resolved: list[tuple[Path, str]] = field(default_factory=list)
266
+ # Parking-lot items a `Partly-Resolves-Parked:` trailer annotates. They stay open.
267
+ noted: list[Path] = field(default_factory=list)
268
+ # Trailer names that were not acted on, with the reason: (name as written, why).
269
+ parked_refused: list[tuple[str, str]] = field(default_factory=list)
270
+ # Anything that stopped the run: a bad commit range (nothing changed) or an I/O failure.
271
+ errors: list[str] = field(default_factory=list)
177
272
  # Subset of frontmatter_less/unknown_status that ISN'T grandfathered by
178
273
  # LEGACY_FRONTMATTER_EXEMPT - these are what actually fail --check.
179
274
  check_flagged: list[Path] = field(default_factory=list)
@@ -182,6 +277,11 @@ class Report:
182
277
  # values are, in the failure text, without opening another document.
183
278
  violations: list[tuple[Path, str]] = field(default_factory=list)
184
279
 
280
+ @property
281
+ def blocked(self) -> bool:
282
+ """True when --apply must not (or did not) finish: a collision or an error."""
283
+ return bool(self.collisions or self.errors)
284
+
185
285
  def check_violations(self) -> int:
186
286
  return len(self.check_flagged)
187
287
 
@@ -199,7 +299,7 @@ def _run_git_mv(repo_root: Path, src: Path, dst: Path) -> bool:
199
299
  """Returns True on success. Degrades to plain move (caller's job) on any failure."""
200
300
  try:
201
301
  result = subprocess.run(
202
- ["git", "mv", str(src), str(dst)],
302
+ ["git", "mv", "--", str(src), str(dst)],
203
303
  cwd=str(repo_root), capture_output=True, text=True, timeout=30,
204
304
  )
205
305
  return result.returncode == 0
@@ -207,23 +307,165 @@ def _run_git_mv(repo_root: Path, src: Path, dst: Path) -> bool:
207
307
  return False
208
308
 
209
309
 
210
- def _move_one(repo_root: Path, item: Item, apply: bool, report: Report) -> None:
211
- done_dir = item.path.parent / "done"
212
- dst = done_dir / item.path.name
213
- if dst.exists():
214
- report.collisions.append(item.path)
215
- return
216
- if not apply:
217
- report.would_move.append(item.path)
218
- return
219
- done_dir.mkdir(parents=True, exist_ok=True)
310
+ def _destination(src: Path) -> Path:
311
+ return src.parent / "done" / src.name
312
+
313
+
314
+ def _collides(src: Path) -> bool:
315
+ done_dir = src.parent / "done"
316
+ dst = _destination(src)
317
+ return (done_dir.exists() and not done_dir.is_dir()) or dst.exists() or dst.is_symlink()
318
+
319
+
320
+ def _move_one(repo_root: Path, src: Path, report: Report) -> None:
321
+ """One named `git mv` into the sibling `done/`; a plain rename when git cannot (no repo, or
322
+ an untracked file). Only called once every destination is known to be free."""
323
+ dst = _destination(src)
324
+ dst.parent.mkdir(parents=True, exist_ok=True)
220
325
  moved_via_git = False
221
326
  if _is_git_repo(repo_root):
222
- moved_via_git = _run_git_mv(repo_root, item.path, dst)
327
+ moved_via_git = _run_git_mv(repo_root, src, dst)
223
328
  if not moved_via_git:
224
- dst.parent.mkdir(parents=True, exist_ok=True)
225
- item.path.rename(dst)
226
- report.moved.append(item.path)
329
+ src.rename(dst)
330
+ report.moved.append(src)
331
+
332
+
333
+ @dataclass
334
+ class Resolution:
335
+ """One parking-lot trailer from one commit in the range."""
336
+ kind: str # "resolves" or "partly"
337
+ name: str # the trailer value as written
338
+ sha: str
339
+ date: str # the commit date, YYYY-MM-DD
340
+ path: Path | None = None # the open item; None when refused or already in done/
341
+ problem: str | None = None # why it was refused; None when it can be acted on
342
+
343
+
344
+ def _parked_target(root: Path, name: str) -> tuple[Path | None, str | None]:
345
+ """Where a trailer value points: (open item, None), (None, None) when it is already in done/,
346
+ or (None, reason) when it is refused or missing. Only a top-level item of this repo's
347
+ `.caddis/parking-lot/` is ever a target, named bare, as `parking-lot/<file>` (what
348
+ `caddis_todo.py done` prints) or by its repo-relative path. The last word is
349
+ caddis_todo._resolves_path, the check `caddis_todo add --resolves` already applies."""
350
+ lot = root / ".caddis" / PARKING_LOT_DIR
351
+ posix = name.replace("\\", "/")
352
+ if not posix:
353
+ return None, "empty value"
354
+ if posix.startswith("/") or re.match(r"^[A-Za-z]:", posix):
355
+ return None, "refused: absolute path"
356
+ parts = posix.split("/")
357
+ if any(part in {"", ".", ".."} for part in parts):
358
+ return None, "refused: `.`, `..` and empty path parts are not allowed"
359
+ # Exact prefixes only: `.caddis/foo.md` must not resolve to `.caddis/parking-lot/foo.md`.
360
+ if parts[:2] == [".caddis", PARKING_LOT_DIR]:
361
+ parts = parts[2:]
362
+ elif parts[:1] == [PARKING_LOT_DIR]:
363
+ parts = parts[1:]
364
+ if len(parts) != 1:
365
+ return None, f"refused: not a top-level item of .caddis/{PARKING_LOT_DIR}/"
366
+ leaf = parts[0]
367
+ path = lot / leaf
368
+ # The exact name, not a case-insensitive filesystem's match: `git mv` needs the indexed name.
369
+ listed = path.is_file() and not lot.is_symlink() and leaf in {p.name for p in lot.iterdir()}
370
+ if not listed and not path.is_symlink():
371
+ if (lot / "done" / leaf).is_file():
372
+ return None, None # closed by an earlier run: rerunning a range is a no-op
373
+ return None, f"does not exist in .caddis/{PARKING_LOT_DIR}/"
374
+ try:
375
+ caddis_todo._resolves_path(root, f"{PARKING_LOT_DIR}/{leaf}")
376
+ except (OSError, ValueError) as exc:
377
+ return None, f"refused: {exc}"
378
+ return path, None
379
+
380
+
381
+ def parked_resolutions(root: Path, commit_range: str) -> list[Resolution]:
382
+ """Every `Resolves-Parked:` / `Partly-Resolves-Parked:` trailer in `commit_range`, oldest
383
+ commit first. Reads git only; changes nothing. Raises ValueError when git cannot read the
384
+ 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}")
389
+ try:
390
+ done = subprocess.run(
391
+ ["git", "log", "--reverse", "--date=short", f"--abbrev={_SHA_ABBREV}",
392
+ "--format=%h%x1f%cd%x1f%(trailers:only,unfold)%x1e", commit_range, "--"],
393
+ cwd=str(root), capture_output=True, text=True, encoding="utf-8", errors="replace",
394
+ timeout=30,
395
+ )
396
+ except (OSError, subprocess.SubprocessError) as exc:
397
+ raise ValueError(f"git log failed: {exc}") from exc
398
+ if done.returncode != 0:
399
+ raise ValueError(f"git log {commit_range} failed: {done.stderr.strip()}")
400
+ found = []
401
+ for record in done.stdout.split("\x1e"):
402
+ fields = record.strip("\n").split("\x1f")
403
+ if len(fields) != 3:
404
+ continue
405
+ sha, date, trailers = fields
406
+ for line in trailers.splitlines():
407
+ key, sep, value = line.partition(":")
408
+ kind = _PARKED_TRAILERS.get(key.strip().lower())
409
+ if not sep or kind is None:
410
+ continue
411
+ name = value.strip()
412
+ path, problem = _parked_target(root, name)
413
+ found.append(Resolution(kind, name, sha, date, path, problem))
414
+ return found
415
+
416
+
417
+ def _with_note(text: str, line: str) -> str:
418
+ """Append one note line, keeping consecutive notes together as one list."""
419
+ eol = "\r\n" if "\r\n" in text else "\n"
420
+ lines = text.splitlines()
421
+ last = next((ln for ln in reversed(lines) if ln.strip()), "")
422
+ lead = "" if last.startswith("- ") and ": partly resolved by " in last else eol
423
+ body = text if text.endswith(("\n", "\r")) or not text else text + eol
424
+ return f"{body}{lead}{line}{eol}"
425
+
426
+
427
+ def _plan_parked(root: Path, commit_range: str, report: Report,
428
+ moves: list[Path], rewrites: dict[Path, str]) -> None:
429
+ try:
430
+ found = parked_resolutions(root, commit_range)
431
+ except ValueError as exc:
432
+ report.errors.append(str(exc))
433
+ return
434
+ closing: dict[Path, Resolution] = {}
435
+ for res in found:
436
+ if res.problem:
437
+ report.parked_refused.append((res.name, res.problem))
438
+ elif res.path is not None and res.kind == "resolves":
439
+ closing.setdefault(res.path, res) # the oldest commit in the range closed it
440
+ for path, res in closing.items():
441
+ text = _read_exact(path)
442
+ status = str(caddis_frontmatter.parse(text or "").get("status") or "").strip().lower()
443
+ if status == "dropped":
444
+ # `dropped` carries a written reason it was abandoned; a trailer must not overwrite it.
445
+ report.parked_refused.append((res.name, "refused: the item is dropped"))
446
+ continue
447
+ new = None if text is None else _set_header(text, {"status": "done",
448
+ "resolved_by": res.sha})
449
+ if new is None:
450
+ report.parked_refused.append((res.name, "refused: no readable header to stamp"))
451
+ continue
452
+ rewrites[path] = new
453
+ if path not in moves:
454
+ moves.append(path)
455
+ report.resolved.append((path, res.sha))
456
+ for res in found:
457
+ if res.kind != "partly" or res.problem or res.path is None or res.path in closing:
458
+ continue
459
+ text = rewrites.get(res.path) or _read_exact(res.path)
460
+ if text is None:
461
+ report.parked_refused.append((res.name, "refused: not clean UTF-8"))
462
+ continue
463
+ line = f"- {res.date}: partly resolved by {res.sha}"
464
+ if line in text.splitlines():
465
+ continue # this commit's note is already there
466
+ rewrites[res.path] = _with_note(text, line)
467
+ if res.path not in report.noted:
468
+ report.noted.append(res.path)
227
469
 
228
470
 
229
471
  def _is_legacy_exempt(repo_root: Path, path: Path) -> bool:
@@ -275,8 +517,16 @@ def _check_parking_lot_item(item: Item, report: Report) -> None:
275
517
 
276
518
 
277
519
  def tidy(repo_root: Path, apply: bool = False, check: bool = False,
278
- kinds: tuple[str, ...] = _KINDS) -> Report:
520
+ kinds: tuple[str, ...] = _KINDS, commit_range: str | None = None) -> Report:
521
+ """Plan every change first, then make all of them or none.
522
+
523
+ 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.
525
+ """
279
526
  report = Report()
527
+ moves: list[Path] = []
528
+ rewrites: dict[Path, str] = {} # applied before the moves, so a failed move leaves a
529
+ # finished status at the top level, where the next run moves it by status alone
280
530
  for kind in kinds:
281
531
  items = scan(repo_root / ".caddis" / kind)
282
532
  for item in items:
@@ -285,11 +535,11 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
285
535
  # A terminal item still moves even if it broke another rule. Sweeping finished work
286
536
  # out of the way is independent of whether its frontmatter was tidy.
287
537
  if classify_status(item.status, PARKING_LOT_DIR) == "terminal":
288
- _move_one(repo_root, item, apply, report)
538
+ moves.append(item.path)
289
539
  continue
290
540
  cls = classify_status(item.status)
291
541
  if cls == "terminal":
292
- _move_one(repo_root, item, apply, report)
542
+ moves.append(item.path)
293
543
  continue
294
544
  if cls == "none":
295
545
  report.frontmatter_less.append(item.path)
@@ -301,8 +551,35 @@ def tidy(repo_root: Path, apply: bool = False, check: bool = False,
301
551
  report.stale_suspects.append(item.path)
302
552
  continue
303
553
  # active status
554
+ if item.phases_all_done and kind == "plans":
555
+ text = _read_exact(item.path)
556
+ new = None if text is None else _set_header(text, {"status": "done"})
557
+ if new is not None:
558
+ rewrites[item.path] = new
559
+ moves.append(item.path)
560
+ report.completed.append(item.path)
561
+ continue
304
562
  if item.phases_all_done:
305
563
  report.stale_suspects.append(item.path)
564
+ if commit_range is not None and PARKING_LOT_DIR in kinds:
565
+ _plan_parked(repo_root, commit_range, report, moves, rewrites)
566
+
567
+ report.collisions.extend(src for src in moves if _collides(src))
568
+ if not apply:
569
+ report.would_move.extend(src for src in moves if src not in report.collisions)
570
+ return report
571
+ if report.blocked:
572
+ return report
573
+ # Past this point only an I/O failure can stop the run. Every step is safe to repeat: a
574
+ # written status or stamp that did not move yet is moved by status on the next run, and a
575
+ # note already present is not appended again.
576
+ try:
577
+ for path, text in rewrites.items():
578
+ atomic_write(path, text)
579
+ for src in moves:
580
+ _move_one(repo_root, src, report)
581
+ except (OSError, ValueError) as exc:
582
+ report.errors.append(f"stopped part-way, run again to finish: {exc}")
306
583
  return report
307
584
 
308
585
 
@@ -313,6 +590,10 @@ def nudge_line(repo_root: Path) -> str | None:
313
590
  report = tidy(repo_root, apply=False)
314
591
  except Exception:
315
592
  return None
593
+ if report.collisions:
594
+ # /handoff cannot move anything while this stands, so do not send people there.
595
+ return (f"[caddis] tidy is blocked by {len(report.collisions)} done/ name collision(s) "
596
+ "— run scripts/caddis_tidy.py to see them")
316
597
  n = len(report.would_move)
317
598
  if not n:
318
599
  return None
@@ -320,38 +601,296 @@ def nudge_line(repo_root: Path) -> str | None:
320
601
  return f"[caddis] {n} finished {plural} awaiting tidy — /handoff moves them"
321
602
 
322
603
 
604
+ # ── prune: old local state on fixed ages (R2 Phase 11) ──────────────────────────────────────────
605
+ # `.caddis/` grew without bound: one session-state file per session, a `.lock` left beside every
606
+ # document ever locked, and two append-only logs. These ages and sizes are FIXED on purpose; a
607
+ # settings surface for them was considered and rejected. Git decides what is local: a file is only
608
+ # ever listed when git both ignores it and does not track it, so a tracked or user-visible file can
609
+ # never be deleted, whatever its age. Outside a git repo that cannot be proven, so prune refuses.
610
+ PRUNE_MAX_AGE_S = 7 * 86_400
611
+ LOG_ROTATE_BYTES = 5 * 1024 * 1024
612
+ ROTATED_LOGS = ("usage-log.jsonl", "agent-log.jsonl")
613
+ # Top-level `.caddis/` folders never walked for locks: relays and to-dos have their own writers.
614
+ _PRUNE_SKIP_DIRS = {"relay", "todo-list"}
615
+ _LANE_BRANCH_PREFIX = "refs/heads/lane/"
616
+
617
+
618
+ @dataclass
619
+ class PruneReport:
620
+ delete: list[Path] = field(default_factory=list) # listed: old session-state files
621
+ rotate: list[Path] = field(default_factory=list) # listed: logs above LOG_ROTATE_BYTES
622
+ # Report only. Deleting a lock a writer is waiting on lets a second writer create a new lock
623
+ # file and enter at the same time (POSIX), so orphan locks are never deleted. They are empty.
624
+ orphan_locks: list[Path] = field(default_factory=list)
625
+ deleted: list[Path] = field(default_factory=list)
626
+ rotated: list[Path] = field(default_factory=list)
627
+ old_worktrees: list[tuple[Path, int]] = field(default_factory=list) # (path, days); report only
628
+ notes: list[str] = field(default_factory=list)
629
+ errors: list[str] = field(default_factory=list)
630
+ refused: str | None = None
631
+
632
+
633
+ def _git_run(root: Path, *args: str, stdin: bytes | None = None):
634
+ try:
635
+ return subprocess.run(["git", "-C", str(root), *args], input=stdin,
636
+ capture_output=True, timeout=60)
637
+ except (OSError, subprocess.SubprocessError):
638
+ return None
639
+
640
+
641
+ def _prune_refusal(root: Path) -> str | None:
642
+ """Why prune may not run here, or None. It needs the top of a git work tree."""
643
+ result = _git_run(root, "rev-parse", "--is-inside-work-tree", "--show-cdup")
644
+ if result is None or result.returncode != 0:
645
+ return "not a git repository, so no file can be proven untracked."
646
+ lines = result.stdout.decode("utf-8", "replace").splitlines()
647
+ if not lines or lines[0].strip() != "true":
648
+ return "not inside a git work tree."
649
+ if len(lines) > 1 and lines[1].strip():
650
+ return "not the top of its git repository; pass --repo-root <repo root>."
651
+ if (root / ".caddis").is_symlink():
652
+ return "`.caddis` is a symlink; prune never follows one."
653
+ return None
654
+
655
+
656
+ def _regular(path: Path) -> os.stat_result | None:
657
+ """lstat of a regular file, or None (missing, a symlink, a folder...). Links never followed."""
658
+ try:
659
+ st = os.lstat(path)
660
+ except OSError:
661
+ return None
662
+ return st if stat.S_ISREG(st.st_mode) else None
663
+
664
+
665
+ def _old_session_states(caddis: Path, now: float) -> list[Path]:
666
+ folder = caddis / "session-state"
667
+ if folder.is_symlink() or not folder.is_dir():
668
+ return []
669
+ found = []
670
+ for entry in os.scandir(folder):
671
+ path = Path(entry.path)
672
+ st = _regular(path)
673
+ # Only `*.md`: the `.session-state-*.tmp` files are session_state.py's write scratch.
674
+ if entry.name.endswith(".md") and st and now - st.st_mtime > PRUNE_MAX_AGE_S:
675
+ found.append(path)
676
+ return sorted(found)
677
+
678
+
679
+ def _orphan_locks(caddis: Path) -> list[Path]:
680
+ """`x.lock` files whose document `x` is gone (the caddis_file_lock naming)."""
681
+ found = []
682
+ for dirpath, dirnames, filenames in os.walk(caddis, followlinks=False):
683
+ at_top = Path(dirpath) == caddis
684
+ if at_top:
685
+ dirnames[:] = [d for d in dirnames if d not in _PRUNE_SKIP_DIRS]
686
+ for name in filenames:
687
+ if not name.endswith(".lock") or (at_top and name.startswith("relay.md")):
688
+ continue
689
+ path = Path(dirpath) / name
690
+ if _regular(path) and not os.path.lexists(str(path)[:-len(".lock")]):
691
+ found.append(path)
692
+ return sorted(found)
693
+
694
+
695
+ def _local_only(root: Path, rels: list[str]) -> set[str] | None:
696
+ """The subset of `rels` (repo-relative, posix) that git ignores and does not track."""
697
+ if not rels:
698
+ return set()
699
+ tracked = _git_run(root, "ls-files", "-z", "--", ".caddis")
700
+ ignored = _git_run(root, "check-ignore", "-z", "--stdin",
701
+ stdin=b"\0".join(r.encode("utf-8") for r in rels) + b"\0")
702
+ # check-ignore exits 1 when nothing is ignored; anything else non-zero is a failure.
703
+ if tracked is None or tracked.returncode != 0 or ignored is None or ignored.returncode > 1:
704
+ return None
705
+ tracked_set = set(tracked.stdout.decode("utf-8", "replace").split("\0"))
706
+ ignored_set = set(ignored.stdout.decode("utf-8", "replace").split("\0"))
707
+ return {r for r in rels if r in ignored_set and r not in tracked_set}
708
+
709
+
710
+ def _old_lane_worktrees(root: Path, now: float) -> list[tuple[Path, int]]:
711
+ result = _git_run(root, "worktree", "list", "--porcelain")
712
+ if result is None or result.returncode != 0:
713
+ return []
714
+ found = []
715
+ for block in result.stdout.decode("utf-8", "replace").split("\n\n"):
716
+ fields = dict(line.split(" ", 1) for line in block.splitlines() if " " in line)
717
+ if not fields.get("branch", "").startswith(_LANE_BRANCH_PREFIX) or "worktree" not in fields:
718
+ continue
719
+ path = Path(fields["worktree"])
720
+ try:
721
+ touched = os.lstat(path).st_mtime
722
+ except OSError:
723
+ continue
724
+ # The folder's mtime misses edits in subfolders, so a new commit also counts as use.
725
+ head = _git_run(path, "log", "-1", "--format=%ct")
726
+ if head is not None and head.returncode == 0 and head.stdout.strip().isdigit():
727
+ touched = max(touched, int(head.stdout.strip()))
728
+ age = now - touched
729
+ if age > PRUNE_MAX_AGE_S:
730
+ found.append((path, int(age // 86_400)))
731
+ return found
732
+
733
+
734
+ def prune(root: Path, *, apply: bool = False, now: float | None = None) -> PruneReport:
735
+ """List (or, with `apply`, delete) old local state under `<root>/.caddis/`."""
736
+ root = Path(root)
737
+ now = time.time() if now is None else now
738
+ report = PruneReport()
739
+ report.refused = _prune_refusal(root)
740
+ if report.refused:
741
+ return report
742
+ caddis = root / ".caddis"
743
+ if not caddis.is_dir():
744
+ return report
745
+
746
+ def rel(p: Path) -> str:
747
+ return p.relative_to(root).as_posix()
748
+
749
+ files = _old_session_states(caddis, now)
750
+ locks = _orphan_locks(caddis)
751
+ logs = []
752
+ for name in ROTATED_LOGS:
753
+ log = caddis / name
754
+ st = _regular(log)
755
+ if st and st.st_size > LOG_ROTATE_BYTES:
756
+ logs.append(log)
757
+ copies = {log: log.with_name(log.name + ".1") for log in logs}
758
+ local = _local_only(root, [rel(p) for p in files + locks + logs]
759
+ + [rel(c) for c in copies.values()])
760
+ if local is None:
761
+ report.errors.append("git could not say which files are ignored; nothing was listed.")
762
+ return report
763
+
764
+ report.delete = [p for p in files if rel(p) in local]
765
+ report.orphan_locks = [p for p in locks if rel(p) in local]
766
+ for log in logs:
767
+ copy = copies[log]
768
+ if rel(log) not in local:
769
+ continue
770
+ if rel(copy) not in local:
771
+ report.notes.append(f"not rotating {rel(log)}: {rel(copy)} is not gitignored, or is "
772
+ "tracked. Add it to .gitignore.")
773
+ elif os.path.lexists(copy) and not _regular(copy):
774
+ report.notes.append(f"not rotating {rel(log)}: {rel(copy)} is not a regular file.")
775
+ else:
776
+ report.rotate.append(log)
777
+ report.old_worktrees = _old_lane_worktrees(root, now)
778
+ if not apply:
779
+ return report
780
+
781
+ for path in report.delete:
782
+ if not _regular(path):
783
+ report.notes.append(f"skipped {rel(path)}: no longer a regular file.")
784
+ continue
785
+ try:
786
+ os.unlink(path)
787
+ except OSError as exc:
788
+ report.errors.append(f"could not delete {rel(path)}: {exc}")
789
+ continue
790
+ report.deleted.append(path)
791
+ for log in report.rotate:
792
+ try:
793
+ os.replace(log, copies[log]) # replaces the one `.1` copy; there is never a `.2`
794
+ report.rotated.append(log)
795
+ except OSError as exc:
796
+ report.errors.append(f"could not rotate {rel(log)}: {exc}")
797
+ return report
798
+
799
+
800
+ def _print_prune(root: Path, report: PruneReport, apply: bool) -> int:
801
+ def rel(p: Path) -> str:
802
+ return p.relative_to(root).as_posix()
803
+
804
+ print(f"[caddis_tidy] mode=PRUNE-{'APPLY' if apply else 'LIST'} root={root}")
805
+ if report.refused:
806
+ print(f" REFUSED: {report.refused} Nothing was listed or deleted.")
807
+ return 1
808
+ for p in (report.deleted if apply else report.delete):
809
+ print(f" {'deleted' if apply else 'would delete'} (session state older than 7 days): "
810
+ f"{rel(p)}")
811
+ if report.orphan_locks:
812
+ print(f" {len(report.orphan_locks)} orphan lock file(s) left in place (report only; "
813
+ "empty files, and deleting a lock a writer waits on is unsafe)")
814
+ for p in (report.rotated if apply else report.rotate):
815
+ print(f" {'rotated' if apply else 'would rotate'} (over 5 MB): {rel(p)} -> {rel(p)}.1")
816
+ for path, days in report.old_worktrees:
817
+ print(f" lane worktree {days} days old (report only; `caddis_lanes.py abandon` removes "
818
+ f"it): {path}")
819
+ for note in report.notes:
820
+ print(f" NOTE: {note}")
821
+ for msg in report.errors:
822
+ print(f" ERROR: {msg}")
823
+ if not apply and (report.delete or report.rotate):
824
+ print(" Run `caddis_tidy.py --prune --apply` to delete these. That is the user's choice.")
825
+ elif not (report.delete or report.rotate or report.old_worktrees or report.orphan_locks
826
+ or report.notes or report.errors):
827
+ print(" nothing to prune.")
828
+ return 1 if report.errors else 0
829
+
830
+
323
831
  def main(argv: list[str] | None = None) -> int:
324
- ap = argparse.ArgumentParser(description="Move terminal-status plans/prompts into done/")
832
+ ap = argparse.ArgumentParser(description="Move finished plans, prompts and parking-lot items "
833
+ "into done/")
325
834
  ap.add_argument("--repo-root", default=".", help="repo root containing .caddis/ (default: cwd)")
326
835
  ap.add_argument("--apply", action="store_true", help="actually move files (default: dry-run report)")
327
836
  ap.add_argument("--check", action="store_true",
328
837
  help="conformance check - exit 1 if any top-level artifact lacks a known status")
838
+ ap.add_argument("--commit-range", default=None,
839
+ help="also read Resolves-Parked / Partly-Resolves-Parked trailers in these "
840
+ "commits only, e.g. origin/main..HEAD (default: read no trailers)")
841
+ ap.add_argument("--prune", action="store_true",
842
+ help="list old local state under .caddis/ (with --apply: delete it)")
329
843
  args = ap.parse_args(argv)
330
844
 
331
845
  repo_root = Path(args.repo_root).resolve()
332
- report = tidy(repo_root, apply=args.apply, check=args.check)
846
+ if args.prune:
847
+ if args.check or args.commit_range:
848
+ ap.error("--prune runs alone: not with --check or --commit-range")
849
+ 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)
851
+
852
+ def rel(p: Path) -> str:
853
+ return p.relative_to(repo_root).as_posix()
333
854
 
334
855
  mode = "APPLY" if args.apply else ("CHECK" if args.check else "DRY-RUN")
335
856
  print(f"[caddis_tidy] mode={mode} root={repo_root}")
857
+ done_now = args.apply and not report.blocked
858
+ for msg in report.errors:
859
+ print(f" ERROR: {msg}")
860
+ for p in report.completed:
861
+ print(f" {'set' if done_now else 'would set'} status: done (Tracker complete): {rel(p)}")
862
+ for p, sha in report.resolved:
863
+ print(f" {'resolved' if done_now else 'would resolve'}: {rel(p)} (Resolves-Parked in {sha})")
864
+ for p in report.noted:
865
+ print(f" {'noted' if done_now else 'would note'}: {rel(p)} (Partly-Resolves-Parked)")
336
866
  moved_or_would = report.moved if args.apply else report.would_move
337
867
  label = "moved" if args.apply else "would move"
338
868
  for p in moved_or_would:
339
- print(f" {label}: {p.relative_to(repo_root)}")
869
+ print(f" {label}: {rel(p)}")
870
+ for name, why in report.parked_refused:
871
+ print(f" REFUSED trailer `{name}`: {why}")
340
872
  for p in report.collisions:
341
- print(f" COLLISION (skipped, done/ already has this name): {p.relative_to(repo_root)}")
873
+ print(f" COLLISION (done/ already has this name): {rel(p)}")
874
+ if report.blocked:
875
+ print(" BLOCKED: " + ("nothing was changed." if args.apply else
876
+ "--apply would change nothing.")
877
+ + " Fix the collision or error above and run again.")
342
878
  for p in report.stale_suspects:
343
- print(f" stale-suspect (all phases done, status still active): {p.relative_to(repo_root)}")
879
+ print(f" stale-suspect (all phases done, status still active): {rel(p)}")
344
880
  if args.check:
345
881
  exempt = [p for p in report.frontmatter_less if p not in report.check_flagged]
346
882
  detailed = {p for p, _ in report.violations}
347
883
  for p, msg in report.violations:
348
- print(f" CHECK VIOLATION: {p.relative_to(repo_root)}: {msg}")
884
+ print(f" CHECK VIOLATION: {rel(p)}: {msg}")
349
885
  for p in report.check_flagged:
350
886
  if p not in detailed:
351
- print(f" CHECK VIOLATION: no status field: {p.relative_to(repo_root)}")
887
+ print(f" CHECK VIOLATION: no status field: {rel(p)}")
352
888
  for p in exempt:
353
- print(f" (legacy, grandfathered): {p.relative_to(repo_root)}")
889
+ print(f" (legacy, grandfathered): {rel(p)}")
354
890
 
891
+ # A collision is reported, not fatal: /handoff and /implement surface it and carry on.
892
+ if report.errors:
893
+ return 1
355
894
  if args.check:
356
895
  return 1 if report.check_violations() else 0
357
896
  return 0