@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.
- package/bundles/antigravity-plugin/agents/code-reviewer.md +4 -0
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_file_lock.py +121 -0
- package/bundles/antigravity-plugin/scripts/caddis_frontmatter.py +12 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +210 -6
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +9 -9
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +262 -1
- package/bundles/antigravity-plugin/scripts/caddis_relay_git.py +159 -0
- package/bundles/antigravity-plugin/scripts/caddis_routing.py +103 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +579 -40
- package/bundles/antigravity-plugin/scripts/caddis_todo.py +462 -0
- package/bundles/antigravity-plugin/scripts/caddis_workstreams.py +631 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +27 -13
- package/bundles/antigravity-plugin/scripts/oss_review.py +30 -3
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +5 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +2 -2
- package/bundles/antigravity-plugin/session_end_agy.py +16 -1
- package/bundles/antigravity-plugin/skills/_registry.md +35 -32
- package/bundles/antigravity-plugin/skills/adopt-app/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/advisory-hub/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/api-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/backend-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/catchup/SKILL.md +31 -33
- package/bundles/antigravity-plugin/skills/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cloud-phases/SKILL.md +185 -0
- package/bundles/antigravity-plugin/skills/code-documentation/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/codebase-audit/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/confluence-hla/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/cross-review/SKILL.md +9 -1
- package/bundles/antigravity-plugin/skills/css-architecture/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/database-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/db-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/decide/SKILL.md +61 -0
- package/bundles/antigravity-plugin/skills/digress/SKILL.md +41 -23
- package/bundles/antigravity-plugin/skills/error-handling/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/explainer-doc/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +20 -0
- package/bundles/antigravity-plugin/skills/frontend-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/gate-preflight/SKILL.md +12 -0
- package/bundles/antigravity-plugin/skills/handoff/SKILL.md +74 -13
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +31 -2
- package/bundles/antigravity-plugin/skills/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/mmr/SKILL.md +8 -0
- package/bundles/antigravity-plugin/skills/particle-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/playwright/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/port-to-github/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/pr-merge/SKILL.md +33 -2
- package/bundles/antigravity-plugin/skills/python/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/rca/SKILL.md +97 -0
- package/bundles/antigravity-plugin/skills/react-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +27 -7
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +36 -3
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +6 -1
- package/bundles/antigravity-plugin/skills/skill-creator/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/sql/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/tech-explainer/SKILL.md +7 -12
- package/bundles/antigravity-plugin/skills/technical-writing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/test-strategy/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ui-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/using-git-worktrees/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/webapp-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/windows-deployment/SKILL.md +1 -1
- package/bundles/antigravity-plugin/warm_start_agy.py +138 -42
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/_registry.md +61 -61
- package/bundles/antigravity-plugin-extras/skills/agent-md-refactor/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/algorithmic-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/api-client-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-diagram/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-document/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/artifacts-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ascii-art/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-agentic-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cdk-development/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-cost-operations/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/aws-serverless-eda/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/backend-to-frontend-handoff/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/banner-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-guidelines/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/brand-voice/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/building-ai-agent-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/building-mcp-server-on-cloudflare/SKILL.md +3 -7
- package/bundles/antigravity-plugin-extras/skills/canvas-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/changelog-generator/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/code-explainer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/component-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/cross-review/SKILL.md +10 -2
- package/bundles/antigravity-plugin-extras/skills/data-contract-pipeline/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-loader/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/data-validation/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/deploy-local/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-md/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/design-system-tokens/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/developer-growth-analysis/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/doc-coauthoring/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/documentation-analyzer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/enterprise-dashboard-aesthetic-system/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/file-organizer/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/game-changing-features/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-nuggets/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/golden-plan/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/hld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/internal-comms/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/jira-issues/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/lld/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/llm-application-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/mcp-builder/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/monorepo/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/nextjs-app-router/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/observability/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/performance-testing/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/popular-web-designs/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/prd-to-code/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/react-useeffect/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/receiving-code-review/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/responsive-mobile-native/SKILL.md +4 -9
- package/bundles/antigravity-plugin-extras/skills/safe-cleanup/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/schema-migration/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/setup-project-ai/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/shadcn-radix/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/sketch/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/streamlit-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/theme-factory/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-styling-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/ui-ux-intelligence/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/understand-anything/SKILL.md +5 -9
- package/bundles/antigravity-plugin-extras/skills/update-readme/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/word-cloud/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/api-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/backend-development/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/code-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/codebase-audit/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/dashboard-perf-sweep/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/error-handling/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/python/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/coding/sql/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/database-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/data/db-diagram/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/adopt-app/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/ci-cd-pipeline/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/port-to-github/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/safe-cleanup/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/using-git-worktrees/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/devops/windows-deployment/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/code-documentation/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/confluence-hla/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/explainer-doc/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/docs/tech-explainer/SKILL.md +7 -12
- package/bundles/codex/.codex/skills/docs/technical-writing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/css-architecture/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/frontend-design/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/react-dev/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/ui-review/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/frontend/warm-editorial-ui/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/mermaid-diagrams/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/media/particle-art/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/playwright/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/test-strategy/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/testing/webapp-testing/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/advisory-hub/SKILL.md +1 -1
- package/bundles/codex/.codex/skills/workflow/skill-creator/SKILL.md +1 -1
- package/bundles/codex/AGENTS.md +5 -1
- package/bundles/manifest.json +3 -3
- 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
|
-
|
|
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())
|