@caddis/cli 0.4.34 → 0.4.36
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/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +210 -6
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +262 -1
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +103 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
- package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
- package/bundles/antigravity-plugin/session_end_agy.py +16 -1
- package/bundles/antigravity-plugin/skills/_registry.md +35 -32
- package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
- package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +185 -0
- package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
- package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
- package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +20 -0
- package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -2
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
- package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +33 -2
- package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
- package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +27 -7
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +36 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
- package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
- package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
- package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
- package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
- package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
- package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
- package/bundles/codex/AGENTS.md +5 -1
- package/bundles/manifest.json +3 -3
- 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
|
-
-
|
|
23
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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=
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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,
|
|
327
|
+
moved_via_git = _run_git_mv(repo_root, src, dst)
|
|
223
328
|
if not moved_via_git:
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
538
|
+
moves.append(item.path)
|
|
289
539
|
continue
|
|
290
540
|
cls = classify_status(item.status)
|
|
291
541
|
if cls == "terminal":
|
|
292
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
887
|
+
print(f" CHECK VIOLATION: no status field: {rel(p)}")
|
|
352
888
|
for p in exempt:
|
|
353
|
-
print(f" (legacy, grandfathered): {p
|
|
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
|