@caddis/cli 0.4.34 → 0.4.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +210 -6
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +262 -1
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +103 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
- package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
- package/bundles/antigravity-plugin/session_end_agy.py +16 -1
- package/bundles/antigravity-plugin/skills/_registry.md +35 -32
- package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
- package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +185 -0
- package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
- package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
- package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +20 -0
- package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -2
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
- package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +33 -2
- package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
- package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +27 -7
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +36 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
- package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
- package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
- package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
- package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
- package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
- package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
- package/bundles/codex/AGENTS.md +5 -1
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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
|
|
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
|
|
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
|
|
458
|
-
|
|
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
|
-
|
|
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:
|