@caddis/cli 0.4.52 → 0.4.54
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/CADDIS-GLOSSARY.md +1 -0
- package/bundles/antigravity-plugin/guard_agy.py +184 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +132 -5
- package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
- package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
- package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
- package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
- package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
- package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
- package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
- package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
- package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
- package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
- package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
- package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
- package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
- package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
- package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
- package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
- package/bundles/antigravity-plugin/session_end_agy.py +3 -3
- package/bundles/antigravity-plugin/skills/_registry.md +4 -6
- package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
- package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
- package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
- package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
- package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
- package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
- package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
- package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
- package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
- package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
- package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
- package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
- package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
- package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
- package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
- package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
- package/bundles/codex/AGENTS.md +5 -4
- package/bundles/manifest.json +3 -3
- package/package.json +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
- package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
- package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
- package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/succession.md +0 -118
|
@@ -126,6 +126,7 @@ plain word gets them wrong. Usage counts were measured across 444 markdown files
|
|
|
126
126
|
| model lane | A named provider route reached by its own binary — `claude-oss`, `claude-glm`, `claude-deepseek` — all speaking the Anthropic protocol. A lane is a route, not a model. | provider, backend, endpoint |
|
|
127
127
|
| core / extras | The two shipping tiers. Core is an EXPLICIT allowlist in `.github/runtime-targets.json`; anything not named there lands in extras. A skill defaults to extras, so placement is a decision, never an accident. | tier 1 / tier 2, full / lite |
|
|
128
128
|
| harness | The agent runtime that loads caddis — Claude Code, agy, Codex, Copilot. caddis is agent-agnostic; "the harness" without a name means whichever one is running. *(135 uses)* | client, IDE, platform |
|
|
129
|
+
| card | One full unit of work handed to a lane or a reviewer: scope, files and pass criteria, kept in a roadmap's cards/ folder. | task file, ticket |
|
|
129
130
|
|
|
130
131
|
## Flagged ambiguities
|
|
131
132
|
|
|
@@ -32,6 +32,7 @@ from __future__ import annotations
|
|
|
32
32
|
import json
|
|
33
33
|
import os
|
|
34
34
|
import re
|
|
35
|
+
import shlex
|
|
35
36
|
import sys
|
|
36
37
|
|
|
37
38
|
# Functional identity, mirrored from scripts/claudster_config.py.
|
|
@@ -87,6 +88,187 @@ _CATASTROPHIC_TARGET = re.compile(
|
|
|
87
88
|
_FORK_BOMB = re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")
|
|
88
89
|
|
|
89
90
|
|
|
91
|
+
# `git add` of everything. A tokenizer, not a regex: three regex rounds each opened a new gap, and one backtracked
|
|
92
|
+
# exponentially. Linear in the command length. Copied from hooks/guard.py; test_guard_agy.py checks they agree.
|
|
93
|
+
_GA_SEPARATORS = {";", "&", "&&", "|", "||", "(", ")", "{", "}", "$", "!", "\n"}
|
|
94
|
+
_GA_PUNCT = set(";&|(){}$!\n") # a token made only of these is a separator (`$(`, `&&`, `);`, `|!`)
|
|
95
|
+
_GA_KEYWORDS = {"if", "then", "else", "elif", "do", "while", "until"} # the next word starts a command
|
|
96
|
+
_GA_WRAPPERS = {"sudo", "command", "env", "exec", "nohup", "time", "timeout", "nice", "xargs", "call", "wsl",
|
|
97
|
+
"busybox"}
|
|
98
|
+
_GA_SHELLS = {"bash", "sh", "zsh", "dash", "pwsh", "powershell", "cmd"}
|
|
99
|
+
_GA_JOINING_SHELLS = {"cmd", "pwsh", "powershell"} # they run ALL the words after the flag; bash -c runs one
|
|
100
|
+
_GA_SHELL_RUN_FLAGS = {"-c", "/c", "/k", "-command", "-commandwithargs"}
|
|
101
|
+
_GA_GIT_VALUE_OPTS = {"-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--config-env"}
|
|
102
|
+
_GA_EVERYTHING = {"-A", "--all", ".", "./", ".\\", ":/", "*"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ga_sep(token: str) -> bool:
|
|
106
|
+
"""A command separator: one of _GA_SEPARATORS, or a token made only of their characters (`$(`, `|!`)."""
|
|
107
|
+
return token in _GA_SEPARATORS or (bool(token) and set(token) <= _GA_PUNCT)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ga_name(token: str) -> str:
|
|
111
|
+
"""`/usr/bin/git`, `"C:\\Program Files\\Git\\cmd\\git.exe"`, `GIT` -> `git`."""
|
|
112
|
+
base = re.split(r"[\\/]", token)[-1].lower()
|
|
113
|
+
return base[:-4] if base.endswith(".exe") else base
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _ga_tokens(text: str) -> list[str]:
|
|
117
|
+
"""Shell tokens, quotes removed (also mid-token: --work-tree="my dir"), backslashes kept (Windows paths).
|
|
118
|
+
; & | ( ) { } $ ! and newlines become separator tokens."""
|
|
119
|
+
def lex(quotes: str) -> list[str]:
|
|
120
|
+
lexer = shlex.shlex(text.replace("`", "\n"), posix=True, punctuation_chars=";&|(){}$!\n")
|
|
121
|
+
lexer.whitespace, lexer.whitespace_split, lexer.escape, lexer.quotes = " \t\r", True, "", quotes
|
|
122
|
+
return list(lexer)
|
|
123
|
+
try:
|
|
124
|
+
return lex("'\"")
|
|
125
|
+
except ValueError: # unbalanced quotes: read it again without quote handling
|
|
126
|
+
return lex("")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def git_add_everything(command: str, depth: int = 0) -> bool:
|
|
130
|
+
"""True when `command` runs `git add` with -A/--all (also bundled, e.g. -Av), `.`, `./`, `:/` or `*`.
|
|
131
|
+
|
|
132
|
+
`git` must start a command (after a separator, a shell keyword such as `do`, VAR=value, or a wrapper such as
|
|
133
|
+
sudo, env, xargs, nice -n 10), with any path, case or .exe. `bash -c "..."`, `pwsh -Command ...` and
|
|
134
|
+
`cmd /c ...` are read inside. Only git's global options may stand between `git` and `add`, so
|
|
135
|
+
`git commit -m "add ."` and `printf "git add ."` pass. Callers pass heredoc-free text and must treat an
|
|
136
|
+
exception as "no match": this check must never stop the guard's other rules.
|
|
137
|
+
"""
|
|
138
|
+
if depth > 3:
|
|
139
|
+
return False
|
|
140
|
+
text = re.sub(r"\\\r?\n|`\r?\n", " ", command) # line continuations (bash \, PowerShell `)
|
|
141
|
+
tokens = _ga_tokens(text)
|
|
142
|
+
i, start = 0, True
|
|
143
|
+
while i < len(tokens):
|
|
144
|
+
tok = tokens[i]
|
|
145
|
+
if _ga_sep(tok):
|
|
146
|
+
i, start = i + 1, True
|
|
147
|
+
continue
|
|
148
|
+
if not start:
|
|
149
|
+
i += 1
|
|
150
|
+
continue
|
|
151
|
+
if tok in _GA_KEYWORDS or re.match(r"^\w+=", tok): # `do git ...`, VAR=value before the command
|
|
152
|
+
i += 1
|
|
153
|
+
continue
|
|
154
|
+
name = _ga_name(tok)
|
|
155
|
+
if name in _GA_WRAPPERS: # skip the wrapper's own flags and values up to the real command
|
|
156
|
+
i += 1
|
|
157
|
+
while i < len(tokens) and not _ga_sep(tokens[i]) and _ga_name(tokens[i]) not in ({"git"} | _GA_SHELLS):
|
|
158
|
+
i += 1
|
|
159
|
+
continue
|
|
160
|
+
start = False
|
|
161
|
+
if name in _GA_SHELLS:
|
|
162
|
+
j = i + 1
|
|
163
|
+
while j < len(tokens) and not _ga_sep(tokens[j]):
|
|
164
|
+
if tokens[j].lower() in _GA_SHELL_RUN_FLAGS:
|
|
165
|
+
rest = tokens[j + 1:]
|
|
166
|
+
end = next((k for k, t in enumerate(rest) if _ga_sep(t)), len(rest))
|
|
167
|
+
if end == 0: # `bash -c` with nothing after it
|
|
168
|
+
break
|
|
169
|
+
inner = " ".join(rest[:end]) if name in _GA_JOINING_SHELLS else rest[0]
|
|
170
|
+
if git_add_everything(inner, depth + 1):
|
|
171
|
+
return True
|
|
172
|
+
break
|
|
173
|
+
j += 1
|
|
174
|
+
i = j
|
|
175
|
+
continue
|
|
176
|
+
if name != "git":
|
|
177
|
+
i += 1
|
|
178
|
+
continue
|
|
179
|
+
j = i + 1
|
|
180
|
+
while j < len(tokens) and tokens[j].startswith("-") and not _ga_sep(tokens[j]):
|
|
181
|
+
j += 2 if tokens[j] in _GA_GIT_VALUE_OPTS else 1
|
|
182
|
+
if j >= len(tokens) or tokens[j] != "add":
|
|
183
|
+
i = j
|
|
184
|
+
continue
|
|
185
|
+
after_dashdash = False
|
|
186
|
+
for arg in tokens[j + 1:]:
|
|
187
|
+
if _ga_sep(arg):
|
|
188
|
+
break
|
|
189
|
+
if arg == "--":
|
|
190
|
+
after_dashdash = True
|
|
191
|
+
continue
|
|
192
|
+
if after_dashdash and arg.startswith("-"): # `git add -- -A` names a file called -A
|
|
193
|
+
continue
|
|
194
|
+
if arg in _GA_EVERYTHING or re.fullmatch(r"-[a-zA-Z]*A[a-zA-Z]*", arg):
|
|
195
|
+
return True
|
|
196
|
+
i = j + 1
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# Copied from hooks/guard.py: a heredoc body that only carries data is not a command.
|
|
201
|
+
_HEREDOC_MARKER = re.compile(
|
|
202
|
+
r"<<(-?)[ \t]*(?:'([A-Za-z_][A-Za-z_0-9]*)'|\"([A-Za-z_][A-Za-z_0-9]*)\"|([A-Za-z_][A-Za-z_0-9]*))"
|
|
203
|
+
)
|
|
204
|
+
_HEREDOC_INTERPRETER = re.compile(
|
|
205
|
+
r"^\s*(?:\S*/)?(?:bash|sh|zsh|dash|python|python3|py|node|ruby|perl|pwsh|powershell)\b",
|
|
206
|
+
re.I,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _unquoted_at(line: str, position: int) -> bool:
|
|
211
|
+
"""Whether a token at position is outside single and double quotes on this line."""
|
|
212
|
+
quote = ""
|
|
213
|
+
escaped = False
|
|
214
|
+
for char in line[:position]:
|
|
215
|
+
if escaped:
|
|
216
|
+
escaped = False
|
|
217
|
+
elif char == "\\" and quote != "'":
|
|
218
|
+
escaped = True
|
|
219
|
+
elif quote:
|
|
220
|
+
if char == quote:
|
|
221
|
+
quote = ""
|
|
222
|
+
elif char in "'\"":
|
|
223
|
+
quote = char
|
|
224
|
+
return not quote and not escaped
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _without_heredoc_bodies(command: str) -> str:
|
|
228
|
+
"""Skip complete data-only heredocs; preserve executable and incomplete bodies."""
|
|
229
|
+
lines = command.splitlines(keepends=True)
|
|
230
|
+
kept: list[str] = []
|
|
231
|
+
i = 0
|
|
232
|
+
while i < len(lines):
|
|
233
|
+
line = lines[i]
|
|
234
|
+
markers = []
|
|
235
|
+
executable = False
|
|
236
|
+
for marker in _HEREDOC_MARKER.finditer(line):
|
|
237
|
+
pos = marker.start()
|
|
238
|
+
if (pos and not (line[pos - 1].isspace() or line[pos - 1] in ";&|")
|
|
239
|
+
or not _unquoted_at(line, pos)):
|
|
240
|
+
continue
|
|
241
|
+
markers.append((next(group for group in marker.groups()[1:] if group is not None),
|
|
242
|
+
marker.group(1) == "-"))
|
|
243
|
+
owner = re.split(r"[;&|]", line[:pos])[-1]
|
|
244
|
+
executable |= bool(_HEREDOC_INTERPRETER.match(owner))
|
|
245
|
+
if not markers:
|
|
246
|
+
kept.append(line)
|
|
247
|
+
i += 1
|
|
248
|
+
continue
|
|
249
|
+
end = i
|
|
250
|
+
for tag, strip_tabs in markers:
|
|
251
|
+
end += 1
|
|
252
|
+
while end < len(lines):
|
|
253
|
+
candidate = lines[end].rstrip("\r\n")
|
|
254
|
+
if (candidate.lstrip("\t") if strip_tabs else candidate) == tag:
|
|
255
|
+
break
|
|
256
|
+
end += 1
|
|
257
|
+
if end == len(lines):
|
|
258
|
+
return command
|
|
259
|
+
kept.extend(lines[i:end + 1] if executable else [line])
|
|
260
|
+
i = end + 1
|
|
261
|
+
return "".join(kept)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _git_add_everything_safe(text: str) -> bool:
|
|
265
|
+
"""git_add_everything, but an error is "no match": one rule must never switch off the rest of the guard."""
|
|
266
|
+
try:
|
|
267
|
+
return git_add_everything(text)
|
|
268
|
+
except Exception: # noqa: BLE001 - fail open for this rule only; the other rules still run
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
|
|
90
272
|
def _strip_quotes(s: str) -> str:
|
|
91
273
|
"""Drop quotes so quoted flags/targets still match (rm '-rf' \"/\" → rm -rf /)."""
|
|
92
274
|
return s.replace('"', "").replace("'", "")
|
|
@@ -191,6 +373,8 @@ def classify_bash(command: str) -> tuple[str, str]:
|
|
|
191
373
|
return "deny", "fork bomb"
|
|
192
374
|
if re.search(r"\bdd\b.*\bof=/dev/", nl) or re.search(r"\bmkfs", nl) or re.search(r">\s*/dev/sd[a-z]", nl):
|
|
193
375
|
return "deny", "writes directly to a disk device"
|
|
376
|
+
if _git_add_everything_safe(_without_heredoc_bodies(c)):
|
|
377
|
+
return "deny", "git add of everything (-A, --all, . or :/): stage named files only"
|
|
194
378
|
|
|
195
379
|
# ── ask: destructive-but-legit ──
|
|
196
380
|
if rm_rf:
|
|
@@ -24,10 +24,22 @@ from __future__ import annotations
|
|
|
24
24
|
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
|
+
import re
|
|
27
28
|
import subprocess
|
|
28
29
|
import sys
|
|
29
30
|
import time
|
|
30
31
|
|
|
32
|
+
try:
|
|
33
|
+
_cfg_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
|
|
34
|
+
if _cfg_scripts not in sys.path:
|
|
35
|
+
sys.path.insert(0, _cfg_scripts)
|
|
36
|
+
from claudster_config import ARTIFACT_DIRS, artifact_root # noqa: E402
|
|
37
|
+
except Exception: # pragma: no cover — defensive; a hook must never crash a session start
|
|
38
|
+
ARTIFACT_DIRS = (".caddis",)
|
|
39
|
+
|
|
40
|
+
def artifact_root(root):
|
|
41
|
+
return os.path.join(str(root), ARTIFACT_DIRS[0])
|
|
42
|
+
|
|
31
43
|
# A narrow Windows console (cp1252) must not raise on a branch name with any Unicode in it.
|
|
32
44
|
_reconfig = getattr(sys.stdout, "reconfigure", None)
|
|
33
45
|
if _reconfig:
|
|
@@ -38,6 +50,7 @@ if _reconfig:
|
|
|
38
50
|
|
|
39
51
|
FETCH_TIMEOUT_S = 15 # ceiling: a slow or unreachable remote must not stall a session
|
|
40
52
|
FETCH_MAX_AGE_S = 600 # a restart within 10 minutes reuses the last fetch
|
|
53
|
+
PEER_FRESH_S = 1800 # live peer session-state threshold (30 minutes, matches inject_relay)
|
|
41
54
|
STAMP_NAME = "caddis-freshness-stamp"
|
|
42
55
|
|
|
43
56
|
|
|
@@ -83,7 +96,112 @@ def _count(cwd: str, rev_range: str) -> int:
|
|
|
83
96
|
return int(out) if code == 0 and out.isdigit() else 0
|
|
84
97
|
|
|
85
98
|
|
|
86
|
-
def
|
|
99
|
+
def _norm_path(raw: str) -> str:
|
|
100
|
+
"""One path for comparison: no backticks, forward slashes, no leading `./`."""
|
|
101
|
+
norm = raw.strip().replace("`", "").strip().replace("\\", "/")
|
|
102
|
+
while norm.startswith("./"):
|
|
103
|
+
norm = norm[2:]
|
|
104
|
+
return norm
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _status_entries(top: str) -> list[list[str]]:
|
|
108
|
+
"""`git status --porcelain -z` at the repo top: one list of paths per entry (a rename gives two).
|
|
109
|
+
NUL-separated, so a path is never quoted. [] when git fails."""
|
|
110
|
+
try:
|
|
111
|
+
r = subprocess.run(["git", "status", "--porcelain", "-z"], cwd=top, capture_output=True, timeout=15)
|
|
112
|
+
except (OSError, subprocess.SubprocessError):
|
|
113
|
+
return []
|
|
114
|
+
if r.returncode != 0:
|
|
115
|
+
return []
|
|
116
|
+
parts = (r.stdout or b"").decode("utf-8", errors="replace").split("\0")
|
|
117
|
+
entries: list[list[str]] = []
|
|
118
|
+
i = 0
|
|
119
|
+
while i < len(parts):
|
|
120
|
+
entry = parts[i]
|
|
121
|
+
i += 1
|
|
122
|
+
if len(entry) < 4:
|
|
123
|
+
continue
|
|
124
|
+
paths = [entry[3:]]
|
|
125
|
+
if ("R" in entry[:2] or "C" in entry[:2]) and i < len(parts):
|
|
126
|
+
paths.append(parts[i])
|
|
127
|
+
i += 1
|
|
128
|
+
entries.append(paths)
|
|
129
|
+
return entries
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_session_state(text: str) -> tuple[str, list[str]]:
|
|
133
|
+
m = re.search(r"\*\*Branch:\*\*\s*`?([^\s`·]+)`?", text)
|
|
134
|
+
branch = m.group(1).strip() if m else "unknown"
|
|
135
|
+
files: list[str] = []
|
|
136
|
+
in_files = False
|
|
137
|
+
for line in text.splitlines():
|
|
138
|
+
s = line.strip()
|
|
139
|
+
if s.startswith("## "):
|
|
140
|
+
in_files = (s == "## Files changed this session")
|
|
141
|
+
continue
|
|
142
|
+
if in_files and s.startswith("- "):
|
|
143
|
+
item = s[2:].strip()
|
|
144
|
+
if item and not item.startswith("_("):
|
|
145
|
+
item = _norm_path(item)
|
|
146
|
+
if item:
|
|
147
|
+
files.append(item)
|
|
148
|
+
return branch, files
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _dirty_owners(top: str, dirty_paths: set[str], own_session_id: str = "") -> list[str]:
|
|
152
|
+
"""Live peer sessions whose recorded files are dirty here. `top` is the repo top level: the
|
|
153
|
+
session-state folder and every path are read from there, whatever the session's cwd is."""
|
|
154
|
+
try:
|
|
155
|
+
if not dirty_paths:
|
|
156
|
+
return []
|
|
157
|
+
base = os.path.join(artifact_root(top), "session-state")
|
|
158
|
+
if not os.path.isdir(base):
|
|
159
|
+
legacy = os.path.join(top, ".claudster", "session-state")
|
|
160
|
+
if os.path.isdir(legacy):
|
|
161
|
+
base = legacy
|
|
162
|
+
else:
|
|
163
|
+
return []
|
|
164
|
+
now = time.time()
|
|
165
|
+
owners: list[str] = []
|
|
166
|
+
for name in sorted(os.listdir(base)):
|
|
167
|
+
try:
|
|
168
|
+
if not name.endswith(".md"):
|
|
169
|
+
continue
|
|
170
|
+
sid = name[:-3]
|
|
171
|
+
if own_session_id and sid == own_session_id:
|
|
172
|
+
continue
|
|
173
|
+
path = os.path.join(base, name)
|
|
174
|
+
mtime = os.path.getmtime(path)
|
|
175
|
+
if now - mtime > PEER_FRESH_S:
|
|
176
|
+
continue
|
|
177
|
+
ended = os.path.join(base, sid + ".ended")
|
|
178
|
+
try:
|
|
179
|
+
if os.path.getmtime(ended) >= mtime:
|
|
180
|
+
continue
|
|
181
|
+
except OSError:
|
|
182
|
+
pass
|
|
183
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
184
|
+
text = fh.read()
|
|
185
|
+
peer_branch, peer_files = _parse_session_state(text)
|
|
186
|
+
matched = False
|
|
187
|
+
for pf in peer_files:
|
|
188
|
+
if pf in dirty_paths or any(
|
|
189
|
+
dp.endswith("/") and pf.startswith(dp) for dp in dirty_paths
|
|
190
|
+
):
|
|
191
|
+
matched = True
|
|
192
|
+
break
|
|
193
|
+
if matched:
|
|
194
|
+
label = f"owned by session {sid[:8]} (branch {peer_branch})"
|
|
195
|
+
if label not in owners:
|
|
196
|
+
owners.append(label)
|
|
197
|
+
except Exception:
|
|
198
|
+
continue
|
|
199
|
+
return owners
|
|
200
|
+
except Exception:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def report(cwd: str, own_session_id: str = "") -> list[str]:
|
|
87
205
|
"""The lines to print. Empty when there is nothing worth saying."""
|
|
88
206
|
if _git(cwd, "rev-parse", "--is-inside-work-tree")[0] != 0:
|
|
89
207
|
return [] # not a git repository
|
|
@@ -102,7 +220,11 @@ def report(cwd: str) -> list[str]:
|
|
|
102
220
|
lines.append("[HARNESS] could not fetch origin - the figures below may be stale.")
|
|
103
221
|
|
|
104
222
|
ref = _default_ref(cwd) if has_origin else ""
|
|
105
|
-
|
|
223
|
+
# The repo top level, so a session started in a subfolder still finds `.caddis/session-state`.
|
|
224
|
+
top = _git(cwd, "rev-parse", "--show-toplevel")[1] or cwd
|
|
225
|
+
entries = _status_entries(top)
|
|
226
|
+
dirty = len(entries)
|
|
227
|
+
dirty_paths = {_norm_path(p) for paths in entries for p in paths if _norm_path(p)}
|
|
106
228
|
behind = _count(cwd, f"HEAD..{ref}") if ref else 0
|
|
107
229
|
ahead = _count(cwd, f"{ref}..HEAD") if ref else 0
|
|
108
230
|
default_branch = ref.split("/", 1)[-1] if ref else ""
|
|
@@ -112,8 +234,12 @@ def report(cwd: str) -> list[str]:
|
|
|
112
234
|
lines.append(f"[HARNESS] branch '{branch}' | {behind} behind {ref} | {ahead} ahead "
|
|
113
235
|
f"| {dirty} uncommitted")
|
|
114
236
|
if dirty:
|
|
115
|
-
|
|
116
|
-
|
|
237
|
+
msg = ("[HARNESS] There are uncommitted changes here. They may not be yours - these "
|
|
238
|
+
"checkouts are shared. Look before you touch: git status")
|
|
239
|
+
owners = _dirty_owners(top, dirty_paths, own_session_id=own_session_id)
|
|
240
|
+
if owners:
|
|
241
|
+
msg = f"{msg} ({'; '.join(owners)})"
|
|
242
|
+
lines.append(msg)
|
|
117
243
|
# The recommendation, only when following it can lose nothing: behind, clean, and with
|
|
118
244
|
# nothing of your own unpushed. Suggesting a branch switch to someone holding uncommitted
|
|
119
245
|
# work in a shared checkout is how the branch-switch hazard gets triggered.
|
|
@@ -134,8 +260,9 @@ def main() -> int:
|
|
|
134
260
|
except Exception:
|
|
135
261
|
payload = {}
|
|
136
262
|
cwd = (payload.get("cwd") if isinstance(payload, dict) else None) or os.getcwd()
|
|
263
|
+
session_id = (payload.get("session_id") if isinstance(payload, dict) else None) or ""
|
|
137
264
|
try:
|
|
138
|
-
for line in report(cwd):
|
|
265
|
+
for line in report(cwd, own_session_id=str(session_id)):
|
|
139
266
|
print(line)
|
|
140
267
|
except Exception:
|
|
141
268
|
pass # fail open: a freshness report must never break a session start
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Growth control: size budgets and archive rules for handoff-grown files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from typing import NamedTuple
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import caddis_exit
|
|
13
|
+
except ModuleNotFoundError as exc:
|
|
14
|
+
if exc.name != "caddis_exit":
|
|
15
|
+
raise
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
18
|
+
import caddis_exit
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Budget(NamedTuple):
|
|
22
|
+
glob: str
|
|
23
|
+
unit: str
|
|
24
|
+
limit: int
|
|
25
|
+
rule: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Over(NamedTuple):
|
|
29
|
+
path: str
|
|
30
|
+
unit: str
|
|
31
|
+
value: int
|
|
32
|
+
limit: int
|
|
33
|
+
rule: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BUDGETS: tuple[Budget, ...] = (
|
|
37
|
+
Budget(
|
|
38
|
+
".caddis/relay/*.md",
|
|
39
|
+
"chars",
|
|
40
|
+
4000,
|
|
41
|
+
"rewrite: current state only, git keeps history",
|
|
42
|
+
),
|
|
43
|
+
Budget(
|
|
44
|
+
".caddis/relay/*.tasks.md",
|
|
45
|
+
"table rows",
|
|
46
|
+
40,
|
|
47
|
+
"done rows are never written (Phase 1)",
|
|
48
|
+
),
|
|
49
|
+
Budget(
|
|
50
|
+
".caddis/relay.md",
|
|
51
|
+
"lines",
|
|
52
|
+
60,
|
|
53
|
+
"rendered by render-index; drop relays with status closed",
|
|
54
|
+
),
|
|
55
|
+
Budget(
|
|
56
|
+
".caddis/open-work.md",
|
|
57
|
+
"rows with Status done",
|
|
58
|
+
10,
|
|
59
|
+
"move done rows to .caddis/done/open-work-archive.md with a one-line pointer",
|
|
60
|
+
),
|
|
61
|
+
Budget(
|
|
62
|
+
"DECISIONS.md, .caddis/DECISIONS.md, .caddis/decisions/DECISIONS.md", # the last is the fleet's real path
|
|
63
|
+
"History table rows",
|
|
64
|
+
30,
|
|
65
|
+
"move older rows to DECISIONS-archive.md beside it",
|
|
66
|
+
),
|
|
67
|
+
Budget(
|
|
68
|
+
".caddis/usage-log.jsonl",
|
|
69
|
+
"bytes",
|
|
70
|
+
5000000,
|
|
71
|
+
"rotate to usage-log-<YYYYMM>.jsonl",
|
|
72
|
+
),
|
|
73
|
+
Budget(
|
|
74
|
+
".caddis/session-state/*.md",
|
|
75
|
+
"files",
|
|
76
|
+
8,
|
|
77
|
+
"session_state.py prune (not in a shared checkout)",
|
|
78
|
+
),
|
|
79
|
+
Budget(
|
|
80
|
+
".caddis/kb/*.md",
|
|
81
|
+
"lines per file",
|
|
82
|
+
300,
|
|
83
|
+
"split, or move closed parts to .caddis/kb/done/",
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _ParsedTable(NamedTuple):
|
|
89
|
+
heading: str | None
|
|
90
|
+
columns: list[str]
|
|
91
|
+
data_rows: list[list[str]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _split_cells(line: str) -> list[str]:
|
|
95
|
+
s = line.strip()
|
|
96
|
+
if not s:
|
|
97
|
+
return []
|
|
98
|
+
parts = re.split(r"(?<!\\)\|", s)
|
|
99
|
+
if parts and parts[0] == "":
|
|
100
|
+
parts.pop(0)
|
|
101
|
+
if parts and parts[-1] == "":
|
|
102
|
+
parts.pop(-1)
|
|
103
|
+
return [p.strip() for p in parts]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_separator(line: str) -> bool:
|
|
107
|
+
cells = _split_cells(line)
|
|
108
|
+
if not cells:
|
|
109
|
+
return False
|
|
110
|
+
for cell in cells:
|
|
111
|
+
c = cell.strip()
|
|
112
|
+
if not c or any(ch not in "-: " for ch in c) or "-" not in c:
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_tables(text: str) -> tuple[list[_ParsedTable], int]:
|
|
118
|
+
lines = text.splitlines()
|
|
119
|
+
tables: list[_ParsedTable] = []
|
|
120
|
+
loose_rows = 0
|
|
121
|
+
n = len(lines)
|
|
122
|
+
i = 0
|
|
123
|
+
while i < n:
|
|
124
|
+
if lines[i].lstrip().startswith("|"):
|
|
125
|
+
run_start = i
|
|
126
|
+
while i < n and lines[i].lstrip().startswith("|"):
|
|
127
|
+
i += 1
|
|
128
|
+
run = lines[run_start:i]
|
|
129
|
+
if len(run) >= 2 and _is_separator(run[1]):
|
|
130
|
+
heading: str | None = None
|
|
131
|
+
for k in range(run_start - 1, -1, -1):
|
|
132
|
+
s = lines[k].strip()
|
|
133
|
+
if s.startswith("#"):
|
|
134
|
+
heading = s.lstrip("#").strip()
|
|
135
|
+
break
|
|
136
|
+
columns = _split_cells(run[0])
|
|
137
|
+
data_rows = [_split_cells(r) for r in run[2:] if not _is_separator(r)]
|
|
138
|
+
tables.append(_ParsedTable(heading=heading, columns=columns, data_rows=data_rows))
|
|
139
|
+
else:
|
|
140
|
+
for r in run:
|
|
141
|
+
if not _is_separator(r) and not r.lstrip().startswith(("| Old #", "| # |")):
|
|
142
|
+
loose_rows += 1
|
|
143
|
+
else:
|
|
144
|
+
i += 1
|
|
145
|
+
return tables, loose_rows
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _count_table_rows(text: str) -> int:
|
|
149
|
+
tables, loose_rows = _parse_tables(text)
|
|
150
|
+
return sum(len(t.data_rows) for t in tables) + loose_rows
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _norm_status(cell: str) -> str:
|
|
154
|
+
return cell.replace("**", "").replace("__", "").replace("`", "").strip().lower()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _count_done_rows(text: str) -> int:
|
|
158
|
+
tables, _ = _parse_tables(text)
|
|
159
|
+
done_re = re.compile(r"^done\b")
|
|
160
|
+
count = 0
|
|
161
|
+
if tables:
|
|
162
|
+
for t in tables:
|
|
163
|
+
col_map = {c.lower(): idx for idx, c in enumerate(t.columns)}
|
|
164
|
+
if "status" in col_map:
|
|
165
|
+
s_idx = col_map["status"]
|
|
166
|
+
for row in t.data_rows:
|
|
167
|
+
if len(row) > s_idx and done_re.search(_norm_status(row[s_idx])):
|
|
168
|
+
count += 1
|
|
169
|
+
else:
|
|
170
|
+
for row in t.data_rows:
|
|
171
|
+
if any(_norm_status(c) == "done" for c in row):
|
|
172
|
+
count += 1
|
|
173
|
+
return count
|
|
174
|
+
|
|
175
|
+
for line in text.splitlines():
|
|
176
|
+
if line.lstrip().startswith("|") and not _is_separator(line):
|
|
177
|
+
cells = _split_cells(line)
|
|
178
|
+
if any(_norm_status(c) == "done" for c in cells):
|
|
179
|
+
count += 1
|
|
180
|
+
return count
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _count_history_rows(text: str) -> int:
|
|
184
|
+
tables, loose_rows = _parse_tables(text)
|
|
185
|
+
history_tables = [t for t in tables if t.heading and "history" in t.heading.lower()]
|
|
186
|
+
if history_tables:
|
|
187
|
+
return sum(len(t.data_rows) for t in history_tables)
|
|
188
|
+
return sum(len(t.data_rows) for t in tables) + loose_rows
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _measure_file(path: Path, unit: str) -> int:
|
|
192
|
+
if unit == "bytes":
|
|
193
|
+
return path.stat().st_size
|
|
194
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
195
|
+
if unit == "chars":
|
|
196
|
+
return len(text)
|
|
197
|
+
if unit == "table rows":
|
|
198
|
+
return _count_table_rows(text)
|
|
199
|
+
if unit in ("lines", "lines per file"):
|
|
200
|
+
return len(text.splitlines())
|
|
201
|
+
if unit == "rows with Status done":
|
|
202
|
+
return _count_done_rows(text)
|
|
203
|
+
if unit == "History table rows":
|
|
204
|
+
return _count_history_rows(text)
|
|
205
|
+
raise ValueError(f"unknown budget unit: {unit}")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check(root: Path) -> list[Over]:
|
|
209
|
+
"""Return every file under ``root`` that exceeds its configured size budget."""
|
|
210
|
+
root = Path(root)
|
|
211
|
+
overs: list[Over] = []
|
|
212
|
+
|
|
213
|
+
for budget in BUDGETS:
|
|
214
|
+
if budget.unit == "files":
|
|
215
|
+
files = [p for p in sorted(root.glob(budget.glob)) if p.is_file()]
|
|
216
|
+
value = len(files)
|
|
217
|
+
if value > budget.limit:
|
|
218
|
+
overs.append(Over(budget.glob, budget.unit, value, budget.limit, budget.rule))
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
patterns = [g.strip() for g in budget.glob.split(",")]
|
|
222
|
+
for pattern in patterns:
|
|
223
|
+
if "*" in pattern:
|
|
224
|
+
candidates = [p for p in sorted(root.glob(pattern)) if p.is_file()]
|
|
225
|
+
else:
|
|
226
|
+
candidate = root / pattern
|
|
227
|
+
candidates = [candidate] if candidate.is_file() else []
|
|
228
|
+
|
|
229
|
+
for p in candidates:
|
|
230
|
+
if pattern == ".caddis/relay/*.md" and p.name.endswith(".tasks.md"):
|
|
231
|
+
continue
|
|
232
|
+
rel = p.relative_to(root).as_posix()
|
|
233
|
+
value = _measure_file(p, budget.unit)
|
|
234
|
+
if value > budget.limit:
|
|
235
|
+
overs.append(Over(rel, budget.unit, value, budget.limit, budget.rule))
|
|
236
|
+
|
|
237
|
+
return overs
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv: list[str] | None = None) -> int:
|
|
241
|
+
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
242
|
+
try:
|
|
243
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
if sys.stderr and hasattr(sys.stderr, "reconfigure"):
|
|
247
|
+
try:
|
|
248
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
parser = argparse.ArgumentParser(
|
|
253
|
+
prog="caddis_budget.py",
|
|
254
|
+
description="Check handoff-grown files against their size budgets.",
|
|
255
|
+
)
|
|
256
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
257
|
+
p_check = sub.add_parser("check", help="Check files under --root against BUDGETS.")
|
|
258
|
+
p_check.add_argument("--root", type=Path, default=Path("."), help="Repository root.")
|
|
259
|
+
p_check.add_argument(
|
|
260
|
+
"--report",
|
|
261
|
+
action="store_true",
|
|
262
|
+
help="Report files over budget but always exit 0.",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
args = parser.parse_args(argv)
|
|
266
|
+
try:
|
|
267
|
+
overs = check(args.root)
|
|
268
|
+
except OSError as exc: # a locked or unreadable file (Windows): --report still never fails the caller
|
|
269
|
+
print(f"[BUDGET] could not read every file: {exc}")
|
|
270
|
+
return caddis_exit.CLEAN if args.report else caddis_exit.NOT_RUN
|
|
271
|
+
if not overs:
|
|
272
|
+
print("[BUDGET] all within budget")
|
|
273
|
+
return caddis_exit.CLEAN
|
|
274
|
+
|
|
275
|
+
for over in overs:
|
|
276
|
+
print(f"OVER {over.path}: {over.value} {over.unit} > {over.limit}; fix: {over.rule}")
|
|
277
|
+
if args.report:
|
|
278
|
+
return caddis_exit.CLEAN
|
|
279
|
+
return caddis_exit.BLOCKED
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
raise SystemExit(main())
|