@caddis/cli 0.4.13 → 0.4.14
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/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +146 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.ps1 +11 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.sh +9 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +24 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +542 -0
- package/bundles/antigravity-plugin/scripts/caddis_host_guard.py +689 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +1632 -0
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +393 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +30 -4
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +48 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +38 -4
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""SessionStart: where this checkout stands against the remote default branch.
|
|
2
|
+
|
|
3
|
+
WHY IT FETCHES. Nobody runs git by hand before opening an agent — so without a fetch,
|
|
4
|
+
`origin/main` is whatever it was last time. The agent then branches from a stale ref and
|
|
5
|
+
believes it is current. The fetch is what makes every later "branch from origin/main"
|
|
6
|
+
mean the newest main. One consumer repo carries the same lesson as a rule of its own:
|
|
7
|
+
audit `origin/main`, never the working tree.
|
|
8
|
+
|
|
9
|
+
FAIL OPEN, ALWAYS. Every path exits 0 and every git call is wrapped. A hook that can hang
|
|
10
|
+
or fail a session start is worse than the staleness it prevents, so a failed or slow fetch
|
|
11
|
+
reports and continues.
|
|
12
|
+
|
|
13
|
+
IT ONLY PRINTS. It never switches, stashes, resets, commits or creates anything except one
|
|
14
|
+
throttle stamp inside .git/. A SessionStart hook cannot ask a question, and these checkouts
|
|
15
|
+
are shared with other live sessions — acting without an answer could take work with it.
|
|
16
|
+
|
|
17
|
+
QUIET WHEN THERE IS NOTHING TO SAY. On the default branch, clean and level, it prints
|
|
18
|
+
nothing at all, so the output stays worth reading.
|
|
19
|
+
|
|
20
|
+
Ported into caddis 2026-09-12 from a personal hook that existed on one machine only
|
|
21
|
+
(~/.claude/hooks/repo-freshness.sh), so agy gets it too and it is versioned and tested.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
# A narrow Windows console (cp1252) must not raise on a branch name with any Unicode in it.
|
|
32
|
+
_reconfig = getattr(sys.stdout, "reconfigure", None)
|
|
33
|
+
if _reconfig:
|
|
34
|
+
try:
|
|
35
|
+
_reconfig(encoding="utf-8")
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
FETCH_TIMEOUT_S = 15 # ceiling: a slow or unreachable remote must not stall a session
|
|
40
|
+
FETCH_MAX_AGE_S = 600 # a restart within 10 minutes reuses the last fetch
|
|
41
|
+
STAMP_NAME = "caddis-freshness-stamp"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _git(cwd: str, *args: str, timeout: int = 15) -> tuple[int, str]:
|
|
45
|
+
"""(exit code, stdout). Never raises; a timeout is just a non-zero code."""
|
|
46
|
+
try:
|
|
47
|
+
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
|
|
48
|
+
encoding="utf-8", errors="replace", timeout=timeout)
|
|
49
|
+
except (OSError, subprocess.SubprocessError):
|
|
50
|
+
return 1, ""
|
|
51
|
+
return r.returncode, (r.stdout or "").strip()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _default_ref(cwd: str) -> str:
|
|
55
|
+
"""The remote's default branch, e.g. origin/main. '' when there is no usable one."""
|
|
56
|
+
code, out = _git(cwd, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
57
|
+
if code == 0 and out:
|
|
58
|
+
return out
|
|
59
|
+
for cand in ("origin/main", "origin/master"):
|
|
60
|
+
if _git(cwd, "rev-parse", "--verify", "--quiet", cand)[0] == 0:
|
|
61
|
+
return cand
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fetch_is_due(git_dir: str) -> bool:
|
|
66
|
+
stamp = os.path.join(git_dir, STAMP_NAME)
|
|
67
|
+
try:
|
|
68
|
+
return (time.time() - os.path.getmtime(stamp)) > FETCH_MAX_AGE_S
|
|
69
|
+
except OSError:
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mark_fetched(git_dir: str) -> None:
|
|
74
|
+
try:
|
|
75
|
+
with open(os.path.join(git_dir, STAMP_NAME), "w", encoding="utf-8") as fh:
|
|
76
|
+
fh.write(str(int(time.time())))
|
|
77
|
+
except OSError:
|
|
78
|
+
pass # the throttle is an optimisation, never a reason to fail
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _count(cwd: str, rev_range: str) -> int:
|
|
82
|
+
code, out = _git(cwd, "rev-list", "--count", rev_range)
|
|
83
|
+
return int(out) if code == 0 and out.isdigit() else 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def report(cwd: str) -> list[str]:
|
|
87
|
+
"""The lines to print. Empty when there is nothing worth saying."""
|
|
88
|
+
if _git(cwd, "rev-parse", "--is-inside-work-tree")[0] != 0:
|
|
89
|
+
return [] # not a git repository
|
|
90
|
+
branch = _git(cwd, "branch", "--show-current")[1]
|
|
91
|
+
if not branch:
|
|
92
|
+
return [] # detached HEAD: nothing useful to say
|
|
93
|
+
lines: list[str] = []
|
|
94
|
+
# A repo with no `origin` has nothing to be behind. Fetching it would fail every time and
|
|
95
|
+
# the "could not fetch" note would be pure noise — common for scratch and local-only repos.
|
|
96
|
+
has_origin = "origin" in _git(cwd, "remote")[1].split()
|
|
97
|
+
git_dir = _git(cwd, "rev-parse", "--absolute-git-dir")[1] or os.path.join(cwd, ".git")
|
|
98
|
+
if has_origin and _fetch_is_due(git_dir):
|
|
99
|
+
if _git(cwd, "fetch", "origin", "--quiet", timeout=FETCH_TIMEOUT_S)[0] == 0:
|
|
100
|
+
_mark_fetched(git_dir)
|
|
101
|
+
else:
|
|
102
|
+
lines.append("[HARNESS] could not fetch origin - the figures below may be stale.")
|
|
103
|
+
|
|
104
|
+
ref = _default_ref(cwd) if has_origin else ""
|
|
105
|
+
dirty = len([ln for ln in _git(cwd, "status", "--porcelain")[1].splitlines() if ln.strip()])
|
|
106
|
+
behind = _count(cwd, f"HEAD..{ref}") if ref else 0
|
|
107
|
+
ahead = _count(cwd, f"{ref}..HEAD") if ref else 0
|
|
108
|
+
default_branch = ref.split("/", 1)[-1] if ref else ""
|
|
109
|
+
|
|
110
|
+
# With no remote to compare against, the only honest line left is the dirty warning.
|
|
111
|
+
if ref and (branch != default_branch or behind or dirty):
|
|
112
|
+
lines.append(f"[HARNESS] branch '{branch}' | {behind} behind {ref} | {ahead} ahead "
|
|
113
|
+
f"| {dirty} uncommitted")
|
|
114
|
+
if dirty:
|
|
115
|
+
lines.append("[HARNESS] There are uncommitted changes here. They may not be yours - these "
|
|
116
|
+
"checkouts are shared. Look before you touch: git status")
|
|
117
|
+
# The recommendation, only when following it can lose nothing: behind, clean, and with
|
|
118
|
+
# nothing of your own unpushed. Suggesting a branch switch to someone holding uncommitted
|
|
119
|
+
# work in a shared checkout is how the branch-switch hazard gets triggered.
|
|
120
|
+
if behind and not dirty and not ahead:
|
|
121
|
+
lines.append(f"[HARNESS] This checkout is behind and clean. Start new work with: "
|
|
122
|
+
f"git checkout -b <type>/<name> {ref}")
|
|
123
|
+
if ahead:
|
|
124
|
+
lines.append(f"[HARNESS] {ahead} local commit(s) are not on {ref}. Do not reset or "
|
|
125
|
+
f"re-branch without checking them: git log --oneline {ref}..HEAD")
|
|
126
|
+
return lines
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main() -> int:
|
|
130
|
+
# The session's cwd, not the hook process's: a session launched in one repo but working
|
|
131
|
+
# in another must be reported on the repo it is actually in (same rule as inject_relay).
|
|
132
|
+
try:
|
|
133
|
+
payload = json.load(sys.stdin)
|
|
134
|
+
except Exception:
|
|
135
|
+
payload = {}
|
|
136
|
+
cwd = (payload.get("cwd") if isinstance(payload, dict) else None) or os.getcwd()
|
|
137
|
+
try:
|
|
138
|
+
for line in report(cwd):
|
|
139
|
+
print(line)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass # fail open: a freshness report must never break a session start
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# caddis-run — start an agent session inside a capped Job Object (see docs/guide/resource-protection.md in the caddis source repo).
|
|
2
|
+
# Usage: caddis-run claude | caddis-run codex | caddis-run agy | caddis-run claude-glm
|
|
3
|
+
# Always goes through the governor: an environment variable is not proof of a cap. Prefers the
|
|
4
|
+
# admin-installed governor copy, which an agent running as the user cannot edit.
|
|
5
|
+
if ($args.Count -eq 0) { Write-Error "usage: caddis-run <command> [args...]"; exit 2 }
|
|
6
|
+
$installed = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'caddis\caddis_governor.py'
|
|
7
|
+
$gov = if (Test-Path $installed) { $installed } else { Join-Path $PSScriptRoot 'caddis_governor.py' }
|
|
8
|
+
$py = (Get-Command python -ErrorAction SilentlyContinue).Source
|
|
9
|
+
if (-not $py) { Write-Error "caddis-run: python not found on PATH; refusing to start ungoverned"; exit 3 }
|
|
10
|
+
& $py $gov exec --scope session -- @args
|
|
11
|
+
exit $LASTEXITCODE
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# caddis-run — start an agent session inside a capped scope (see docs/guide/resource-protection.md in the caddis source repo).
|
|
3
|
+
# Always goes through the governor: an environment variable is not proof of a cap.
|
|
4
|
+
[ "$#" -eq 0 ] && { echo "usage: caddis-run <command> [args...]" >&2; exit 2; }
|
|
5
|
+
here=$(dirname "$0")
|
|
6
|
+
gov="$here/caddis_governor.py"
|
|
7
|
+
[ -f /etc/caddis/caddis_governor.py ] && gov=/etc/caddis/caddis_governor.py
|
|
8
|
+
py=$(command -v python3 || command -v python) || { echo "caddis-run: python not found; refusing to start ungoverned" >&2; exit 3; }
|
|
9
|
+
exec "$py" "$gov" exec --scope session -- "$@"
|
|
@@ -83,11 +83,20 @@ def phase_lane(block: str) -> str:
|
|
|
83
83
|
|
|
84
84
|
|
|
85
85
|
def launch_command(block: str) -> str:
|
|
86
|
-
"""The literal command for a non-claude lane, if the phase names one.
|
|
87
|
-
|
|
86
|
+
"""The literal command for a non-claude lane, if the phase names one.
|
|
87
|
+
|
|
88
|
+
Also matches a caddis_lanes.py run command for the codex, agy and glm lanes.
|
|
89
|
+
"""
|
|
90
|
+
m = re.search(
|
|
91
|
+
r'`((?:claude-(?:glm|oss|deepseek)|' + _LANES_RUN + r')[^`]*)`',
|
|
92
|
+
block,
|
|
93
|
+
)
|
|
88
94
|
return m.group(1).strip() if m else ""
|
|
89
95
|
|
|
90
96
|
|
|
97
|
+
_LANES_RUN = r'python\s+"?\$\{CLAUDE_PLUGIN_ROOT\}/scripts/caddis_lanes\.py"?\s+run'
|
|
98
|
+
|
|
99
|
+
|
|
91
100
|
# ── gate: lane-check ────────────────────────────────────────────────────────
|
|
92
101
|
|
|
93
102
|
def gate_lane(plan: Path, phase: int) -> int:
|
|
@@ -110,7 +119,19 @@ def gate_lane(plan: Path, phase: int) -> int:
|
|
|
110
119
|
if not cmd:
|
|
111
120
|
sys.stderr.write(f"[caddis-gate] phase {phase} is lane '{lane}' but names no launch command\n")
|
|
112
121
|
return EXIT_MALFORMED
|
|
113
|
-
if
|
|
122
|
+
if re.match(_LANES_RUN, cmd):
|
|
123
|
+
named = re.search(r"--phase[\s=]+(\d+)", cmd)
|
|
124
|
+
if not named:
|
|
125
|
+
sys.stderr.write(
|
|
126
|
+
f"[caddis-gate] phase {phase}'s caddis_lanes command has no --phase N: {cmd}\n"
|
|
127
|
+
" Without it the lane would run the whole plan. Refusing to run it.\n")
|
|
128
|
+
return EXIT_MALFORMED
|
|
129
|
+
if int(named.group(1)) != phase:
|
|
130
|
+
sys.stderr.write(
|
|
131
|
+
f"[caddis-gate] phase {phase}'s caddis_lanes command names --phase {named.group(1)}: {cmd}\n"
|
|
132
|
+
" The lane would run another phase's work. Refusing to run it.\n")
|
|
133
|
+
return EXIT_MALFORMED
|
|
134
|
+
elif not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
|
|
114
135
|
sys.stderr.write(
|
|
115
136
|
f"[caddis-gate] phase {phase}'s launch command has no -p/--print: {cmd}\n"
|
|
116
137
|
" Without it the child is not marked headless, reads the same Lane line, and spawns\n"
|