@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.
- 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 +185 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -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 +34 -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/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 +5 -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 +21 -0
- 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 +15 -0
- 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 +19 -4
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -0
- 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
|
|
@@ -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
|
|
458
|
-
|
|
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
|
-
|
|
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())
|