@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.
Files changed (170) hide show
  1. package/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +210 -6
  6. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
  7. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +262 -1
  8. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_routing.py +103 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
  11. package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
  13. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
  14. package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
  15. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
  16. package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
  17. package/bundles/antigravity-plugin/session_end_agy.py +16 -1
  18. package/bundles/antigravity-plugin/skills/_registry.md +35 -32
  19. package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
  21. package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
  22. package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
  23. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
  24. package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
  25. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +185 -0
  26. package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
  28. package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
  29. package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
  31. package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
  33. package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
  34. package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
  36. package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
  37. package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +20 -0
  40. package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
  41. package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
  42. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
  43. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -2
  44. package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
  46. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +33 -2
  50. package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
  51. package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
  52. package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
  53. package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
  54. package/bundles/antigravity-plugin/skills/ship/SKILL.md +27 -7
  55. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +36 -3
  56. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
  57. package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
  58. package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
  59. package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
  60. package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
  61. package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
  62. package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
  64. package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
  65. package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
  66. package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
  67. package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
  68. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  69. package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
  70. package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
  71. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
  72. package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
  73. package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
  74. package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
  76. package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
  77. package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
  78. package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
  79. package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
  80. package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
  81. package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
  82. package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
  83. package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
  84. package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
  85. package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
  86. package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
  87. package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
  88. package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
  89. package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
  90. package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
  91. package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
  92. package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
  93. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
  94. package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
  95. package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
  96. package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
  97. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
  98. package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
  99. package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
  100. package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
  101. package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
  102. package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
  103. package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
  104. package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
  105. package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
  106. package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
  107. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  108. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
  109. package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
  110. package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
  111. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
  112. package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
  113. package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
  114. package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
  115. package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
  116. package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
  117. package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
  118. package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
  119. package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
  120. package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
  121. package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
  122. package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
  123. package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
  124. package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
  125. package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
  126. package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
  127. package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
  128. package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
  129. package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
  130. package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
  131. package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
  132. package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
  133. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  134. package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
  135. package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
  136. package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
  137. package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
  138. package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
  139. package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
  140. package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
  141. package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
  142. package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
  143. package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
  144. package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
  145. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
  146. package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
  147. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
  148. package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
  149. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
  150. package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
  151. package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
  152. package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
  153. package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
  154. package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
  155. package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
  156. package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
  157. package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
  158. package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
  159. package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
  160. package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
  161. package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
  162. package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
  163. package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
  164. package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
  165. package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
  166. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
  167. package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
  168. package/bundles/codex/AGENTS.md +5 -1
  169. package/bundles/manifest.json +3 -3
  170. package/package.json +1 -1
@@ -21,6 +21,10 @@ You are a principal engineer doing a focused code review. You read the change an
21
21
  - **Conventions** — per the project's `AGENTS.md` (typed boundaries, no absolute paths, no silent
22
22
  failures/logging, layering, framework idioms).
23
23
  - **Simplicity** — dead code, duplication, needless abstraction (YAGNI), clearer alternative.
24
+ - **Reuse** — flag new code that repeats an existing function, and a fix made at one caller
25
+ instead of the shared point. The rules:
26
+ Before writing new code, search for an existing function to reuse. Prefer the standard library or an installed package over new code or a new dependency.
27
+ Before changing a function, find all its callers. Fix the shared point, unless the callers need different behaviour.
24
28
 
25
29
  ## Severity
26
30
  - **blocking** — must fix before merge (bug, security hole, missing test for behavior change, broken
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.121",
3
+ "version": "1.3.123",
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
  }
@@ -0,0 +1,121 @@
1
+ """Portable advisory locks and atomic UTF-8 document writes.
2
+
3
+ Callers that have an artifact root should pass ``root=``. The resolved target
4
+ must stay within that root, including when a path component is a symlink.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import errno
11
+ import math
12
+ import os
13
+ from pathlib import Path
14
+ import stat
15
+ import tempfile
16
+ import time
17
+ from typing import BinaryIO, Iterator
18
+
19
+ if os.name == "nt":
20
+ import msvcrt
21
+ else:
22
+ import fcntl
23
+
24
+
25
+ def _destination(path: Path, root: Path | None) -> Path:
26
+ path = Path(path)
27
+ if ".." in path.parts:
28
+ raise ValueError("destination may not contain parent traversal")
29
+ resolved = path.resolve()
30
+ if root is not None and not resolved.is_relative_to(Path(root).resolve()):
31
+ raise ValueError("destination escapes artifact root")
32
+ if path.is_symlink():
33
+ raise ValueError("destination may not be a symlink")
34
+ return resolved
35
+
36
+
37
+ def _document_mode(target: Path) -> int:
38
+ """Mode the replaced document should keep: the existing one, else what ``open()`` would give."""
39
+ try:
40
+ return stat.S_IMODE(target.stat().st_mode)
41
+ except FileNotFoundError:
42
+ # os.umask has no read-only form; the probe is process-wide for one line, so a file
43
+ # another thread creates in that window gets 0o666. caddis writers are single-threaded.
44
+ umask = os.umask(0)
45
+ os.umask(umask)
46
+ return 0o666 & ~umask
47
+
48
+
49
+ def _try_lock(handle: BinaryIO) -> bool:
50
+ try:
51
+ if os.name == "nt":
52
+ handle.seek(0)
53
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
54
+ else:
55
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
56
+ return True
57
+ except OSError as exc:
58
+ if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
59
+ raise
60
+ return False
61
+
62
+
63
+ def _unlock(handle: BinaryIO) -> None:
64
+ if os.name == "nt":
65
+ handle.seek(0)
66
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
67
+ else:
68
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
69
+
70
+
71
+ @contextlib.contextmanager
72
+ def locked_path(
73
+ path: Path, timeout_s: float = 5.0, *, root: Path | None = None
74
+ ) -> Iterator[Path]:
75
+ """Hold a stable adjacent lock for ``path``; never remove its inode."""
76
+ if not math.isfinite(timeout_s) or timeout_s < 0:
77
+ raise ValueError("timeout_s must be a finite nonnegative number")
78
+ target = _destination(path, root)
79
+ target.parent.mkdir(parents=True, exist_ok=True)
80
+ lock_path = target.with_name(target.name + ".lock")
81
+ if lock_path.is_symlink():
82
+ raise ValueError("lock file may not be a symlink")
83
+ # O_NOFOLLOW closes the gap between the check above and the open on POSIX.
84
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
85
+ deadline = time.monotonic() + timeout_s
86
+ with os.fdopen(os.open(lock_path, flags, 0o666), "r+b") as handle:
87
+ while not _try_lock(handle):
88
+ remaining = deadline - time.monotonic()
89
+ if remaining <= 0:
90
+ raise TimeoutError(f"timed out waiting for lock: {lock_path}")
91
+ time.sleep(min(0.05, remaining))
92
+ try:
93
+ yield target
94
+ finally:
95
+ _unlock(handle)
96
+
97
+
98
+ def atomic_write(path: Path, text: str, *, root: Path | None = None) -> None:
99
+ """Atomically replace one document; hold ``locked_path`` for read/modify/write.
100
+
101
+ A failed write or replace removes only this call's temporary file.
102
+ """
103
+ target = _destination(path, root)
104
+ target.parent.mkdir(parents=True, exist_ok=True)
105
+ temporary: Path | None = None
106
+ try:
107
+ with tempfile.NamedTemporaryFile(
108
+ mode="w", encoding="utf-8", newline="", dir=target.parent,
109
+ prefix=f".{target.name}.", suffix=".tmp", delete=False,
110
+ ) as handle:
111
+ temporary = Path(handle.name)
112
+ handle.write(text)
113
+ handle.flush()
114
+ os.fsync(handle.fileno())
115
+ # NamedTemporaryFile creates 0o600; without this every write would narrow the document.
116
+ os.chmod(temporary, _document_mode(target))
117
+ os.replace(temporary, target)
118
+ temporary = None # replaced: that name may now belong to another writer
119
+ finally:
120
+ if temporary is not None:
121
+ temporary.unlink(missing_ok=True)
@@ -108,6 +108,18 @@ def parse(text: str) -> dict:
108
108
  return out
109
109
 
110
110
 
111
+ def split_document(text: str) -> tuple[dict, str]:
112
+ """Return parsed metadata and the untouched body after a complete leading header."""
113
+ source = text.lstrip("\ufeff")
114
+ lines = source.splitlines(keepends=True)
115
+ if not lines or lines[0].strip() != "---":
116
+ return {}, text
117
+ for index, line in enumerate(lines[1:], 1):
118
+ if line.strip() == "---":
119
+ return parse(source), "".join(lines[index + 1:])
120
+ return {}, text
121
+
122
+
111
123
  def validate(text: str, allowed_types: set[str]) -> str | None:
112
124
  """Report the first missing or unsupported required header field."""
113
125
  if _header_lines(text) is None:
@@ -21,6 +21,9 @@ that hard-fails when it cannot read its inputs breaks every consumer the day it
21
21
  first thing anyone does with a gate that cries wolf is switch it off. Only a genuine, positively
22
22
  identified violation is non-zero.
23
23
 
24
+ One exception: `deploy-boundary` guards the production boundary and FAILS CLOSED. When it
25
+ cannot tell, it exits 3 ("could not run" on the caddis scale), never 0.
26
+
24
27
  EXIT CODES
25
28
  0 proceed
26
29
  1 blocked — a real violation; the caller must stop
@@ -93,10 +96,11 @@ def phase_lane(block: str) -> str:
93
96
  def launch_command(block: str) -> str:
94
97
  """The literal command for a non-claude lane, if the phase names one.
95
98
 
96
- Also matches a caddis_lanes.py run command for the codex, agy and glm lanes.
99
+ Also matches a caddis_lanes.py run command or a direct codex/agy command.
97
100
  """
98
101
  m = re.search(
99
- r'`((?:claude-(?:glm|oss|deepseek)|' + _LANES_RUN + r')[^`]*)`',
102
+ r'`((?:claude-(?:glm|oss|deepseek)\b|' + _LANES_RUN
103
+ + r'\b|codex\s+exec\b|agy\b)[^`]*)`',
100
104
  block,
101
105
  )
102
106
  return m.group(1).strip() if m else ""
@@ -139,6 +143,28 @@ def gate_lane(plan: Path, phase: int) -> int:
139
143
  f"[caddis-gate] phase {phase}'s caddis_lanes command names --phase {named.group(1)}: {cmd}\n"
140
144
  " The lane would run another phase's work. Refusing to run it.\n")
141
145
  return EXIT_MALFORMED
146
+ elif re.match(r"(?:codex\s+exec|agy)\b", cmd):
147
+ if "HEADLESS RUN RULES" not in cmd:
148
+ sys.stderr.write(
149
+ f"[caddis-gate] phase {phase}'s direct command has no HEADLESS RUN RULES: {cmd}\n"
150
+ " The child has no headless run rules. Refusing to run it.\n")
151
+ return EXIT_MALFORMED
152
+ named = re.search(r"Implement Phase (\d+) only", cmd)
153
+ if not named:
154
+ sys.stderr.write(
155
+ f"[caddis-gate] phase {phase}'s direct command has no phase restriction: {cmd}\n"
156
+ " The lane could run the wrong phase. Refusing to run it.\n")
157
+ return EXIT_MALFORMED
158
+ if int(named.group(1)) != phase:
159
+ sys.stderr.write(
160
+ f"[caddis-gate] phase {phase}'s direct command names Phase {named.group(1)}: {cmd}\n"
161
+ " The lane would run another phase's work. Refusing to run it.\n")
162
+ return EXIT_MALFORMED
163
+ if cmd.startswith("agy") and not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
164
+ sys.stderr.write(
165
+ f"[caddis-gate] phase {phase}'s direct agy command has no -p/--print: {cmd}\n"
166
+ " The child would not run headless. Refusing to run it.\n")
167
+ return EXIT_MALFORMED
142
168
  elif not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
143
169
  sys.stderr.write(
144
170
  f"[caddis-gate] phase {phase}'s launch command has no -p/--print: {cmd}\n"
@@ -440,7 +466,7 @@ def gate_vendor_drift(repo_root: Path) -> int:
440
466
  _DOC_PATH = re.compile(r"`([^`\s<>{}*?]+?\.(?:md|py|ts|tsx|json|ya?ml|sh|ps1|html|toml|sql))`")
441
467
 
442
468
 
443
- def gate_handover_check(doc: Path, repo_root: Path) -> int:
469
+ def gate_handover_check(doc: Path, repo_root: Path, max_chars: int | None = None) -> int:
444
470
  """Every file a handover names must exist. Nothing else in a handover is machine-checkable.
445
471
 
446
472
  A succession prompt is written from memory, so it names files the writer BELIEVES are there.
@@ -454,8 +480,10 @@ def gate_handover_check(doc: Path, repo_root: Path) -> int:
454
480
  later withdrawal" — need conventions that do not exist yet, and a gate that pretends to check
455
481
  them would be worse than one that says it does not.
456
482
 
457
- Degrades open on a missing/unreadable doc. Blocks only on a path the doc claims and the repo
458
- does not have.
483
+ Degrades open on a missing/unreadable doc. Blocks on a path the doc claims and the repo does
484
+ not have, and -- only when the caller passes ``max_chars`` -- on a doc longer than that. The
485
+ cap is opt-in: /handoff caps a workstream relay at 4,000 characters, but a /spawn-hub prompt
486
+ must embed the full inventory verbatim and would never fit.
459
487
  """
460
488
  if not doc.is_file():
461
489
  return _degrade(f"no handover doc at {doc}")
@@ -464,6 +492,12 @@ def gate_handover_check(doc: Path, repo_root: Path) -> int:
464
492
  except OSError as exc:
465
493
  return _degrade(f"cannot read {doc}: {type(exc).__name__}")
466
494
 
495
+ if max_chars is not None and len(text) > max_chars:
496
+ sys.stderr.write(
497
+ f"[caddis-gate] handover doc {doc.name} exceeds {max_chars:,} characters ({len(text)} chars)\n"
498
+ )
499
+ return EXIT_BLOCKED
500
+
467
501
  missing: list[str] = []
468
502
  for raw in sorted(set(_DOC_PATH.findall(text))):
469
503
  # A backtick often wraps a whole command; the path is the token that looks like one.
@@ -617,6 +651,166 @@ def gate_docs_check(root: Path) -> int:
617
651
  return EXIT_BLOCKED if failures else EXIT_OK
618
652
 
619
653
 
654
+ # ── gate: deploy-boundary ───────────────────────────────────────────────────
655
+ # The owner's production boundary: an agent never merges into a repo whose default branch deploys
656
+ # on every push. /ship, /ship-merge and /pr-merge run this before any merge or push to the default
657
+ # branch, and hand the merge to the owner on anything but exit 0.
658
+ #
659
+ # FAILS CLOSED — the one deliberate exception to DEGRADE OPEN above. "Cannot tell" is exit 3,
660
+ # never 0, because a false "clean" here is a production deploy nobody approved. Any positive
661
+ # signal wins over every negative one: config cannot switch the check off.
662
+ EXIT_CANNOT_TELL = 3
663
+
664
+ _WORKFLOW_DIRS = (".github/workflows", ".gitea/workflows", ".forgejo/workflows")
665
+
666
+ # An inherited GIT_DIR (from a hook or wrapper) would make `git -C <repo>` read another repository.
667
+ _GIT_REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY",
668
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE")
669
+
670
+
671
+ def _git_out(repo: Path, *args: str) -> str | None:
672
+ """stdout of a git command in `repo`, or None when it fails."""
673
+ env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_ENV}
674
+ try:
675
+ r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
676
+ encoding="utf-8", errors="replace", timeout=30, env=env)
677
+ except (OSError, subprocess.SubprocessError):
678
+ return None
679
+ return r.stdout if r.returncode == 0 else None
680
+
681
+
682
+ def _ref_exists(repo: Path, ref: str) -> bool:
683
+ return _git_out(repo, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}") is not None
684
+
685
+
686
+ def _default_branch_refs(repo: Path) -> list[str]:
687
+ """Every copy of the default branch: each remote's and the local branch of that name.
688
+
689
+ The name comes from any remote's recorded HEAD; with none recorded, `main` and `master` stand
690
+ in. Reading every copy is deliberate: a positive signal on any of them blocks.
691
+ """
692
+ remotes = (_git_out(repo, "remote") or "").split()
693
+ names: set[str] = set()
694
+ for remote in remotes:
695
+ target = (_git_out(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD") or "").strip()
696
+ if target.startswith(f"refs/remotes/{remote}/"):
697
+ names.add(target[len(f"refs/remotes/{remote}/"):])
698
+ names = names or {"main", "master"}
699
+ refs = [f"refs/remotes/{r}/{n}" for r in remotes for n in sorted(names)]
700
+ refs += [f"refs/heads/{n}" for n in sorted(names)]
701
+ return [r for r in refs if _ref_exists(repo, r)]
702
+
703
+
704
+ def _config_production_on_main(text: str) -> bool | None:
705
+ """True/False from `[deploy] production_on_main`; None when absent. Raises on a bad file."""
706
+ import tomllib
707
+ deploy = tomllib.loads(text).get("deploy", {})
708
+ if not isinstance(deploy, dict):
709
+ raise ValueError("[deploy] is not a table")
710
+ value = deploy.get("production_on_main")
711
+ if value is not None and not isinstance(value, bool):
712
+ raise ValueError(f"production_on_main is {value!r}, not true or false")
713
+ return value
714
+
715
+
716
+ # A deploy is often not named "deploy". Letters-only boundaries on prod, so `deploy_prod` matches
717
+ # and `product` or `reproduce` do not.
718
+ _DEPLOY_WORD = re.compile(r"deploy|release|publish|(?<![a-z])prod(?:uction)?(?![a-z])", re.I)
719
+
720
+
721
+ def _workflow_deploys(text: str) -> bool:
722
+ """True when the workflow's name, a job id or a job name looks like a deploy, or a job sets a
723
+ GitHub `environment:` (the forge's own marker for a deployment). Raises on a bad file.
724
+
725
+ The caller also checks the file name: a forge shows the file path as the workflow's name when
726
+ `name:` is unset.
727
+ """
728
+ import yaml
729
+ doc = yaml.safe_load(text)
730
+ if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
731
+ raise ValueError("not a workflow (no jobs mapping)")
732
+ labels = [doc.get("name")]
733
+ for job_id, job in doc["jobs"].items():
734
+ labels.append(job_id)
735
+ if isinstance(job, dict):
736
+ if job.get("environment"):
737
+ return True
738
+ labels.append(job.get("name"))
739
+ return any(_DEPLOY_WORD.search(str(label)) for label in labels if label is not None)
740
+
741
+
742
+ def gate_deploy_boundary(repo: Path) -> int:
743
+ """EXIT_BLOCKED when a push to the default branch deploys to production, EXIT_OK when it
744
+ positively does not, EXIT_CANNOT_TELL otherwise."""
745
+ blocked: list[str] = []
746
+ unknown: list[str] = []
747
+
748
+ top = _git_out(repo, "rev-parse", "--show-toplevel") if repo.is_dir() else None
749
+ root = Path(top.strip()) if top else repo
750
+ refs = _default_branch_refs(root) if top else []
751
+ if not repo.is_dir():
752
+ unknown.append(f"{repo} is not a directory")
753
+ elif not top:
754
+ unknown.append(f"{repo} is not a git repository")
755
+ elif not refs:
756
+ unknown.append("cannot find the default branch (no remote HEAD, no main or master)")
757
+ # HEAD is what is about to be merged or pushed: a deploy it adds lands on the default branch.
758
+ # It only ever adds signals; the default branch is still read, so deleting there hides nothing.
759
+ sources = refs + (["HEAD"] if top and _ref_exists(root, "HEAD") else [])
760
+
761
+ # Config: the working tree and every source. Only `true` counts; config cannot switch it off.
762
+ configs: list[tuple[str, str | None]] = []
763
+ cfg = root / ".caddis" / "config.toml"
764
+ if cfg.is_file():
765
+ configs.append(("working tree", None))
766
+ for ref in sources:
767
+ text = _git_out(root, "show", f"{ref}:.caddis/config.toml")
768
+ if text is not None:
769
+ configs.append((ref, text))
770
+ for where, text in configs:
771
+ try:
772
+ if _config_production_on_main(cfg.read_text(encoding="utf-8") if text is None else text):
773
+ blocked.append(f".caddis/config.toml ({where}) sets production_on_main = true")
774
+ except Exception as exc:
775
+ unknown.append(f".caddis/config.toml ({where}) unreadable: {exc}")
776
+
777
+ # Workflows: read from committed trees, never the working tree.
778
+ for ref in sources:
779
+ listing = _git_out(root, "ls-tree", "-r", "-z", "--name-only", ref, "--", *_WORKFLOW_DIRS)
780
+ if listing is None:
781
+ unknown.append(f"cannot list workflows on {ref}")
782
+ continue
783
+ for path in listing.split("\0"):
784
+ # Forges run only the files directly inside the workflows directory.
785
+ if path.rsplit("/", 1)[0] not in _WORKFLOW_DIRS:
786
+ continue
787
+ if not path.lower().endswith((".yml", ".yaml")):
788
+ continue
789
+ text = _git_out(root, "show", f"{ref}:{path}")
790
+ try:
791
+ if text is None:
792
+ raise OSError("git show failed")
793
+ if _DEPLOY_WORD.search(path.rsplit("/", 1)[1]) or _workflow_deploys(text):
794
+ blocked.append(f"{path} on {ref} deploys")
795
+ except Exception as exc:
796
+ unknown.append(f"{path} on {ref} unreadable: {type(exc).__name__}: {exc}")
797
+
798
+ if blocked:
799
+ print("[gate] deploy-boundary: BLOCKED — a push to the default branch deploys to production")
800
+ for reason in dict.fromkeys(blocked):
801
+ print(f" {reason}")
802
+ print(" Do not merge. Hand the merge to the owner with the PR link.")
803
+ return EXIT_BLOCKED
804
+ if unknown:
805
+ print("[gate] deploy-boundary: CANNOT TELL — treat as production")
806
+ for reason in dict.fromkeys(unknown):
807
+ print(f" {reason}")
808
+ print(" Do not merge. Hand the merge to the owner with the PR link.")
809
+ return EXIT_CANNOT_TELL
810
+ print(f"[gate] deploy-boundary: clean — no deploy signal found on {', '.join(sources)}")
811
+ return EXIT_OK
812
+
813
+
620
814
  def main(argv: list[str] | None = None) -> int:
621
815
  ap = argparse.ArgumentParser(description="caddis machine gates")
622
816
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -640,11 +834,21 @@ def main(argv: list[str] | None = None) -> int:
640
834
  hc = sub.add_parser("handover-check")
641
835
  hc.add_argument("--doc", required=True)
642
836
  hc.add_argument("--repo-root", default=".")
837
+ hc.add_argument("--max-chars", type=int, default=None,
838
+ help="block a doc longer than this (relays: 4000); default: no cap")
643
839
  dc = sub.add_parser("docs-check")
644
840
  dc.add_argument("--root", default=".")
841
+ db = sub.add_parser("deploy-boundary")
842
+ db.add_argument("--repo", default=".")
645
843
  a = ap.parse_args(argv)
646
844
 
647
845
  plan = Path(getattr(a, "plan", "") or ".")
846
+ if a.cmd == "deploy-boundary":
847
+ try:
848
+ return gate_deploy_boundary(Path(a.repo).resolve())
849
+ except Exception as exc:
850
+ sys.stderr.write(f"[caddis-gate] deploy-boundary cannot evaluate: {type(exc).__name__}: {exc}\n")
851
+ return EXIT_CANNOT_TELL # never degrade open across the production boundary
648
852
  if a.cmd == "docs-check":
649
853
  try:
650
854
  return gate_docs_check(Path(a.root).resolve())
@@ -665,7 +869,7 @@ def main(argv: list[str] | None = None) -> int:
665
869
  if a.cmd == "vendor-drift":
666
870
  return gate_vendor_drift(Path(a.repo_root).resolve())
667
871
  if a.cmd == "handover-check":
668
- return gate_handover_check(Path(a.doc), Path(a.repo_root).resolve())
872
+ return gate_handover_check(Path(a.doc), Path(a.repo_root).resolve(), a.max_chars)
669
873
  return gate_tracker(plan)
670
874
  except Exception as exc: # pragma: no cover — a gate must never crash the caller
671
875
  return _degrade(f"{type(exc).__name__}: {exc}")
@@ -47,6 +47,14 @@ import sys
47
47
  from dataclasses import dataclass, field
48
48
  from pathlib import Path
49
49
 
50
+ try:
51
+ import caddis_frontmatter
52
+ except ModuleNotFoundError as exc:
53
+ if exc.name != "caddis_frontmatter":
54
+ raise
55
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
56
+ import caddis_frontmatter
57
+
50
58
  if hasattr(sys.stdout, "reconfigure"):
51
59
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
52
60
 
@@ -71,15 +79,7 @@ def frontmatter(path: Path) -> dict[str, str]:
71
79
  text = path.read_text(encoding="utf-8", errors="ignore").lstrip("")
72
80
  except OSError:
73
81
  return {}
74
- if not text.startswith("---"):
75
- return {}
76
- end = text.find("\n---", 3)
77
- out: dict[str, str] = {}
78
- for line in text[3:end if end > 0 else 800].splitlines():
79
- if ":" in line and not line.startswith((" ", "\t")):
80
- k, _, v = line.partition(":")
81
- out[k.strip()] = v.strip()
82
- return out
82
+ return caddis_frontmatter.parse(text)
83
83
 
84
84
 
85
85
  def title_of(path: Path) -> str: