@caddis/cli 0.4.51 → 0.4.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +86 -2
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +2 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
|
@@ -126,6 +126,7 @@ plain word gets them wrong. Usage counts were measured across 444 markdown files
|
|
|
126
126
|
| model lane | A named provider route reached by its own binary — `claude-oss`, `claude-glm`, `claude-deepseek` — all speaking the Anthropic protocol. A lane is a route, not a model. | provider, backend, endpoint |
|
|
127
127
|
| core / extras | The two shipping tiers. Core is an EXPLICIT allowlist in `.github/runtime-targets.json`; anything not named there lands in extras. A skill defaults to extras, so placement is a decision, never an accident. | tier 1 / tier 2, full / lite |
|
|
128
128
|
| harness | The agent runtime that loads caddis — Claude Code, agy, Codex, Copilot. caddis is agent-agnostic; "the harness" without a name means whichever one is running. *(135 uses)* | client, IDE, platform |
|
|
129
|
+
| card | One full unit of work handed to a lane or a reviewer: scope, files and pass criteria, kept in a roadmap's cards/ folder. | task file, ticket |
|
|
129
130
|
|
|
130
131
|
## Flagged ambiguities
|
|
131
132
|
|
|
@@ -24,10 +24,22 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
|
+
import re
|
|
27
28
|
import subprocess
|
|
28
29
|
import sys
|
|
29
30
|
import time
|
|
30
31
|
|
|
32
|
+
try:
|
|
33
|
+
_cfg_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
|
34
|
+
if _cfg_scripts not in sys.path:
|
|
35
|
+
sys.path.insert(0, _cfg_scripts)
|
|
36
|
+
from claudster_config import ARTIFACT_DIRS, artifact_root # noqa: E402
|
|
37
|
+
except Exception: # pragma: no cover — defensive; a hook must never crash a session start
|
|
38
|
+
ARTIFACT_DIRS = (".caddis",)
|
|
39
|
+
|
|
40
|
+
def artifact_root(root):
|
|
41
|
+
return os.path.join(str(root), ARTIFACT_DIRS[0])
|
|
42
|
+
|
|
31
43
|
# A narrow Windows console (cp1252) must not raise on a branch name with any Unicode in it.
|
|
32
44
|
_reconfig = getattr(sys.stdout, "reconfigure", None)
|
|
33
45
|
if _reconfig:
|
|
@@ -38,6 +50,7 @@ if _reconfig:
|
|
|
38
50
|
|
|
39
51
|
FETCH_TIMEOUT_S = 15 # ceiling: a slow or unreachable remote must not stall a session
|
|
40
52
|
FETCH_MAX_AGE_S = 600 # a restart within 10 minutes reuses the last fetch
|
|
53
|
+
PEER_FRESH_S = 1800 # live peer session-state threshold (30 minutes, matches inject_relay)
|
|
41
54
|
STAMP_NAME = "caddis-freshness-stamp"
|
|
42
55
|
|
|
43
56
|
|
|
@@ -83,7 +96,112 @@ def _count(cwd: str, rev_range: str) -> int:
|
|
|
83
96
|
return int(out) if code == 0 and out.isdigit() else 0
|
|
84
97
|
|
|
85
98
|
|
|
86
|
-
def
|
|
99
|
+
def _norm_path(raw: str) -> str:
|
|
100
|
+
"""One path for comparison: no backticks, forward slashes, no leading `./`."""
|
|
101
|
+
norm = raw.strip().replace("`", "").strip().replace("\\", "/")
|
|
102
|
+
while norm.startswith("./"):
|
|
103
|
+
norm = norm[2:]
|
|
104
|
+
return norm
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _status_entries(top: str) -> list[list[str]]:
|
|
108
|
+
"""`git status --porcelain -z` at the repo top: one list of paths per entry (a rename gives two).
|
|
109
|
+
NUL-separated, so a path is never quoted. [] when git fails."""
|
|
110
|
+
try:
|
|
111
|
+
r = subprocess.run(["git", "status", "--porcelain", "-z"], cwd=top, capture_output=True, timeout=15)
|
|
112
|
+
except (OSError, subprocess.SubprocessError):
|
|
113
|
+
return []
|
|
114
|
+
if r.returncode != 0:
|
|
115
|
+
return []
|
|
116
|
+
parts = (r.stdout or b"").decode("utf-8", errors="replace").split("\0")
|
|
117
|
+
entries: list[list[str]] = []
|
|
118
|
+
i = 0
|
|
119
|
+
while i < len(parts):
|
|
120
|
+
entry = parts[i]
|
|
121
|
+
i += 1
|
|
122
|
+
if len(entry) < 4:
|
|
123
|
+
continue
|
|
124
|
+
paths = [entry[3:]]
|
|
125
|
+
if ("R" in entry[:2] or "C" in entry[:2]) and i < len(parts):
|
|
126
|
+
paths.append(parts[i])
|
|
127
|
+
i += 1
|
|
128
|
+
entries.append(paths)
|
|
129
|
+
return entries
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_session_state(text: str) -> tuple[str, list[str]]:
|
|
133
|
+
m = re.search(r"\*\*Branch:\*\*\s*`?([^\s`·]+)`?", text)
|
|
134
|
+
branch = m.group(1).strip() if m else "unknown"
|
|
135
|
+
files: list[str] = []
|
|
136
|
+
in_files = False
|
|
137
|
+
for line in text.splitlines():
|
|
138
|
+
s = line.strip()
|
|
139
|
+
if s.startswith("## "):
|
|
140
|
+
in_files = (s == "## Files changed this session")
|
|
141
|
+
continue
|
|
142
|
+
if in_files and s.startswith("- "):
|
|
143
|
+
item = s[2:].strip()
|
|
144
|
+
if item and not item.startswith("_("):
|
|
145
|
+
item = _norm_path(item)
|
|
146
|
+
if item:
|
|
147
|
+
files.append(item)
|
|
148
|
+
return branch, files
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _dirty_owners(top: str, dirty_paths: set[str], own_session_id: str = "") -> list[str]:
|
|
152
|
+
"""Live peer sessions whose recorded files are dirty here. `top` is the repo top level: the
|
|
153
|
+
session-state folder and every path are read from there, whatever the session's cwd is."""
|
|
154
|
+
try:
|
|
155
|
+
if not dirty_paths:
|
|
156
|
+
return []
|
|
157
|
+
base = os.path.join(artifact_root(top), "session-state")
|
|
158
|
+
if not os.path.isdir(base):
|
|
159
|
+
legacy = os.path.join(top, ".claudster", "session-state")
|
|
160
|
+
if os.path.isdir(legacy):
|
|
161
|
+
base = legacy
|
|
162
|
+
else:
|
|
163
|
+
return []
|
|
164
|
+
now = time.time()
|
|
165
|
+
owners: list[str] = []
|
|
166
|
+
for name in sorted(os.listdir(base)):
|
|
167
|
+
try:
|
|
168
|
+
if not name.endswith(".md"):
|
|
169
|
+
continue
|
|
170
|
+
sid = name[:-3]
|
|
171
|
+
if own_session_id and sid == own_session_id:
|
|
172
|
+
continue
|
|
173
|
+
path = os.path.join(base, name)
|
|
174
|
+
mtime = os.path.getmtime(path)
|
|
175
|
+
if now - mtime > PEER_FRESH_S:
|
|
176
|
+
continue
|
|
177
|
+
ended = os.path.join(base, sid + ".ended")
|
|
178
|
+
try:
|
|
179
|
+
if os.path.getmtime(ended) >= mtime:
|
|
180
|
+
continue
|
|
181
|
+
except OSError:
|
|
182
|
+
pass
|
|
183
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
184
|
+
text = fh.read()
|
|
185
|
+
peer_branch, peer_files = _parse_session_state(text)
|
|
186
|
+
matched = False
|
|
187
|
+
for pf in peer_files:
|
|
188
|
+
if pf in dirty_paths or any(
|
|
189
|
+
dp.endswith("/") and pf.startswith(dp) for dp in dirty_paths
|
|
190
|
+
):
|
|
191
|
+
matched = True
|
|
192
|
+
break
|
|
193
|
+
if matched:
|
|
194
|
+
label = f"owned by session {sid[:8]} (branch {peer_branch})"
|
|
195
|
+
if label not in owners:
|
|
196
|
+
owners.append(label)
|
|
197
|
+
except Exception:
|
|
198
|
+
continue
|
|
199
|
+
return owners
|
|
200
|
+
except Exception:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def report(cwd: str, own_session_id: str = "") -> list[str]:
|
|
87
205
|
"""The lines to print. Empty when there is nothing worth saying."""
|
|
88
206
|
if _git(cwd, "rev-parse", "--is-inside-work-tree")[0] != 0:
|
|
89
207
|
return [] # not a git repository
|
|
@@ -102,7 +220,11 @@ def report(cwd: str) -> list[str]:
|
|
|
102
220
|
lines.append("[HARNESS] could not fetch origin - the figures below may be stale.")
|
|
103
221
|
|
|
104
222
|
ref = _default_ref(cwd) if has_origin else ""
|
|
105
|
-
|
|
223
|
+
# The repo top level, so a session started in a subfolder still finds `.caddis/session-state`.
|
|
224
|
+
top = _git(cwd, "rev-parse", "--show-toplevel")[1] or cwd
|
|
225
|
+
entries = _status_entries(top)
|
|
226
|
+
dirty = len(entries)
|
|
227
|
+
dirty_paths = {_norm_path(p) for paths in entries for p in paths if _norm_path(p)}
|
|
106
228
|
behind = _count(cwd, f"HEAD..{ref}") if ref else 0
|
|
107
229
|
ahead = _count(cwd, f"{ref}..HEAD") if ref else 0
|
|
108
230
|
default_branch = ref.split("/", 1)[-1] if ref else ""
|
|
@@ -112,8 +234,12 @@ def report(cwd: str) -> list[str]:
|
|
|
112
234
|
lines.append(f"[HARNESS] branch '{branch}' | {behind} behind {ref} | {ahead} ahead "
|
|
113
235
|
f"| {dirty} uncommitted")
|
|
114
236
|
if dirty:
|
|
115
|
-
|
|
116
|
-
|
|
237
|
+
msg = ("[HARNESS] There are uncommitted changes here. They may not be yours - these "
|
|
238
|
+
"checkouts are shared. Look before you touch: git status")
|
|
239
|
+
owners = _dirty_owners(top, dirty_paths, own_session_id=own_session_id)
|
|
240
|
+
if owners:
|
|
241
|
+
msg = f"{msg} ({'; '.join(owners)})"
|
|
242
|
+
lines.append(msg)
|
|
117
243
|
# The recommendation, only when following it can lose nothing: behind, clean, and with
|
|
118
244
|
# nothing of your own unpushed. Suggesting a branch switch to someone holding uncommitted
|
|
119
245
|
# work in a shared checkout is how the branch-switch hazard gets triggered.
|
|
@@ -134,8 +260,9 @@ def main() -> int:
|
|
|
134
260
|
except Exception:
|
|
135
261
|
payload = {}
|
|
136
262
|
cwd = (payload.get("cwd") if isinstance(payload, dict) else None) or os.getcwd()
|
|
263
|
+
session_id = (payload.get("session_id") if isinstance(payload, dict) else None) or ""
|
|
137
264
|
try:
|
|
138
|
-
for line in report(cwd):
|
|
265
|
+
for line in report(cwd, own_session_id=str(session_id)):
|
|
139
266
|
print(line)
|
|
140
267
|
except Exception:
|
|
141
268
|
pass # fail open: a freshness report must never break a session start
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Protect a shared checkout from lanes writing into it, and tell handoff when a checkout is shared.
|
|
2
|
+
|
|
3
|
+
`shared` (handoff-integrity Phase 5, the design of fleet-handoff Phase 2): a checkout is shared when its root
|
|
4
|
+
`AGENTS.md` says `Shared checkout: yes`, or when `git status` lists a path this session did not change. In a
|
|
5
|
+
shared checkout handoff writes no tracked file there; it uses `caddis_docs_pr.py` instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import caddis_exit
|
|
19
|
+
except ModuleNotFoundError as exc:
|
|
20
|
+
if exc.name != "caddis_exit":
|
|
21
|
+
raise
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
24
|
+
import caddis_exit
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_porcelain_lines(repo: Path) -> tuple[int, list[str]]:
|
|
28
|
+
"""Run git status --porcelain=v1 --untracked-files=all -z and parse entries."""
|
|
29
|
+
proc = subprocess.run(
|
|
30
|
+
["git", "-C", str(repo), "status", "--porcelain=v1", "--untracked-files=all", "-z"],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
errors="replace",
|
|
35
|
+
)
|
|
36
|
+
if proc.returncode != 0:
|
|
37
|
+
return proc.returncode, []
|
|
38
|
+
|
|
39
|
+
raw = proc.stdout
|
|
40
|
+
if not raw:
|
|
41
|
+
return 0, []
|
|
42
|
+
|
|
43
|
+
parts = raw.split("\0")
|
|
44
|
+
if parts and parts[-1] == "":
|
|
45
|
+
parts.pop()
|
|
46
|
+
|
|
47
|
+
entries: list[str] = []
|
|
48
|
+
idx = 0
|
|
49
|
+
while idx < len(parts):
|
|
50
|
+
part = parts[idx]
|
|
51
|
+
# A rename or copy (X or Y is R or C) is followed by one more field: the original path.
|
|
52
|
+
if ("R" in part[:2] or "C" in part[:2]) and idx + 1 < len(parts):
|
|
53
|
+
orig_path = parts[idx + 1]
|
|
54
|
+
entries.append(f"{part} -> {orig_path}")
|
|
55
|
+
idx += 2
|
|
56
|
+
else:
|
|
57
|
+
entries.append(part)
|
|
58
|
+
idx += 1
|
|
59
|
+
return 0, entries
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
SHARED_RE = re.compile(r"^Shared checkout:\s*yes\b", re.IGNORECASE | re.MULTILINE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def entry_paths(entry: str) -> list[str]:
|
|
66
|
+
"""The paths of one `get_porcelain_lines` entry: `XY path`, or `XY new -> orig` (both count) for a rename."""
|
|
67
|
+
rest = entry[3:]
|
|
68
|
+
if ("R" in entry[:2] or "C" in entry[:2]) and " -> " in rest:
|
|
69
|
+
new, orig = rest.split(" -> ", 1)
|
|
70
|
+
return [new, orig]
|
|
71
|
+
return [rest]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _norm(path: str) -> str:
|
|
75
|
+
return path.strip().replace("\\", "/")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_shared_checkout(root: Path, own_files: set[str]) -> tuple[bool, str]:
|
|
79
|
+
"""(True, reason) when handoff must not write tracked files in `root`; else (False, "").
|
|
80
|
+
|
|
81
|
+
`Shared checkout: no` in AGENTS.md never hides a change this session did not make. When git status cannot
|
|
82
|
+
run, the checkout counts as shared: a wrong "no" could commit another session's files.
|
|
83
|
+
"""
|
|
84
|
+
agents = Path(root) / "AGENTS.md"
|
|
85
|
+
try:
|
|
86
|
+
text = agents.read_text(encoding="utf-8", errors="replace") if agents.is_file() else ""
|
|
87
|
+
except OSError:
|
|
88
|
+
text = ""
|
|
89
|
+
if SHARED_RE.search(text):
|
|
90
|
+
return True, "AGENTS.md says Shared checkout: yes"
|
|
91
|
+
ret, lines = get_porcelain_lines(Path(root))
|
|
92
|
+
if ret != 0:
|
|
93
|
+
return True, "git status failed, so changes by other sessions cannot be ruled out"
|
|
94
|
+
own = {_norm(p) for p in own_files}
|
|
95
|
+
foreign: list[str] = []
|
|
96
|
+
for entry in lines:
|
|
97
|
+
for path in entry_paths(entry):
|
|
98
|
+
if _norm(path) not in own and path not in foreign:
|
|
99
|
+
foreign.append(path)
|
|
100
|
+
if foreign:
|
|
101
|
+
return True, "changes not made by this session: " + ", ".join(foreign[:3])
|
|
102
|
+
return False, ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def read_own_files(path: Path | None) -> set[str]:
|
|
106
|
+
"""One path per line. The session-state list form `- `path`` is read too; blank lines are skipped.
|
|
107
|
+
|
|
108
|
+
A missing or unreadable file gives an empty set, so every dirty path counts as foreign (the safe side).
|
|
109
|
+
"""
|
|
110
|
+
if path is None:
|
|
111
|
+
return set()
|
|
112
|
+
try:
|
|
113
|
+
raw = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
114
|
+
except OSError:
|
|
115
|
+
return set()
|
|
116
|
+
own: set[str] = set()
|
|
117
|
+
for line in raw.splitlines():
|
|
118
|
+
item = line.strip()
|
|
119
|
+
if item.startswith("- "):
|
|
120
|
+
item = item[2:].strip()
|
|
121
|
+
if len(item) >= 2 and item.startswith("`") and item.endswith("`"):
|
|
122
|
+
item = item[1:-1]
|
|
123
|
+
if item:
|
|
124
|
+
own.add(_norm(item))
|
|
125
|
+
return own
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def shared(root: Path, own_files_path: Path | None) -> int:
|
|
129
|
+
"""Print `SHARED: yes <reason>` or `SHARED: no`; always exit 0."""
|
|
130
|
+
is_shared, reason = is_shared_checkout(root, read_own_files(own_files_path))
|
|
131
|
+
print(f"SHARED: {'yes' if is_shared else 'no'} {reason}".rstrip())
|
|
132
|
+
return caddis_exit.CLEAN
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def snapshot(repo: Path, out_path: Path) -> int:
|
|
136
|
+
"""Take a snapshot of current porcelain entries."""
|
|
137
|
+
ret, lines = get_porcelain_lines(repo)
|
|
138
|
+
if ret != 0:
|
|
139
|
+
sys.stderr.write(f"git status failed on {repo}\n")
|
|
140
|
+
return caddis_exit.NOT_RUN
|
|
141
|
+
|
|
142
|
+
data = {
|
|
143
|
+
"repo": str(repo),
|
|
144
|
+
"lines": lines,
|
|
145
|
+
}
|
|
146
|
+
try:
|
|
147
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
out_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
sys.stderr.write(f"failed to write snapshot to {out_path}: {exc}\n")
|
|
151
|
+
return caddis_exit.NOT_RUN
|
|
152
|
+
return caddis_exit.CLEAN
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def compare(repo: Path, snapshot_path: Path) -> int:
|
|
156
|
+
"""Compare current entries to snapshot."""
|
|
157
|
+
if not snapshot_path.is_file():
|
|
158
|
+
sys.stderr.write(f"snapshot file not found: {snapshot_path}\n")
|
|
159
|
+
return caddis_exit.NOT_RUN
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
data = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
|
163
|
+
if not isinstance(data, dict) or not isinstance(data.get("lines"), list):
|
|
164
|
+
raise ValueError("no 'lines' list")
|
|
165
|
+
snapshot_lines = set(data["lines"])
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
sys.stderr.write(f"invalid snapshot JSON {snapshot_path}: {exc}\n")
|
|
168
|
+
return caddis_exit.NOT_RUN
|
|
169
|
+
|
|
170
|
+
ret, current_lines = get_porcelain_lines(repo)
|
|
171
|
+
if ret != 0:
|
|
172
|
+
sys.stderr.write(f"git status failed on {repo}\n")
|
|
173
|
+
return caddis_exit.NOT_RUN
|
|
174
|
+
|
|
175
|
+
new_entries = [entry for entry in current_lines if entry not in snapshot_lines]
|
|
176
|
+
if new_entries:
|
|
177
|
+
for entry in new_entries:
|
|
178
|
+
print(f"NEW {entry}")
|
|
179
|
+
return caddis_exit.BLOCKED
|
|
180
|
+
else:
|
|
181
|
+
print("checkout unchanged")
|
|
182
|
+
return caddis_exit.CLEAN
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main(argv: list[str] | None = None) -> int:
|
|
186
|
+
"""CLI entry point for caddis_checkout_guard."""
|
|
187
|
+
if argv is None:
|
|
188
|
+
argv = sys.argv[1:]
|
|
189
|
+
|
|
190
|
+
parser = argparse.ArgumentParser(description="Shared checkout guard.")
|
|
191
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
192
|
+
|
|
193
|
+
snap_parser = subparsers.add_parser("snapshot")
|
|
194
|
+
snap_parser.add_argument("--repo", required=True, type=Path, help="Repository path")
|
|
195
|
+
snap_parser.add_argument("--out", required=True, type=Path, help="Snapshot output path")
|
|
196
|
+
|
|
197
|
+
cmp_parser = subparsers.add_parser("compare")
|
|
198
|
+
cmp_parser.add_argument("--repo", required=True, type=Path, help="Repository path")
|
|
199
|
+
cmp_parser.add_argument("--snapshot", required=True, type=Path, help="Snapshot file path")
|
|
200
|
+
|
|
201
|
+
shared_parser = subparsers.add_parser("shared", help="print SHARED: yes|no <reason> for handoff")
|
|
202
|
+
shared_parser.add_argument("--root", required=True, type=Path, help="Repository root")
|
|
203
|
+
shared_parser.add_argument("--own-files", type=Path, default=None,
|
|
204
|
+
help="File with one path per line: the files this session changed")
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
args = parser.parse_args(argv)
|
|
208
|
+
except SystemExit:
|
|
209
|
+
return caddis_exit.NOT_RUN
|
|
210
|
+
|
|
211
|
+
if args.command == "snapshot":
|
|
212
|
+
return snapshot(args.repo, args.out)
|
|
213
|
+
elif args.command == "compare":
|
|
214
|
+
return compare(args.repo, args.snapshot)
|
|
215
|
+
elif args.command == "shared":
|
|
216
|
+
return shared(args.root, args.own_files)
|
|
217
|
+
else:
|
|
218
|
+
parser.print_help(sys.stderr)
|
|
219
|
+
return caddis_exit.NOT_RUN
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
sys.exit(main())
|