@caddis/cli 0.4.34 → 0.4.36

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 (170) hide show
  1. package/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
  2. package/bundles/antigravity-plugin/plugin.json +1 -1
  3. package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
  4. package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
  5. package/bundles/antigravity-plugin/scripts/caddis_gate.py +210 -6
  6. package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
  7. package/bundles/antigravity-plugin/scripts/caddis_lanes.py +262 -1
  8. package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
  9. package/bundles/antigravity-plugin/scripts/caddis_routing.py +103 -0
  10. package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
  11. package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
  12. package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
  13. package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
  14. package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
  15. package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
  16. package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
  17. package/bundles/antigravity-plugin/session_end_agy.py +16 -1
  18. package/bundles/antigravity-plugin/skills/_registry.md +35 -32
  19. package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
  20. package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
  21. package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
  22. package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
  23. package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
  24. package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
  25. package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +185 -0
  26. package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
  27. package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
  28. package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
  29. package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
  30. package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
  31. package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
  32. package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
  33. package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
  34. package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
  35. package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
  36. package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
  37. package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
  38. package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
  39. package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +20 -0
  40. package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
  41. package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
  42. package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
  43. package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -2
  44. package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
  45. package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
  46. package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
  47. package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
  48. package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
  49. package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +33 -2
  50. package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
  51. package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
  52. package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
  53. package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
  54. package/bundles/antigravity-plugin/skills/ship/SKILL.md +27 -7
  55. package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +36 -3
  56. package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
  57. package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
  58. package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
  59. package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
  60. package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
  61. package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
  62. package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
  63. package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
  64. package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
  65. package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
  66. package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
  67. package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
  68. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  69. package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
  70. package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
  71. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
  72. package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
  73. package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
  74. package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
  75. package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
  76. package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
  77. package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
  78. package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
  79. package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
  80. package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
  81. package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
  82. package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
  83. package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
  84. package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
  85. package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
  86. package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
  87. package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
  88. package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
  89. package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
  90. package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
  91. package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
  92. package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
  93. package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
  94. package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
  95. package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
  96. package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
  97. package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
  98. package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
  99. package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
  100. package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
  101. package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
  102. package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
  103. package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
  104. package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
  105. package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
  106. package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
  107. package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
  108. package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
  109. package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
  110. package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
  111. package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
  112. package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
  113. package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
  114. package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
  115. package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
  116. package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
  117. package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
  118. package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
  119. package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
  120. package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
  121. package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
  122. package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
  123. package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
  124. package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
  125. package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
  126. package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
  127. package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
  128. package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
  129. package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
  130. package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
  131. package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
  132. package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
  133. package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
  134. package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
  135. package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
  136. package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
  137. package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
  138. package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
  139. package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
  140. package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
  141. package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
  142. package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
  143. package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
  144. package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
  145. package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
  146. package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
  147. package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
  148. package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
  149. package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
  150. package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
  151. package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
  152. package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
  153. package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
  154. package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
  155. package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
  156. package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
  157. package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
  158. package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
  159. package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
  160. package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
  161. package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
  162. package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
  163. package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
  164. package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
  165. package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
  166. package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
  167. package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
  168. package/bundles/codex/AGENTS.md +5 -1
  169. package/bundles/manifest.json +3 -3
  170. package/package.json +1 -1
@@ -28,6 +28,8 @@ except ModuleNotFoundError as exc:
28
28
  sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "claude-harness" / "scripts"))
29
29
  import caddis_exit
30
30
 
31
+ import caddis_routing
32
+
31
33
  if hasattr(sys.stdout, "reconfigure"):
32
34
  sys.stdout.reconfigure(encoding="utf-8", errors="replace")
33
35
  sys.stderr.reconfigure(encoding="utf-8", errors="replace")
@@ -69,7 +71,17 @@ DEFAULT_CHAINS = {
69
71
  "docs": ("agy", "glm", "claude"),
70
72
  }
71
73
  BUDGET_PATTERNS: dict[str, tuple[str, ...]] = {
72
- "claude": (),
74
+ # Claude Code 2.1.283 CLI bundle (limit-message builder and its prefix list).
75
+ "claude": (
76
+ r"You[’']ve hit your (?:session|weekly|Opus|Sonnet|Fable|usage credit) limit\b",
77
+ r"You[’']ve hit your (?:channel's )?monthly spend limit\b",
78
+ r"You[’']ve hit your team's shared budget\b",
79
+ r"You[’']ve reached your Fable limit\b",
80
+ r"You[’']re out of (?:usage credits|extra usage)\b",
81
+ r"Your org is out of usage\b",
82
+ r"Your usage allocation has been disabled by your admin\b",
83
+ r"Your group's usage limit is set to \$0\b",
84
+ ),
73
85
  # https://docs.z.ai/api-reference/api-code
74
86
  "glm": (
75
87
  r"API Error: 429.*\"type\"\s*:\s*\"(?:1113|1308|1309|1310|1316|1317)\"",
@@ -943,6 +955,216 @@ def classify_exit(
943
955
  return RESULT_LANE_FAILED
944
956
 
945
957
 
958
+ CLAUDE_FAMILY = frozenset({"opus", "sonnet", "claude"})
959
+
960
+
961
+ def next_lane(
962
+ role: str,
963
+ after: str,
964
+ *,
965
+ home: Path,
966
+ repo: Path,
967
+ which=shutil.which,
968
+ ) -> str | None:
969
+ """Find the next available model, sharing one budget across Claude models."""
970
+ routing = caddis_routing.resolve(home, repo, which=which)
971
+ chain = routing[role]
972
+ if role != "coding" and after not in chain and after not in CLAUDE_FAMILY:
973
+ # `after` came from the coding fallback (a Claude model ran out first): carry on down
974
+ # the coding chain, and never hand the exhausted Claude budget back.
975
+ coding = routing["coding"]
976
+ if after not in coding:
977
+ return None
978
+ rest = [model for model in coding[coding.index(after) + 1:] if model not in CLAUDE_FAMILY]
979
+ return rest[0] if rest else None
980
+ candidates = chain[chain.index(after) + 1:] if after in chain else chain
981
+ if after in CLAUDE_FAMILY:
982
+ candidates = [model for model in candidates if model not in CLAUDE_FAMILY]
983
+ if not candidates and role != "coding":
984
+ # Fall to coding models this role has not already tried. The coding chain ran before
985
+ # its own Claude fallback, and a role that lists agy or codex ran them before Claude;
986
+ # restarting either would loop.
987
+ candidates = [
988
+ model for model in routing["coding"]
989
+ if model not in CLAUDE_FAMILY and model not in chain
990
+ ]
991
+ return candidates[0] if candidates else None
992
+
993
+
994
+ def _ci_watch_block(output: str) -> dict[str, str] | None:
995
+ """Read the last complete watch report, ignoring an echoed prompt template."""
996
+ fields: dict[str, str] = {}
997
+ reports: list[dict[str, str]] = []
998
+ for line in output.splitlines():
999
+ match = re.match(
1000
+ r"^\s*(?:[`*]+\s*)?(RESULT|CHECKS|MERGE_STATE|FAILING)[`*]*\s*:\s*[`*]*\s*(.*?)\s*[`*]*\s*$",
1001
+ line, re.I,
1002
+ )
1003
+ if not match:
1004
+ continue
1005
+ key, value = match.group(1).upper(), match.group(2).strip().strip("`* ")
1006
+ if key == "RESULT":
1007
+ if fields:
1008
+ reports.append(fields)
1009
+ fields = {}
1010
+ if fields or key == "RESULT":
1011
+ fields[key] = value
1012
+ if fields:
1013
+ reports.append(fields)
1014
+ if not reports:
1015
+ return None
1016
+ last = reports[-1]
1017
+ if set(last) != {"RESULT", "CHECKS", "MERGE_STATE", "FAILING"}:
1018
+ return None
1019
+ if last["RESULT"].lower() not in {"pass", "fail", "timeout"}:
1020
+ return None
1021
+ return last
1022
+
1023
+
1024
+ def _print_ci_watch(result: str, checks: str, merge_state: str, failing: str, watcher: str) -> None:
1025
+ print(f"RESULT: {result}")
1026
+ print(f"CHECKS: {checks}")
1027
+ print(f"MERGE_STATE: {merge_state}")
1028
+ print(f"FAILING: {failing}")
1029
+ print(f"WATCHER: {watcher}")
1030
+
1031
+
1032
+ def ci_watch(
1033
+ target_kind: str,
1034
+ target_id: int,
1035
+ repo_slug: str | None,
1036
+ timeout_s: int,
1037
+ *,
1038
+ home: Path,
1039
+ repo: Path,
1040
+ which=shutil.which,
1041
+ run_agy=run_lane,
1042
+ run_gh=subprocess.run,
1043
+ clock=time.monotonic,
1044
+ ) -> int:
1045
+ """Wait for CI with the resolved watcher, using one shared time budget."""
1046
+ if target_kind not in {"pr", "run"} or target_id <= 0 or timeout_s <= 0:
1047
+ print("[caddis-lanes] ci-watch: target and timeout must be positive", file=sys.stderr)
1048
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1049
+ return EXIT_CONFIG
1050
+ if repo_slug is not None and not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo_slug):
1051
+ print("[caddis-lanes] ci-watch: --repo must be OWNER/NAME", file=sys.stderr)
1052
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1053
+ return EXIT_CONFIG
1054
+
1055
+ started = clock()
1056
+ try:
1057
+ watchers = caddis_routing.resolve(home, repo, which=which)["ci-watch"]
1058
+ except caddis_routing.RoutingConfigError as exc:
1059
+ print(f"[caddis-lanes] {exc}", file=sys.stderr)
1060
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1061
+ return EXIT_CONFIG
1062
+
1063
+ repo_opt = f" --repo {repo_slug}" if repo_slug else ""
1064
+ if target_kind == "pr":
1065
+ poll = f"gh pr checks {target_id}{repo_opt}"
1066
+ state = f"gh pr view {target_id} --json mergeStateStatus,headRefOid{repo_opt}"
1067
+ else:
1068
+ poll = f"gh run view {target_id}{repo_opt}"
1069
+ state = "n/a"
1070
+ prompt = (
1071
+ "Read-only CI watch. Poll with " + poll + " until every check has finished or the time runs out. "
1072
+ + (f"For the PR, also poll {state}. " if target_kind == "pr" else "")
1073
+ + "Never merge, push, comment, re-run, approve, close or edit anything. "
1074
+ + "Then print exactly this block as the last thing you output, with actual values:\n"
1075
+ "RESULT: pass|fail|timeout\n"
1076
+ "CHECKS: <n passed>/<n total>\n"
1077
+ "MERGE_STATE: <mergeStateStatus or n/a>\n"
1078
+ "FAILING: <comma-separated failing check names, or none>"
1079
+ )
1080
+
1081
+ for watcher in watchers:
1082
+ if watcher == "claude":
1083
+ break
1084
+ if watcher != "agy":
1085
+ print(f"[caddis-lanes] ci-watch: unsupported watcher {watcher}; skipping", file=sys.stderr)
1086
+ continue
1087
+ agy_path = which("agy")
1088
+ if agy_path is None:
1089
+ break
1090
+ remaining = timeout_s - (clock() - started)
1091
+ if remaining <= 0:
1092
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1093
+ return EXIT_CONFIG
1094
+ try:
1095
+ model = resolve_models(load_lanes_config(repo))["agy"]
1096
+ effort = DEFAULT_EFFORT["agy"]
1097
+ except LaneConfigError as exc:
1098
+ print(f"[caddis-lanes] {exc}", file=sys.stderr)
1099
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1100
+ return EXIT_CONFIG
1101
+ agy_argv = [
1102
+ agy_path, "--model", f"{model}-{effort}",
1103
+ "--dangerously-skip-permissions", "--output-format", "text", "--print",
1104
+ ]
1105
+ try:
1106
+ run = run_agy(agy_argv, repo, prompt, False, remaining, child_env(os.environ, "agy", model))
1107
+ except OSError as exc:
1108
+ print(f"[caddis-lanes] ci-watch: agy could not start ({exc}); watching with gh", file=sys.stderr)
1109
+ break
1110
+ outcome = classify_exit("agy", run.returncode, run.output, run.timed_out)
1111
+ if run.timed_out:
1112
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "agy")
1113
+ return EXIT_CONFIG
1114
+ if outcome in {RESULT_OUT_OF_BUDGET, RESULT_CONFIG_ERROR}:
1115
+ reason = "out of budget" if outcome == RESULT_OUT_OF_BUDGET else "configuration error"
1116
+ print(f"[caddis-lanes] ci-watch: agy {reason}; watching with gh", file=sys.stderr)
1117
+ break
1118
+ report = _ci_watch_block(run.output)
1119
+ if report is not None:
1120
+ result = report["RESULT"].lower()
1121
+ _print_ci_watch(result, report["CHECKS"], report["MERGE_STATE"], report["FAILING"], "agy")
1122
+ return {"pass": EXIT_OK, "fail": EXIT_FAIL, "timeout": EXIT_CONFIG}[result]
1123
+ break
1124
+
1125
+ remaining = timeout_s - (clock() - started)
1126
+ if remaining <= 0:
1127
+ print("[caddis-lanes] ci-watch: time budget expired before gh", file=sys.stderr)
1128
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1129
+ return EXIT_CONFIG
1130
+ gh_path = which("gh")
1131
+ if gh_path is None:
1132
+ print("[caddis-lanes] ci-watch: gh not found on PATH", file=sys.stderr)
1133
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1134
+ return EXIT_CONFIG
1135
+ command = (
1136
+ [gh_path, "pr", "checks", str(target_id), "--watch"]
1137
+ if target_kind == "pr" else [gh_path, "run", "watch", str(target_id), "--exit-status"]
1138
+ )
1139
+ if repo_slug:
1140
+ command.extend(["--repo", repo_slug])
1141
+ try:
1142
+ completed = run_gh(
1143
+ command, cwd=repo, capture_output=True, text=True,
1144
+ encoding="utf-8", errors="replace", timeout=remaining,
1145
+ )
1146
+ except subprocess.TimeoutExpired:
1147
+ print("[caddis-lanes] ci-watch: gh timed out", file=sys.stderr)
1148
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1149
+ return EXIT_CONFIG
1150
+ except OSError as exc:
1151
+ print(f"[caddis-lanes] ci-watch: gh could not start ({exc})", file=sys.stderr)
1152
+ _print_ci_watch("timeout", "n/a", "n/a", "none", "gh")
1153
+ return EXIT_CONFIG
1154
+ output = "\n".join(part for part in (completed.stdout, completed.stderr) if part)
1155
+ if output:
1156
+ print("\n".join(output.splitlines()[-40:]), file=sys.stderr)
1157
+ no_checks = "no checks reported" in output.lower()
1158
+ code = EXIT_OK if completed.returncode == 0 else (
1159
+ EXIT_FAIL if completed.returncode == 1 and not no_checks else EXIT_CONFIG
1160
+ )
1161
+ _print_ci_watch(
1162
+ "pass" if code == EXIT_OK else "fail" if code == EXIT_FAIL else "timeout",
1163
+ "n/a", "n/a", "see gh output" if code == EXIT_FAIL else "none", "gh",
1164
+ )
1165
+ return code
1166
+
1167
+
946
1168
  def load_wording_findings(text: str) -> list[str]:
947
1169
  findings = []
948
1170
  seen = set()
@@ -1595,6 +1817,17 @@ def main(argv: list[str] | None = None) -> int:
1595
1817
  show_chain.add_argument("--lane")
1596
1818
  show_chain.add_argument("--repo", type=Path)
1597
1819
 
1820
+ next_model = subparsers.add_parser("next-lane")
1821
+ next_model.add_argument("--role", required=True, choices=tuple(caddis_routing.DEFAULT_ROUTING))
1822
+ next_model.add_argument("--after", required=True)
1823
+
1824
+ watch = subparsers.add_parser("ci-watch")
1825
+ watch_target = watch.add_mutually_exclusive_group(required=True)
1826
+ watch_target.add_argument("--pr", type=int)
1827
+ watch_target.add_argument("--run", type=int)
1828
+ watch.add_argument("--repo")
1829
+ watch.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT_S)
1830
+
1598
1831
  run = subparsers.add_parser("run")
1599
1832
  _add_phase_arguments(run)
1600
1833
  run.add_argument("--type", required=True)
@@ -1618,6 +1851,34 @@ def main(argv: list[str] | None = None) -> int:
1618
1851
  args = parser.parse_args(argv)
1619
1852
 
1620
1853
  try:
1854
+ if args.command == "next-lane":
1855
+ if args.after not in caddis_routing.KNOWN_MODELS:
1856
+ print(f"[caddis-lanes] next-lane: unknown model {args.after!r}", file=sys.stderr)
1857
+ return EXIT_CONFIG
1858
+ try:
1859
+ candidate = next_lane(
1860
+ args.role, args.after, home=Path.home(), repo=Path.cwd(), which=shutil.which
1861
+ )
1862
+ except caddis_routing.RoutingConfigError as exc:
1863
+ print(f"[caddis-lanes] next-lane: {exc}", file=sys.stderr)
1864
+ return EXIT_CONFIG
1865
+ if candidate is None:
1866
+ print(
1867
+ f"[caddis-lanes] next-lane: no lane left for {args.role} after {args.after}",
1868
+ file=sys.stderr,
1869
+ )
1870
+ return EXIT_CONFIG
1871
+ print(candidate)
1872
+ return EXIT_OK
1873
+ if args.command == "ci-watch":
1874
+ return ci_watch(
1875
+ "pr" if args.pr is not None else "run",
1876
+ args.pr if args.pr is not None else args.run,
1877
+ args.repo,
1878
+ args.timeout,
1879
+ home=Path.home(),
1880
+ repo=Path.cwd(),
1881
+ )
1621
1882
  if args.command == "show-chain":
1622
1883
  cfg = load_lanes_config(args.repo if args.repo is not None else Path.cwd())
1623
1884
  chain = resolve_chain(args.type, cfg, first_lane=args.lane)
@@ -0,0 +1,159 @@
1
+ """Transfer one workstream relay through an isolated, leased Git ref."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ try:
12
+ from caddis_workstreams import _name, _validate_relay, read_relay, write_relay
13
+ import secret_filter
14
+ except ModuleNotFoundError as exc:
15
+ if exc.name not in {"caddis_workstreams", "secret_filter"}:
16
+ raise
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / ".github" / "tools"))
19
+ from caddis_workstreams import _name, _validate_relay, read_relay, write_relay
20
+ import secret_filter
21
+
22
+
23
+ MAX_NOTE_CHARS = 4000
24
+
25
+
26
+ def _git(repo: Path, *args: str, input_data: bytes | None = None,
27
+ env: dict[str, str] | None = None) -> bytes:
28
+ result = subprocess.run(["git", *args], cwd=repo, env=env, input=input_data,
29
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
30
+ if result.returncode:
31
+ # Name the subcommand, not a leading "-c key=value" option.
32
+ command = next((arg for i, arg in enumerate(args) if not arg.startswith("-") and (i == 0 or args[i - 1] != "-c")), "?")
33
+ raise RuntimeError(f"git {command} failed: {result.stderr.decode('utf-8', 'replace').strip()}")
34
+ return result.stdout
35
+
36
+
37
+ def _ref(workstream: str) -> str:
38
+ return "refs/caddis/relay/" + _name(workstream)
39
+
40
+
41
+ def _check_note(workstream: str, note: str) -> None:
42
+ if len(note) > MAX_NOTE_CHARS:
43
+ raise ValueError("relay exceeds 4,000 characters")
44
+ _validate_relay(workstream, note)
45
+
46
+
47
+ def _pre_send(note: str) -> None:
48
+ # A synthetic new-file diff makes the existing filter scan every line, not
49
+ # only lines changed since a prior relay. No user-configured allowlist is
50
+ # accepted for this outbound note.
51
+ lines = note.splitlines()
52
+ diff = ("diff --git a/relay.md b/relay.md\nnew file mode 100644\n"
53
+ "--- /dev/null\n+++ b/relay.md\n"
54
+ f"@@ -0,0 +1,{len(lines)} @@\n" +
55
+ "".join("+" + line + "\n" for line in lines))
56
+ result = secret_filter.filter_diff(diff, list(secret_filter.DEFAULT_DENY_GLOBS), [])
57
+ if result.hits or result.dropped_paths or not result.kept_diff:
58
+ raise ValueError("relay refused by pre-send filter")
59
+
60
+
61
+ def push_relay(repo: Path, workstream: str, *, confirmed: bool, interactive: bool) -> int:
62
+ """Return 0 on a leased push, 1 on refusal or conflict.
63
+
64
+ A Git or network failure before the push (for example an unreachable remote in
65
+ ``ls-remote``) raises RuntimeError, so the caller reports it instead of a refusal.
66
+
67
+ The caller must obtain this invocation's interactive decision. This API
68
+ never reads an ambient confirmation variable or prompts by itself.
69
+ """
70
+ if not confirmed or not interactive:
71
+ return 1
72
+ repo = Path(repo)
73
+ ref = _ref(workstream)
74
+ note, digest = read_relay(repo, workstream)
75
+ if digest is None:
76
+ return 1
77
+ _check_note(workstream, note)
78
+ _pre_send(note)
79
+ with tempfile.TemporaryDirectory(prefix="caddis-relay-index-") as temp:
80
+ index = Path(temp) / "index"
81
+ env = os.environ.copy()
82
+ env["GIT_INDEX_FILE"] = str(index)
83
+ _git(repo, "read-tree", "--empty", env=env)
84
+ blob = _git(repo, "hash-object", "-w", "--stdin", input_data=note.encode("utf-8"), env=env).decode().strip()
85
+ _git(repo, "update-index", "--add", "--cacheinfo", "100644", blob, "relay.md", env=env)
86
+ tree = _git(repo, "write-tree", env=env).decode().strip()
87
+ commit = _git(repo, "-c", "user.name=Caddis Relay", "-c", "user.email=relay@localhost",
88
+ "commit-tree", tree, input_data=b"Relay note\n", env=env).decode().strip()
89
+ old = _git(repo, "ls-remote", "origin", ref, env=env).decode().strip()
90
+ expected = old.split("\t", 1)[0] if old else ""
91
+ try:
92
+ _git(repo, "push", f"--force-with-lease={ref}:{expected}", "origin",
93
+ f"{commit}:{ref}", env=env)
94
+ except RuntimeError:
95
+ return 1
96
+ return 0
97
+
98
+
99
+ def fetch_relay(repo: Path, workstream: str, *, replace: bool = False) -> str:
100
+ """Fetch one explicit ref, validate its one-file tree, then store by local CAS.
101
+
102
+ A local relay that differs from the fetched one is never replaced unless the caller
103
+ passes ``replace=True``: it may hold newer, never-pushed work.
104
+ """
105
+ repo = Path(repo)
106
+ ref = _ref(workstream)
107
+ local, expected_hash = read_relay(repo, workstream)
108
+ _git(repo, "fetch", "--no-tags", "origin", ref)
109
+ fetched = _git(repo, "rev-parse", "FETCH_HEAD").decode().strip()
110
+ entries = _git(repo, "ls-tree", "-z", fetched).split(b"\0")
111
+ if len(entries) != 2 or not entries[0].startswith(b"100644 blob ") or not entries[0].endswith(b"\trelay.md"):
112
+ raise ValueError("remote relay tree must contain only an ordinary relay.md")
113
+ size = int(_git(repo, "cat-file", "-s", f"{fetched}:relay.md").decode().strip())
114
+ if size > MAX_NOTE_CHARS * 4: # UTF-8 upper bound; read nothing larger into memory
115
+ raise ValueError("remote relay exceeds 4,000 characters")
116
+ note = _git(repo, "show", f"{fetched}:relay.md").decode("utf-8")
117
+ _check_note(workstream, note)
118
+ if expected_hash is not None and local != note and not replace:
119
+ raise ValueError("local relay differs from the fetched one; pass replace=True to overwrite it")
120
+ result = write_relay(repo, workstream, note, expected_hash)
121
+ if result.status != "written":
122
+ raise RuntimeError("local relay changed during fetch")
123
+ return note
124
+
125
+
126
+ def _headless() -> bool:
127
+ """No human is watching: the caddis launchers and the docket runner set these."""
128
+ return any(os.environ.get(name) for name in ("CADDIS_HEADLESS", "DOCKET_PLAN", "DOCKET_BRANCH"))
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ import argparse
133
+ parser = argparse.ArgumentParser(description="Move one workstream relay between machines")
134
+ common = argparse.ArgumentParser(add_help=False)
135
+ common.add_argument("--repo", type=Path, default=Path("."))
136
+ sub = parser.add_subparsers(dest="command", required=True)
137
+ push_p = sub.add_parser("push", parents=[common], help="publish .caddis/relay/<name>.md to refs/caddis/relay/<name>")
138
+ push_p.add_argument("name")
139
+ push_p.add_argument("--user-said-yes", action="store_true",
140
+ help="pass only after the user answered y to the push question in this session")
141
+ fetch_p = sub.add_parser("fetch", parents=[common], help="fetch refs/caddis/relay/<name> into .caddis/relay/<name>.md")
142
+ fetch_p.add_argument("name")
143
+ fetch_p.add_argument("--replace", action="store_true",
144
+ help="overwrite a different local relay (it may hold never-pushed work)")
145
+ args = parser.parse_args(argv)
146
+ try:
147
+ if args.command == "push":
148
+ code = push_relay(args.repo, args.name, confirmed=args.user_said_yes, interactive=not _headless())
149
+ print("relay pushed" if code == 0 else "relay not pushed (no consent, headless, missing relay, or remote conflict)")
150
+ return code
151
+ print(fetch_relay(args.repo, args.name, replace=args.replace), end="")
152
+ return 0
153
+ except (RuntimeError, ValueError, OSError) as exc:
154
+ print(f"caddis-relay: {exc}", file=sys.stderr)
155
+ return 1
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve the local model order for each caddis role."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Callable, Mapping
13
+
14
+ import oss_model
15
+
16
+
17
+ DEFAULT_ROUTING: dict[str, list[str]] = {
18
+ "critical": ["opus"],
19
+ "light": ["sonnet"],
20
+ "coding": ["agy", "codex"],
21
+ "review": ["glm", "deepseek"],
22
+ "consult": ["agy", "glm", "codex"],
23
+ "ci-watch": ["agy"],
24
+ }
25
+ KNOWN_MODELS = frozenset({"opus", "sonnet", "agy", "codex", "glm", "deepseek", "claude"})
26
+
27
+
28
+ class RoutingConfigError(Exception):
29
+ """A routing file has an invalid value or cannot be parsed."""
30
+
31
+
32
+ def _routing_from(path: Path) -> dict[str, list[str]]:
33
+ if not path.is_file():
34
+ return {}
35
+ try:
36
+ with path.open("rb") as file:
37
+ config = tomllib.load(file)
38
+ except (OSError, tomllib.TOMLDecodeError) as exc:
39
+ raise RoutingConfigError(f"{path}: {exc}") from exc
40
+
41
+ routing = config.get("routing", {})
42
+ if not isinstance(routing, dict):
43
+ raise RoutingConfigError(f"{path}: [routing] must be a table")
44
+ for role, models in routing.items():
45
+ if role not in DEFAULT_ROUTING:
46
+ raise RoutingConfigError(f"{path}: unknown routing role {role!r}")
47
+ if not isinstance(models, list):
48
+ raise RoutingConfigError(f"{path}: routing.{role} must be a list")
49
+ for model in models:
50
+ if not isinstance(model, str) or model not in KNOWN_MODELS:
51
+ raise RoutingConfigError(f"{path}: unknown model {model!r} in routing.{role}")
52
+ return routing
53
+
54
+
55
+ def resolve(
56
+ home: Path,
57
+ repo: Path,
58
+ which: Callable[[str], str | None] = shutil.which,
59
+ env: Mapping[str, str] | None = None,
60
+ ) -> dict[str, list[str]]:
61
+ """Apply user and repo overrides, then keep only locally available models."""
62
+ env = os.environ if env is None else env
63
+ configured = dict(DEFAULT_ROUTING)
64
+ configured.update(_routing_from(Path(home) / ".caddis" / "config.toml"))
65
+ configured.update(_routing_from(Path(repo) / ".caddis" / "config.toml"))
66
+
67
+ providers = set(oss_model.configured_providers(dict(env)))
68
+ available = {"opus", "sonnet", "claude"} | (providers & {"glm", "deepseek"})
69
+ for tool in ("agy", "codex"):
70
+ if which(tool):
71
+ available.add(tool)
72
+
73
+ resolved = {}
74
+ for role, models in configured.items():
75
+ resolved[role] = list(dict.fromkeys(
76
+ model for model in models if model != "claude" and model in available
77
+ )) + ["claude"]
78
+ return resolved
79
+
80
+
81
+ def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int:
82
+ parser = argparse.ArgumentParser(description=__doc__)
83
+ commands = parser.add_subparsers(dest="command", required=True)
84
+ show = commands.add_parser("show", help="print the resolved routing table")
85
+ show.add_argument("--json", action="store_true", help="print JSON")
86
+ show.add_argument("--repo", type=Path, default=Path.cwd(), help="repository path")
87
+ args = parser.parse_args(argv)
88
+
89
+ try:
90
+ routing = resolve(Path.home(), args.repo, env=env)
91
+ except RoutingConfigError as exc:
92
+ print(exc, file=sys.stderr)
93
+ return 3
94
+ if args.json:
95
+ print(json.dumps(routing))
96
+ else:
97
+ for role, models in routing.items():
98
+ print(f"{role}: {', '.join(models)}")
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())