@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.
Files changed (111) hide show
  1. package/bundles/antigravity-plugin/CADDIS-GLOSSARY.md +1 -0
  2. package/bundles/antigravity-plugin/guard_agy.py +184 -0
  3. package/bundles/antigravity-plugin/plugin.json +1 -1
  4. package/bundles/antigravity-plugin/repo_freshness.py +132 -5
  5. package/bundles/antigravity-plugin/scripts/caddis_budget.py +283 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_checkout_guard.py +227 -0
  7. package/bundles/antigravity-plugin/scripts/caddis_docs_pr.py +284 -0
  8. package/bundles/antigravity-plugin/scripts/caddis_exit.py +2 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_fleet.py +439 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +1 -0
  11. package/bundles/antigravity-plugin/scripts/caddis_gate.py +255 -195
  12. package/bundles/antigravity-plugin/scripts/caddis_glossary.py +3 -3
  13. package/bundles/antigravity-plugin/scripts/caddis_governor.py +48 -4
  14. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +38 -0
  15. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +411 -127
  16. package/bundles/antigravity-plugin/scripts/caddis_lineage_publish.py +458 -0
  17. package/bundles/antigravity-plugin/scripts/caddis_merge_chain.py +237 -0
  18. package/bundles/antigravity-plugin/scripts/caddis_notify.py +125 -0
  19. package/bundles/antigravity-plugin/scripts/caddis_prod_check.py +380 -0
  20. package/bundles/antigravity-plugin/scripts/caddis_redact.py +99 -0
  21. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +2 -2
  22. package/bundles/antigravity-plugin/scripts/caddis_review.py +1309 -0
  23. package/bundles/antigravity-plugin/scripts/caddis_roadmap.py +2109 -0
  24. package/bundles/antigravity-plugin/scripts/caddis_roadmap_page.py +676 -0
  25. package/bundles/antigravity-plugin/scripts/caddis_roadmap_run.py +2605 -0
  26. package/bundles/antigravity-plugin/scripts/caddis_routing.py +7 -6
  27. package/bundles/antigravity-plugin/scripts/caddis_rows.py +476 -0
  28. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +8 -9
  29. package/bundles/antigravity-plugin/scripts/caddis_spend.py +415 -0
  30. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +357 -20
  31. package/bundles/antigravity-plugin/scripts/caddis_todo.py +55 -0
  32. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +178 -19
  33. package/bundles/antigravity-plugin/scripts/caddis_worktree_close.py +349 -0
  34. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +0 -56
  35. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +1 -1
  36. package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +0 -1
  37. package/bundles/antigravity-plugin/scripts/oss_review.py +2 -2
  38. package/bundles/antigravity-plugin/scripts/roadmap-recheck.ps1 +89 -0
  39. package/bundles/antigravity-plugin/scripts/roadmap-run.ps1 +88 -0
  40. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +43 -1
  41. package/bundles/antigravity-plugin/session_end_agy.py +3 -3
  42. package/bundles/antigravity-plugin/skills/_registry.md +4 -6
  43. package/bundles/antigravity-plugin/skills/best-practices/agents/best-practices-referencer.md +1 -1
  44. package/bundles/antigravity-plugin/skills/best-practices/agents/codebase-context-builder.md +1 -1
  45. package/bundles/antigravity-plugin/skills/best-practices/agents/task-intent-analyzer.md +1 -1
  46. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +11 -0
  47. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +5 -2
  49. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +35 -83
  50. package/bundles/antigravity-plugin/skills/fleet-close/SKILL.md +167 -0
  51. package/bundles/antigravity-plugin/skills/fleet-open/SKILL.md +110 -0
  52. package/bundles/antigravity-plugin/skills/gh-cli/SKILL.md +2 -1
  53. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +177 -48
  54. package/bundles/antigravity-plugin/skills/implement/SKILL.md +87 -147
  55. package/bundles/antigravity-plugin/skills/migrate-dir/SKILL.md +4 -3
  56. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +5 -2
  57. package/bundles/antigravity-plugin/skills/office-documents-official/SKILL.md +24 -0
  58. package/bundles/antigravity-plugin/skills/park/SKILL.md +0 -1
  59. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +28 -20
  60. package/bundles/antigravity-plugin/skills/prd/SKILL.md +2 -10
  61. package/bundles/antigravity-plugin/skills/refactoring/SKILL.md +2 -1
  62. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/roadmap/SKILL.md +260 -0
  64. package/bundles/antigravity-plugin/skills/roadmap/assets/plan-resume-prompt.md +77 -0
  65. package/bundles/antigravity-plugin/skills/roadmap/assets/render-check.template.mjs +68 -0
  66. package/bundles/antigravity-plugin/skills/roadmap/assets/review-protocol.md +78 -0
  67. package/bundles/antigravity-plugin/skills/roadmap/assets/roadmap.template.md +32 -0
  68. package/bundles/antigravity-plugin/skills/roadmap/assets/value-check.template.py +161 -0
  69. package/bundles/antigravity-plugin/skills/ship/SKILL.md +16 -16
  70. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +15 -5
  71. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +36 -16
  72. package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +4 -8
  73. package/bundles/antigravity-plugin/warm_start_agy.py +1 -3
  74. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/_registry.md +1 -2
  76. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +5 -5
  77. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +5 -2
  78. package/bundles/antigravity-plugin-extras/skills/runner-move/SKILL.md +214 -0
  79. package/bundles/codex/.codex/skills/coding/refactoring/SKILL.md +2 -1
  80. package/bundles/codex/.codex/skills/devops/gh-cli/SKILL.md +2 -1
  81. package/bundles/codex/.codex/skills/docs/office-documents-official/SKILL.md +24 -0
  82. package/bundles/codex/.codex/skills/workflow/best-practices/agents/best-practices-referencer.md +1 -1
  83. package/bundles/codex/.codex/skills/workflow/best-practices/agents/codebase-context-builder.md +1 -1
  84. package/bundles/codex/.codex/skills/workflow/best-practices/agents/task-intent-analyzer.md +1 -1
  85. package/bundles/codex/.codex/skills/workflow/roadmap/SKILL.md +260 -0
  86. package/bundles/codex/.codex/skills/workflow/roadmap/assets/plan-resume-prompt.md +77 -0
  87. package/bundles/codex/.codex/skills/workflow/roadmap/assets/render-check.template.mjs +68 -0
  88. package/bundles/codex/.codex/skills/workflow/roadmap/assets/review-protocol.md +78 -0
  89. package/bundles/codex/.codex/skills/workflow/roadmap/assets/roadmap.template.md +32 -0
  90. package/bundles/codex/.codex/skills/workflow/roadmap/assets/value-check.template.py +161 -0
  91. package/bundles/codex/AGENTS.md +5 -4
  92. package/bundles/manifest.json +3 -3
  93. package/package.json +1 -1
  94. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +0 -233
  95. package/bundles/antigravity-plugin/skills/advisory-hub/reference/context-template.md +0 -214
  96. package/bundles/antigravity-plugin/skills/advisory-hub/reference/hub-handbook.md +0 -233
  97. package/bundles/antigravity-plugin/skills/advisory-hub/reference/report-contract.md +0 -94
  98. package/bundles/antigravity-plugin/skills/advisory-hub/reference/succession.md +0 -118
  99. package/bundles/antigravity-plugin/skills/resume/SKILL.md +0 -6
  100. package/bundles/antigravity-plugin/skills/review/SKILL.md +0 -6
  101. package/bundles/antigravity-plugin/skills/spawn-hub/SKILL.md +0 -341
  102. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +0 -6
  103. package/bundles/antigravity-plugin/skills/validate-phase/SKILL.md +0 -143
  104. package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +0 -158
  105. package/bundles/antigravity-plugin-extras/skills/data-lineage/scripts/convert_doc_to_confluence.py +0 -1430
  106. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +0 -586
  107. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +0 -233
  108. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/context-template.md +0 -214
  109. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/hub-handbook.md +0 -233
  110. package/bundles/codex/.codex/skills/workflow/advisory-hub/reference/report-contract.md +0 -94
  111. 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:
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.138",
3
+ "version": "1.3.140",
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
  }
@@ -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 report(cwd: str) -> list[str]:
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
- dirty = len([ln for ln in _git(cwd, "status", "--porcelain")[1].splitlines() if ln.strip()])
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
- lines.append("[HARNESS] There are uncommitted changes here. They may not be yours - these "
116
- "checkouts are shared. Look before you touch: git status")
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())