@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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.103",
3
+ "version": "1.3.105",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -0,0 +1,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
- m = re.search(r"`(claude-(?:glm|oss|deepseek)[^`]*)`", block)
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 not re.search(r"(?:^|\s)(?:-p|--print)(?:\s|$)", cmd):
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"