@caddis/cli 0.4.30 → 0.4.33
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/mcp_ping_server.py +1 -1
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +301 -66
- package/bundles/antigravity-plugin/scripts/caddis_exit.py +15 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +145 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +33 -10
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +5 -2
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +13 -1
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +2 -2
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +9 -14
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +66 -0
- package/bundles/antigravity-plugin/scripts/new-machine.ps1 +1 -1
- package/bundles/antigravity-plugin/scripts/oss_ask.py +6 -5
- package/bundles/antigravity-plugin/scripts/oss_model.py +1 -1
- package/bundles/antigravity-plugin/scripts/oss_review.py +84 -15
- package/bundles/antigravity-plugin/scripts/secret_filter.py +316 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +9 -4
- package/bundles/antigravity-plugin/skills/_registry.md +5 -3
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +37 -2
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +8 -1
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +3 -1
- package/bundles/antigravity-plugin/skills/install-statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +14 -8
- package/bundles/antigravity-plugin/skills/resume/SKILL.md +2 -32
- package/bundles/antigravity-plugin/skills/review/SKILL.md +2 -96
- package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +100 -0
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +2 -101
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +3 -3
- package/bundles/antigravity-plugin-extras/skills/data-lineage/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
- package/bundles/codex/.codex/config.toml.example +0 -16
- package/bundles/codex/AGENTS.md +2 -2
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +1 -1
- package/dist/claude-glm.js +1 -1
- package/dist/claude-oss.js +1 -1
- package/dist/cli.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""Minimal self-contained MCP stdio server (raw JSON-RPC 2.0, zero dependencies).
|
|
3
3
|
|
|
4
4
|
Ships in the caddis agy plugin to prove the MCP wiring end-to-end without pulling in the `mcp`
|
|
5
|
-
package or any retired server
|
|
5
|
+
package or any retired server. Exposes ONE tool, `caddis_ping`, which returns
|
|
6
6
|
the marker `CADDIS-MCP-OK`. Transport: newline-delimited JSON-RPC on stdin/stdout (MCP stdio).
|
|
7
7
|
"""
|
|
8
8
|
import json
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
Executes ad-hoc tasks across model lanes (GLM -> Codex -> Claude) in an isolated
|
|
5
5
|
sandbox with automatic rate-limit failover, test gates, cross-vendor review,
|
|
6
|
-
and
|
|
6
|
+
and verified worktree cleanup. Also performs direct diff reviews.
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import argparse
|
|
11
|
+
import ast
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
13
14
|
import re
|
|
@@ -17,14 +18,23 @@ import sys
|
|
|
17
18
|
from datetime import datetime, timezone
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
21
|
+
try:
|
|
22
|
+
import caddis_exit
|
|
23
|
+
except ModuleNotFoundError as exc:
|
|
24
|
+
if exc.name != "caddis_exit":
|
|
25
|
+
raise
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
|
|
27
|
+
import caddis_exit
|
|
28
|
+
|
|
20
29
|
if hasattr(sys.stdout, "reconfigure"):
|
|
21
30
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
22
31
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
23
32
|
|
|
24
|
-
EXIT_OK =
|
|
25
|
-
EXIT_FAIL =
|
|
26
|
-
EXIT_USAGE =
|
|
27
|
-
EXIT_CONFIG =
|
|
33
|
+
EXIT_OK = caddis_exit.CLEAN
|
|
34
|
+
EXIT_FAIL = caddis_exit.BLOCKED
|
|
35
|
+
EXIT_USAGE = caddis_exit.ERROR
|
|
36
|
+
EXIT_CONFIG = caddis_exit.NOT_RUN
|
|
37
|
+
EXCLUDED_PREFIXES = (".caddis/orchestrator/",)
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
def repo_root(start: Path | None = None) -> Path:
|
|
@@ -164,6 +174,71 @@ def read_latest(repo: Path) -> dict | None:
|
|
|
164
174
|
return None
|
|
165
175
|
|
|
166
176
|
|
|
177
|
+
# Tool caches every gate run regenerates. They are the only ignored files a worktree may be
|
|
178
|
+
# removed with: any other ignored file (a local config, a generated .env) may be someone's work.
|
|
179
|
+
_DISPOSABLE_DIRS = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "node_modules"}
|
|
180
|
+
_DISPOSABLE_SUFFIXES = (".pyc", ".pyo")
|
|
181
|
+
_DISPOSABLE_NAMES = {".coverage"} # plus .coverage.* (parallel mode), checked below
|
|
182
|
+
# node_modules is not a cache but a dependency tree a gate reinstalls; nobody's work lives in it.
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _work_left(porcelain_z: bytes) -> bool:
|
|
186
|
+
"""True if `git status --porcelain=v1 -z --ignored` shows anything but tool-cache litter."""
|
|
187
|
+
for entry in porcelain_z.split(b"\0"):
|
|
188
|
+
if not entry:
|
|
189
|
+
continue
|
|
190
|
+
code, path = entry[:2], entry[3:].decode("utf-8", errors="replace").rstrip("/")
|
|
191
|
+
if code != b"!!":
|
|
192
|
+
return True # modified, staged or untracked: real work
|
|
193
|
+
parts = path.split("/")
|
|
194
|
+
if (_DISPOSABLE_DIRS.intersection(parts) or path.endswith(_DISPOSABLE_SUFFIXES)
|
|
195
|
+
or parts[-1] in _DISPOSABLE_NAMES or parts[-1].startswith(".coverage.")):
|
|
196
|
+
continue
|
|
197
|
+
return True # an ignored file that is not a known cache
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def packet_changed_files(value: object) -> list[str]:
|
|
202
|
+
"""Decode the C-style path quoting used by git status --porcelain."""
|
|
203
|
+
if not isinstance(value, list) or not all(isinstance(name, str) for name in value):
|
|
204
|
+
return []
|
|
205
|
+
files = []
|
|
206
|
+
for name in value:
|
|
207
|
+
if name.startswith('"') and name.endswith('"'):
|
|
208
|
+
try:
|
|
209
|
+
# git escapes non-ASCII as UTF-8 octal bytes ("caf\303\251"); decode them as
|
|
210
|
+
# bytes, or a str literal turns each byte into its own code point (mojibake).
|
|
211
|
+
name = ast.literal_eval("b" + name).decode("utf-8")
|
|
212
|
+
except (SyntaxError, ValueError, UnicodeDecodeError):
|
|
213
|
+
return []
|
|
214
|
+
files.append(name)
|
|
215
|
+
return files
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def lane_worktrees(repo: Path, *, lane_only: bool = True) -> set[Path]:
|
|
219
|
+
"""Return registered paths, optionally limited to branches under lane/."""
|
|
220
|
+
paths: set[Path] = set()
|
|
221
|
+
for entry in git_output(repo, "worktree", "list", "--porcelain").split("\n\n"):
|
|
222
|
+
lines = entry.splitlines()
|
|
223
|
+
branch = next((line for line in lines if line.startswith("branch ")), "")
|
|
224
|
+
worktree = next((line[9:] for line in lines if line.startswith("worktree ")), "")
|
|
225
|
+
if worktree and (not lane_only or branch.startswith("branch refs/heads/lane/")):
|
|
226
|
+
paths.add(Path(worktree).resolve())
|
|
227
|
+
return paths
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def packet_worktree(raw: str) -> Path | None:
|
|
231
|
+
"""Recover a complete worktree string even when the rest of JSON is truncated."""
|
|
232
|
+
match = re.search(r'"worktree"\s*:\s*("(?:\\.|[^"\\])*")', raw)
|
|
233
|
+
if not match:
|
|
234
|
+
return None
|
|
235
|
+
try:
|
|
236
|
+
value = json.loads(match.group(1))
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
return None
|
|
239
|
+
return Path(value).resolve() if isinstance(value, str) and value else None
|
|
240
|
+
|
|
241
|
+
|
|
167
242
|
def run_adhoc(
|
|
168
243
|
repo: Path,
|
|
169
244
|
prompt: str,
|
|
@@ -210,8 +285,14 @@ def run_adhoc(
|
|
|
210
285
|
if allow_load:
|
|
211
286
|
cmd.append("--allow-load")
|
|
212
287
|
|
|
288
|
+
before_worktrees = lane_worktrees(repo, lane_only=False)
|
|
213
289
|
print(f"[mmr] Starting isolated task run: {title}")
|
|
214
|
-
|
|
290
|
+
lane_error = ""
|
|
291
|
+
try:
|
|
292
|
+
res = subprocess.run(cmd, cwd=repo)
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
lane_error = f"lane crashed: {type(exc).__name__}: {exc}"
|
|
295
|
+
res = None
|
|
215
296
|
|
|
216
297
|
packet_path = (
|
|
217
298
|
repo
|
|
@@ -221,75 +302,200 @@ def run_adhoc(
|
|
|
221
302
|
/ slug
|
|
222
303
|
/ "phase-01.attempt-1.json"
|
|
223
304
|
)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
305
|
+
packet = None
|
|
306
|
+
raw_packet = ""
|
|
307
|
+
packet_error = "no lane packet"
|
|
228
308
|
try:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
if packet_path.is_file():
|
|
310
|
+
raw_packet = packet_path.read_text(encoding="utf-8")
|
|
311
|
+
packet = json.loads(raw_packet)
|
|
312
|
+
if not isinstance(packet, dict):
|
|
313
|
+
raise ValueError("lane packet is not an object")
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
packet_error = f"failed to read packet: {type(exc).__name__}: {exc}"
|
|
316
|
+
|
|
317
|
+
if lane_error or packet is None:
|
|
318
|
+
reason = lane_error or packet_error
|
|
319
|
+
new_worktrees = lane_worktrees(repo) - before_worktrees
|
|
320
|
+
preferred = packet_worktree(raw_packet)
|
|
321
|
+
kept_paths = sorted(new_worktrees, key=str)
|
|
322
|
+
if preferred and preferred.exists() and preferred not in before_worktrees:
|
|
323
|
+
kept_paths = [preferred, *[path for path in kept_paths if path != preferred]]
|
|
324
|
+
for path in kept_paths:
|
|
325
|
+
print(f"KEPT: {path}")
|
|
326
|
+
print(f"[mmr] {reason}")
|
|
327
|
+
state = {
|
|
328
|
+
"slug": slug,
|
|
329
|
+
"title": title,
|
|
330
|
+
"task": prompt,
|
|
331
|
+
"task_type": task_type,
|
|
332
|
+
"branch": f"lane/adhoc-{slug}",
|
|
333
|
+
"base_sha": head_sha,
|
|
334
|
+
"commit_sha": head_sha,
|
|
335
|
+
"changed_files": [],
|
|
336
|
+
"lane": lane or "",
|
|
337
|
+
"model": "",
|
|
338
|
+
"gates_passed": False,
|
|
339
|
+
"review_status": "MISSING",
|
|
340
|
+
"diff_stat": "",
|
|
341
|
+
"modified_deps": [],
|
|
342
|
+
"verdict": "FAIL",
|
|
343
|
+
"kept_worktree": str(kept_paths[0]) if kept_paths else None,
|
|
344
|
+
"reason": reason,
|
|
345
|
+
"plan_path": plan_rel,
|
|
346
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
347
|
+
}
|
|
348
|
+
if len(kept_paths) > 1:
|
|
349
|
+
state["kept_worktrees"] = [str(path) for path in kept_paths]
|
|
350
|
+
adhoc_latest_path(repo).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
232
351
|
return EXIT_FAIL
|
|
233
352
|
|
|
234
353
|
worktree_str = packet.get("worktree")
|
|
235
|
-
changed_files = packet.get("changed_files", [])
|
|
354
|
+
changed_files = packet_changed_files(packet.get("changed_files", []))
|
|
355
|
+
code_changes = [
|
|
356
|
+
name for name in changed_files
|
|
357
|
+
if not name.replace("\\", "/").startswith(EXCLUDED_PREFIXES)
|
|
358
|
+
]
|
|
236
359
|
gates_passed = packet.get("gates_passed", False)
|
|
360
|
+
no_tests_collected = any(
|
|
361
|
+
isinstance(gate, dict) and gate.get("returncode") == 5
|
|
362
|
+
and "pytest" in str(gate.get("cmd", ""))
|
|
363
|
+
for gate in packet.get("gates", [])
|
|
364
|
+
)
|
|
365
|
+
if no_tests_collected:
|
|
366
|
+
gates_passed = False
|
|
237
367
|
review_info = packet.get("review", {})
|
|
238
368
|
review_status = review_info.get("status", "MISSING")
|
|
239
369
|
assigned_lane = packet.get("lane", "")
|
|
240
370
|
assigned_model = packet.get("model", "")
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
371
|
+
pending = res.returncode == 0 and packet.get("result") == "PENDING"
|
|
372
|
+
adhoc_branch = (packet.get("branch") or f"lane/adhoc-{slug}") if pending else f"lane/adhoc-{slug}"
|
|
373
|
+
base_sha = packet.get("base_sha", head_sha)
|
|
374
|
+
commit_sha = base_sha
|
|
375
|
+
kept_worktree = worktree_str or None
|
|
376
|
+
reason = ""
|
|
377
|
+
verified_names: list[str] = []
|
|
378
|
+
|
|
379
|
+
# A failed lane can return before it records changed_files. Never use an empty
|
|
380
|
+
# file list as evidence that its worktree is safe to remove.
|
|
381
|
+
lane_succeeded = res.returncode == 0 and packet.get("result") == "OK"
|
|
382
|
+
dep_files = {"package.json", "requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml"}
|
|
383
|
+
modified_deps = [name for name in changed_files if Path(name).name in dep_files]
|
|
384
|
+
if pending and (not worktree_str or not Path(worktree_str).is_dir()):
|
|
385
|
+
reason = "PENDING worktree missing"
|
|
386
|
+
elif pending:
|
|
387
|
+
pass
|
|
388
|
+
elif not lane_succeeded:
|
|
389
|
+
reason = f"lane result: {packet.get('result', 'MISSING')} (exit {res.returncode})"
|
|
390
|
+
elif no_tests_collected:
|
|
391
|
+
reason = "no tests collected"
|
|
392
|
+
elif not gates_passed:
|
|
393
|
+
reason = "gates failed"
|
|
394
|
+
elif not code_changes:
|
|
395
|
+
reason = "no code changes"
|
|
396
|
+
elif review_status != "CLEAN":
|
|
397
|
+
reason = f"review is {review_status}"
|
|
398
|
+
elif modified_deps:
|
|
399
|
+
reason = f"dependency files changed: {', '.join(modified_deps)}"
|
|
400
|
+
elif not worktree_str:
|
|
401
|
+
reason = "lane packet has no worktree path"
|
|
402
|
+
elif not Path(worktree_str).exists():
|
|
403
|
+
reason = f"worktree missing: {worktree_str}"
|
|
404
|
+
else:
|
|
246
405
|
wt_path = Path(worktree_str)
|
|
247
|
-
|
|
248
|
-
|
|
406
|
+
try:
|
|
407
|
+
add_res = subprocess.run(
|
|
408
|
+
["git", "-C", str(wt_path), "add", "--", *code_changes],
|
|
409
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
410
|
+
)
|
|
411
|
+
if add_res.returncode != 0:
|
|
412
|
+
raise subprocess.CalledProcessError(add_res.returncode, add_res.args, stderr=add_res.stderr)
|
|
413
|
+
commit_res = subprocess.run(
|
|
414
|
+
["git", "-C", str(wt_path), "commit", "-m", f"adhoc({slug}): {title}"],
|
|
415
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
416
|
+
)
|
|
417
|
+
if commit_res.returncode != 0:
|
|
418
|
+
raise subprocess.CalledProcessError(commit_res.returncode, commit_res.args, stderr=commit_res.stderr)
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
stderr = getattr(exc, "stderr", None) or str(exc)
|
|
421
|
+
if isinstance(stderr, bytes):
|
|
422
|
+
stderr = stderr.decode("utf-8", errors="replace")
|
|
423
|
+
reason = f"commit failed: {stderr.splitlines()[0] if stderr.splitlines() else str(exc)}"
|
|
424
|
+
|
|
425
|
+
if not reason:
|
|
426
|
+
try:
|
|
427
|
+
commit_sha = git_output(wt_path, "rev-parse", "HEAD")
|
|
428
|
+
except Exception as exc:
|
|
429
|
+
reason = f"commit verification failed: {type(exc).__name__}: {exc}"
|
|
430
|
+
if not commit_sha:
|
|
431
|
+
reason = reason or "commit verification failed: could not resolve HEAD"
|
|
432
|
+
elif not reason:
|
|
249
433
|
try:
|
|
250
|
-
subprocess.run(
|
|
251
|
-
["git", "-C", str(wt_path), "
|
|
252
|
-
check=True,
|
|
434
|
+
diff_res = subprocess.run(
|
|
435
|
+
["git", "-C", str(wt_path), "diff", "--name-only", "-z", f"{base_sha}..{commit_sha}"],
|
|
253
436
|
capture_output=True,
|
|
254
437
|
)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if
|
|
262
|
-
|
|
438
|
+
if diff_res.returncode != 0:
|
|
439
|
+
reason = "commit verification failed: git diff failed"
|
|
440
|
+
else:
|
|
441
|
+
verified_names = [name.decode("utf-8", errors="replace") for name in diff_res.stdout.split(b"\0") if name]
|
|
442
|
+
if not verified_names or set(verified_names) != set(code_changes):
|
|
443
|
+
reason = "commit verification failed: committed files differ from reported files"
|
|
444
|
+
if not reason:
|
|
445
|
+
# The lane copied our generated plan into its worktree. Remove only that
|
|
446
|
+
# exact copy; any edited plan or other excluded file remains protected.
|
|
447
|
+
generated_plan = wt_path / plan_rel
|
|
448
|
+
if (plan_rel in changed_files and generated_plan.is_file()
|
|
449
|
+
and generated_plan.read_bytes() == plan_path.read_bytes()):
|
|
450
|
+
generated_plan.unlink()
|
|
451
|
+
status_res = subprocess.run(
|
|
452
|
+
["git", "-C", str(wt_path), "status", "--porcelain=v1", "-z",
|
|
453
|
+
"--untracked-files=all", "--ignored"],
|
|
454
|
+
capture_output=True,
|
|
455
|
+
)
|
|
456
|
+
if status_res.returncode != 0 or _work_left(status_res.stdout):
|
|
457
|
+
reason = "commit verification failed: uncommitted or ignored work remains"
|
|
263
458
|
except Exception as exc:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
# Point adhoc_branch to the result commit
|
|
267
|
-
subprocess.run(
|
|
268
|
-
["git", "-C", str(repo), "branch", "-f", adhoc_branch, commit_sha],
|
|
269
|
-
capture_output=True,
|
|
270
|
-
)
|
|
459
|
+
reason = f"commit verification failed: {type(exc).__name__}: {exc}"
|
|
271
460
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
|
|
278
|
-
|
|
279
|
-
# Clean up the phase-specific attempt branch
|
|
280
|
-
attempt_branch = packet.get("branch")
|
|
281
|
-
if attempt_branch and attempt_branch != adhoc_branch:
|
|
282
|
-
subprocess.run(
|
|
283
|
-
["git", "-C", str(repo), "branch", "-D", attempt_branch],
|
|
284
|
-
capture_output=True,
|
|
461
|
+
if not reason:
|
|
462
|
+
try:
|
|
463
|
+
branch_res = subprocess.run(
|
|
464
|
+
["git", "-C", str(repo), "branch", "-f", adhoc_branch, commit_sha],
|
|
465
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
285
466
|
)
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
467
|
+
if branch_res.returncode != 0:
|
|
468
|
+
reason = f"branch update failed: {branch_res.stderr.strip()}"
|
|
469
|
+
else:
|
|
470
|
+
remove_res = subprocess.run(
|
|
471
|
+
["git", "-C", str(repo), "worktree", "remove", "--force", str(wt_path)],
|
|
472
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
473
|
+
)
|
|
474
|
+
if remove_res.returncode != 0 or wt_path.exists():
|
|
475
|
+
reason = f"worktree remove failed: {remove_res.stderr.strip() or wt_path}"
|
|
476
|
+
else:
|
|
477
|
+
kept_worktree = None
|
|
478
|
+
subprocess.run(["git", "-C", str(repo), "worktree", "prune"], capture_output=True)
|
|
479
|
+
attempt_branch = packet.get("branch")
|
|
480
|
+
if attempt_branch and attempt_branch != adhoc_branch:
|
|
481
|
+
subprocess.run(
|
|
482
|
+
["git", "-C", str(repo), "branch", "-D", attempt_branch],
|
|
483
|
+
capture_output=True,
|
|
484
|
+
)
|
|
485
|
+
except OSError as exc:
|
|
486
|
+
reason = f"worktree cleanup failed: {exc}"
|
|
487
|
+
|
|
488
|
+
if reason:
|
|
489
|
+
print(f"[mmr] {reason}")
|
|
490
|
+
if pending and not reason:
|
|
491
|
+
print(f"MMR: PENDING — Claude lane. Worktree kept: {kept_worktree}")
|
|
492
|
+
print(f"NEXT: {packet.get('next', 'claude-implement')} worktree={kept_worktree}")
|
|
493
|
+
if kept_worktree:
|
|
494
|
+
print(f"KEPT: {kept_worktree}")
|
|
495
|
+
|
|
496
|
+
diff_ref = commit_sha if verified_names else adhoc_branch
|
|
497
|
+
diff_stat = git_output(repo, "diff", "--shortstat", base_sha, diff_ref)
|
|
498
|
+
diff_names = verified_names or changed_files
|
|
293
499
|
|
|
294
500
|
# Save state to latest.json
|
|
295
501
|
state = {
|
|
@@ -307,14 +513,18 @@ def run_adhoc(
|
|
|
307
513
|
"review_status": review_status,
|
|
308
514
|
"diff_stat": diff_stat.strip(),
|
|
309
515
|
"modified_deps": modified_deps,
|
|
516
|
+
"verdict": "PENDING" if pending and not reason else "FAIL" if reason or kept_worktree else "PASS",
|
|
517
|
+
"kept_worktree": kept_worktree,
|
|
518
|
+
"reason": reason,
|
|
310
519
|
"plan_path": plan_rel,
|
|
311
520
|
"created": datetime.now(timezone.utc).isoformat(),
|
|
312
521
|
}
|
|
313
522
|
adhoc_latest_path(repo).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
|
|
314
523
|
|
|
315
524
|
# Render Verdict Card
|
|
316
|
-
is_success =
|
|
317
|
-
status_label = "PASS" if is_success else "FAIL"
|
|
525
|
+
is_success = not reason and kept_worktree is None
|
|
526
|
+
status_label = "PENDING" if pending and not reason else "PASS" if is_success else "FAIL"
|
|
527
|
+
gates_label = "not run" if not packet.get("gates") else "PASS" if gates_passed else "FAILED"
|
|
318
528
|
|
|
319
529
|
card = [
|
|
320
530
|
"┌────────────────────────────────────────────────────────┐",
|
|
@@ -323,7 +533,7 @@ def run_adhoc(
|
|
|
323
533
|
f"│ Branch: {adhoc_branch:<47}│",
|
|
324
534
|
f"│ Model: {assigned_lane or 'auto'} ({assigned_model or 'default'}){' ' * max(0, 39 - len(assigned_lane or '') - len(assigned_model or ''))}│",
|
|
325
535
|
f"│ Diff: {(diff_stat or 'no changes')[:48]:<49}│",
|
|
326
|
-
f"│ Gates: {
|
|
536
|
+
f"│ Gates: {gates_label:<48}│",
|
|
327
537
|
f"│ Review: {review_status:<47}│",
|
|
328
538
|
]
|
|
329
539
|
if modified_deps:
|
|
@@ -338,7 +548,7 @@ def run_adhoc(
|
|
|
338
548
|
]
|
|
339
549
|
)
|
|
340
550
|
print("\n" + "\n".join(card) + "\n")
|
|
341
|
-
return EXIT_OK if is_success else EXIT_FAIL
|
|
551
|
+
return EXIT_OK if is_success or pending and not reason else EXIT_FAIL
|
|
342
552
|
|
|
343
553
|
|
|
344
554
|
def keep_adhoc(repo: Path) -> int:
|
|
@@ -387,6 +597,27 @@ def drop_adhoc(repo: Path) -> int:
|
|
|
387
597
|
slug = state.get("slug", "")
|
|
388
598
|
title = state.get("title", slug)
|
|
389
599
|
|
|
600
|
+
kept_worktree = state.get("kept_worktree")
|
|
601
|
+
if kept_worktree and Path(kept_worktree).resolve() not in lane_worktrees(repo):
|
|
602
|
+
# latest.json is a plain file: never let it point `drop` at a worktree /mmr did not make.
|
|
603
|
+
print(f"[mmr] Refusing to remove {kept_worktree}: not a registered lane/* worktree")
|
|
604
|
+
return EXIT_FAIL
|
|
605
|
+
if kept_worktree:
|
|
606
|
+
removed = subprocess.run(
|
|
607
|
+
["git", "-C", str(repo), "worktree", "remove", "--force", kept_worktree],
|
|
608
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
609
|
+
)
|
|
610
|
+
if removed.returncode != 0:
|
|
611
|
+
print(f"[mmr] Could not remove kept worktree: {removed.stderr.strip()}")
|
|
612
|
+
return EXIT_FAIL
|
|
613
|
+
pruned = subprocess.run(
|
|
614
|
+
["git", "-C", str(repo), "worktree", "prune"],
|
|
615
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
616
|
+
)
|
|
617
|
+
if pruned.returncode != 0:
|
|
618
|
+
print(f"[mmr] Could not prune worktrees: {pruned.stderr.strip()}")
|
|
619
|
+
return EXIT_FAIL
|
|
620
|
+
|
|
390
621
|
if branch:
|
|
391
622
|
subprocess.run(["git", "-C", str(repo), "branch", "-D", branch], capture_output=True)
|
|
392
623
|
|
|
@@ -443,9 +674,7 @@ def status_adhoc(repo: Path) -> int:
|
|
|
443
674
|
def review_diff(repo: Path, git_range: str | None = None) -> int:
|
|
444
675
|
# Resolve oss_review.py
|
|
445
676
|
candidates = [
|
|
446
|
-
|
|
447
|
-
repo / ".github" / "tools" / "oss_review.py",
|
|
448
|
-
Path(__file__).resolve().parent / "oss_review.py",
|
|
677
|
+
Path(__file__).with_name("oss_review.py"),
|
|
449
678
|
Path(__file__).resolve().parents[1] / ".github" / "tools" / "oss_review.py",
|
|
450
679
|
]
|
|
451
680
|
tool = next((c for c in candidates if c.is_file()), None)
|
|
@@ -495,6 +724,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
495
724
|
review_parser = subparsers.add_parser("review", help="Run multi-model review on a git diff")
|
|
496
725
|
review_parser.add_argument("--range", type=str, default=None, help="Git range (e.g. origin/main..HEAD)")
|
|
497
726
|
|
|
727
|
+
if args_list and re.fullmatch(r"[^\s]+\.\.\.?[^\s]*", args_list[0]):
|
|
728
|
+
return review_diff(repo_root(), args_list[0])
|
|
729
|
+
if args_list and args_list[0].startswith("--") and args_list[0] not in ("--help",):
|
|
730
|
+
print(parser.format_usage().strip())
|
|
731
|
+
return EXIT_USAGE
|
|
732
|
+
|
|
498
733
|
# Direct shorthand: if first argument is not a known subcommand and not a flag, treat as 'run'
|
|
499
734
|
if args_list and args_list[0] not in ("run", "keep", "drop", "status", "review", "-h", "--help"):
|
|
500
735
|
args_list = ["run", *args_list]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared exit-code scale for caddis scripts."""
|
|
2
|
+
|
|
3
|
+
CLEAN = 0
|
|
4
|
+
BLOCKED = 1
|
|
5
|
+
ADVISORY = 2
|
|
6
|
+
NOT_RUN = 3
|
|
7
|
+
ERROR = 4
|
|
8
|
+
|
|
9
|
+
MEANING = {
|
|
10
|
+
CLEAN: "clean",
|
|
11
|
+
BLOCKED: "blocked",
|
|
12
|
+
ADVISORY: "advisory",
|
|
13
|
+
NOT_RUN: "could not run",
|
|
14
|
+
ERROR: "error or bad usage",
|
|
15
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Read the simple frontmatter shapes used by caddis artifacts.
|
|
2
|
+
|
|
3
|
+
This is intentionally a small reader, not a YAML implementation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import re
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Single source of truth for allowed document types.
|
|
15
|
+
# .github/instructions/document-frontmatter.instructions.md must list the same set.
|
|
16
|
+
ALLOWED_TYPES: set[str] = {
|
|
17
|
+
"plan",
|
|
18
|
+
"prd",
|
|
19
|
+
"adr",
|
|
20
|
+
"design",
|
|
21
|
+
"runbook",
|
|
22
|
+
"handoff",
|
|
23
|
+
"analysis",
|
|
24
|
+
"review",
|
|
25
|
+
"prompt",
|
|
26
|
+
"parking-lot",
|
|
27
|
+
"rca",
|
|
28
|
+
"todo",
|
|
29
|
+
"relay",
|
|
30
|
+
"comms",
|
|
31
|
+
"reference",
|
|
32
|
+
"note",
|
|
33
|
+
"phase-report",
|
|
34
|
+
"implement-review",
|
|
35
|
+
"phase-verdict",
|
|
36
|
+
"hub-spawn",
|
|
37
|
+
"advisory-context",
|
|
38
|
+
"phase-prompt",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _header_lines(text: str) -> list[str] | None:
|
|
43
|
+
lines = text.lstrip("\ufeff").splitlines()
|
|
44
|
+
if not lines or lines[0] != "---":
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
end = lines.index("---", 1)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
return lines[1:end]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _value(raw: str) -> str:
|
|
54
|
+
value = raw.strip()
|
|
55
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
56
|
+
return value[1:-1]
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse(text: str) -> dict:
|
|
61
|
+
"""Read top-level scalars and one level of indented maps or lists."""
|
|
62
|
+
lines = _header_lines(text)
|
|
63
|
+
if lines is None:
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
out: dict = {}
|
|
67
|
+
parent: str | None = None
|
|
68
|
+
child_indent: int | None = None
|
|
69
|
+
for line in lines:
|
|
70
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if not line[0].isspace():
|
|
74
|
+
parent = None
|
|
75
|
+
child_indent = None
|
|
76
|
+
if ":" not in line:
|
|
77
|
+
continue
|
|
78
|
+
key, _, raw = line.partition(":")
|
|
79
|
+
if not key:
|
|
80
|
+
continue
|
|
81
|
+
if raw.strip():
|
|
82
|
+
out[key] = _value(raw)
|
|
83
|
+
else:
|
|
84
|
+
out[key] = {}
|
|
85
|
+
parent = key
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if parent is None:
|
|
89
|
+
continue
|
|
90
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
91
|
+
if not indent:
|
|
92
|
+
continue
|
|
93
|
+
if child_indent is None:
|
|
94
|
+
child_indent = indent
|
|
95
|
+
if indent != child_indent:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
item = line[indent:]
|
|
99
|
+
if item.startswith("- "):
|
|
100
|
+
if isinstance(out[parent], dict) and not out[parent]:
|
|
101
|
+
out[parent] = []
|
|
102
|
+
if isinstance(out[parent], list):
|
|
103
|
+
out[parent].append(_value(item[2:]))
|
|
104
|
+
elif ":" in item and isinstance(out[parent], dict):
|
|
105
|
+
subkey, _, raw = item.partition(":")
|
|
106
|
+
if subkey:
|
|
107
|
+
out[parent][subkey] = _value(raw)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate(text: str, allowed_types: set[str]) -> str | None:
|
|
112
|
+
"""Report the first missing or unsupported required header field."""
|
|
113
|
+
if _header_lines(text) is None:
|
|
114
|
+
return "missing frontmatter"
|
|
115
|
+
kind = parse(text).get("type")
|
|
116
|
+
if not isinstance(kind, str) or not kind:
|
|
117
|
+
return "missing required field: type"
|
|
118
|
+
if kind not in allowed_types:
|
|
119
|
+
return f"unknown type: {kind}"
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Which .caddis documents must carry a valid header. Defined here, not in the write hook,
|
|
124
|
+
# because this module ships in every bundle and the hook ships only in the Claude plugin:
|
|
125
|
+
# caddis_gate docs-check (agy, Codex, CI) and doc_header_guard must share one rule.
|
|
126
|
+
_DOC_SCOPE = re.compile(
|
|
127
|
+
r"^\.caddis/(plans|prd|kb|rca|decisions|parking-lot|reviews|todo-list|"
|
|
128
|
+
r"comms|handoffs|prompts|relay)/.+\.md$"
|
|
129
|
+
)
|
|
130
|
+
_DOC_EXCLUDED = {"DOC-MAP.md", "README.md", "AGENTS.md", "CLAUDE.md"}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def doc_in_scope(relative: str, root: Path) -> bool:
|
|
134
|
+
"""True if the repo-relative POSIX path must carry a valid header."""
|
|
135
|
+
if not _DOC_SCOPE.fullmatch(relative) or relative.rsplit("/", 1)[-1] in _DOC_EXCLUDED:
|
|
136
|
+
return False
|
|
137
|
+
config = root / ".caddis" / "config.toml"
|
|
138
|
+
if not config.is_file():
|
|
139
|
+
return True
|
|
140
|
+
with config.open("rb") as stream:
|
|
141
|
+
data = tomllib.load(stream)
|
|
142
|
+
patterns = data.get("docs", {}).get("header_exempt", [])
|
|
143
|
+
if not isinstance(patterns, list) or not all(isinstance(p, str) for p in patterns):
|
|
144
|
+
raise ValueError("[docs] header_exempt must be a list of globs")
|
|
145
|
+
return not any(fnmatch.fnmatchcase(relative, pattern) for pattern in patterns)
|