@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
@@ -34,6 +34,8 @@ except ModuleNotFoundError as exc:
34
34
  from caddis_heavy_slot import HeavySlotBusy, heavy_slot
35
35
 
36
36
  import caddis_routing
37
+ import caddis_redact
38
+ import caddis_spend
37
39
 
38
40
  if hasattr(sys.stdout, "reconfigure"):
39
41
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
@@ -63,8 +65,11 @@ DEFAULT_MODELS = {
63
65
  "glm": "glm-5.3[1m]",
64
66
  "codex-sol": "gpt-5.6-sol",
65
67
  "codex-astra": "gpt-6-astra",
66
- "agy": "gemini-3.8-flash",
68
+ "agy": "gemini-4-argon",
67
69
  }
70
+ AGY_FALLBACK_MODEL = "gemini-3.8-flash" # used when `agy models` does not offer the default (other teams)
71
+ AGY_PROBE_TIMEOUT_S = 30
72
+ _AGY_MODELS_CACHE: str | None = None
68
73
  DEFAULT_EFFORT = {
69
74
  "claude": "high",
70
75
  "glm": "medium",
@@ -110,7 +115,9 @@ BUDGET_PATTERNS: dict[str, tuple[str, ...]] = {
110
115
  r"Quota exceeded\. Check your plan",
111
116
  r"workspace is out of credits",
112
117
  ),
113
- "agy": (r"RESOURCE_EXHAUSTED.*quota", r"Individual quota reached"),
118
+ # caddis_spend refusing over the global EUR cap: the chain moves on, like a provider quota.
119
+ "agy": (r"RESOURCE_EXHAUSTED.*quota", r"Individual quota reached", r"(?m)^caddis_spend: over cap",
120
+ r"(?m)^caddis_spend: could not lock"),
114
121
  }
115
122
  CONFIG_PATTERNS: dict[str, tuple[str, ...]] = {
116
123
  "claude": (),
@@ -127,7 +134,9 @@ CONFIG_PATTERNS: dict[str, tuple[str, ...]] = {
127
134
  r"model is not supported when using Codex",
128
135
  r"Model metadata for .* not found",
129
136
  ),
130
- "agy": (r"invalid model selection", r"is not recognized as a known model"),
137
+ "agy": (r"invalid model selection", r"is not recognized as a known model",
138
+ r"(?m)^caddis_spend: spend cap not configured",
139
+ r"(?m)^caddis_spend: could not check the spend cap"),
131
140
  }
132
141
  LOAD_WORDING = (
133
142
  r"(?i)\bcpu\s+burner",
@@ -559,6 +568,12 @@ def _windows_process_names(pids: list[int]) -> dict[int, str]:
559
568
  return names
560
569
 
561
570
 
571
+ def _refused_run(returncode: int, message: str) -> LaneRun:
572
+ """A lane that never started: the spend cap refused it. pid 0, no time spent."""
573
+ return LaneRun(returncode=returncode, output=message + "\n", timed_out=False, duration_s=0.0,
574
+ pid=0, started=time.time())
575
+
576
+
562
577
  def run_lane(
563
578
  argv: list[str],
564
579
  cwd: Path,
@@ -570,7 +585,25 @@ def run_lane(
570
585
  clock=time.monotonic,
571
586
  sleep=time.sleep,
572
587
  probe=default_probe,
588
+ spend_caller: str = "lane",
573
589
  ) -> LaneRun:
590
+ # Every agy launch is booked against the global spend cap first (caddis_spend). ci_watch reaches
591
+ # agy through here too, so each call is booked exactly once. Any spend problem refuses the lane
592
+ # (fail closed) and never crashes the unattended chain.
593
+ spend_id = None
594
+ if Path(argv[0]).stem.lower() == "agy":
595
+ spend_home = Path.home()
596
+ try:
597
+ spend_id = caddis_spend.reserve(spend_home, spend_caller, datetime.now(timezone.utc))
598
+ except caddis_spend.NotConfigured:
599
+ return _refused_run(3, caddis_spend.NOT_CONFIGURED_MESSAGE)
600
+ except TimeoutError as exc:
601
+ return _refused_run(3, str(exc))
602
+ except Exception as exc: # an unreadable log or file error: refuse, never crash
603
+ return _refused_run(3, f"caddis_spend: could not check the spend cap: {exc!r}")
604
+ if spend_id is None:
605
+ return _refused_run(1, "caddis_spend: over cap: agy refused ("
606
+ + caddis_spend.status_line(spend_home, datetime.now(timezone.utc)) + ")")
574
607
  job_name = f"Local\\caddis-governor-{os.getpid()}-{secrets.token_hex(8)}" if sys.platform == "win32" else None
575
608
  full = governed_argv(argv, job_name)
576
609
  if not stdin_prompt:
@@ -582,15 +615,23 @@ def run_lane(
582
615
  popen_options = {}
583
616
  if sys.platform != "win32":
584
617
  popen_options["start_new_session"] = True
585
- proc = subprocess.Popen(
586
- full,
587
- cwd=cwd,
588
- stdin=subprocess.PIPE if stdin_prompt else subprocess.DEVNULL,
589
- stdout=subprocess.PIPE,
590
- stderr=subprocess.STDOUT,
591
- env=env,
592
- **popen_options,
593
- )
618
+ try:
619
+ proc = subprocess.Popen(
620
+ full,
621
+ cwd=cwd,
622
+ stdin=subprocess.PIPE if stdin_prompt else subprocess.DEVNULL,
623
+ stdout=subprocess.PIPE,
624
+ stderr=subprocess.STDOUT,
625
+ env=env,
626
+ **popen_options,
627
+ )
628
+ except OSError:
629
+ if spend_id is not None: # nothing started, nothing was spent
630
+ try:
631
+ caddis_spend.settle(spend_home, spend_id, (0, 0), datetime.now(timezone.utc))
632
+ except Exception:
633
+ pass
634
+ raise
594
635
  timed_out = stalled = False
595
636
  idle_seconds = 0
596
637
  processes = None
@@ -682,9 +723,24 @@ def run_lane(
682
723
  kill_governed(proc)
683
724
  raise
684
725
 
726
+ # Mask key values before any caller sees the lane's output (printed tails, ci-watch reports); packets are
727
+ # masked again in write_packet, which also covers the gate and review tails.
728
+ output = caddis_redact.redact(b"".join(chunks).decode("utf-8", errors="replace"),
729
+ caddis_redact.load_secrets(Path.home()))
730
+ if spend_id is not None:
731
+ # Tokens only from JSON mode: in text mode the model's own lines could pose as a usage document.
732
+ if proc.returncode == 127:
733
+ counts = (0, 0) # the governor found no agy binary: nothing ran, nothing was spent
734
+ else:
735
+ counts = caddis_spend.tokens_from_output(output) if caddis_spend.json_mode(argv) else None
736
+ try:
737
+ caddis_spend.settle(spend_home, spend_id, counts, datetime.now(timezone.utc),
738
+ duration_s=clock() - monotonic_started)
739
+ except Exception as exc: # the pending reserve keeps counting; never lose the lane's result
740
+ output += f"\ncaddis_spend: could not settle {spend_id}: {exc!r}\n"
685
741
  return LaneRun(
686
742
  returncode=proc.returncode,
687
- output=b"".join(chunks).decode("utf-8", errors="replace"),
743
+ output=output,
688
744
  timed_out=timed_out,
689
745
  duration_s=clock() - monotonic_started,
690
746
  pid=proc.pid,
@@ -966,8 +1022,11 @@ def run_dir(repo_root: Path, plan_stem: str) -> Path:
966
1022
  def write_packet(path: Path, packet: dict) -> Path:
967
1023
  path.parent.mkdir(parents=True, exist_ok=True)
968
1024
  temporary = path.with_name(path.name + ".tmp")
1025
+ # Every string in the packet (gate and review tails come from model-written code) is masked here,
1026
+ # the one place all packets pass through.
1027
+ safe = caddis_redact.redact_obj(packet, caddis_redact.load_secrets(Path.home()))
969
1028
  temporary.write_text(
970
- json.dumps(packet, indent=2, sort_keys=True) + "\n",
1029
+ json.dumps(safe, indent=2, sort_keys=True) + "\n",
971
1030
  encoding="utf-8",
972
1031
  )
973
1032
  os.replace(temporary, path)
@@ -1003,14 +1062,64 @@ def idle_timeout(cfg: dict, override: int | None = None) -> int:
1003
1062
  return value
1004
1063
 
1005
1064
 
1006
- def resolve_models(cfg: dict) -> dict[str, str]:
1065
+ def _probe_agy_models() -> str:
1066
+ """The text of `agy models` (one model id per line), or "" when agy is missing, fails or prints nothing. Only a
1067
+ non-empty listing is cached for the process: a failed probe is not cached, so a later call asks agy again."""
1068
+ global _AGY_MODELS_CACHE
1069
+ if _AGY_MODELS_CACHE:
1070
+ return _AGY_MODELS_CACHE
1071
+ agy = shutil.which("agy")
1072
+ if not agy:
1073
+ return ""
1074
+ try:
1075
+ proc = subprocess.run(
1076
+ [agy, "models"], capture_output=True, text=True, encoding="utf-8", errors="replace",
1077
+ timeout=AGY_PROBE_TIMEOUT_S, check=False, stdin=subprocess.DEVNULL,
1078
+ )
1079
+ except (OSError, subprocess.SubprocessError):
1080
+ return ""
1081
+ listing = (proc.stdout or "") if proc.returncode == 0 else ""
1082
+ if listing.strip():
1083
+ _AGY_MODELS_CACHE = listing
1084
+ return listing
1085
+
1086
+
1087
+ # The agy default is used at three effort levels: ci-watch uses -low, lanes -medium, review -high. Keep it only
1088
+ # when `agy models` offers all three exact ids.
1089
+ AGY_DEFAULT_EFFORT_IDS = ("low", "medium", "high")
1090
+ _AGY_FALLBACK_NOTED = False
1091
+
1092
+
1093
+ def _agy_default_model(probe=None) -> str:
1094
+ """The configured default agy model when `agy models` lists every `<default>-low`, `-medium` and `-high` id
1095
+ exactly, else AGY_FALLBACK_MODEL. The fallback note goes to stderr once per process."""
1096
+ global _AGY_FALLBACK_NOTED
1097
+ probe = probe or _probe_agy_models
1098
+ default = DEFAULT_MODELS["agy"]
1099
+ try:
1100
+ listing = probe()
1101
+ except (OSError, subprocess.SubprocessError):
1102
+ listing = ""
1103
+ offered = {line.split()[0] for line in (listing or "").splitlines() if line.split()} # id, then a name
1104
+ if all(f"{default}-{level}" in offered for level in AGY_DEFAULT_EFFORT_IDS):
1105
+ return default
1106
+ if not _AGY_FALLBACK_NOTED:
1107
+ _AGY_FALLBACK_NOTED = True
1108
+ print(f"[caddis-lanes] agy: {default} not offered here; using {AGY_FALLBACK_MODEL}", file=sys.stderr)
1109
+ return AGY_FALLBACK_MODEL
1110
+
1111
+
1112
+ def resolve_models(cfg: dict, *, probe=None) -> dict[str, str]:
1007
1113
  models = DEFAULT_MODELS.copy()
1008
- for key, value in cfg.get("models", {}).items():
1114
+ configured = cfg.get("models", {})
1115
+ for key, value in configured.items():
1009
1116
  if key not in LANE_IDS:
1010
1117
  raise LaneConfigError(f"unknown lane in [lanes.models]: {key}")
1011
1118
  if not isinstance(value, str) or not value:
1012
1119
  raise LaneConfigError(f"empty model id for lane {key}")
1013
1120
  models[key] = value
1121
+ if "agy" not in configured: # a config pin wins and is never probed
1122
+ models["agy"] = _agy_default_model(probe)
1014
1123
  return models
1015
1124
 
1016
1125
 
@@ -1206,48 +1315,160 @@ def next_lane(
1206
1315
  return candidates[0] if candidates else None
1207
1316
 
1208
1317
 
1209
- def _ci_watch_block(output: str) -> dict[str, str] | None:
1210
- """Read the last complete watch report, ignoring an echoed prompt template.
1318
+ # Why gh waits and agy only summarises (measured 2026-10-15): agy `--print` moves a long command
1319
+ # (`gh pr checks --watch`) into its own background task, reports "root agent idle", then ends that task
1320
+ # on exit and returns no report. agy also does not read stdin in text mode, so any data it needs must
1321
+ # sit inside the prompt. So `gh` always does the waiting, and agy gets one short, tool-free job after a
1322
+ # failure: read the failed log (placed in the prompt) and name the failing job and first error.
1323
+ _CI_SUMMARY_MAX_CHARS = 300
1324
+ _CI_LOG_TAIL_LINES = 80
1325
+ _CI_LOG_TAIL_CHARS = 8000
1326
+ _CI_AGY_TIMEOUT_S = 300
1327
+ _CI_GH_SHORT_TIMEOUT_S = 60
1328
+ _RUN_ID_IN_URL = re.compile(r"/actions/runs/(\d+)")
1329
+
1330
+
1331
+ def _print_ci_watch(result: str, checks: str, merge_state: str, failing: str, watcher: str,
1332
+ summary: str | None = None, head: str = "n/a") -> None:
1333
+ print(f"RESULT: {result}")
1334
+ print(f"CHECKS: {checks}")
1335
+ print(f"MERGE_STATE: {merge_state}")
1336
+ print(f"HEAD: {head}")
1337
+ print(f"FAILING: {failing}")
1338
+ print(f"WATCHER: {watcher}")
1339
+ print(f"SUMMARY: {summary or 'none'}")
1340
+
1211
1341
 
1212
- The block must hold exactly RESULT, CHECKS, MERGE_STATE and FAILING, RESULT first. A reordered
1213
- or extended block returns None, and the caller then falls back to `gh` without saying why:
1214
- that is safe (the watch prompt pins the order) but invisible, so keep the prompt and this
1215
- reader in step.
1342
+ _ROLLUP_GREEN = {"SUCCESS", "SKIPPED", "NEUTRAL"}
1343
+ _STATUS_CONTEXT_DONE = {"SUCCESS", "FAILURE", "ERROR"} # a commit status' final states; PENDING/EXPECTED are not
1344
+
1345
+
1346
+ def _rollup_verdict(rollup) -> tuple[int, list[str], list[str]] | None:
1347
+ """(green count, failed names, unfinished names) from `statusCheckRollup` of the PR's head commit.
1348
+
1349
+ A check run is green only when COMPLETED with SUCCESS, SKIPPED or NEUTRAL; a status context only on SUCCESS.
1350
+ None when the rollup is missing, empty or not a list: nothing proves the head is green.
1351
+ """
1352
+ if not isinstance(rollup, list) or not rollup:
1353
+ return None
1354
+ green, failed, unfinished = 0, [], []
1355
+ for item in rollup:
1356
+ if not isinstance(item, dict):
1357
+ return None
1358
+ name = str(item.get("name") or item.get("context") or "?")
1359
+ kind = item.get("__typename") or ("StatusContext" if "context" in item and "status" not in item
1360
+ else "CheckRun")
1361
+ if kind == "StatusContext": # a commit status
1362
+ state = str(item.get("state") or "").upper()
1363
+ outcome = state if state in _STATUS_CONTEXT_DONE else ""
1364
+ else: # a check run
1365
+ done = str(item.get("status") or "").upper() == "COMPLETED"
1366
+ outcome = str(item.get("conclusion") or "").upper() if done else ""
1367
+ if outcome in _ROLLUP_GREEN:
1368
+ green += 1
1369
+ elif outcome: # FAILURE, CANCELLED, TIMED_OUT, ... and any final state not known here: never a pass
1370
+ failed.append(name)
1371
+ else:
1372
+ unfinished.append(name)
1373
+ return green, failed, unfinished
1374
+
1375
+
1376
+ def _parse_pr_checks(output: str) -> list[tuple[str, str, str]]:
1377
+ """Read `gh pr checks` rows (tab-separated: name, state, elapsed, url) as (name, state, url).
1378
+
1379
+ `--watch` may print the table more than once; a later row for the same name replaces an earlier one.
1216
1380
  """
1217
- fields: dict[str, str] = {}
1218
- reports: list[dict[str, str]] = []
1381
+ rows: dict[str, tuple[str, str]] = {}
1219
1382
  for line in output.splitlines():
1220
- match = re.match(
1221
- r"^\s*(?:[`*]+\s*)?(RESULT|CHECKS|MERGE_STATE|FAILING)[`*]*\s*:\s*[`*]*\s*(.*?)\s*[`*]*\s*$",
1222
- line, re.I,
1223
- )
1224
- if not match:
1383
+ parts = line.split("\t")
1384
+ if len(parts) < 2 or not parts[0].strip():
1225
1385
  continue
1226
- key, value = match.group(1).upper(), match.group(2).strip().strip("`* ")
1227
- if key == "RESULT":
1228
- if fields:
1229
- reports.append(fields)
1230
- fields = {}
1231
- if fields or key == "RESULT":
1232
- fields[key] = value
1233
- if fields:
1234
- reports.append(fields)
1235
- if not reports:
1386
+ url = parts[3].strip() if len(parts) > 3 else ""
1387
+ rows[parts[0].strip()] = (parts[1].strip().lower(), url)
1388
+ return [(name, state, url) for name, (state, url) in rows.items()]
1389
+
1390
+
1391
+ def _ci_gh_run(run_gh, argv: list[str], repo: Path, timeout_s: float):
1392
+ """One short gh call. Returns the CompletedProcess, or None on any start or time-out problem."""
1393
+ try:
1394
+ return run_gh(argv, cwd=repo, capture_output=True, text=True,
1395
+ encoding="utf-8", errors="replace", timeout=timeout_s)
1396
+ except (subprocess.TimeoutExpired, OSError):
1397
+ return None
1398
+
1399
+
1400
+ def _ci_failure_summary(
1401
+ log_tail: str, *, agy_path: str, repo: Path, remaining_s: float, run_agy,
1402
+ ) -> str | None:
1403
+ """Ask agy for a one-line failure summary. The log is inside the prompt. None on any problem."""
1404
+ masked_values = caddis_redact.load_secrets(Path.home())
1405
+ prompt = (
1406
+ "Do not run any tools. Read this CI failure log and reply with one line: the failing job "
1407
+ "and the first failing test or error line. LOG:\n" + caddis_redact.redact(log_tail, masked_values)
1408
+ )
1409
+ try:
1410
+ model = resolve_models(load_lanes_config(repo))["agy"]
1411
+ effort = "low" # a one-line summary needs no deep thinking
1412
+ argv = [
1413
+ # The log is untrusted (a PR can write anything into it), so this call can use no tools:
1414
+ # plan mode and the sandbox, and never --dangerously-skip-permissions. Checked 2026-10-15:
1415
+ # with these flags agy wrote a plan and did not run the command a prompt asked for.
1416
+ agy_path, "--model", f"{model}-{effort}", "--mode", "plan", "--sandbox",
1417
+ "--output-format", "json", "--print",
1418
+ ]
1419
+ # The idle watchdog is off for ci-watch (as before); the wall-clock limit still applies.
1420
+ runner = ((lambda *a: run_lane(*a, idle_timeout_s=0, spend_caller="ci-watch"))
1421
+ if run_agy is _LANE_RUNNER else run_agy)
1422
+ run = runner(argv, repo, prompt, False, min(_CI_AGY_TIMEOUT_S, remaining_s),
1423
+ child_env(os.environ, "agy", model))
1424
+ except LaneConfigError as exc:
1425
+ print(f"[caddis-lanes] ci-watch: agy summary skipped ({exc})", file=sys.stderr)
1426
+ return None
1427
+ except OSError as exc:
1428
+ print(f"[caddis-lanes] ci-watch: agy could not start ({exc}); no summary", file=sys.stderr)
1236
1429
  return None
1237
- last = reports[-1]
1238
- if set(last) != {"RESULT", "CHECKS", "MERGE_STATE", "FAILING"}:
1430
+ if run.timed_out:
1431
+ print("[caddis-lanes] ci-watch: agy summary timed out; no summary", file=sys.stderr)
1239
1432
  return None
1240
- if last["RESULT"].lower() not in {"pass", "fail", "timeout"}:
1433
+ if run.returncode != 0:
1434
+ print(f"[caddis-lanes] ci-watch: agy exited {run.returncode}; no summary", file=sys.stderr)
1241
1435
  return None
1242
- return last
1436
+ reply = caddis_redact.redact(caddis_spend.result_text(run.output), masked_values)
1437
+ for line in reply.splitlines():
1438
+ line = re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", line).strip()
1439
+ if line:
1440
+ return line[:_CI_SUMMARY_MAX_CHARS]
1441
+ print("[caddis-lanes] ci-watch: agy gave an empty reply; no summary", file=sys.stderr)
1442
+ return None
1243
1443
 
1244
1444
 
1245
- def _print_ci_watch(result: str, checks: str, merge_state: str, failing: str, watcher: str) -> None:
1246
- print(f"RESULT: {result}")
1247
- print(f"CHECKS: {checks}")
1248
- print(f"MERGE_STATE: {merge_state}")
1249
- print(f"FAILING: {failing}")
1250
- print(f"WATCHER: {watcher}")
1445
+ def _ci_summary_from_log(gh_path, run_id, repo_args, repo, run_gh, remaining, agy_path, run_agy) -> str | None:
1446
+ """Fetch the failed log, then ask agy for one line. None (with a stderr note) on any problem."""
1447
+ if run_id is None:
1448
+ print("[caddis-lanes] ci-watch: no run id for the failed check; no summary", file=sys.stderr)
1449
+ return None
1450
+ left = remaining()
1451
+ if left <= 0:
1452
+ print("[caddis-lanes] ci-watch: time budget used up; no summary", file=sys.stderr)
1453
+ return None
1454
+ log = _ci_gh_run(
1455
+ run_gh, [gh_path, "run", "view", run_id, "--log-failed", *repo_args],
1456
+ repo, min(_CI_GH_SHORT_TIMEOUT_S, left),
1457
+ )
1458
+ text = (log.stdout or "") if log is not None and log.returncode == 0 else ""
1459
+ tail = "\n".join(text.splitlines()[-_CI_LOG_TAIL_LINES:]).strip()[-_CI_LOG_TAIL_CHARS:]
1460
+ if not tail:
1461
+ print("[caddis-lanes] ci-watch: no failed log available; no summary", file=sys.stderr)
1462
+ return None
1463
+ left = remaining()
1464
+ if left <= 0:
1465
+ print("[caddis-lanes] ci-watch: time budget used up; no summary", file=sys.stderr)
1466
+ return None
1467
+ try:
1468
+ return _ci_failure_summary(tail, agy_path=agy_path, repo=repo, remaining_s=left, run_agy=run_agy)
1469
+ except Exception as exc: # agy must never change the result or the exit code
1470
+ print(f"[caddis-lanes] ci-watch: agy summary failed ({exc!r}); no summary", file=sys.stderr)
1471
+ return None
1251
1472
 
1252
1473
 
1253
1474
  def ci_watch(
@@ -1263,7 +1484,17 @@ def ci_watch(
1263
1484
  run_gh=subprocess.run,
1264
1485
  clock=time.monotonic,
1265
1486
  ) -> int:
1266
- """Wait for CI with the resolved watcher, using one shared time budget."""
1487
+ """Wait for CI with `gh`, using one shared time budget. agy only summarises a failure.
1488
+
1489
+ `gh pr checks --watch` (or `gh run watch`) does the waiting, but its exit code is not proof. On a PR,
1490
+ RESULT is `pass` only when the check rollup of the PR's head (one `gh pr view --json
1491
+ mergeStateStatus,headRefOid,statusCheckRollup`) is non-empty and every check is COMPLETED and green, and
1492
+ the head is the one read before the watch began (a push during the watch is exit 3). On a run, `gh run
1493
+ view --json status,conclusion,headSha` must read COMPLETED and SUCCESS (a skipped run ran no job). HEAD prints
1494
+ the commit. When the result is `fail` and the `ci-watch` routing chain holds agy, agy reads the last lines
1495
+ of the failed log (inside its prompt) and returns one line for SUMMARY. agy never changes the result or
1496
+ the exit code.
1497
+ """
1267
1498
  if target_kind not in {"pr", "run"} or target_id <= 0 or timeout_s <= 0:
1268
1499
  print("[caddis-lanes] ci-watch: target and timeout must be positive", file=sys.stderr)
1269
1500
  _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
@@ -1281,73 +1512,10 @@ def ci_watch(
1281
1512
  _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1282
1513
  return EXIT_CONFIG
1283
1514
 
1284
- repo_opt = f" --repo {repo_slug}" if repo_slug else ""
1285
- if target_kind == "pr":
1286
- poll = f"gh pr checks {target_id}{repo_opt}"
1287
- state = f"gh pr view {target_id} --json mergeStateStatus,headRefOid{repo_opt}"
1288
- else:
1289
- poll = f"gh run view {target_id}{repo_opt}"
1290
- state = "n/a"
1291
- prompt = (
1292
- "Read-only CI watch. Poll with " + poll + " until every check has finished or the time runs out. "
1293
- + (f"For the PR, also poll {state}. " if target_kind == "pr" else "")
1294
- + "Never merge, push, comment, re-run, approve, close or edit anything. "
1295
- + "Then print exactly this block as the last thing you output, with actual values:\n"
1296
- "RESULT: pass|fail|timeout\n"
1297
- "CHECKS: <n passed>/<n total>\n"
1298
- "MERGE_STATE: <mergeStateStatus or n/a>\n"
1299
- "FAILING: <comma-separated failing check names, or none>"
1300
- )
1515
+ def remaining() -> float:
1516
+ return timeout_s - (clock() - started)
1301
1517
 
1302
- for watcher in watchers:
1303
- if watcher == "claude":
1304
- break
1305
- if watcher != "agy":
1306
- print(f"[caddis-lanes] ci-watch: unsupported watcher {watcher}; skipping", file=sys.stderr)
1307
- continue
1308
- agy_path = which("agy")
1309
- if agy_path is None:
1310
- break
1311
- remaining = timeout_s - (clock() - started)
1312
- if remaining <= 0:
1313
- _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1314
- return EXIT_CONFIG
1315
- try:
1316
- model = resolve_models(load_lanes_config(repo))["agy"]
1317
- effort = DEFAULT_EFFORT["agy"]
1318
- except LaneConfigError as exc:
1319
- print(f"[caddis-lanes] {exc}", file=sys.stderr)
1320
- _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1321
- return EXIT_CONFIG
1322
- agy_argv = [
1323
- agy_path, "--model", f"{model}-{effort}",
1324
- "--dangerously-skip-permissions", "--output-format", "text", "--print",
1325
- ]
1326
- try:
1327
- # A CI watcher legitimately sits idle while CI runs, so the idle watchdog is off for it;
1328
- # the wall-clock limit (`remaining`) still applies.
1329
- watcher = (lambda *a: run_lane(*a, idle_timeout_s=0)) if run_agy is _LANE_RUNNER else run_agy
1330
- run = watcher(agy_argv, repo, prompt, False, remaining, child_env(os.environ, "agy", model))
1331
- except OSError as exc:
1332
- print(f"[caddis-lanes] ci-watch: agy could not start ({exc}); watching with gh", file=sys.stderr)
1333
- break
1334
- outcome = classify_exit("agy", run.returncode, run.output, run.timed_out)
1335
- if run.timed_out:
1336
- _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1337
- return EXIT_CONFIG
1338
- if outcome in {RESULT_OUT_OF_BUDGET, RESULT_CONFIG_ERROR}:
1339
- reason = "out of budget" if outcome == RESULT_OUT_OF_BUDGET else "configuration error"
1340
- print(f"[caddis-lanes] ci-watch: agy {reason}; watching with gh", file=sys.stderr)
1341
- break
1342
- report = _ci_watch_block(run.output)
1343
- if report is not None:
1344
- result = report["RESULT"].lower()
1345
- _print_ci_watch(result, report["CHECKS"], report["MERGE_STATE"], report["FAILING"], "agy")
1346
- return {"pass": EXIT_OK, "fail": EXIT_FAIL, "timeout": EXIT_CONFIG}[result]
1347
- break
1348
-
1349
- remaining = timeout_s - (clock() - started)
1350
- if remaining <= 0:
1518
+ if remaining() <= 0:
1351
1519
  print("[caddis-lanes] ci-watch: time budget expired before gh", file=sys.stderr)
1352
1520
  _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1353
1521
  return EXIT_CONFIG
@@ -1356,16 +1524,25 @@ def ci_watch(
1356
1524
  print("[caddis-lanes] ci-watch: gh not found on PATH", file=sys.stderr)
1357
1525
  _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1358
1526
  return EXIT_CONFIG
1359
- command = (
1360
- [gh_path, "pr", "checks", str(target_id), "--watch"]
1361
- if target_kind == "pr" else [gh_path, "run", "watch", str(target_id), "--exit-status"]
1362
- )
1363
- if repo_slug:
1364
- command.extend(["--repo", repo_slug])
1527
+ repo_args = ["--repo", repo_slug] if repo_slug else []
1528
+ head_before: str | None = None
1529
+ if target_kind == "pr":
1530
+ # The head before the watch: a push during the watch can make a new head look green before all its
1531
+ # checks exist, so a pass needs the same head after the watch.
1532
+ before = _ci_gh_run(run_gh, [gh_path, "pr", "view", str(target_id), "--json", "headRefOid", *repo_args],
1533
+ repo, min(_CI_GH_SHORT_TIMEOUT_S, remaining()))
1534
+ if before is not None and before.returncode == 0:
1535
+ try:
1536
+ head_before = str(json.loads(before.stdout or "").get("headRefOid") or "") or None
1537
+ except (ValueError, AttributeError):
1538
+ head_before = None
1539
+ command = [gh_path, "pr", "checks", str(target_id), "--watch", "--interval", "30", *repo_args]
1540
+ else:
1541
+ command = [gh_path, "run", "watch", str(target_id), "--exit-status", "--interval", "30", *repo_args]
1365
1542
  try:
1366
1543
  completed = run_gh(
1367
1544
  command, cwd=repo, capture_output=True, text=True,
1368
- encoding="utf-8", errors="replace", timeout=remaining,
1545
+ encoding="utf-8", errors="replace", timeout=remaining(),
1369
1546
  )
1370
1547
  except subprocess.TimeoutExpired:
1371
1548
  print("[caddis-lanes] ci-watch: gh timed out", file=sys.stderr)
@@ -1382,10 +1559,116 @@ def ci_watch(
1382
1559
  code = EXIT_OK if completed.returncode == 0 else (
1383
1560
  EXIT_FAIL if completed.returncode == 1 and not no_checks else EXIT_CONFIG
1384
1561
  )
1385
- _print_ci_watch(
1386
- "pass" if code == EXIT_OK else "fail" if code == EXIT_FAIL else "timeout",
1387
- "n/a", "n/a", "see gh output" if code == EXIT_FAIL else "none", "gh",
1388
- )
1562
+ result = "pass" if code == EXIT_OK else "fail" if code == EXIT_FAIL else "timeout"
1563
+
1564
+ checks, merge_state = "n/a", "n/a"
1565
+ failing = "see gh output" if code == EXIT_FAIL else "none"
1566
+ run_id: str | None = str(target_id) if target_kind == "run" else None
1567
+ if target_kind == "pr" and code != EXIT_CONFIG:
1568
+ rows = _parse_pr_checks(completed.stdout or "")
1569
+ if rows:
1570
+ # `skipping` is not a failure: count it as passed.
1571
+ passed = sum(1 for _, state, _ in rows if state in {"pass", "skipping"})
1572
+ checks = f"{passed}/{len(rows)}"
1573
+ # Failed checks first; cancelled ones only when nothing failed (they name the wrong run).
1574
+ bad = ([(name, url) for name, state, url in rows if state == "fail"]
1575
+ or [(name, url) for name, state, url in rows if state == "cancel"])
1576
+ if code == EXIT_FAIL and bad:
1577
+ failing = ", ".join(name for name, _ in bad)
1578
+ for _, url in bad:
1579
+ found = _RUN_ID_IN_URL.search(url)
1580
+ if found:
1581
+ run_id = found.group(1)
1582
+ break
1583
+ head = "n/a"
1584
+ if target_kind == "pr" and code != EXIT_CONFIG:
1585
+ # The watch table alone is not proof. gh exited 0 on 16 Oct 2026 with a cancelled row (CHECKS 6/7) and
1586
+ # with a row still unfinished (5/6), and its table can still show an older head's checks. So a pass
1587
+ # also needs the check rollup of the PR's CURRENT head: every check COMPLETED and green. HEAD names
1588
+ # that commit, so a caller can merge only that head.
1589
+ rollup_view = None
1590
+ left = remaining()
1591
+ if left > 0:
1592
+ view = _ci_gh_run(
1593
+ run_gh,
1594
+ [gh_path, "pr", "view", str(target_id), "--json", "mergeStateStatus,headRefOid,statusCheckRollup",
1595
+ *repo_args],
1596
+ repo, min(_CI_GH_SHORT_TIMEOUT_S, left),
1597
+ )
1598
+ if view is not None and view.returncode == 0:
1599
+ try:
1600
+ rollup_view = json.loads(view.stdout or "")
1601
+ except ValueError:
1602
+ rollup_view = None
1603
+ if isinstance(rollup_view, dict):
1604
+ merge_state = str(rollup_view.get("mergeStateStatus") or "") or "n/a"
1605
+ head = str(rollup_view.get("headRefOid") or "") or "n/a"
1606
+ verdict = _rollup_verdict(rollup_view.get("statusCheckRollup")) if isinstance(rollup_view, dict) else None
1607
+ if code == EXIT_OK:
1608
+ if verdict is None or head == "n/a":
1609
+ print("[caddis-lanes] ci-watch: gh said pass, but the head's checks could not be read",
1610
+ file=sys.stderr)
1611
+ code, result, failing = EXIT_CONFIG, "timeout", "none"
1612
+ else:
1613
+ green, failed, unfinished = verdict
1614
+ checks = f"{green}/{green + len(failed) + len(unfinished)}"
1615
+ if failed:
1616
+ code, result, failing = EXIT_FAIL, "fail", ", ".join(failed)
1617
+ elif unfinished:
1618
+ print(f"[caddis-lanes] ci-watch: gh said pass, but not finished: {', '.join(unfinished)}",
1619
+ file=sys.stderr)
1620
+ code, result, failing = EXIT_CONFIG, "timeout", "none"
1621
+ elif head_before is None:
1622
+ print("[caddis-lanes] ci-watch: the head before the watch could not be read, so a push "
1623
+ "during the watch cannot be ruled out; watch again", file=sys.stderr)
1624
+ code, result, failing = EXIT_CONFIG, "timeout", "head not read before the watch"
1625
+ elif head != head_before:
1626
+ print(f"[caddis-lanes] ci-watch: the head moved during the watch ({head_before} -> {head}); "
1627
+ "watch again", file=sys.stderr)
1628
+ code, result, failing = EXIT_CONFIG, "timeout", "head moved during the watch"
1629
+ elif target_kind == "run" and code == EXIT_OK:
1630
+ # The same rule for one run: pass only when the run reads COMPLETED and SUCCESS; HEAD is its commit. A whole
1631
+ # run that is SKIPPED or NEUTRAL ran no job (gh run watch --exit-status exits 0 on it): could not tell.
1632
+ run_view = None
1633
+ left = remaining()
1634
+ if left > 0:
1635
+ view = _ci_gh_run(
1636
+ run_gh, [gh_path, "run", "view", str(target_id), "--json", "status,conclusion,headSha", *repo_args],
1637
+ repo, min(_CI_GH_SHORT_TIMEOUT_S, left),
1638
+ )
1639
+ if view is not None and view.returncode == 0:
1640
+ try:
1641
+ run_view = json.loads(view.stdout or "")
1642
+ except ValueError:
1643
+ run_view = None
1644
+ if not isinstance(run_view, dict) or not run_view.get("headSha"):
1645
+ print("[caddis-lanes] ci-watch: gh said pass, but the run could not be read", file=sys.stderr)
1646
+ code, result = EXIT_CONFIG, "timeout"
1647
+ else:
1648
+ head = str(run_view["headSha"])
1649
+ status = str(run_view.get("status") or "").upper()
1650
+ conclusion = str(run_view.get("conclusion") or "").upper()
1651
+ if status != "COMPLETED":
1652
+ print(f"[caddis-lanes] ci-watch: gh said pass, but run {target_id} is {status.lower() or 'unknown'}",
1653
+ file=sys.stderr)
1654
+ code, result = EXIT_CONFIG, "timeout"
1655
+ elif conclusion in {"SKIPPED", "NEUTRAL"}:
1656
+ print(f"[caddis-lanes] ci-watch: run {target_id} is {conclusion.lower()}: no job ran, so this is "
1657
+ "not a pass", file=sys.stderr)
1658
+ code, result, failing = EXIT_CONFIG, "timeout", f"run {target_id} ({conclusion.lower()})"
1659
+ elif conclusion != "SUCCESS":
1660
+ code, result, failing = EXIT_FAIL, "fail", f"run {target_id} ({conclusion.lower() or 'unknown'})"
1661
+
1662
+ summary: str | None = None
1663
+ # Walk the chain in order: agy summarises only when it comes before claude (claude = no helper).
1664
+ helper = next((w for w in watchers if w in {"agy", "claude"}), None)
1665
+ if code == EXIT_FAIL and helper == "agy":
1666
+ agy_path = which("agy")
1667
+ if agy_path is not None:
1668
+ summary = _ci_summary_from_log(
1669
+ gh_path, run_id, repo_args, repo, run_gh, remaining, agy_path, run_agy,
1670
+ )
1671
+ _print_ci_watch(result, checks, merge_state, failing, "gh+agy" if summary else "gh", summary, head)
1389
1672
  return code
1390
1673
 
1391
1674
 
@@ -1744,6 +2027,7 @@ def _run_command(
1744
2027
  args.timeout,
1745
2028
  child_env(dict(os.environ), lane, model),
1746
2029
  idle_timeout_s=idle_timeout_s,
2030
+ spend_caller=f"lane:{lane}:{plan.stem}:p{args.phase}",
1747
2031
  )
1748
2032
  killed = guard_killed_since(lane_run.started, {lane_run.pid})
1749
2033
  result = classify_exit(