@caddis/cli 0.4.34 → 0.4.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +185 -4
  6. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
  7. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
  9. package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
  11. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
  12. package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
  13. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
  14. package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
  15. package/bundles/antigravity-plugin/session_end_agy.py +16 -1
  16. package/bundles/antigravity-plugin/skills/_registry.md +34 -32
  17. package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
  18. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
  19. package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
  21. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
  22. package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
  23. package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
  24. package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
  25. package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
  26. package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
  28. package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
  29. package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
  30. package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
  31. package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
  33. package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
  34. package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
  36. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +5 -0
  37. package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
  39. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
  40. package/bundles/antigravity-plugin/skills/implement/SKILL.md +21 -0
  41. package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
  42. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
  43. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
  44. package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
  46. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +15 -0
  47. package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
  49. package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
  50. package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
  51. package/bundles/antigravity-plugin/skills/ship/SKILL.md +19 -4
  52. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -0
  53. package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
  54. package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
  55. package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
  56. package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
  57. package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
  58. package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
  59. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
  60. package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
  61. package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
  62. package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
  64. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  65. package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
  66. package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
  67. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
  68. package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
  69. package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
  70. package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
  71. package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
  72. package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
  73. package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
  74. package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
  76. package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
  77. package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
  78. package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
  79. package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
  80. package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
  81. package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
  82. package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
  83. package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
  84. package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
  85. package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
  86. package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
  87. package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
  88. package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
  89. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
  90. package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
  91. package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
  92. package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
  93. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
  94. package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
  95. package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
  96. package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
  97. package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
  98. package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
  99. package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
  100. package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
  101. package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
  102. package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
  103. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  104. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
  105. package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
  106. package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
  107. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
  108. package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
  109. package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
  110. package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
  111. package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
  112. package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
  113. package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
  114. package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
  115. package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
  116. package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
  117. package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
  118. package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
  119. package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
  120. package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
  121. package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
  122. package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
  123. package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
  124. package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
  125. package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
  126. package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
  127. package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
  128. package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
  129. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  130. package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
  131. package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
  132. package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
  133. package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
  134. package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
  135. package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
  136. package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
  137. package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
  138. package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
  139. package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
  140. package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
  141. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
  142. package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
  143. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
  144. package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
  145. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
  146. package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
  147. package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
  148. package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
  149. package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
  150. package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
  151. package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
  152. package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
  153. package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
  154. package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
  155. package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
  156. package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
  157. package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
  158. package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
  159. package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
  160. package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
  161. package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
  162. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
  163. package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
  164. package/bundles/codex/AGENTS.md +5 -1
  165. package/bundles/manifest.json +3 -3
  166. package/package.json +1 -1
@@ -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.122",
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
@@ -440,7 +443,7 @@ def gate_vendor_drift(repo_root: Path) -> int:
440
443
  _DOC_PATH = re.compile(r"`([^`\s<>{}*?]+?\.(?:md|py|ts|tsx|json|ya?ml|sh|ps1|html|toml|sql))`")
441
444
 
442
445
 
443
- def gate_handover_check(doc: Path, repo_root: Path) -> int:
446
+ def gate_handover_check(doc: Path, repo_root: Path, max_chars: int | None = None) -> int:
444
447
  """Every file a handover names must exist. Nothing else in a handover is machine-checkable.
445
448
 
446
449
  A succession prompt is written from memory, so it names files the writer BELIEVES are there.
@@ -454,8 +457,10 @@ def gate_handover_check(doc: Path, repo_root: Path) -> int:
454
457
  later withdrawal" — need conventions that do not exist yet, and a gate that pretends to check
455
458
  them would be worse than one that says it does not.
456
459
 
457
- Degrades open on a missing/unreadable doc. Blocks only on a path the doc claims and the repo
458
- does not have.
460
+ Degrades open on a missing/unreadable doc. Blocks on a path the doc claims and the repo does
461
+ not have, and -- only when the caller passes ``max_chars`` -- on a doc longer than that. The
462
+ cap is opt-in: /handoff caps a workstream relay at 4,000 characters, but a /spawn-hub prompt
463
+ must embed the full inventory verbatim and would never fit.
459
464
  """
460
465
  if not doc.is_file():
461
466
  return _degrade(f"no handover doc at {doc}")
@@ -464,6 +469,12 @@ def gate_handover_check(doc: Path, repo_root: Path) -> int:
464
469
  except OSError as exc:
465
470
  return _degrade(f"cannot read {doc}: {type(exc).__name__}")
466
471
 
472
+ if max_chars is not None and len(text) > max_chars:
473
+ sys.stderr.write(
474
+ f"[caddis-gate] handover doc {doc.name} exceeds {max_chars:,} characters ({len(text)} chars)\n"
475
+ )
476
+ return EXIT_BLOCKED
477
+
467
478
  missing: list[str] = []
468
479
  for raw in sorted(set(_DOC_PATH.findall(text))):
469
480
  # A backtick often wraps a whole command; the path is the token that looks like one.
@@ -617,6 +628,166 @@ def gate_docs_check(root: Path) -> int:
617
628
  return EXIT_BLOCKED if failures else EXIT_OK
618
629
 
619
630
 
631
+ # ── gate: deploy-boundary ───────────────────────────────────────────────────
632
+ # The owner's production boundary: an agent never merges into a repo whose default branch deploys
633
+ # on every push. /ship, /ship-merge and /pr-merge run this before any merge or push to the default
634
+ # branch, and hand the merge to the owner on anything but exit 0.
635
+ #
636
+ # FAILS CLOSED — the one deliberate exception to DEGRADE OPEN above. "Cannot tell" is exit 3,
637
+ # never 0, because a false "clean" here is a production deploy nobody approved. Any positive
638
+ # signal wins over every negative one: config cannot switch the check off.
639
+ EXIT_CANNOT_TELL = 3
640
+
641
+ _WORKFLOW_DIRS = (".github/workflows", ".gitea/workflows", ".forgejo/workflows")
642
+
643
+ # An inherited GIT_DIR (from a hook or wrapper) would make `git -C <repo>` read another repository.
644
+ _GIT_REPO_ENV = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY",
645
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_NAMESPACE")
646
+
647
+
648
+ def _git_out(repo: Path, *args: str) -> str | None:
649
+ """stdout of a git command in `repo`, or None when it fails."""
650
+ env = {k: v for k, v in os.environ.items() if k not in _GIT_REPO_ENV}
651
+ try:
652
+ r = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True,
653
+ encoding="utf-8", errors="replace", timeout=30, env=env)
654
+ except (OSError, subprocess.SubprocessError):
655
+ return None
656
+ return r.stdout if r.returncode == 0 else None
657
+
658
+
659
+ def _ref_exists(repo: Path, ref: str) -> bool:
660
+ return _git_out(repo, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}") is not None
661
+
662
+
663
+ def _default_branch_refs(repo: Path) -> list[str]:
664
+ """Every copy of the default branch: each remote's and the local branch of that name.
665
+
666
+ The name comes from any remote's recorded HEAD; with none recorded, `main` and `master` stand
667
+ in. Reading every copy is deliberate: a positive signal on any of them blocks.
668
+ """
669
+ remotes = (_git_out(repo, "remote") or "").split()
670
+ names: set[str] = set()
671
+ for remote in remotes:
672
+ target = (_git_out(repo, "symbolic-ref", "--quiet", f"refs/remotes/{remote}/HEAD") or "").strip()
673
+ if target.startswith(f"refs/remotes/{remote}/"):
674
+ names.add(target[len(f"refs/remotes/{remote}/"):])
675
+ names = names or {"main", "master"}
676
+ refs = [f"refs/remotes/{r}/{n}" for r in remotes for n in sorted(names)]
677
+ refs += [f"refs/heads/{n}" for n in sorted(names)]
678
+ return [r for r in refs if _ref_exists(repo, r)]
679
+
680
+
681
+ def _config_production_on_main(text: str) -> bool | None:
682
+ """True/False from `[deploy] production_on_main`; None when absent. Raises on a bad file."""
683
+ import tomllib
684
+ deploy = tomllib.loads(text).get("deploy", {})
685
+ if not isinstance(deploy, dict):
686
+ raise ValueError("[deploy] is not a table")
687
+ value = deploy.get("production_on_main")
688
+ if value is not None and not isinstance(value, bool):
689
+ raise ValueError(f"production_on_main is {value!r}, not true or false")
690
+ return value
691
+
692
+
693
+ # A deploy is often not named "deploy". Letters-only boundaries on prod, so `deploy_prod` matches
694
+ # and `product` or `reproduce` do not.
695
+ _DEPLOY_WORD = re.compile(r"deploy|release|publish|(?<![a-z])prod(?:uction)?(?![a-z])", re.I)
696
+
697
+
698
+ def _workflow_deploys(text: str) -> bool:
699
+ """True when the workflow's name, a job id or a job name looks like a deploy, or a job sets a
700
+ GitHub `environment:` (the forge's own marker for a deployment). Raises on a bad file.
701
+
702
+ The caller also checks the file name: a forge shows the file path as the workflow's name when
703
+ `name:` is unset.
704
+ """
705
+ import yaml
706
+ doc = yaml.safe_load(text)
707
+ if not isinstance(doc, dict) or not isinstance(doc.get("jobs"), dict):
708
+ raise ValueError("not a workflow (no jobs mapping)")
709
+ labels = [doc.get("name")]
710
+ for job_id, job in doc["jobs"].items():
711
+ labels.append(job_id)
712
+ if isinstance(job, dict):
713
+ if job.get("environment"):
714
+ return True
715
+ labels.append(job.get("name"))
716
+ return any(_DEPLOY_WORD.search(str(label)) for label in labels if label is not None)
717
+
718
+
719
+ def gate_deploy_boundary(repo: Path) -> int:
720
+ """EXIT_BLOCKED when a push to the default branch deploys to production, EXIT_OK when it
721
+ positively does not, EXIT_CANNOT_TELL otherwise."""
722
+ blocked: list[str] = []
723
+ unknown: list[str] = []
724
+
725
+ top = _git_out(repo, "rev-parse", "--show-toplevel") if repo.is_dir() else None
726
+ root = Path(top.strip()) if top else repo
727
+ refs = _default_branch_refs(root) if top else []
728
+ if not repo.is_dir():
729
+ unknown.append(f"{repo} is not a directory")
730
+ elif not top:
731
+ unknown.append(f"{repo} is not a git repository")
732
+ elif not refs:
733
+ unknown.append("cannot find the default branch (no remote HEAD, no main or master)")
734
+ # HEAD is what is about to be merged or pushed: a deploy it adds lands on the default branch.
735
+ # It only ever adds signals; the default branch is still read, so deleting there hides nothing.
736
+ sources = refs + (["HEAD"] if top and _ref_exists(root, "HEAD") else [])
737
+
738
+ # Config: the working tree and every source. Only `true` counts; config cannot switch it off.
739
+ configs: list[tuple[str, str | None]] = []
740
+ cfg = root / ".caddis" / "config.toml"
741
+ if cfg.is_file():
742
+ configs.append(("working tree", None))
743
+ for ref in sources:
744
+ text = _git_out(root, "show", f"{ref}:.caddis/config.toml")
745
+ if text is not None:
746
+ configs.append((ref, text))
747
+ for where, text in configs:
748
+ try:
749
+ if _config_production_on_main(cfg.read_text(encoding="utf-8") if text is None else text):
750
+ blocked.append(f".caddis/config.toml ({where}) sets production_on_main = true")
751
+ except Exception as exc:
752
+ unknown.append(f".caddis/config.toml ({where}) unreadable: {exc}")
753
+
754
+ # Workflows: read from committed trees, never the working tree.
755
+ for ref in sources:
756
+ listing = _git_out(root, "ls-tree", "-r", "-z", "--name-only", ref, "--", *_WORKFLOW_DIRS)
757
+ if listing is None:
758
+ unknown.append(f"cannot list workflows on {ref}")
759
+ continue
760
+ for path in listing.split("\0"):
761
+ # Forges run only the files directly inside the workflows directory.
762
+ if path.rsplit("/", 1)[0] not in _WORKFLOW_DIRS:
763
+ continue
764
+ if not path.lower().endswith((".yml", ".yaml")):
765
+ continue
766
+ text = _git_out(root, "show", f"{ref}:{path}")
767
+ try:
768
+ if text is None:
769
+ raise OSError("git show failed")
770
+ if _DEPLOY_WORD.search(path.rsplit("/", 1)[1]) or _workflow_deploys(text):
771
+ blocked.append(f"{path} on {ref} deploys")
772
+ except Exception as exc:
773
+ unknown.append(f"{path} on {ref} unreadable: {type(exc).__name__}: {exc}")
774
+
775
+ if blocked:
776
+ print("[gate] deploy-boundary: BLOCKED — a push to the default branch deploys to production")
777
+ for reason in dict.fromkeys(blocked):
778
+ print(f" {reason}")
779
+ print(" Do not merge. Hand the merge to the owner with the PR link.")
780
+ return EXIT_BLOCKED
781
+ if unknown:
782
+ print("[gate] deploy-boundary: CANNOT TELL — treat as production")
783
+ for reason in dict.fromkeys(unknown):
784
+ print(f" {reason}")
785
+ print(" Do not merge. Hand the merge to the owner with the PR link.")
786
+ return EXIT_CANNOT_TELL
787
+ print(f"[gate] deploy-boundary: clean — no deploy signal found on {', '.join(sources)}")
788
+ return EXIT_OK
789
+
790
+
620
791
  def main(argv: list[str] | None = None) -> int:
621
792
  ap = argparse.ArgumentParser(description="caddis machine gates")
622
793
  sub = ap.add_subparsers(dest="cmd", required=True)
@@ -640,11 +811,21 @@ def main(argv: list[str] | None = None) -> int:
640
811
  hc = sub.add_parser("handover-check")
641
812
  hc.add_argument("--doc", required=True)
642
813
  hc.add_argument("--repo-root", default=".")
814
+ hc.add_argument("--max-chars", type=int, default=None,
815
+ help="block a doc longer than this (relays: 4000); default: no cap")
643
816
  dc = sub.add_parser("docs-check")
644
817
  dc.add_argument("--root", default=".")
818
+ db = sub.add_parser("deploy-boundary")
819
+ db.add_argument("--repo", default=".")
645
820
  a = ap.parse_args(argv)
646
821
 
647
822
  plan = Path(getattr(a, "plan", "") or ".")
823
+ if a.cmd == "deploy-boundary":
824
+ try:
825
+ return gate_deploy_boundary(Path(a.repo).resolve())
826
+ except Exception as exc:
827
+ sys.stderr.write(f"[caddis-gate] deploy-boundary cannot evaluate: {type(exc).__name__}: {exc}\n")
828
+ return EXIT_CANNOT_TELL # never degrade open across the production boundary
648
829
  if a.cmd == "docs-check":
649
830
  try:
650
831
  return gate_docs_check(Path(a.root).resolve())
@@ -665,7 +846,7 @@ def main(argv: list[str] | None = None) -> int:
665
846
  if a.cmd == "vendor-drift":
666
847
  return gate_vendor_drift(Path(a.repo_root).resolve())
667
848
  if a.cmd == "handover-check":
668
- return gate_handover_check(Path(a.doc), Path(a.repo_root).resolve())
849
+ return gate_handover_check(Path(a.doc), Path(a.repo_root).resolve(), a.max_chars)
669
850
  return gate_tracker(plan)
670
851
  except Exception as exc: # pragma: no cover — a gate must never crash the caller
671
852
  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:
@@ -0,0 +1,159 @@
1
+ """Transfer one workstream relay through an isolated, leased Git ref."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ try:
12
+ from caddis_workstreams import _name, _validate_relay, read_relay, write_relay
13
+ import secret_filter
14
+ except ModuleNotFoundError as exc:
15
+ if exc.name not in {"caddis_workstreams", "secret_filter"}:
16
+ raise
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / ".github" / "tools"))
19
+ from caddis_workstreams import _name, _validate_relay, read_relay, write_relay
20
+ import secret_filter
21
+
22
+
23
+ MAX_NOTE_CHARS = 4000
24
+
25
+
26
+ def _git(repo: Path, *args: str, input_data: bytes | None = None,
27
+ env: dict[str, str] | None = None) -> bytes:
28
+ result = subprocess.run(["git", *args], cwd=repo, env=env, input=input_data,
29
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
30
+ if result.returncode:
31
+ # Name the subcommand, not a leading "-c key=value" option.
32
+ command = next((arg for i, arg in enumerate(args) if not arg.startswith("-") and (i == 0 or args[i - 1] != "-c")), "?")
33
+ raise RuntimeError(f"git {command} failed: {result.stderr.decode('utf-8', 'replace').strip()}")
34
+ return result.stdout
35
+
36
+
37
+ def _ref(workstream: str) -> str:
38
+ return "refs/caddis/relay/" + _name(workstream)
39
+
40
+
41
+ def _check_note(workstream: str, note: str) -> None:
42
+ if len(note) > MAX_NOTE_CHARS:
43
+ raise ValueError("relay exceeds 4,000 characters")
44
+ _validate_relay(workstream, note)
45
+
46
+
47
+ def _pre_send(note: str) -> None:
48
+ # A synthetic new-file diff makes the existing filter scan every line, not
49
+ # only lines changed since a prior relay. No user-configured allowlist is
50
+ # accepted for this outbound note.
51
+ lines = note.splitlines()
52
+ diff = ("diff --git a/relay.md b/relay.md\nnew file mode 100644\n"
53
+ "--- /dev/null\n+++ b/relay.md\n"
54
+ f"@@ -0,0 +1,{len(lines)} @@\n" +
55
+ "".join("+" + line + "\n" for line in lines))
56
+ result = secret_filter.filter_diff(diff, list(secret_filter.DEFAULT_DENY_GLOBS), [])
57
+ if result.hits or result.dropped_paths or not result.kept_diff:
58
+ raise ValueError("relay refused by pre-send filter")
59
+
60
+
61
+ def push_relay(repo: Path, workstream: str, *, confirmed: bool, interactive: bool) -> int:
62
+ """Return 0 on a leased push, 1 on refusal or conflict.
63
+
64
+ A Git or network failure before the push (for example an unreachable remote in
65
+ ``ls-remote``) raises RuntimeError, so the caller reports it instead of a refusal.
66
+
67
+ The caller must obtain this invocation's interactive decision. This API
68
+ never reads an ambient confirmation variable or prompts by itself.
69
+ """
70
+ if not confirmed or not interactive:
71
+ return 1
72
+ repo = Path(repo)
73
+ ref = _ref(workstream)
74
+ note, digest = read_relay(repo, workstream)
75
+ if digest is None:
76
+ return 1
77
+ _check_note(workstream, note)
78
+ _pre_send(note)
79
+ with tempfile.TemporaryDirectory(prefix="caddis-relay-index-") as temp:
80
+ index = Path(temp) / "index"
81
+ env = os.environ.copy()
82
+ env["GIT_INDEX_FILE"] = str(index)
83
+ _git(repo, "read-tree", "--empty", env=env)
84
+ blob = _git(repo, "hash-object", "-w", "--stdin", input_data=note.encode("utf-8"), env=env).decode().strip()
85
+ _git(repo, "update-index", "--add", "--cacheinfo", "100644", blob, "relay.md", env=env)
86
+ tree = _git(repo, "write-tree", env=env).decode().strip()
87
+ commit = _git(repo, "-c", "user.name=Caddis Relay", "-c", "user.email=relay@localhost",
88
+ "commit-tree", tree, input_data=b"Relay note\n", env=env).decode().strip()
89
+ old = _git(repo, "ls-remote", "origin", ref, env=env).decode().strip()
90
+ expected = old.split("\t", 1)[0] if old else ""
91
+ try:
92
+ _git(repo, "push", f"--force-with-lease={ref}:{expected}", "origin",
93
+ f"{commit}:{ref}", env=env)
94
+ except RuntimeError:
95
+ return 1
96
+ return 0
97
+
98
+
99
+ def fetch_relay(repo: Path, workstream: str, *, replace: bool = False) -> str:
100
+ """Fetch one explicit ref, validate its one-file tree, then store by local CAS.
101
+
102
+ A local relay that differs from the fetched one is never replaced unless the caller
103
+ passes ``replace=True``: it may hold newer, never-pushed work.
104
+ """
105
+ repo = Path(repo)
106
+ ref = _ref(workstream)
107
+ local, expected_hash = read_relay(repo, workstream)
108
+ _git(repo, "fetch", "--no-tags", "origin", ref)
109
+ fetched = _git(repo, "rev-parse", "FETCH_HEAD").decode().strip()
110
+ entries = _git(repo, "ls-tree", "-z", fetched).split(b"\0")
111
+ if len(entries) != 2 or not entries[0].startswith(b"100644 blob ") or not entries[0].endswith(b"\trelay.md"):
112
+ raise ValueError("remote relay tree must contain only an ordinary relay.md")
113
+ size = int(_git(repo, "cat-file", "-s", f"{fetched}:relay.md").decode().strip())
114
+ if size > MAX_NOTE_CHARS * 4: # UTF-8 upper bound; read nothing larger into memory
115
+ raise ValueError("remote relay exceeds 4,000 characters")
116
+ note = _git(repo, "show", f"{fetched}:relay.md").decode("utf-8")
117
+ _check_note(workstream, note)
118
+ if expected_hash is not None and local != note and not replace:
119
+ raise ValueError("local relay differs from the fetched one; pass replace=True to overwrite it")
120
+ result = write_relay(repo, workstream, note, expected_hash)
121
+ if result.status != "written":
122
+ raise RuntimeError("local relay changed during fetch")
123
+ return note
124
+
125
+
126
+ def _headless() -> bool:
127
+ """No human is watching: the caddis launchers and the docket runner set these."""
128
+ return any(os.environ.get(name) for name in ("CADDIS_HEADLESS", "DOCKET_PLAN", "DOCKET_BRANCH"))
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ import argparse
133
+ parser = argparse.ArgumentParser(description="Move one workstream relay between machines")
134
+ common = argparse.ArgumentParser(add_help=False)
135
+ common.add_argument("--repo", type=Path, default=Path("."))
136
+ sub = parser.add_subparsers(dest="command", required=True)
137
+ push_p = sub.add_parser("push", parents=[common], help="publish .caddis/relay/<name>.md to refs/caddis/relay/<name>")
138
+ push_p.add_argument("name")
139
+ push_p.add_argument("--user-said-yes", action="store_true",
140
+ help="pass only after the user answered y to the push question in this session")
141
+ fetch_p = sub.add_parser("fetch", parents=[common], help="fetch refs/caddis/relay/<name> into .caddis/relay/<name>.md")
142
+ fetch_p.add_argument("name")
143
+ fetch_p.add_argument("--replace", action="store_true",
144
+ help="overwrite a different local relay (it may hold never-pushed work)")
145
+ args = parser.parse_args(argv)
146
+ try:
147
+ if args.command == "push":
148
+ code = push_relay(args.repo, args.name, confirmed=args.user_said_yes, interactive=not _headless())
149
+ print("relay pushed" if code == 0 else "relay not pushed (no consent, headless, missing relay, or remote conflict)")
150
+ return code
151
+ print(fetch_relay(args.repo, args.name, replace=args.replace), end="")
152
+ return 0
153
+ except (RuntimeError, ValueError, OSError) as exc:
154
+ print(f"caddis-relay: {exc}", file=sys.stderr)
155
+ return 1
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())