Skills › Compound Engineering › ce-doc-review › cross-model-doc-review.sh
cross-model-doc-review.sh
1284 lines · skills/ce-doc-review/scripts/cross-model-doc-review.sh at 3449230
1#!/usr/bin/env bash
2# cross-model-doc-review.sh
3#
4# Runs ONE ce-doc-review judgment persona through ONE or more DIFFERENT model
5# PROVIDERS than the host (the "peer(s)") in separate, read-only, least-privilege
6# processes, and writes each peer's findings as JSON into the run dir. Each peer
7# gets the same canonical persona brief the in-process reviewer uses
8# (references/personas/<persona-file>.md) so it is genuinely "that persona, on a
9# different model." One invocation per persona is required because each lens
10# carries its own persona brief and produces its own <lens>-<provider>.json
11# return that folds in and fingerprints against its in-process twin.
12#
13# Independence is by PROVIDER, not CLI brand. A provider is reached by a ROUTE:
14# its dedicated CLI, or (for fixed grok-cursor / composer routes) cursor-agent.
15# Every route runs through acpx (the Agent Client Protocol client). All
16# activated lenses run on ONE model per provider at high reasoning, except codex
17# and native grok on extra-high; Cursor's grok preset is high and composer's
18# fast tier is its ceiling (accepted exceptions).
19#
20# Usage:
21# cross-model-doc-review.sh <host-serving-family> <candidates> <reviewer-name> \
22# <document-path> <document-type> <origin> <run-dir>
23#
24# <host-serving-family>
25# the peer-key of the host's OWN serving family, attested by
26# the calling skill (it knows its harness). A peer-key, never
27# a provider name: openai->codex, anthropic->claude,
28# xai->grok, cursor/composer->composer, google->gemini.
29# Excluded from selection when attested. `unknown` is allowed,
30# but any returned review remains non-independent and cannot
31# promote agreement.
32# <candidates> comma-separated ordered provider keys to consider, e.g.
33# "codex,claude,grok,antigravity". The skill front-loads any
34# resolved preference (conversation > CE config cascade >
35# project-instructions-in-context); the script excludes the
36# host, applies the CROSS_MODEL_PEERS allowlist, and walks this
37# order picking the first available provider(s) up to
38# CROSS_MODEL_MAX_PEERS.
39# <reviewer-name> one of the three trio lenses: security-lens | adversarial |
40# product-lens. The SHORT name the in-process persona emits; it
41# forces the fold-in reviewer field to <reviewer-name>-<provider>
42# so cross-persona agreement in synthesis matches the in-process
43# twin. The persona-brief filename is DERIVED from it (not a
44# caller argument) so a caller cannot point the brief read at an
45# arbitrary path.
46# <document-path> the document under review (embedded into the peer prompt)
47# <document-type> requirements | plan | unified-requirements | unified-plan
48# <origin> the Origin context slot (a path, product_contract_source:<v>,
49# or the literal token none)
50# <run-dir> an existing dir; output -> <run-dir>/<reviewer-name>-<provider>.json
51#
52# Test/introspection mode (no model call, no side effects):
53# cross-model-doc-review.sh --emit-adapter <route>
54# prints the exact argv the given route would run (route in:
55# codex | claude | grok-cli | grok-cursor | cursor | composer | opencode |
56# antigravity).
57# Both this mode and the live run build their argv from adapter_argv(), so
58# the route-safety test asserts on the same command string the peer runs.
59#
60# Self-locates its sibling reference files via BASH_SOURCE (NOT the CWD, which is
61# the user's project on every host). The agent passes the values above.
62#
63# NON-BLOCKING BY DESIGN: every failure logs to stderr and exits 0 without an
64# output file. The cross-model pass is additive and must never fail the review;
65# the caller detects success purely by the presence of the output file(s).
66#
67# DATA-EGRESS NOTE: this embeds the full document content into an external model
68# CLI prompt, so document content is transmitted to each peer provider. The log
69# lines below record every send so the egress is auditable even in headless mode.
71set -uo pipefail
73# Survive SIGHUP when the orchestrator backgrounds this script and the parent
74# shell exits (common on Cursor/Codex Bash tools). Without this, a detached
75# peer process group can keep running while this script dies
76# before normalize — leaving fold-in files with a bare `reviewer` field.
77trap '' HUP
79# Filled while a peer process group is live; TERM/INT handler (installed after
80# reap() is defined) reaps it so an orchestrator kill cannot leave orphans.
81ACTIVE_PEER_PID=""
82PEER_ROUTE=""
83SESSION_STALLED=false
84PY_BIN=""
86log() { printf '[cross-model-doc] %s\n' "$*" >&2; }
87skip() { log "$*"; exit 0; } # non-blocking: announce reason, exit clean, no output
89TRANSIENT_RETRY_DELAY_SECS="${CROSS_MODEL_TRANSIENT_RETRY_DELAY_SECS:-5}"
90case "$TRANSIENT_RETRY_DELAY_SECS" in ''|*[!0-9]*) skip "transient retry delay must be an integer from 0 to 60; skipping" ;; esac
91[ "$TRANSIENT_RETRY_DELAY_SECS" -le 60 ] || skip "transient retry delay must be an integer from 0 to 60; skipping"
93# --- model + reasoning per provider ----------------------------------------
94# ONE model per provider at high reasoning, except codex and native grok on
95# extra-high. Concrete IDs are the CURRENT instance of the tier principle and
96# the single maintenance point when model families change.
97# A checkout may override the model (CROSS_MODEL_MODEL_OVERRIDE_TARGET +
98# CROSS_MODEL_MODEL_OVERRIDE, same target/family only) and the reasoning effort
99# (CROSS_MODEL_EFFORT_OVERRIDE, validated per route); both fail closed.
100# codex: luna/xhigh is the benchmarked pick on API dollars (~0.30x sol-medium, tied
101# detection, slower tail) -- docs/solutions/skill-design/benchmark-review-peer-model-and-reasoning-tier.md
102M_CODEX="gpt-6-luna" # codex (reasoning_effort=xhigh)
103M_CLAUDE="claude-opus-5-5" # claude (effort=high)
104M_GROK="grok-4.7" # grok CLI (reasoning_effort=xhigh)
105# Cursor's ACP server offers one preset per model and rejects effort variants,
106# so grok-cursor runs at high/fast and composer at its fast tier.
107M_GROK_CURSOR="grok-4.7[context=256k,reasoning_effort=high,fast=true]"
108M_COMPOSER="composer-2.5[fast=true]"
109# Antigravity offers only the models the account is entitled to. Argon is
110# preferred; an account without it falls back once to Flash before any prompt.
111M_ANTIGRAVITY="gemini-4-argon" # antigravity (thinking_level=high)
112M_ANTIGRAVITY_FALLBACK="gemini-3.8-flash"
113ANTIGRAVITY_MODEL="" # set by the fallback; empty requests M_ANTIGRAVITY
115route_effort() { # <route> -> requested effort: the override where the route takes one, else editorial
116 if [ -n "${CROSS_MODEL_EFFORT_OVERRIDE:-}" ]; then
117 case "$1" in
118 codex|claude|grok-cli|antigravity|opencode) printf '%s' "$CROSS_MODEL_EFFORT_OVERRIDE"; return 0 ;;
119 esac
120 fi
121 case "$1" in
122 codex|grok-cli) printf 'xhigh' ;;
123 claude|antigravity) printf 'high' ;;
124 grok-cursor) printf 'model-implied-high' ;;
125 composer) printf 'fast' ;;
126 cursor|opencode) printf 'unverified' ;;
127 esac
128}
130route_model() { # <route> -> the M_* constant that route requests
131 local target
132 target="$(route_target "$1")"
133 if [ -n "${CROSS_MODEL_MODEL_OVERRIDE:-}" ] &&
134 [ "${CROSS_MODEL_MODEL_OVERRIDE_TARGET:-}" = "$target" ] &&
135 [ "$target" != "cursor" ]; then
136 printf '%s' "$CROSS_MODEL_MODEL_OVERRIDE"
137 return 0
138 fi
139 case "$1" in
140 codex) printf '%s' "$M_CODEX" ;;
141 claude) printf '%s' "$M_CLAUDE" ;;
142 grok-cli) printf '%s' "$M_GROK" ;;
143 grok-cursor) printf '%s' "$M_GROK_CURSOR" ;;
144 cursor) printf 'auto' ;;
145 composer) printf '%s' "$M_COMPOSER" ;;
146 opencode) printf 'auto' ;;
147 antigravity) printf '%s' "${ANTIGRAVITY_MODEL:-$M_ANTIGRAVITY}" ;;
148 esac
149}
151route_target() {
152 case "$1" in
153 codex|claude|cursor|composer|antigravity) printf '%s' "$1" ;;
154 grok-cli|grok-cursor) printf 'grok' ;;
155 opencode) printf 'opencode' ;;
156 esac
157}
159route_harness() {
160 case "$1" in
161 codex) printf 'codex' ;;
162 claude) printf 'claude' ;;
163 grok-cli) printf 'grok' ;;
164 grok-cursor|cursor|composer) printf 'cursor-agent' ;;
165 opencode) printf 'opencode' ;;
166 antigravity) printf 'antigravity' ;;
167 esac
168}
170target_serving_family() {
171 case "$1" in
172 codex|claude|grok|composer) printf '%s' "$1" ;;
173 antigravity) printf 'gemini' ;;
174 cursor) printf 'unknown' ;;
175 opencode) printf 'unknown' ;;
176 esac
177}
179route_available() {
180 case "$1" in
181 codex) command -v codex >/dev/null 2>&1 ;;
182 claude) command -v claude >/dev/null 2>&1 ;;
183 grok-cli) command -v grok >/dev/null 2>&1 ;;
184 grok-cursor|cursor|composer) command -v cursor-agent >/dev/null 2>&1 ;;
185 opencode) command -v opencode >/dev/null 2>&1 ;;
186 antigravity) command -v agy_acp_server.par >/dev/null 2>&1 ;;
187 *) return 1 ;;
188 esac
189}
191# --- acpx transport (keep byte-identical across migrated peer workers) -----
192# Every route reaches its agent through acpx, which speaks the Agent Client
193# Protocol and prints the JSON-RPC stream, so one parser covers every CLI.
194# The calling worker supplies route_available, route_model, and log.
195ACPX_VERSION="0.21.0"
196# 0.18.0 fixes approve-reads allowing ambiguous destructive permission requests.
197ACPX_MIN_VERSION="0.18.0"
198ACPX_UNAVAILABLE="" # pre-egress reason; ACPX_SCOPE says whether every route shares it
199ACPX_SCOPE=""
200ACPX_NPM_REFRESH=false
202acpx_package_spec() {
203 printf 'acpx@>=%s <=%s' "$ACPX_MIN_VERSION" "$ACPX_VERSION"
204}
206acpx_agent() { # <route> -> acpx built-in agent name
207 case "$1" in
208 codex) printf 'codex' ;;
209 claude) printf 'claude' ;;
210 grok-cli) printf 'grok-build' ;;
211 grok-cursor|cursor|composer) printf 'cursor' ;;
212 opencode) printf 'opencode' ;;
213 antigravity) printf 'antigravity' ;;
214 *) return 1 ;;
215 esac
216}
218# acpx launches Google's Antigravity ACP runtime by name. It is a separate
219# download from the agy CLI and needs its localharness_external helper from the
220# same release, beside it or named by ANTIGRAVITY_HARNESS_PATH.
221acpx_antigravity_helper() {
222 local runtime
223 if [ -n "${ANTIGRAVITY_HARNESS_PATH:-}" ]; then printf '%s' "$ANTIGRAVITY_HARNESS_PATH"; return 0; fi
224 runtime="$(command -v agy_acp_server.par 2>/dev/null)" || return 1
225 printf '%s/localharness_external' "$(dirname "$runtime")"
226}
228# The runtime reads skills and settings from GEMINI_HOME, so every run gets a
229# private one, deleted with the caller's scratch. A file-backed login (no macOS
230# keychain) is read only from there, so it is copied in; refreshes are not
231# written back, so concurrent peers cannot clobber the user's token.
232acpx_antigravity_home() { # <private-dir> -> GEMINI_HOME path
233 local home="$1/gemini-home"
234 local token="${GEMINI_HOME:-${HOME:-}/.gemini}/antigravity-acp/acp_token.json"
235 (umask 077; mkdir -p "$home/antigravity-acp") || return 1
236 printf '{"auth":{"type":"oauth-personal"}}\n' > "$home/antigravity-acp/settings.json" || return 1
237 if [ -f "$token" ]; then
238 (umask 077; cp "$token" "$home/antigravity-acp/acp_token.json") || return 1
239 fi
240 printf '%s' "$home"
241}
243acpx_unavailable() { # <shared|route> <reason>
244 ACPX_SCOPE="$1"; ACPX_UNAVAILABLE="$2"
245 return 1
246}
248# acpx lets ~/.acpx/config.json and <cwd>/.acpxrc.json replace a built-in
249# agent's launch command; a route that would run something else is skipped.
250# opencode launches the installed CLI by path, which no config entry overrides.
251acpx_config_guard() { # <route> <cwd>
252 local agent file
253 [ "$1" = opencode ] && return 0
254 agent="$(acpx_agent "$1")" || return 1
255 for file in "${HOME:-}/.acpx/config.json" "$2/.acpxrc.json"; do
256 [ -e "$file" ] || continue
257 if ! jq -e --arg a "$agent" '(.agents // {}) | keys | map(ascii_downcase) | index($a) == null' "$file" >/dev/null 2>&1; then
258 acpx_unavailable route "$file overrides or cannot be checked for the '$agent' agent launch"
259 return 1
260 fi
261 done
262}
264acpx_preflight() { # <route> <cwd>; nothing is sent to a provider here
265 local v major minor
266 ACPX_UNAVAILABLE=""; ACPX_SCOPE=""
267 command -v node >/dev/null 2>&1 || { acpx_unavailable shared "node not found; acpx needs Node 22.13 or newer"; return 1; }
268 v="$(node -p 'process.versions.node' 2>/dev/null)"
269 major="${v%%.*}"; minor="${v#*.}"; minor="${minor%%.*}"
270 case "$major$minor" in ''|*[!0-9]*) acpx_unavailable shared "cannot read the Node version; acpx needs Node 22.13 or newer"; return 1 ;; esac
271 if [ "$major" -lt 22 ] || { [ "$major" -eq 22 ] && [ "$minor" -lt 13 ]; }; then
272 acpx_unavailable shared "Node $v is too old; acpx needs Node 22.13 or newer"
273 return 1
274 fi
275 command -v npx >/dev/null 2>&1 || { acpx_unavailable shared "npx not found; acpx runs through npx"; return 1; }
276 if [ "$1" = antigravity ]; then
277 case "$(uname -s 2>/dev/null)" in
278 MINGW*|MSYS*|CYGWIN*) acpx_unavailable route "the Antigravity route is not yet supported on native Windows"; return 1 ;;
279 esac
280 route_available "$1" || { acpx_unavailable route "Antigravity's ACP runtime (agy_acp_server.par) is not on PATH; it is a separate download from the agy CLI (run ce-setup for the link)"; return 1; }
281 [ -x "$(acpx_antigravity_helper)" ] || { acpx_unavailable route "Antigravity's localharness_external helper is not beside agy_acp_server.par or at ANTIGRAVITY_HARNESS_PATH; install both from the same release (run ce-setup for the link)"; return 1; }
282 fi
283 route_available "$1" || { acpx_unavailable route "the agent CLI for route '$1' is not installed"; return 1; }
284 if [ "$1" = opencode ]; then
285 case "$(uname -s 2>/dev/null)" in
286 MINGW*|MSYS*|CYGWIN*) acpx_unavailable route "opencode launches through acpx's raw --agent command, which acpx rejects on native Windows"; return 1 ;;
287 esac
288 fi
289 acpx_config_guard "$1" "$2"
290}
292# Claude's ACP adapter always loads project settings, so the launch goes
293# through a wrapper that adds --safe-mode.
294acpx_claude_wrapper() { # <private-dir> -> wrapper path
295 local real wrapper="$1/claude-safe-mode"
296 real="$(command -v claude)" || return 1
297 printf '#!/bin/sh\nexec %s --safe-mode "$@"\n' "$(printf '%q' "$real")" > "$wrapper" && chmod 700 "$wrapper" || return 1
298 printf '%s' "$wrapper"
299}
301acpx_npm_failure_reason() {
302 if grep -qE 'No matching version found for .* with a date before ' "$1" 2>/dev/null; then
303 printf '%s' "npm could not resolve $(acpx_package_spec) or a dependency before its date cutoff; check min-release-age/before policy or wait for the cooldown"
304 else
305 grep -m1 '^npm error' "$1" 2>/dev/null
306 fi
307}
309# ETARGET is ambiguous: refresh metadata once while preserving npm's date policy.
310# npm ignores prefer_online while prefer_offline is set, so refresh disables it.
311acpx_npm_resolution_retryable() { # <stderr-log> <stream-log>
312 [ "$ACPX_NPM_REFRESH" = true ] && return 1
313 grep -qE 'ETARGET|notarget|No matching version found' "$1" "$2" 2>/dev/null
314}
316# The codex and claude adapters are pointed at the installed CLIs so the pin
317# bounds the transport version, not which models the agent can serve.
318acpx_base_argv() { # <route> <cwd> <mcp-config> <timeout-secs> <claude-wrapper> <gemini-home>
319 printf '%s\0' env
320 # An ambient Google API key or cloud project would bill it instead of the
321 # Antigravity subscription. env takes -u only before its first assignment.
322 case "$1" in
323 antigravity) printf '%s\0' -u GEMINI_API_KEY -u GOOGLE_API_KEY -u GOOGLE_APPLICATION_CREDENTIALS \
324 -u GOOGLE_CLOUD_PROJECT -u GOOGLE_CLOUD_LOCATION -u GOOGLE_GENAI_USE_VERTEXAI -u AGY_LLM_GATEWAY_URL ;;
325 esac
326 if [ "$ACPX_NPM_REFRESH" = true ]; then
327 printf '%s\0' npm_config_prefer_offline=false npm_config_fetch_retries=0 npm_config_prefer_online=true
328 else
329 printf '%s\0' npm_config_prefer_offline=true npm_config_fetch_retries=0
330 fi
331 case "$1" in
332 # codex-acp ignores app-server CLI flags.
333 codex) printf '%s\0' "CODEX_PATH=$(command -v codex)" 'CODEX_CONFIG={"features":{"plugins":false}}' ;;
334 claude) printf '%s\0' "CLAUDE_CODE_EXECUTABLE=$5" ;;
335 antigravity) printf '%s\0' "GEMINI_HOME=$6" "ANTIGRAVITY_HARNESS_PATH=$(acpx_antigravity_helper)" ;;
336 esac
337 # Explicit --package avoids npx reusing a global binary that violates npm age policy.
338 # npx also exports npm_config_package, which breaks the nested npx acpx uses
339 # to launch the codex adapter when npm is not beside node (exit 127).
340 printf '%s\0' npx -y --package "$(acpx_package_spec)" -- env -u npm_config_package acpx --cwd "$2" --format json --mcp-config "$3" --timeout "$4"
341}
343acpx_agent_argv() { # <route>
344 # The preflight refuses opencode when it is not installed; the bare name keeps
345 # --emit-adapter output readable on a machine without it.
346 if [ "$1" = opencode ]; then printf '%s\0' --agent "$(command -v opencode || printf opencode) acp" exec
347 else printf '%s\0' "$(acpx_agent "$1")" exec; fi
348}
350# The run's outcome is the result of its own session/prompt request, matched by
351# id because --model adds a session/set_model request before the prompt.
352acpx_outcome() { # <stream-log> -> end_turn | <other stopReason> | error | incomplete | not-sent
353 local id outcome
354 id="$(jq -R 'fromjson? | select(.method == "session/prompt") | .id' "$1" 2>/dev/null | tail -1)"
355 [ -n "$id" ] || { printf 'not-sent'; return 0; }
356 outcome="$(jq -rR --argjson id "$id" 'fromjson? | select(.method == null and .id == $id) | if .error then "error" else (.result.stopReason // "error") end' "$1" 2>/dev/null | tail -1)"
357 printf '%s' "${outcome:-incomplete}"
358}
360# Antigravity holds session/new open while it waits for a browser sign-in it
361# will never get, so a run without a usable login would otherwise sit out the
362# whole idle cap. Nothing has been sent while the session is pending.
363ACPX_SESSION_START_SECS="${CROSS_MODEL_SESSION_START_SECS:-60}"
364acpx_session_state() { # <stream-log> -> starting | pending | open
365 local id
366 jq -eR 'fromjson? | select(.result.protocolVersion? != null)' "$1" >/dev/null 2>&1 || { printf 'starting'; return 0; }
367 id="$(jq -R 'fromjson? | select(.method == "session/new") | .id' "$1" 2>/dev/null | tail -1)"
368 if [ -n "$id" ] && jq -eR --argjson id "$id" 'fromjson? | select(.method == null and .id == $id)' "$1" >/dev/null 2>&1; then
369 printf 'open'
370 else
371 printf 'pending'
372 fi
373}
375acpx_text() { # <stream-log> <outfile>: the agent's reply text
376 jq -jR 'fromjson? | select(.method == "session/update" and .params.update.sessionUpdate == "agent_message_chunk") | .params.update.content.text // empty' "$1" > "$2" 2>/dev/null
377}
379# Model ids the adapter reports in the prompt result's _meta. acpx documents
380# _meta as adapter-defined, so these are the adapter's assertion, not proof.
381acpx_served_models() { # <stream-log> -> "<model> <tokens>" lines
382 jq -rR 'fromjson? | select(.result.stopReason?) | .result._meta // {} |
383 ((.quota.model_usage // [])[] | "\(.model) \(.token_count.totalTokens // 0)"),
384 (.modelId // empty | "\(.) 0")' "$1" 2>/dev/null
385}
387# Bounded failure evidence from the stream. The stream echoes the outbound
388# prompt, so only error messages and the stop reason are read from it.
389acpx_failure_evidence() { # <stream-log>
390 local evidence
391 evidence="$(jq -rR 'fromjson? | (.error.message? // empty), (.result.stopReason? // empty | select(. != "end_turn") | "stopReason=" + .)' "$1" 2>/dev/null | tr '\n' ' ')"
392 evidence="${evidence% }"
393 [ "${#evidence}" -gt 300 ] && evidence="${evidence:0:147} ... ${evidence: -147}"
394 printf '%s' "$evidence"
395}
397# codex-acp streams a provider rejection (such as a model the account may not
398# use) as reply text and still ends the turn with end_turn, so only the reply
399# carries it. Bounded inside jq so a long reply never enters a bash string.
400acpx_reply_evidence() { # <stream-log>
401 jq -nrR '[inputs | fromjson? | select(.method == "session/update" and .params.update.sessionUpdate == "agent_message_chunk") | .params.update.content.text // empty]
402 | join("") | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ")
403 | if length > 300 then .[0:147] + " ... " + .[-147:] else . end' "$1" 2>/dev/null
404}
406acpx_advertised_models() { # <stream-log>
407 jq -nrR '[inputs | fromjson? | .result.models.availableModels[]?.modelId | sub("\\[.*$"; "")]
408 | reduce .[] as $m ([]; if any(.[]; . == $m) then . else . + [$m] end)
409 | join(", ") | if length > 300 then .[0:296] + " ..." else . end' "$1" 2>/dev/null
410}
412# Exit 5 is acpx's permission refusal. Paired with stopReason cancelled, the
413# refusal ended the turn. Exit 5 with end_turn means the turn finished. A
414# cancelled turn with any other exit is an interrupt.
415acpx_permission_cancel_reason() { # <stream-log> <exit-code>
416 [ "$(acpx_outcome "$1")" = cancelled ] && [ "$2" = 5 ] || return 0
417 printf '%s' "permission request cancelled the turn"
418}
420acpx_node_startup_line() { # <stderr-log>
421 local line=""
422 line="$(grep -m1 -E '^Error ' "$1" 2>/dev/null)"
423 [ -n "$line" ] || line="$(grep -m1 -E '^node:' "$1" 2>/dev/null)"
424 printf '%s' "$line"
425}
426# --- end acpx transport -----------------------------------------------------
428# --- model-identity receipt ---------------------------------------------------
429# "Which model ran" comes from the adapter's _meta report, recorded as the
430# served id; it is never filled from the requested value. Prefer the served id
431# in the requested family (Claude also reports an auxiliary Haiku); otherwise
432# record the model that used the most tokens and warn about the mismatch.
433MODEL_ACTUAL="unverified"
434extract_model_receipt() { # <route>; reads $PEERLOG, sets MODEL_ACTUAL
435 local requested family served m tokens most=-1 heaviest=""
436 MODEL_ACTUAL="unverified"
437 requested="$(route_model "$1")"
438 family="${requested%%\[*}"
439 case "$family" in
440 fable|opus|sonnet|haiku) family="claude-$family" ;;
441 esac
442 served="$(acpx_served_models "$PEERLOG")"
443 [ -n "$served" ] || return 0
444 while read -r m tokens; do
445 [ -n "$m" ] || continue
446 case "$m" in
447 "$family"|"$family"-*|"$family"\[*) MODEL_ACTUAL="$m"; return 0 ;;
448 esac
449 if [ "${tokens:-0}" -gt "$most" ]; then most="${tokens:-0}"; heaviest="$m"; fi
450 done <<EOF
451$served
452EOF
453 MODEL_ACTUAL="$heaviest"
454 # A route that requested no model (Cursor default, OpenCode auto) has nothing to mismatch.
455 [ "$requested" = auto ] && return 0
456 log "WARNING: model mismatch - requested $requested, adapter reported $MODEL_ACTUAL; reconcile must surface this"
457}
459# --- adapter argv (single source of truth for route flags) -----------------
460# Zero-tool intent on every route: the peer runs from an empty per-peer
461# workspace, acpx denies every permission request, and each adapter runs in its
462# read-only mode where it has one (codex mode=read-only, claude mode=default,
463# cursor mode=ask, opencode mode=plan with edits and web denied). Tools that
464# need no permission stay usable; references/cross-model-review.md records
465# which routes can still read files or reach the web. PEER_WORKDIR /
466# PROMPT_FILE / MCP_CONFIG / CLAUDE_WRAPPER are resolved by the caller
467# (placeholders in --emit-adapter mode).
468adapter_argv() {
469 local route="$1" model
470 acpx_agent "$route" >/dev/null || return 1
471 model="$(route_model "$route")"
472 case "$route" in
473 # Denying bash or read outright makes OpenCode's free tier reject the
474 # session over ACP, so those stay behind plan mode and --deny-all.
475 opencode) printf '%s\0' env OPENCODE_DISABLE_PROJECT_CONFIG=1 \
476 'OPENCODE_CONFIG_CONTENT={"permission":{"edit":"deny","webfetch":"deny","websearch":"deny","task":"deny"}}' ;;
477 esac
478 acpx_base_argv "$route" "$PEER_WORKDIR" "$MCP_CONFIG" "$HARD_SECS" "$CLAUDE_WRAPPER" "$GEMINI_HOME_DIR"
479 printf '%s\0' --deny-all
480 case "$route" in
481 claude) printf '%s\0' --max-turns 15 ;;
482 esac
483 [ "$model" = auto ] || printf '%s\0' --model "$model"
484 acpx_agent_argv "$route"
485 case "$route" in
486 codex) printf '%s\0' --config-option mode=read-only --config-option "reasoning_effort=$(route_effort codex)" ;;
487 # Claude otherwise starts in the user's default permission mode, which can
488 # skip permission requests entirely and leave --deny-all nothing to deny.
489 claude) printf '%s\0' --config-option mode=default --config-option "effort=$(route_effort claude)" ;;
490 grok-cli) printf '%s\0' --config-option "reasoning_effort=$(route_effort grok-cli)" ;;
491 grok-cursor|cursor|composer) printf '%s\0' --config-option mode=ask ;;
492 opencode) printf '%s\0' --config-option mode=plan
493 [ -z "${CROSS_MODEL_EFFORT_OVERRIDE:-}" ] || printf '%s\0' --config-option "effort=$(route_effort opencode)" ;;
494 antigravity) printf '%s\0' --config-option mode=default --config-option "thinking_level=$(route_effort antigravity)" ;;
495 esac
496 printf '%s\0' --file "$PROMPT_FILE"
497}
499# Accept a host-discovered replacement only for its declared target and model
500# family. An override for another target is ignored rather than leaking across
501# routes; an unbound or cross-family override is invalid for its own route.
502# A codex id may carry the serving provider's own namespace (openai.gpt-...,
503# openai/gpt-...) when the CLI routes through a non-default model_provider; the
504# family segment after the namespace is still checked.
505validate_model_override() {
506 local route="$1" override="${CROSS_MODEL_MODEL_OVERRIDE:-}" override_target="${CROSS_MODEL_MODEL_OVERRIDE_TARGET:-}" target
507 [ -n "$override" ] || { [ -z "$override_target" ]; return; }
508 [ -n "$override_target" ] || return 1
509 target="$(route_target "$route")" || return 1
510 [ "$override_target" = "$target" ] || return 0
511 [ "$target" != "cursor" ] || return 1
512 case "$route:$override" in
513 codex:gpt-*|codex:o[0-9]*|codex:*[./]gpt-*|codex:*[./]o[0-9]*|claude:fable|claude:opus|claude:sonnet|claude:haiku|claude:claude-*|grok-cli:grok-*|grok-cursor:grok-*|grok-cursor:cursor-grok-*|composer:composer-*|opencode:*/*|antigravity:gemini-*) ;;
514 *) return 1 ;;
515 esac
516}
518# Accept an effort override only where the route's ACP adapter exposes an
519# effort option and the value is one it advertises (claude effort:
520# low|medium|high|xhigh|max; codex reasoning_effort: low|medium|high|xhigh|max|ultra;
521# grok reasoning_effort: low|medium|high|xhigh; antigravity thinking_level:
522# low|medium|high, checked 2026-10-14; opencode 2.x effort: none|low|medium|
523# high|xhigh|max, checked 2026-10-23). Checked 2026-10-06 against the
524# session options each adapter reports through acpx 0.19.4. Codex levels vary
525# and OpenCode levels vary per model, and OpenCode 1.x has no effort option,
526# so a listed level can still be refused before the prompt is sent.
527# Cursor routes fix effort in the model preset, so any override there is
528# invalid for the route rather than silently dropped. Empty means "no override".
529validate_effort_override() {
530 local route="$1" effort="${CROSS_MODEL_EFFORT_OVERRIDE:-}"
531 [ -n "$effort" ] || return 0
532 case "$route:$effort" in
533 claude:low|claude:medium|claude:high|claude:xhigh|claude:max) ;;
534 codex:low|codex:medium|codex:high|codex:xhigh|codex:max|codex:ultra) ;;
535 grok-cli:low|grok-cli:medium|grok-cli:high|grok-cli:xhigh) ;;
536 antigravity:low|antigravity:medium|antigravity:high) ;;
537 opencode:none|opencode:low|opencode:medium|opencode:high|opencode:xhigh|opencode:max) ;;
538 *) return 1 ;;
539 esac
540}
542# --- --emit-adapter <route>: print the argv, no model call, no side effects --
543if [ "${1:-}" = "--emit-adapter" ]; then
544 PEER_WORKDIR="<peer-workdir>"
545 PROMPT_FILE="<prompt-file>"; MCP_CONFIG="<mcp-config>"; CLAUDE_WRAPPER="<claude-safe-mode-wrapper>"; GEMINI_HOME_DIR="<gemini-home>"
546 HARD_SECS="${CROSS_MODEL_HARD_SECS:-1200}"
547 route="${2:-}"
548 validate_model_override "$route" 2>/dev/null || { echo "model override '${CROSS_MODEL_MODEL_OVERRIDE:-}' not compatible with route '$route'" >&2; exit 2; }
549 validate_effort_override "$route" 2>/dev/null || { echo "effort override '${CROSS_MODEL_EFFORT_OVERRIDE:-}' not compatible with route '$route'" >&2; exit 2; }
550 # adapter_argv emits NUL-delimited argv (can't be captured in a shell var), so
551 # validate the route first, then render for humans with NUL -> space.
552 adapter_argv "$route" >/dev/null 2>&1 || { echo "unknown route '$route' (want codex|claude|grok-cli|grok-cursor|cursor|composer|opencode|antigravity)" >&2; exit 2; }
553 adapter_argv "$route" | tr '\0' ' '; echo
554 exit 0
555fi
557HOST_PROVIDER="${1:-}"
558HOST_HARNESS="${CROSS_MODEL_HOST_HARNESS:-unknown}"
559CANDIDATES="${2:-}"
560REVIEWER_NAME="${3:-}"
561DOC_PATH="${4:-}"
562DOC_TYPE="${5:-}"
563ORIGIN="${6:-}"
564RUN_DIR="${7:-}"
566# --- validate inputs -------------------------------------------------------
567[ -n "$REVIEWER_NAME" ] || skip "no reviewer-name given; skipping"
568[ -n "$DOC_PATH" ] && [ -f "$DOC_PATH" ] || skip "document '${DOC_PATH:-<empty>}' not readable on disk; skipping"
569: "${DOC_TYPE:=unified-plan}"
570: "${ORIGIN:=none}"
571[ -n "$RUN_DIR" ] || skip "run-dir not given; skipping"
572# Create the scratch run-dir rather than skipping when it doesn't exist yet:
573# ce-doc-review (unlike ce-code-review) has no pre-existing run-artifact dir, and
574# the caller passes the fresh absolute run dir resolved by the skill.
575# Requiring it to pre-exist would silently no-op the whole pass (no fold-in files).
576mkdir -p "$RUN_DIR" 2>/dev/null
577[ -d "$RUN_DIR" ] || skip "run-dir '$RUN_DIR' could not be created; skipping"
578command -v jq >/dev/null 2>&1 || skip "jq not installed; skipping"
580# Validate the host identity tuple. An unknown serving family is allowed, but
581# normalization marks every result non-independent.
582case "$HOST_PROVIDER" in
583 codex|claude|grok|composer|gemini|unknown) ;;
584 *) skip "host serving family '${HOST_PROVIDER:-<empty>}' invalid (want codex|claude|grok|composer|gemini|unknown); skipping cross-model pass" ;;
585esac
586case "$HOST_HARNESS" in
587 codex|claude|grok|cursor|opencode|antigravity|omp|unknown) ;;
588 *) skip "host harness '$HOST_HARNESS' invalid (want codex|claude|grok|cursor|opencode|antigravity|omp|unknown); skipping cross-model pass" ;;
589esac
590[ "$HOST_PROVIDER" != "unknown" ] || skip "host serving family unattested; automatic cross-model review skipped"
592# --- derive persona-brief filename from the allowlisted reviewer-name -------
593# Never a caller argument -> no path-traversal / arbitrary-file-read surface.
594case "$REVIEWER_NAME" in
595 security-lens) PERSONA_FILE="security-lens-reviewer" ;;
596 adversarial) PERSONA_FILE="adversarial-document-reviewer" ;;
597 product-lens) PERSONA_FILE="product-lens-reviewer" ;;
598 whole-doc) PERSONA_FILE="whole-doc-reviewer" ;; # broad whole-document sweep; embeds the full doc, no in-process twin
599 *) skip "reviewer-name '$REVIEWER_NAME' is not a cross-model reviewer (want security-lens|adversarial|product-lens|whole-doc); skipping" ;;
600esac
602# --- self-locate skill root + canonical sibling files ----------------------
603SKILL_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || skip "cannot resolve skill root; skipping"
604PERSONA="$SKILL_ROOT/references/personas/$PERSONA_FILE.md"
605SCHEMA="$SKILL_ROOT/references/findings-schema.json"
606[ -f "$PERSONA" ] || skip "persona brief not found at $PERSONA; skipping"
607[ -f "$SCHEMA" ] || skip "findings schema not found at $SCHEMA; skipping"
608SCHEMA_CONTENT="$(cat "$SCHEMA")" || skip "cannot read findings schema; skipping"
610# The peer adapts on the same context slots (Document type / Origin) the in-process
611# reviewer does, but the trio persona briefs only define adaptation for the bare
612# `requirements`/`plan` values. The canonical context-slot rules -- which map
613# `unified-*` onto their base branch, carry the unified slice-suppression rules, and
614# define how to read non-path Origin values -- live only in the subagent template, so
615# extract them from there (single source of truth) and fold them into the peer prompt.
616# Best-effort: a missing block degrades unified/Origin scoping but must not fail the pass.
617TEMPLATE="$SKILL_ROOT/references/subagent-template.md"
618CONTEXT_SLOT_RULES="$(awk '/<context-slots-rules>/{f=1} f; /<\/context-slots-rules>/{if(f)exit}' "$TEMPLATE" 2>/dev/null)"
619[ -n "$CONTEXT_SLOT_RULES" ] || log "context-slot rules not found in $TEMPLATE; peer prompt will omit unified/Origin adaptation rules"
621# The trio persona briefs defer their confidence rubric + false-positive catalog to
622# the template's <output-contract> block, which every in-process reviewer receives.
623# The isolated peer can't resolve that reference on its own, so embed it too --
624# otherwise the peer calibrates anchors / suppresses false positives differently from
625# its in-process twin, weakening the cross-model agreement signal.
626OUTPUT_CONTRACT_RULES="$(awk '/<output-contract>/{f=1} f; /<\/output-contract>/{if(f)exit}' "$TEMPLATE" 2>/dev/null)"
627[ -n "$OUTPUT_CONTRACT_RULES" ] || log "output-contract not found in $TEMPLATE; peer prompt omits the shared confidence rubric / FP catalog (calibration may differ from the twin)"
629# --- resolve which provider(s) to run (exclude host, allowlist, availability) --
630ALLOW="${CROSS_MODEL_PEERS:-}" # optional egress allowlist
631MAX_PEERS="${CROSS_MODEL_MAX_PEERS:-1}" # default 1; clamped 0..2 (hard cap)
632case "$MAX_PEERS" in ''|*[!0-9]*) MAX_PEERS=1 ;; esac
633[ "$MAX_PEERS" -gt 2 ] && MAX_PEERS=2
635in_csv() { case ",$2," in *",$1,"*) return 0 ;; *) return 1 ;; esac; }
636# The cursor-agent route egresses content through Cursor even when the *model* is
637# grok (grok-via-cursor-agent). CROSS_MODEL_PEERS is an egress boundary, not
638# just a model-provider filter, so the grok->cursor-agent transport is off-limits
639# under an allowlist that does not sanction Cursor. Cursor egress is sanctioned when
640# no allowlist is set, or when 'composer' (the Cursor-native provider) is allowlisted
641# -- either way the user has accepted that content may reach Cursor.
642cursor_egress_ok() { [ -z "$ALLOW" ] || in_csv cursor "$ALLOW" || in_csv composer "$ALLOW"; }
644# Soft size gate: peer prompt embeds the full document. Over-budget docs skip
645# cleanly rather than collapsing silently inside the provider context window.
646MAX_DOC_CHARS="${CROSS_MODEL_MAX_DOC_CHARS:-200000}"
647case "$MAX_DOC_CHARS" in ''|*[!0-9]*) MAX_DOC_CHARS=200000 ;; esac
648DOC_CHARS="$(wc -c <"$DOC_PATH" | tr -d '[:space:]')"
649if [ "$DOC_CHARS" -gt "$MAX_DOC_CHARS" ]; then
650 skip "document is ${DOC_CHARS} bytes (limit ${MAX_DOC_CHARS}); skipping cross-model pass rather than truncating"
651fi
653# The Codex desktop app (Codex.app, or ChatGPT.app since the July 2026 merger)
654# ships `codex` at Contents/Resources without linking it onto PATH (#1272).
655# Append, never prepend, so a PATH-installed CLI stays authoritative.
656# CROSS_MODEL_CODEX_APP_DIRS (colon-separated) overrides the probed dirs.
657if ! command -v codex >/dev/null 2>&1; then
658 OLDIFS="$IFS"; IFS=':'
659 for d in ${CROSS_MODEL_CODEX_APP_DIRS-"${HOME:-}/Applications/ChatGPT.app/Contents/Resources:/Applications/ChatGPT.app/Contents/Resources:${HOME:-}/Applications/Codex.app/Contents/Resources:/Applications/Codex.app/Contents/Resources"}; do
660 if [ -n "$d" ] && [ -x "$d/codex" ]; then PATH="${PATH:+$PATH:}$d"; export PATH; break; fi
661 done
662 IFS="$OLDIFS"
663fi
665provider_available() {
666 case "$1" in
667 codex) command -v codex >/dev/null 2>&1 ;;
668 claude) command -v claude >/dev/null 2>&1 ;;
669 grok) command -v grok >/dev/null 2>&1 || { cursor_egress_ok && command -v cursor-agent >/dev/null 2>&1; } ;;
670 cursor) command -v cursor-agent >/dev/null 2>&1 ;;
671 composer) command -v cursor-agent >/dev/null 2>&1 ;;
672 opencode) command -v opencode >/dev/null 2>&1 ;;
673 antigravity) command -v agy_acp_server.par >/dev/null 2>&1 ;;
674 *) return 1 ;;
675 esac
676}
678# Collect the ordered list of reachable candidates (installed, allowlisted,
679# non-host, deduped). Discovery reports the first MAX_PEERS; live egress uses the
680# host-sanctioned fixed route's eligible target. `command -v` proves only local
681# route availability; a fixed route that later fails returns to the host instead
682# of changing recipients here.
683# `for p in $CANDIDATES` splits the CSV once at loop start under IFS=',', so IFS
684# stays comma for the whole loop; nothing in the body does IFS-sensitive splitting.
685SELECTED="" # space-separated ordered reachable candidates (bash 3.2-safe)
686OLDIFS="$IFS"; IFS=','
687for p in $CANDIDATES; do
688 p="$(printf '%s' "$p" | tr -d '[:space:]')"
689 [ -n "$p" ] || continue
690 case "$p" in codex|claude|grok|cursor|composer|opencode|antigravity) ;; *) log "ignoring unknown target '$p' in candidates"; continue ;; esac
691 [ "$HOST_PROVIDER" != "unknown" ] && [ "$(target_serving_family "$p")" = "$HOST_PROVIDER" ] && continue
692 # An Antigravity host can serve several families; never send it to itself.
693 [ "$p" = antigravity ] && [ "$HOST_HARNESS" = antigravity ] && continue
694 case " $SELECTED " in *" $p "*) continue ;; esac # dedup
695 if [ -n "$ALLOW" ] && ! in_csv "$p" "$ALLOW"; then log "provider '$p' not in CROSS_MODEL_PEERS allowlist; skipping"; continue; fi
696 if ! provider_available "$p"; then log "provider '$p' has no installed route; skipping"; continue; fi
697 SELECTED="$SELECTED $p"
698done
699IFS="$OLDIFS"
700SELECTED="$(printf '%s' "$SELECTED" | sed 's/^ *//')"
702[ "$MAX_PEERS" -ge 1 ] || skip "CROSS_MODEL_MAX_PEERS=0; cross-model pass disabled"
703[ -n "$SELECTED" ] || skip "no different-provider peer reachable (host=$HOST_PROVIDER, candidates='$CANDIDATES'); the pass needs a peer agent CLI on PATH (codex, claude, grok, cursor-agent, opencode, or the Antigravity ACP runtime), not an API key alone; skipping"
704log "reachable cross-model candidates for lens $REVIEWER_NAME: $SELECTED (host $HOST_PROVIDER excluded; up to $MAX_PEERS successful peer(s))"
706# first_n <max> <space-separated list> -> the first <max> tokens.
707first_n() {
708 local max="$1"; shift; local n=0 out=""
709 for t in "$@"; do [ "$n" -ge "$max" ] && break; out="$out $t"; n=$((n + 1)); done
710 printf '%s' "${out# }"
711}
713# Diagnostic: resolve selection only, no model call, no side effects (used by the
714# selection tests, which stub the route CLIs on PATH). Prints the fixed peer set
715# (the first MAX_PEERS reachable candidates).
716if [ -n "${CROSS_MODEL_DRY_RUN:-}" ]; then
717 printf 'RESOLVED_PEERS: %s\n' "$(first_n "$MAX_PEERS" $SELECTED)"
718 exit 0
719fi
721# --- compose the peer prompt from the canonical persona (single source) ----
722# The full findings schema is embedded so the peer knows every required field.
723# The document content is embedded directly inside the <review-context> block,
724# with the same context slots the in-process persona adapts on. The reviewer
725# field is normalized to <reviewer-name>-<provider> after the run, so the prompt
726# asks only for the short name.
727# Private scratch holds the prompt, the ACP stream, and the launch files. It is
728# kept apart from the peer's empty working directory (created per provider).
729SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/xmodel-doc-scratch-XXXXXX")" || skip "private peer scratch unavailable; skipping"
730PEER_WORKDIR=""
731RAW_OUT=""
732cleanup_temp() {
733 rm -rf "$SCRATCH"
734 [ -n "$PEER_WORKDIR" ] && rm -rf "$PEER_WORKDIR"
735}
736trap 'cleanup_temp' EXIT
737chmod 700 "$SCRATCH" 2>/dev/null || skip "cannot make peer scratch private; skipping"
738PROMPT_FILE="$SCRATCH/prompt.md"
739# acpx prints the ACP stream on stdout; npm and adapter diagnostics go to stderr.
740PEERLOG="$SCRATCH/stdout.log"
741PEERERR="$SCRATCH/stderr.log"
742TEXT_OUT="$SCRATCH/reply.txt"
743MCP_CONFIG="$SCRATCH/mcp.json"
744printf '{"mcpServers":[]}\n' > "$MCP_CONFIG"
745CLAUDE_WRAPPER=""
746GEMINI_HOME_DIR=""
747RUN_SUCCEEDED=false
748PROVIDER_OUTCOME="failed"
749# Basename only in the peer prompt: content is already embedded. An absolute
750# path would give a read-capable route a repo coordinate to walk from.
751DOC_BASENAME="$(basename "$DOC_PATH")"
752{
753 cat "$PERSONA"
754 printf '\n\n---\n\n'
755 # Shared output-contract (confidence rubric + FP catalog) the persona brief defers
756 # to, so the peer calibrates like its in-process twin.
757 [ -n "$OUTPUT_CONTRACT_RULES" ] && printf '%s\n\n' "$OUTPUT_CONTRACT_RULES"
758 printf 'This is an authorized document review of the maintainer\047s own repository.\n'
759 # The whole document is embedded below. Say so, so a peer never goes looking for the
760 # prompt or the document on disk: a read outside its workspace is a permission
761 # request that is denied, and a peer that stops to explain the denial returns no JSON.
762 printf 'The complete document is embedded below under "Document content". Do not call tools or read files; if any read is denied, ignore it and answer from the embedded text.\n'
763 printf 'Return ONE JSON object and nothing else (no prose, no code fence) matching this schema:\n\n'
764 printf '%s' "$SCHEMA_CONTENT"
765 printf '\n\nSet the top-level "reviewer" field to "%s" (it will be namespaced to the peer provider on fold-in).\n' "$REVIEWER_NAME"
766 printf '\n<review-context>\n'
767 printf 'Document type: %s\n' "$DOC_TYPE"
768 printf 'Document path: %s\n' "$DOC_BASENAME"
769 printf 'Origin: %s\n\n' "$ORIGIN"
770 printf '<prior-decisions>\nRound 1 — no prior decisions.\n</prior-decisions>\n\n'
771 printf 'Document content:\n'
772 cat "$DOC_PATH"
773 printf '\n</review-context>\n'
774 [ -n "$CONTEXT_SLOT_RULES" ] && printf '\n%s\n' "$CONTEXT_SLOT_RULES"
775} > "$PROMPT_FILE"
776# codex-acp's only refusal option is cancel, which ends the turn.
777if [ "${CROSS_MODEL_FIXED_ROUTE:-}" = codex ]; then
778 printf '\nYour sandbox is read-only and no approval is available: never request escalated or out-of-sandbox permissions (for example to launch a browser, use the network, or write outside the repository). Any permission request ends this review with no result. When a check needs more access than the sandbox gives, reason from the source instead and name the unrun check in residual_risks.\n' >> "$PROMPT_FILE"
779fi
780chmod 600 "$PROMPT_FILE" "$MCP_CONFIG" 2>/dev/null || skip "cannot make peer scratch files private; skipping"
782# --- run machinery -----------------------------------------------------------
783# Every route streams the ACP session, so the idle cap is the liveness guard and
784# HARD_SECS backstops a peer that stays productive past any useful budget. acpx
785# gets the same budget as --timeout, but it applies that per phase, so this
786# script's wall clock stays authoritative. The idle cap must exceed the peer's
787# worst-case silent turn: a slow xhigh reasoning turn (Luna p95 ~242s, max
788# ~419s) can go quiet past a low cap and be reaped before it answers.
789#
790# HARD_SECS is the ONE knob for the whole peer budget: the runner supervisor
791# window and the orchestrator's shared deadline both derive from it (see
792# references/cross-model-review.md), so raising it here raises all three.
793IDLE_SECS="${CROSS_MODEL_IDLE_SECS:-480}"
794HARD_SECS="${CROSS_MODEL_HARD_SECS:-1200}"
796# Reap a backgrounded job's whole process group: TERM, then KILL after a grace.
797# True while $1 is a live (non-zombie) process. kill -0 succeeds on zombies
798# until wait reaps them, so idle polls must not treat zombies as still running.
799# macOS/BSD often report defunct state as "Z+" (not bare "Z").
800# Match peer-job-runner._pid_running: empty state after ps means not alive
801# (avoids zombie spin). Fall back to kill -0 only when ps itself is missing.
802peer_alive() {
803 local st
804 kill -0 "$1" 2>/dev/null || return 1
805 if ! command -v ps >/dev/null 2>&1; then
806 return 0
807 fi
808 # Git Bash's ps has no -o; fall back to its -p lookup, which has no zombie state.
809 if ! st="$(ps -o state= -p "$1" 2>/dev/null)"; then
810 ps -p "$1" >/dev/null 2>&1
811 return
812 fi
813 st="$(printf '%s' "$st" | tr -d ' \n')"
814 [ -n "$st" ] || return 1
815 [ "${st#Z}" = "$st" ]
816}
818reap() {
819 # Signal the process group and grace-poll without wait(). The caller alone
820 # wait()s the leader so PEER_EXIT is the real exit status — a second
821 # wait here would fail after we already reaped and mark healthy exits as
822 # timed-out (#1270 Bugbot). No background KILL timer: orphaned timers can
823 # hit recycled PIDs under bun --parallel.
824 local pid="$1" grp
825 if kill -TERM -- -"$pid" 2>/dev/null; then grp=1; else kill -TERM "$pid" 2>/dev/null || true; grp=0; fi
826 for _ in 1 2 3 4 5; do
827 if ! peer_alive "$pid"; then
828 # Leader exited/zombied — sweep any group survivors; caller wait()s.
829 [ "$grp" = 1 ] && kill -KILL -- -"$pid" 2>/dev/null || true
830 return 0
831 fi
832 sleep 1
833 done
834 if [ "$grp" = 1 ]; then kill -KILL -- -"$pid" 2>/dev/null; else kill -KILL "$pid" 2>/dev/null; fi
835}
837# TERM/INT: reap the live peer group, then exit cleanly (HUP remains ignored).
838on_term() {
839 if [ -n "${_HEARTBEAT_PID:-}" ]; then
840 stop_heartbeat
841 fi
842 if [ -n "${ACTIVE_PEER_PID:-}" ]; then
843 log "received TERM/INT; reaping peer process group $ACTIVE_PEER_PID"
844 _term_peer="$ACTIVE_PEER_PID"
845 reap "$_term_peer" 2>/dev/null || true
846 # reap only signals the group; wait reaps the leader so it cannot orphan.
847 wait "$_term_peer" 2>/dev/null || true
848 ACTIVE_PEER_PID=""
849 fi
850 exit 0
851}
852trap 'on_term' TERM INT
854# Build the CMD array for a route (bash 3.2-safe: no mapfile).
855build_cmd() {
856 CMD=()
857 # NUL-delimited so a token containing spaces or newlines stays ONE argv element.
858 while IFS= read -r -d '' tok; do CMD+=("$tok"); done < <(adapter_argv "$1")
859}
861# --- liveness heartbeat -----------------------------------------------------
862# The peer CLI streams into $PEERLOG (private), so nothing reaches this script's
863# own stdout/stderr during a long model call. An outer supervisor that watches
864# THIS process's output for liveness (the peer-job runner's out.log byte-growth
865# idle window) would mistake a healthy multi-minute run for a wedge. A background
866# writer emits one stderr line every CROSS_MODEL_HEARTBEAT_SECS (default 60s) so
867# that liveness is visible; it is torn down as soon as the foreground wait returns,
868# so it adds no latency to a fast run. Keep this block byte-identical across
869# cross-model-adversarial-review.sh and cross-model-doc-review.sh (kernel parity).
870_HEARTBEAT_PID=""
871start_heartbeat() {
872 local every="${CROSS_MODEL_HEARTBEAT_SECS:-60}" parent_pid="$$"
873 # Floor to 1s: a non-numeric or 0 value would make `sleep` return instantly and
874 # spin the loop, flooding out.log into the runner's byte cap.
875 case "$every" in ''|*[!0-9]*) every=60 ;; esac; [ "$every" -lt 1 ] && every=1
876 _HEARTBEAT_READY=0
877 trap '_HEARTBEAT_READY=1' USR1
878 # Callers restore set +m after launching the peer, so without this the
879 # heartbeat inherits the worker pgid and kill -- -PID cannot reach the sleep.
880 local prev_m; case "$-" in *m*) prev_m=1;; *) prev_m=0;; esac
881 set -m
882 ( local t0 n sleeper=""
883 trap 'kill "${sleeper:-}" 2>/dev/null || true; exit 0' TERM INT
884 kill -USR1 "$parent_pid"
885 t0="$(date +%s)"
886 while kill -0 "$parent_pid" 2>/dev/null; do
887 sleep "$every" & sleeper=$!
888 wait "$sleeper" 2>/dev/null || exit 0
889 sleeper=""
890 kill -0 "$parent_pid" 2>/dev/null || break
891 n="$(date +%s)"; log "peer alive ($(( n - t0 ))s elapsed)"
892 done ) &
893 _HEARTBEAT_PID=$!
894 [ "$prev_m" = 0 ] && set +m
895 while [ "$_HEARTBEAT_READY" != 1 ] && kill -0 "$_HEARTBEAT_PID" 2>/dev/null; do sleep 0.01 || true; done
896 trap - USR1
897}
898stop_heartbeat() {
899 if [ -n "$_HEARTBEAT_PID" ]; then
900 # Leader-only TERM is deferred until the inner `wait $sleeper` returns, so
901 # the default 60s interval would block this wait. Signal the process group.
902 kill -- -"$_HEARTBEAT_PID" 2>/dev/null || kill "$_HEARTBEAT_PID" 2>/dev/null || true
903 wait "$_HEARTBEAT_PID" 2>/dev/null || true
904 fi
905 _HEARTBEAT_PID=""
906}
908run_peer_cmd() { # CMD already built for PEER_ROUTE; streams to PEERLOG, diagnostics to PEERERR
909 local prev; case "$-" in *m*) prev=1;; *) prev=0;; esac
910 SESSION_STALLED=false
911 set -m
912 # Start npx from private scratch: npx resolves packages from its working
913 # directory's node_modules and .npmrc first, and acpx gets the agent's cwd from --cwd.
914 ( cd "$(dirname "$PEERLOG")" && exec "${CMD[@]}" ) < /dev/null > "$PEERLOG" 2>"$PEERERR" &
915 local pid=$!
916 ACTIVE_PEER_PID="$pid"
917 [ "$prev" = 0 ] && set +m
918 start_heartbeat
919 local start last=-1 lastchg now size session=open
920 [ "$PEER_ROUTE" = antigravity ] && session=starting
921 start="$(date +%s)"; lastchg="$start"
922 while peer_alive "$pid"; do
923 now="$(date +%s)"; size="$(wc -c <"$PEERLOG" 2>/dev/null || echo 0)"
924 [ "$size" != "$last" ] && { last="$size"; lastchg="$now"; }
925 if [ $(( now - lastchg )) -ge "$IDLE_SECS" ]; then
926 log "peer output idle ${IDLE_SECS}s; reaping peer process group"; reap "$pid"; break
927 fi
928 if [ $(( now - start )) -ge "$HARD_SECS" ]; then
929 log "peer exceeded hard cap ${HARD_SECS}s; reaping peer process group"; reap "$pid"; break
930 fi
931 if [ "$session" != open ] && [ $(( now - start )) -ge "$ACPX_SESSION_START_SECS" ]; then
932 session="$(acpx_session_state "$PEERLOG")"
933 if [ "$session" = pending ]; then
934 SESSION_STALLED=true
935 log "Antigravity opened no session within ${ACPX_SESSION_START_SECS}s; reaping peer process group"; reap "$pid"; break
936 fi
937 fi
938 sleep 1
939 done
940 wait "$pid" 2>/dev/null
941 PEER_EXIT=$?
942 # Sweep any survivor the provider left in its OWN process group. `set -m` puts
943 # the provider in a separate pgid, and on a clean worker exit the runner's
944 # final sweep only kills the worker's pgid while a group-orphan reparents off
945 # the worker's process tree -- so it must be reaped here, where the pgid is
946 # known. reap() returns immediately when the group is already empty.
947 reap "$pid" 2>/dev/null || true
948 stop_heartbeat
949 ACTIVE_PEER_PID=""
950}
952resolve_python() {
953 for c in python3 python py; do
954 command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { printf '%s\n' "$c"; return; }
955 done
956}
958# Decode each {...} object in raw stdout via raw_decode (string/escape-aware,
959# unlike brace counting) and keep the last one shaped like findings. Envelope
960# routes nest that object inside a JSON *string* field, so string values that
961# could hold one are re-scanned rather than skipped.
962recover_findings_json() { # <logfile> <outfile>
963 local py="${PY_BIN:-}"
964 [ -n "$py" ] || return 1
965 "$py" - "$1" "$2" <<'PY' 2>/dev/null
966import sys, json
967txt = open(sys.argv[1], encoding="utf-8", errors="replace").read()
968# Any selectable object carries a `findings` key; if the raw text has none,
969# there is nothing to recover. Skip the scan — raw_decode probing every `{` is
970# O(n^2) on brace-dense non-findings stdout (error/crash dumps). Match the bare
971# word, not `"findings"`: nested inside an envelope's JSON string the key
972# arrives escaped as \"findings\", which the quoted form does not match.
973if 'findings' not in txt: sys.exit(0)
974dec = json.JSONDecoder()
975# (obj, depth) — depth>0 means recovered from inside a JSON string (envelope .text)
976found = []
978def scan(text, depth):
979 i = 0
980 while True:
981 j = text.find('{', i)
982 if j < 0: break
983 try:
984 obj, end = dec.raw_decode(text, j)
985 except Exception:
986 i = j + 1
987 continue
988 if isinstance(obj, dict):
989 # structuredOutput is grok-cli's spelling of the same field.
990 for cand in (obj, obj.get("structured_output"), obj.get("structuredOutput")):
991 if isinstance(cand, dict) and isinstance(cand.get("findings"), list):
992 found.append((cand, depth))
993 # An envelope route (grok-cli's `.text`) returns the review as a JSON
994 # *string*, whose `{` were never candidates here — raw_decode consumed
995 # the envelope whole and moved past it. Re-scan its strings so a
996 # wrapped review is recovered instead of reported as "no usable
997 # output". Unconditional: an envelope can carry its own empty
998 # `findings` beside the string holding the real one. The `findings`
999 # substring test bounds the nested scan's cost.
1000 if depth < 3:
1001 for v in obj.values():
1002 if isinstance(v, str) and 'findings' in v:
1003 scan(v, depth + 1)
1004 i = end
1006scan(txt, 0)
1007# Nested (string-unwrapped) candidates are the grok .text stub case: order of
1008# empty vs populated is not guaranteed, so prefer a populated review. Top-level
1009# sequential objects (codex/noisy stdout) keep last-shaped-wins — a final
1010# findings:[] after an earlier draft must not revive the draft.
1011nested = [o for o, d in found if d > 0]
1012top = [o for o, d in found if d == 0]
1013if nested:
1014 nested_pick = next((o for o in reversed(nested) if o["findings"]), nested[-1])
1015 if nested_pick["findings"]:
1016 best = nested_pick
1017 elif top:
1018 best = top[-1]
1019 else:
1020 best = nested_pick
1021elif top:
1022 best = top[-1]
1023else:
1024 best = None
1025if best is not None: open(sys.argv[2], "w").write(json.dumps(best))
1026PY
1027 [ -s "$2" ]
1028}
1030bounded_failure_evidence() { # <logfile>; bounded head+tail of a plain-text log
1031 local path="$1" evidence
1032 # bash 3.2 rewrites newlines in a large string superlinearly, so read only the
1033 # ends of a long log instead of the whole file.
1034 if [ "$(wc -c <"$path")" -le 600 ]; then evidence="$(cat "$path")"
1035 else IFS= read -r -d '' -n 300 evidence <"$path"; evidence="$evidence ... $(tail -c 300 "$path")"; fi
1036 evidence="${evidence//$'\n'/ }"
1037 if [ "${#evidence}" -gt 300 ]; then
1038 evidence="${evidence:0:147} ... ${evidence: -147}"
1039 fi
1040 printf '%s' "$evidence"
1041}
1043# A transient provider-capacity response (HTTP 529), unlike an account or
1044# session quota. Claude's ACP adapter reports one its CLI could not ride out as
1045# the prompt's own error with data.errorKind "overloaded"; the other adapters
1046# retry overloads internally and have no distinct form, so they never match.
1047provider_overloaded() {
1048 [ "$PROVIDER_OUTCOME" = "overloaded" ]
1049}
1051# Run one route for a provider; leaves a schema-shaped (pre-normalization) $RAW_OUT on success.
1052# Success is the prompt's own end_turn result, not acpx's exit status.
1053attempt_route() { # <provider> <route>
1054 local provider="$1" route="$2" outcome reason started remaining node_line cancel_reason
1055 : > "$PEERLOG"; : > "$PEERERR"; rm -f "$RAW_OUT" "$OUT" "$TEXT_OUT"
1056 RUN_SUCCEEDED=false
1057 PROVIDER_OUTCOME="failed"
1058 build_cmd "$route"
1059 PEER_ROUTE="$route"
1060 log "peer run: provider=$provider route=$route model=$(route_model "$route") (effort $(route_effort "$route")) transport=$(acpx_package_spec) lens=$REVIEWER_NAME read-only least-privilege (idle ${IDLE_SECS}s / hard ${HARD_SECS}s); full document content egresses to this provider via this route"
1061 started="$(date +%s)"
1062 run_peer_cmd
1063 if acpx_npm_resolution_retryable "$PEERERR" "$PEERLOG" && [ "$(acpx_outcome "$PEERLOG")" = not-sent ]; then
1064 remaining=$(( HARD_SECS - ( $(date +%s) - started ) ))
1065 if [ "$remaining" -gt 0 ]; then
1066 log "npm could not resolve a package; retrying the same route once with fresh metadata in the ${remaining}s left (nothing was sent; npm policy unchanged)"
1067 ACPX_NPM_REFRESH=true
1068 HARD_SECS="$remaining"
1069 build_cmd "$route"
1070 run_peer_cmd
1071 fi
1072 fi
1073 outcome="$(acpx_outcome "$PEERLOG")"
1074 case "$outcome" in
1075 end_turn)
1076 RUN_SUCCEEDED=true
1077 PROVIDER_OUTCOME="ok"
1078 acpx_text "$PEERLOG" "$TEXT_OUT" && recover_findings_json "$TEXT_OUT" "$RAW_OUT"
1079 extract_model_receipt "$route"
1080 ;;
1081 not-sent)
1082 # Nothing reached the provider: npm could not fetch acpx (every route
1083 # fails alike), or the adapter refused before the prompt (this route only).
1084 reason="$(acpx_npm_failure_reason "$PEERERR")"
1085 if [ -n "$reason" ]; then
1086 PRE_EGRESS="transport unavailable (pre-egress, shared): ${reason:0:200}"
1087 elif [ "$SESSION_STALLED" = true ]; then
1088 PRE_EGRESS="transport unavailable (pre-egress, route): Antigravity is waiting for a Google sign-in; the agy CLI's sign-in does not carry over, so sign the ACP runtime in once: put {\"auth\":{\"type\":\"oauth-personal\"}} in ${GEMINI_HOME:-~/.gemini}/antigravity-acp/settings.json, run npx -y --package '$(acpx_package_spec)' -- acpx antigravity exec ok in a terminal and finish the browser sign-in, then retry"
1089 else
1090 node_line="$(acpx_node_startup_line "$PEERERR")"
1091 if [ -n "$node_line" ]; then
1092 PRE_EGRESS="transport unavailable (pre-egress, route): ${node_line:0:200}"
1093 else
1094 reason="$(acpx_npm_failure_reason "$PEERLOG")"
1095 [ -n "$reason" ] || reason="$(acpx_failure_evidence "$PEERLOG")"
1096 PRE_EGRESS="transport unavailable (pre-egress, route): ${reason:-acpx exited $PEER_EXIT before sending the prompt}"
1097 fi
1098 fi
1099 ;;
1100 *)
1101 if [ "$outcome" = error ] && jq -eR 'fromjson? | select(.error.data.errorKind? == "overloaded")' "$PEERLOG" >/dev/null 2>&1; then
1102 PROVIDER_OUTCOME="overloaded"
1103 fi
1104 cancel_reason="$(acpx_permission_cancel_reason "$PEERLOG" "$PEER_EXIT")"
1105 if [ -n "$cancel_reason" ]; then
1106 log "peer run ended: $cancel_reason (acpx exit $PEER_EXIT)"
1107 else
1108 log "peer run ended with $outcome (acpx exit $PEER_EXIT)"
1109 fi
1110 ;;
1111 esac
1112}
1114# Run one host-resolved provider through its fixed route.
1115run_provider() { # <provider>
1116 local provider="$1" primary="" fixed="${CROSS_MODEL_FIXED_ROUTE:-}"
1117 local provider_deadline remaining
1118 OUT="$RUN_DIR/$REVIEWER_NAME-$provider.json"
1119 RAW_OUT="$SCRATCH/$REVIEWER_NAME-$provider.raw.json"
1120 [ -n "$fixed" ] || { log "host must resolve one fixed route before egress; skipping"; rm -f "$OUT"; return 0; }
1121 [ "$(route_target "$fixed")" = "$provider" ] || { log "fixed route '$fixed' does not match target '$provider'; skipping"; rm -f "$OUT"; return 0; }
1122 if [ "$fixed" = "grok-cursor" ] && ! cursor_egress_ok; then
1123 log "fixed route 'grok-cursor' requires Cursor intermediary sanction; skipping"
1124 rm -f "$OUT"
1125 return 0
1126 fi
1127 primary="$fixed"
1128 PY_BIN="$(resolve_python)"
1129 [ -n "$PY_BIN" ] || { log "working Python 3 interpreter required to recover peer findings; skipping"; rm -f "$OUT"; return 0; }
1130 validate_model_override "$primary" || { log "model override '${CROSS_MODEL_MODEL_OVERRIDE:-}' not compatible with route '$primary'; skipping"; rm -f "$OUT"; return 0; }
1131 validate_effort_override "$primary" || { log "effort override '${CROSS_MODEL_EFFORT_OVERRIDE:-}' not compatible with route '$primary'; skipping"; rm -f "$OUT"; return 0; }
1132 case "$HARD_SECS" in
1133 ''|0*|*[!0-9]*) log "peer hard budget must be a positive integer; skipping"; rm -f "$OUT"; return 0 ;;
1134 esac
1135 # Per-peer empty workspace, kept SEPARATE from the shared fold-in dir (RUN_DIR)
1136 # and from private scratch: it is the peer's working directory, so a peer has
1137 # no path handle to RUN_DIR or to another lens's published artifact from it.
1138 PEER_WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/xmodel-doc-peer-XXXXXX")" || { PEER_WORKDIR=""; log "provider $provider workspace isolation unavailable; skipping"; rm -f "$OUT"; return 0; }
1139 acpx_preflight "$primary" "$PEER_WORKDIR" || { log "transport unavailable (pre-egress, $ACPX_SCOPE): $ACPX_UNAVAILABLE"; rm -f "$OUT"; return 0; }
1140 if [ "$primary" = claude ]; then
1141 CLAUDE_WRAPPER="$(acpx_claude_wrapper "$SCRATCH")" || { log "transport unavailable (pre-egress, route): cannot prepare the Claude --safe-mode launcher"; rm -f "$OUT"; return 0; }
1142 fi
1143 if [ "$primary" = antigravity ]; then
1144 GEMINI_HOME_DIR="$(acpx_antigravity_home "$SCRATCH")" || { log "transport unavailable (pre-egress, route): cannot prepare a private Antigravity home"; rm -f "$OUT"; return 0; }
1145 fi
1146 # Track the route that actually produced the fold-in, so the artifact records
1147 # whether a grok return went out directly (grok-cli -> xAI) or through Cursor
1148 # (grok-cursor -> Cursor also received the full document). The <lens>-<provider>
1149 # filename alone cannot encode that intermediary.
1150 ACTUAL_ROUTE="$primary"
1151 provider_deadline=$(( $(date +%s) + HARD_SECS ))
1152 PRE_EGRESS=""
1153 attempt_route "$provider" "$primary"
1154 # A configured model is exact; only the editorial Argon request falls back.
1155 if [ "$primary" = antigravity ] && [ "$(route_model antigravity)" = "$M_ANTIGRAVITY" ] &&
1156 [ "${CROSS_MODEL_MODEL_OVERRIDE_TARGET:-}" != antigravity ]; then
1157 case "$PRE_EGRESS" in
1158 *"did not advertise"*)
1159 # The fallback shares the original deadline, as the overload retry does.
1160 remaining=$(( provider_deadline - $(date +%s) ))
1161 if [ "$remaining" -gt 0 ]; then
1162 log "Antigravity does not offer $M_ANTIGRAVITY on this account; falling back to $M_ANTIGRAVITY_FALLBACK"
1163 ANTIGRAVITY_MODEL="$M_ANTIGRAVITY_FALLBACK"
1164 PRE_EGRESS=""
1165 HARD_SECS="$remaining"
1166 attempt_route "$provider" "$primary"
1167 fi
1168 ;;
1169 esac
1170 fi
1171 if [ -n "$PRE_EGRESS" ]; then
1172 log "$PRE_EGRESS"
1173 rm -f "$OUT"
1174 return 0
1175 fi
1176 if [ ! -s "$RAW_OUT" ] && provider_overloaded; then
1177 remaining=$(( provider_deadline - $(date +%s) ))
1178 if [ "$remaining" -le "$TRANSIENT_RETRY_DELAY_SECS" ]; then
1179 log "provider overload 529; shared peer budget spent, not retrying"
1180 else
1181 log "provider overload 529; retrying same route once after ${TRANSIENT_RETRY_DELAY_SECS}s"
1182 sleep "$TRANSIENT_RETRY_DELAY_SECS"
1183 remaining=$(( provider_deadline - $(date +%s) ))
1184 if [ "$remaining" -gt 0 ]; then
1185 HARD_SECS="$remaining"
1186 attempt_route "$provider" "$primary"
1187 [ -z "$PRE_EGRESS" ] || log "$PRE_EGRESS"
1188 fi
1189 fi
1190 fi
1192 # --- normalize + validate against the synthesis reviewer-return contract ---
1193 # Force reviewer = <reviewer-name>-<provider>; backfill soft arrays; drop the
1194 # file if findings is not an array. Peer findings fold in as a corroboration
1195 # signal only -- synthesis (references/synthesis-and-presentation.md) never
1196 # auto-applies them and caps the cross-model bonus at one anchor step.
1197 # Downgrade any peer finding's autofix_class from safe_auto to gated_auto: the
1198 # no-silent-fixes rule forbids a peer from granting silent-apply authority, and enforcing it here (not
1199 # only in synthesis prose) means a peer cannot self-authorize a Phase 4 auto-apply
1200 # regardless of what it returns. gated_auto preserves the peer's proposed fix but
1201 # routes it through user confirmation.
1202 # Publish ONLY the normalized OUT into RUN_DIR. RAW_OUT lives in private scratch
1203 # and is never a fold-in artifact — if this script dies before normalize
1204 # (orphaned launch), synthesis finds no .json in RUN_DIR.
1205 rm -f "$OUT"
1206 if [ "$RUN_SUCCEEDED" = true ] && [ -s "$RAW_OUT" ]; then
1207 _norm="$SCRATCH/normalized.json"
1208 case "$ACTUAL_ROUTE:$MODEL_ACTUAL" in
1209 cursor:*) _target_family="unknown" ;;
1210 composer:unverified|grok-cursor:unverified) _target_family="unknown" ;;
1211 *) _target_family="$(target_serving_family "$provider")" ;;
1212 esac
1213 _independent=false
1214 [ "$HOST_PROVIDER" != "unknown" ] && [ "$_target_family" != "unknown" ] && [ "$HOST_PROVIDER" != "$_target_family" ] && _independent=true
1215 if jq --arg r "$REVIEWER_NAME-$provider" --arg route "$ACTUAL_ROUTE" \
1216 --arg target "$provider" --arg harness "$(route_harness "$ACTUAL_ROUTE")" \
1217 --arg family "$_target_family" --argjson independent "$_independent" \
1218 --arg mreq "$(route_model "$ACTUAL_ROUTE")" --arg mact "$MODEL_ACTUAL" \
1219 --arg ereq "$(route_effort "$ACTUAL_ROUTE")" \
1220 'if (.findings|type)=="array"
1221 then { reviewer: $r,
1222 cross_model_route: $route,
1223 cross_model_target: $target,
1224 cross_model_harness: $harness,
1225 serving_family: $family,
1226 independence_verified: $independent,
1227 model_requested: $mreq,
1228 model_actual: $mact,
1229 effort_requested: $ereq,
1230 findings: [ .findings[] | if (.autofix_class? == "safe_auto") then .autofix_class = "gated_auto" else . end ],
1231 residual_risks: (.residual_risks // []),
1232 deferred_questions: (.deferred_questions // []) }
1233 else empty end' \
1234 "$RAW_OUT" > "$_norm" 2>/dev/null; then
1235 mv "$_norm" "$OUT"
1236 else
1237 rm -f "$_norm"
1238 fi
1239 rm -f "$RAW_OUT"
1240 fi
1241 if [ -s "$OUT" ] && jq -e '(.reviewer|type=="string") and (.findings|type=="array") and (.residual_risks|type=="array") and (.deferred_questions|type=="array")' "$OUT" >/dev/null 2>&1; then
1242 n="$(jq '.findings | length' "$OUT" 2>/dev/null || echo '?')"
1243 log "wrote $n finding(s) to $OUT (reviewer $REVIEWER_NAME-$provider)"
1244 else
1245 log "provider $provider produced no usable schema-shaped output; skipping fold-in"
1246 # Surface bounded peer output so the orchestrator can
1247 # reason about WHY it was skipped (quota/usage-limit exhaustion vs an ordinary
1248 # empty review) and, in a repeated-pass session, deprioritize an exhausted
1249 # route. Harness-agnostic: the agent classifies from the text; this only makes
1250 # the evidence visible in out.log. Provider errors arrive as ACP error
1251 # messages or as reply text on stdout; npm and adapter diagnostics on stderr.
1252 _pt="$(acpx_permission_cancel_reason "$PEERLOG" "$PEER_EXIT")"
1253 [ -n "$_pt" ] || _pt="$(acpx_failure_evidence "$PEERLOG")"
1254 [ -n "$_pt" ] && log " peer skip evidence: $_pt"
1255 _pr="$(acpx_reply_evidence "$PEERLOG")"
1256 [ -n "$_pr" ] && log " peer skip evidence (reply): $_pr"
1257 _pm="$(acpx_advertised_models "$PEERLOG")"
1258 [ -n "$_pm" ] && log " peer skip evidence (advertised models): $_pm"
1259 if [ -s "$PEERERR" ]; then
1260 _pe="$(bounded_failure_evidence "$PEERERR")"
1261 log " peer skip evidence (stderr): $_pe"
1262 fi
1263 rm -f "$OUT" "$RAW_OUT"
1264 fi
1265 # Tear down the per-peer workspace (never RUN_DIR, which holds the published OUT).
1266 [ -n "$PEER_WORKDIR" ] && rm -rf "$PEER_WORKDIR"
1267 PEER_WORKDIR=""
1268}
1270# --- run the host-sanctioned fixed target -----------------------------------
1271# Discovery preserves caller order and MAX_PEERS, but live egress is already
1272# frozen to one route. Dispatch that route's target directly so a later eligible
1273# candidate is not discarded by the discovery-order cap. run_provider never
1274# changes recipients after dispatch.
1275FIXED_TARGET="$(route_target "${CROSS_MODEL_FIXED_ROUTE:-}")"
1276if [ -n "$FIXED_TARGET" ]; then
1277 case " $SELECTED " in
1278 *" $FIXED_TARGET "*) run_provider "$FIXED_TARGET" ;;
1279 *) log "fixed route '${CROSS_MODEL_FIXED_ROUTE:-}' target '$FIXED_TARGET' is not an eligible reachable candidate; skipping" ;;
1280 esac
1281else
1282 log "host must resolve one fixed route before egress; skipping"
1283fi
1284exit 0