@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.
Files changed (100) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  4. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +223 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_gate.py +226 -195
  9. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  10. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  11. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +294 -127
  13. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  14. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_notify.py +121 -0
  16. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  19. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2056 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2572 -0
  22. package/bundles/antigravity-plugin/scripts/caddis_routing.py +3 -2
  23. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  25. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  27. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  29. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  30. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  31. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  32. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  33. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  34. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  35. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  36. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +86 -2
  37. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  38. package/bundles/antigravity-plugin/skills/_registry.md +2 -6
  39. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  40. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  41. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  42. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +7 -0
  43. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  45. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +29 -83
  46. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +77 -48
  47. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  48. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  50. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  51. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  52. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +8 -14
  53. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  54. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +259 -0
  55. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  56. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  57. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  58. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  59. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  60. package/bundles/antigravity-plugin/skills/ship/SKILL.md +14 -14
  61. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +6 -0
  62. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +33 -14
  63. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  64. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  65. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  66. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  68. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  69. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  70. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  71. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  72. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  73. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  74. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +259 -0
  75. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  76. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  77. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  78. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  79. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  80. package/bundles/codex/AGENTS.md +5 -4
  81. package/bundles/manifest.json +3 -3
  82. package/package.json +1 -1
  83. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  84. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  85. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  86. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  87. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  88. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  89. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  90. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  91. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  92. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  93. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  94. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  95. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  96. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  97. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  98. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  99. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  100. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.137",
3
+ "version": "1.3.139",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -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 report(cwd: str) -> list[str]:
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
- dirty = len([ln for ln in _git(cwd, "status", "--porcelain")[1].splitlines() if ln.strip()])
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
- lines.append("[HARNESS] There are uncommitted changes here. They may not be yours - these "
116
- "checkouts are shared. Look before you touch: git status")
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())