@caddis/cli 0.4.30 → 0.4.32

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 (41) hide show
  1. package/bundles/antigravity-plugin/mcp_ping_server.py +1 -1
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_adhoc.py +301 -66
  4. package/bundles/antigravity-plugin/scripts/caddis_exit.py +15 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +145 -0
  6. package/bundles/antigravity-plugin/scripts/caddis_gate.py +33 -10
  7. package/bundles/antigravity-plugin/scripts/caddis_governor.py +5 -2
  8. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +13 -1
  9. package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +1 -1
  10. package/bundles/antigravity-plugin/scripts/caddis_spawn.py +2 -2
  11. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +9 -14
  12. package/bundles/antigravity-plugin/scripts/claudster_doctor.py +66 -0
  13. package/bundles/antigravity-plugin/scripts/new-machine.ps1 +1 -1
  14. package/bundles/antigravity-plugin/scripts/oss_ask.py +6 -5
  15. package/bundles/antigravity-plugin/scripts/oss_model.py +1 -1
  16. package/bundles/antigravity-plugin/scripts/oss_review.py +84 -15
  17. package/bundles/antigravity-plugin/scripts/secret_filter.py +316 -0
  18. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +9 -4
  19. package/bundles/antigravity-plugin/skills/_registry.md +5 -3
  20. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +37 -2
  21. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +1 -1
  22. package/bundles/antigravity-plugin/skills/digress/SKILL.md +1 -1
  23. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +8 -1
  24. package/bundles/antigravity-plugin/skills/implement/SKILL.md +3 -1
  25. package/bundles/antigravity-plugin/skills/install-statusline/SKILL.md +105 -0
  26. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +14 -8
  27. package/bundles/antigravity-plugin/skills/resume/SKILL.md +2 -32
  28. package/bundles/antigravity-plugin/skills/review/SKILL.md +2 -96
  29. package/bundles/antigravity-plugin/skills/review-pr/SKILL.md +100 -0
  30. package/bundles/antigravity-plugin/skills/statusline/SKILL.md +2 -101
  31. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  32. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +3 -3
  33. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  34. package/bundles/codex/.codex/config.toml.example +0 -16
  35. package/bundles/codex/AGENTS.md +2 -2
  36. package/bundles/manifest.json +3 -3
  37. package/dist/claude-deepseek.js +1 -1
  38. package/dist/claude-glm.js +1 -1
  39. package/dist/claude-oss.js +1 -1
  40. package/dist/cli.js +1 -1
  41. 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 (junai-mcp is gone). Exposes ONE tool, `caddis_ping`, which returns
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
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.119",
3
+ "version": "1.3.120",
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
  }
@@ -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 immediate worktree cleanup. Also performs direct diff reviews.
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 = 0
25
- EXIT_FAIL = 1
26
- EXIT_USAGE = 2
27
- EXIT_CONFIG = 3
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
- res = subprocess.run(cmd, cwd=repo)
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
- if not packet_path.is_file():
225
- sys.stderr.write(f"[mmr] error: lane run packet not generated at {packet_path}\n")
226
- return res.returncode or EXIT_FAIL
227
-
305
+ packet = None
306
+ raw_packet = ""
307
+ packet_error = "no lane packet"
228
308
  try:
229
- packet = json.loads(packet_path.read_text(encoding="utf-8"))
230
- except (OSError, json.JSONDecodeError) as exc:
231
- sys.stderr.write(f"[mmr] error: failed to read packet: {exc}\n")
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
- adhoc_branch = f"lane/adhoc-{slug}"
242
- commit_sha = packet.get("base_sha", head_sha)
243
-
244
- # Teardown worktree immediately to prevent leaks
245
- if worktree_str:
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
- if wt_path.exists():
248
- if changed_files:
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), "add", "--", *changed_files],
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
- commit_res = subprocess.run(
256
- ["git", "-C", str(wt_path), "commit", "-m", f"adhoc({slug}): {title}"],
257
- capture_output=True,
258
- text=True,
259
- encoding="utf-8",
260
- )
261
- if commit_res.returncode == 0:
262
- commit_sha = git_output(wt_path, "rev-parse", "HEAD")
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
- print(f"[mmr] notice: commit in worktree skipped: {exc}")
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
- # Cleanly remove the worktree
273
- subprocess.run(
274
- ["git", "-C", str(repo), "worktree", "remove", "--force", str(wt_path)],
275
- capture_output=True,
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
- diff_stat = git_output(repo, "diff", "--shortstat", packet.get("base_sha", head_sha), adhoc_branch)
288
- diff_names = git_output(repo, "diff", "--name-only", packet.get("base_sha", head_sha), adhoc_branch).splitlines()
289
-
290
- # Check for dependency file modifications
291
- dep_files = {"package.json", "requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml"}
292
- modified_deps = [f for f in diff_names if Path(f).name in dep_files]
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 = gates_passed and review_status == "CLEAN" and not modified_deps
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: {'PASS' if gates_passed else 'FAILED':<48}│",
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
- repo / "scripts" / "oss_review.py",
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)