mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
092b99dad9
## What
Adds `scripts/skill-eval.sh` — a thin gate that runs one skill's
`SKILL.md` through Jeff Emanuel's `ms` (meta_skill) `lint` + `validate`.
The wrapper decides the gate purely on whether a **blocking** `rule_id`
appears (ms's own severity/exit is not trusted):
- Blocking: `no-secrets`, `no-injection`, `safe-paths`,
`required-metadata`, `no-cycle`, `valid-version` → non-zero exit.
- Everything else → annotated only (`::warning::`), never fails.
- `ms` not on PATH (or `MS_BIN` missing) → **loud `::error::` hard-fail
(exit 3)**, never skip-and-pass — a silent skip would recreate the
no-evaluation gap this gate closes.
Planted `skills/_fixtures/{bad,good}-skill/SKILL.md` fixtures +
`tests/scripts/skill-eval.bats` (10 tests) exercise the
bad/good/ms-absent contracts.
## Fixture scoping
`skills/_fixtures/` is non-skill scaffolding (planted fixtures). The
skill-enumerating gates/generators are taught to skip `skills/_*` so the
fixtures never inflate the skill count (stays 82) or fail registry /
schema / wiring / runtime / count gates. Touched enumerators:
`generate-registry`, `validate-skill-schema`, `sync-skill-counts`,
`validate-skill-count`, `generate-skill-catalog`,
`generate-context-map`, `validate-manifests`, `check-wiring-closure`,
`validate-headless-runtime-skills`, `lib/sku_catalog.py`,
`check-alias-collisions`, `run-all`, `test-runtime-opencode-smoke`,
`release-smoke-test`, `smoke-test`, `validate-skill`.
Does **not** touch `validate.yml` — wiring skill-eval.sh into CI is a
separate bead.
Epic: ag-czzf
## Verification
- `bats tests/scripts/skill-eval.bats` → 10/10 pass (ms 0.1.2 on PATH)
- `shellcheck scripts/skill-eval.sh` → clean
- `check-registry-drift`, `validate-skill-schema`, `sync-skill-counts`,
`run-all`, opencode-smoke → all PASS with fixtures present, skill count
= 82 (fixtures excluded)
Closes-scenario: ag-yzoz#skill-eval
Bounded-context: BC2-Validation
Evidence: scripts/skill-eval.sh
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
667 lines
21 KiB
Bash
Executable File
667 lines
21 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
|
|
RUNTIME="all"
|
|
WORKDIR=""
|
|
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
|
|
CODEX_BIN="${CODEX_BIN:-codex}"
|
|
CODEX_PROFILE="${CODEX_VALIDATE_PROFILE:-}"
|
|
SOURCE_CODEX_HOME="${HEADLESS_RUNTIME_SOURCE_CODEX_HOME:-$HOME/.codex}"
|
|
TIMEOUT_SECONDS="${HEADLESS_RUNTIME_SKILL_TIMEOUT_SECONDS:-120}"
|
|
MAX_BUDGET_USD="${HEADLESS_RUNTIME_SKILL_MAX_BUDGET_USD:-2.00}"
|
|
INVENTORY_RETRIES="${HEADLESS_RUNTIME_SKILL_INVENTORY_RETRIES:-2}"
|
|
SKIP_ENV="${AGENTOPS_SKIP_HEADLESS_RUNTIME_SKILLS:-0}"
|
|
CLAUDE_STRICT="${HEADLESS_RUNTIME_SKILL_CLAUDE_STRICT:-0}"
|
|
CODEX_STRICT="${HEADLESS_RUNTIME_SKILL_CODEX_STRICT:-0}"
|
|
CLAUDE_USED_FALLBACK=0
|
|
CODEX_USED_FALLBACK=0
|
|
|
|
print_runtime_proof_matrix() {
|
|
cat <<'EOF'
|
|
Runtime proof tiers:
|
|
Claude Code: Tier S via tests/skills/test-runtime-claude-code-smoke.sh; Tier I here when claude/auth are available; Tier E opt-in only.
|
|
Codex: Tier S via tests/skills/test-runtime-codex-smoke.sh; Tier I here when codex/auth are available; Tier E opt-in only.
|
|
Cursor: Tier S via tests/skills/test-runtime-cursor-smoke.sh; Tier I not implemented; Tier E not implemented.
|
|
OpenCode: Tier S via tests/skills/test-runtime-opencode-smoke.sh; Tier I not implemented; Tier E not implemented.
|
|
EOF
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
validate-headless-runtime-skills.sh
|
|
|
|
Open fresh headless Claude and/or Codex sessions, ask each runtime to return the
|
|
visible AgentOps skill inventory as JSON, then compare that inventory against
|
|
the repo skill definitions.
|
|
|
|
Options:
|
|
--runtime <all|claude|codex> Which runtime(s) to validate (default: all)
|
|
--repo-root <dir> Repo root to validate (default: current repo)
|
|
--workdir <dir> Ephemeral working directory for headless sessions
|
|
--claude-bin <path> Claude CLI binary (default: claude)
|
|
--codex-bin <path> Codex CLI binary (default: codex)
|
|
--codex-profile <name> Codex profile for headless exec (default: none)
|
|
--timeout <seconds> Per-runtime timeout (default: 120)
|
|
--max-budget-usd <amount> Claude budget cap (default: 2.00)
|
|
--help Show this help
|
|
|
|
Environment:
|
|
AGENTOPS_SKIP_HEADLESS_RUNTIME_SKILLS=1 Skip the validation entirely
|
|
HEADLESS_RUNTIME_SKILL_CLAUDE_STRICT=1 Fail when Claude inventory cannot be collected
|
|
HEADLESS_RUNTIME_SKILL_CODEX_STRICT=1 Fail when Codex inventory cannot be collected
|
|
HEADLESS_RUNTIME_SKILL_INVENTORY_RETRIES Number of inventory compare attempts (default: 2)
|
|
HEADLESS_RUNTIME_SOURCE_CODEX_HOME=/path Reuse auth.json from a real Codex home
|
|
EOF
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--runtime)
|
|
RUNTIME="${2:-}"
|
|
shift 2
|
|
;;
|
|
--repo-root)
|
|
REPO_ROOT="${2:-}"
|
|
shift 2
|
|
;;
|
|
--workdir)
|
|
WORKDIR="${2:-}"
|
|
shift 2
|
|
;;
|
|
--claude-bin)
|
|
CLAUDE_BIN="${2:-}"
|
|
shift 2
|
|
;;
|
|
--codex-bin)
|
|
CODEX_BIN="${2:-}"
|
|
shift 2
|
|
;;
|
|
--codex-profile)
|
|
CODEX_PROFILE="${2:-}"
|
|
shift 2
|
|
;;
|
|
--timeout)
|
|
TIMEOUT_SECONDS="${2:-}"
|
|
shift 2
|
|
;;
|
|
--max-budget-usd)
|
|
MAX_BUDGET_USD="${2:-}"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown arg: $1" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
case "$RUNTIME" in
|
|
all|claude|codex) ;;
|
|
*)
|
|
echo "Invalid --runtime: $RUNTIME" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
if [[ "$REPO_ROOT" != /* ]]; then
|
|
REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
|
|
fi
|
|
|
|
if [[ "$SKIP_ENV" == "1" ]]; then
|
|
echo "SKIP: AGENTOPS_SKIP_HEADLESS_RUNTIME_SKILLS=1"
|
|
exit 0
|
|
fi
|
|
|
|
print_runtime_proof_matrix
|
|
|
|
TMP_DIR=""
|
|
cleanup() {
|
|
if [[ -n "${TMP_DIR:-}" && -d "$TMP_DIR" ]]; then
|
|
rm -rf "$TMP_DIR"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
TMP_DIR="$(mktemp -d)"
|
|
if [[ -z "$WORKDIR" ]]; then
|
|
WORKDIR="$TMP_DIR/workdir"
|
|
fi
|
|
mkdir -p "$WORKDIR"
|
|
|
|
EXPECTED_CLAUDE_JSON="$TMP_DIR/expected-claude.json"
|
|
EXPECTED_CODEX_JSON="$TMP_DIR/expected-codex.json"
|
|
ACTUAL_CLAUDE_JSON="$TMP_DIR/actual-claude.json"
|
|
ACTUAL_CODEX_JSON="$TMP_DIR/actual-codex.json"
|
|
CODEX_STREAM_JSON="$TMP_DIR/codex-stream.jsonl"
|
|
CODEX_HOME_ROOT="$TMP_DIR/codex-home"
|
|
CODEX_USER_HOME="$CODEX_HOME_ROOT/home"
|
|
CODEX_VALIDATION_HOME="$CODEX_USER_HOME/.codex"
|
|
|
|
build_expected_inventory() {
|
|
local skills_root="$1"
|
|
local out_file="$2"
|
|
|
|
python3 - "$skills_root" "$out_file" <<'PY'
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
skills_root = Path(sys.argv[1])
|
|
out_file = Path(sys.argv[2])
|
|
|
|
key_re = re.compile(r"^[A-Za-z0-9_-]+:")
|
|
|
|
def strip_quotes(value: str) -> str:
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
return value[1:-1]
|
|
return value
|
|
|
|
def fold_block(lines, mode):
|
|
cleaned = [line.strip() for line in lines]
|
|
if mode.startswith("|"):
|
|
return "\n".join(cleaned).strip()
|
|
return " ".join(part for part in cleaned if part).strip()
|
|
|
|
def parse_frontmatter(path: Path):
|
|
text = path.read_text()
|
|
if not text.startswith("---\n"):
|
|
raise SystemExit(f"missing frontmatter in {path}")
|
|
_, rest = text.split("---\n", 1)
|
|
frontmatter, _ = rest.split("\n---\n", 1)
|
|
lines = frontmatter.splitlines()
|
|
result = {"name": None, "description": None, "user_invocable": True}
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
stripped = line.strip()
|
|
if stripped.startswith("name:"):
|
|
result["name"] = strip_quotes(stripped.split(":", 1)[1])
|
|
i += 1
|
|
continue
|
|
if stripped.startswith("description:"):
|
|
rest = stripped.split(":", 1)[1].strip()
|
|
if rest and rest not in {">", "|", ">-", "|-"}:
|
|
result["description"] = strip_quotes(rest)
|
|
i += 1
|
|
continue
|
|
block_mode = rest or ">"
|
|
block = []
|
|
i += 1
|
|
while i < len(lines):
|
|
candidate = lines[i]
|
|
if candidate and not candidate.startswith((" ", "\t")) and key_re.match(candidate):
|
|
break
|
|
block.append(candidate)
|
|
i += 1
|
|
result["description"] = fold_block(block, block_mode)
|
|
continue
|
|
if stripped.startswith("user-invocable:"):
|
|
result["user_invocable"] = strip_quotes(stripped.split(":", 1)[1]).lower() != "false"
|
|
i += 1
|
|
continue
|
|
i += 1
|
|
if not result["name"] or result["description"] is None:
|
|
raise SystemExit(f"missing name/description in {path}")
|
|
return result
|
|
|
|
inventory = []
|
|
for skill_file in sorted(skills_root.glob("*/SKILL.md")):
|
|
# Skip leading-underscore scaffolding dirs (e.g. skills/_fixtures/) —
|
|
# planted test fixtures, not real skills.
|
|
if skill_file.parent.name.startswith("_"):
|
|
continue
|
|
inventory.append(parse_frontmatter(skill_file))
|
|
|
|
out_file.write_text(json.dumps(inventory, indent=2) + "\n")
|
|
PY
|
|
}
|
|
|
|
claude_has_openai_docs_mcp() {
|
|
local settings_path="${CLAUDE_SETTINGS_PATH:-$HOME/.claude/settings.json}"
|
|
[[ -f "$settings_path" ]] || return 1
|
|
grep -q '"openaiDeveloperDocs"' "$settings_path"
|
|
}
|
|
|
|
prune_optional_claude_skills() {
|
|
local inventory_file="$1"
|
|
|
|
if claude_has_openai_docs_mcp; then
|
|
return 0
|
|
fi
|
|
|
|
python3 - "$inventory_file" <<'PY'
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
path = Path(sys.argv[1])
|
|
inventory = json.loads(path.read_text())
|
|
filtered = [item for item in inventory if item.get("name") != "openai-docs"]
|
|
path.write_text(json.dumps(filtered, indent=2) + "\n")
|
|
PY
|
|
|
|
echo "WARN: Claude MCP server openaiDeveloperDocs not configured; excluding openai-docs from expected headless inventory." >&2
|
|
}
|
|
|
|
extract_json_array() {
|
|
local input_file="$1"
|
|
local out_file="$2"
|
|
|
|
python3 - "$input_file" "$out_file" <<'PY'
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
text = Path(sys.argv[1]).read_text().strip()
|
|
out = Path(sys.argv[2])
|
|
|
|
if text.startswith("```"):
|
|
lines = text.splitlines()
|
|
if lines and lines[0].startswith("```"):
|
|
lines = lines[1:]
|
|
if lines and lines[-1].startswith("```"):
|
|
lines = lines[:-1]
|
|
text = "\n".join(lines).strip()
|
|
|
|
decoder = json.JSONDecoder()
|
|
for idx, ch in enumerate(text):
|
|
if ch != "[":
|
|
continue
|
|
try:
|
|
value, end = decoder.raw_decode(text[idx:])
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(value, list):
|
|
out.write_text(json.dumps(value, indent=2) + "\n")
|
|
sys.exit(0)
|
|
|
|
print("could not parse JSON array from runtime output", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
}
|
|
|
|
compare_inventory() {
|
|
local expected_file="$1"
|
|
local actual_file="$2"
|
|
local runtime="$3"
|
|
local mode="${4:-name-and-description}"
|
|
|
|
python3 - "$expected_file" "$actual_file" "$runtime" "$mode" <<'PY'
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
expected = json.loads(Path(sys.argv[1]).read_text())
|
|
actual = json.loads(Path(sys.argv[2]).read_text())
|
|
runtime = sys.argv[3]
|
|
mode = sys.argv[4]
|
|
|
|
def norm(text: str) -> str:
|
|
return " ".join(str(text).split())
|
|
|
|
def trim_name(name: str) -> str:
|
|
name = str(name).strip()
|
|
if ":" in name:
|
|
return name.split(":", 1)[1]
|
|
return name
|
|
|
|
def description_matches(expected_text: str, actual_text: str) -> bool:
|
|
expected_norm = norm(expected_text)
|
|
actual_norm = norm(actual_text)
|
|
if actual_norm == expected_norm:
|
|
return True
|
|
if len(actual_norm) >= min(12, len(expected_norm)) and expected_norm.startswith(actual_norm):
|
|
return True
|
|
if actual_norm.endswith("…") and expected_norm.startswith(actual_norm[:-1].rstrip()):
|
|
return True
|
|
if actual_norm.endswith("...") and expected_norm.startswith(actual_norm[:-3].rstrip()):
|
|
return True
|
|
return False
|
|
|
|
if mode == "names-only-invocable":
|
|
expected = [item for item in expected if item.get("user_invocable", True)]
|
|
|
|
expected_map = {item["name"]: norm(item["description"]) for item in expected}
|
|
actual_map = {
|
|
trim_name(item["name"] if isinstance(item, dict) else item): norm(item.get("description", "") if isinstance(item, dict) else "")
|
|
for item in actual
|
|
if (item.get("name") if isinstance(item, dict) else item)
|
|
}
|
|
|
|
missing = sorted(name for name in expected_map if name not in actual_map)
|
|
if mode.startswith("names-only"):
|
|
mismatched = []
|
|
else:
|
|
mismatched = sorted(
|
|
name for name, description in expected_map.items()
|
|
if name in actual_map and not description_matches(description, actual_map[name])
|
|
)
|
|
extras = sorted(name for name in actual_map if name not in expected_map)
|
|
|
|
if missing:
|
|
print(f"{runtime}: missing skills: " + ", ".join(missing), file=sys.stderr)
|
|
if mismatched:
|
|
print(f"{runtime}: description mismatches: " + ", ".join(mismatched), file=sys.stderr)
|
|
if extras:
|
|
print(f"{runtime}: ignoring extra skills: " + ", ".join(extras))
|
|
|
|
if missing or mismatched:
|
|
sys.exit(1)
|
|
|
|
print(f"{runtime}: validated {len(expected_map)} skills")
|
|
PY
|
|
}
|
|
|
|
CLAUDE_PROMPT="List the available AgentOps skills in this session. Return ONLY a compact JSON array of skill names. Use the exact visible AgentOps skill names and exclude any built-in or non-AgentOps system skills."
|
|
CODEX_PROMPT="List the available AgentOps skills in this session. Return ONLY a compact JSON array of skill names. Use the exact visible AgentOps skill names and exclude built-in OpenAI system skills such as skill-creator, skill-installer, slides, and spreadsheets."
|
|
|
|
build_expected_inventory "$REPO_ROOT/skills" "$EXPECTED_CLAUDE_JSON"
|
|
build_expected_inventory "$REPO_ROOT/skills-codex" "$EXPECTED_CODEX_JSON"
|
|
prune_optional_claude_skills "$EXPECTED_CLAUDE_JSON"
|
|
|
|
run_claude_load_check() {
|
|
if timeout 20 "$CLAUDE_BIN" --plugin-dir "$REPO_ROOT" --help >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
if command -v script >/dev/null 2>&1; then
|
|
script -q /dev/null "$CLAUDE_BIN" --plugin-dir "$REPO_ROOT" --help >/dev/null 2>&1
|
|
return $?
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
claude_inventory_failed() {
|
|
local reason="$1"
|
|
local raw_output="${2:-}"
|
|
|
|
echo "WARN: Claude inventory verification failed ($reason)." >&2
|
|
if [[ -n "$raw_output" && -f "$raw_output" ]]; then
|
|
sed -n '1,20p' "$raw_output" >&2 || true
|
|
fi
|
|
|
|
if run_claude_load_check; then
|
|
echo "WARN: Claude load check passed; using load-check fallback instead of verified inventory." >&2
|
|
if [[ "$CLAUDE_STRICT" == "1" ]]; then
|
|
echo "FAIL: HEADLESS_RUNTIME_SKILL_CLAUDE_STRICT=1 requires verified Claude inventory." >&2
|
|
return 1
|
|
fi
|
|
CLAUDE_USED_FALLBACK=1
|
|
echo "claude: load-check fallback passed"
|
|
return 0
|
|
fi
|
|
|
|
echo "Claude load check failed" >&2
|
|
return 1
|
|
}
|
|
|
|
run_codex_load_check() {
|
|
[[ -f "$CODEX_VALIDATION_HOME/.agentops-codex-install.json" ]] || return 1
|
|
env HOME="$CODEX_USER_HOME" CODEX_HOME="$CODEX_VALIDATION_HOME" AGENTOPS_HOOKS_DISABLED=1 \
|
|
timeout 20 "$CODEX_BIN" exec --help >/dev/null 2>&1
|
|
}
|
|
|
|
codex_inventory_failed() {
|
|
local reason="$1"
|
|
local raw_output="${2:-}"
|
|
|
|
echo "WARN: Codex inventory verification failed ($reason)." >&2
|
|
if [[ -n "$raw_output" && -f "$raw_output" ]]; then
|
|
sed -n '1,20p' "$raw_output" >&2 || true
|
|
fi
|
|
|
|
if run_codex_load_check; then
|
|
echo "WARN: Codex load check passed in isolated CODEX_HOME; using load-check fallback instead of verified inventory." >&2
|
|
if [[ "$CODEX_STRICT" == "1" ]]; then
|
|
echo "FAIL: HEADLESS_RUNTIME_SKILL_CODEX_STRICT=1 requires verified Codex inventory." >&2
|
|
return 1
|
|
fi
|
|
CODEX_USED_FALLBACK=1
|
|
echo "codex: load-check fallback passed"
|
|
return 0
|
|
fi
|
|
|
|
echo "Codex load check failed" >&2
|
|
return 1
|
|
}
|
|
|
|
collect_claude_inventory_once() {
|
|
local raw_output="$TMP_DIR/claude-stream.jsonl"
|
|
CLAUDE_USED_FALLBACK=0
|
|
|
|
if (
|
|
cd "$REPO_ROOT"
|
|
AGENTOPS_HOOKS_DISABLED=1 timeout "$TIMEOUT_SECONDS" \
|
|
"$CLAUDE_BIN" -p "$CLAUDE_PROMPT" \
|
|
--plugin-dir "$REPO_ROOT" \
|
|
--dangerously-skip-permissions \
|
|
--max-turns 1 \
|
|
--no-session-persistence \
|
|
--max-budget-usd "$MAX_BUDGET_USD" \
|
|
--output-format stream-json \
|
|
--verbose
|
|
) >"$raw_output" 2>&1; then
|
|
:
|
|
else
|
|
local rc=$?
|
|
claude_inventory_failed "headless command exit $rc" "$raw_output"
|
|
return $?
|
|
fi
|
|
|
|
if ! python3 - "$raw_output" "$TMP_DIR/claude-output.txt" <<'PY'
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
messages = []
|
|
for line in Path(sys.argv[1]).read_text().splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
payload = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if payload.get("type") != "assistant":
|
|
continue
|
|
message = payload.get("message", {})
|
|
for item in message.get("content", []):
|
|
if item.get("type") == "text":
|
|
text = item.get("text", "").strip()
|
|
if text:
|
|
messages.append(text)
|
|
|
|
if not messages:
|
|
print("No assistant text found in Claude stream-json output", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
Path(sys.argv[2]).write_text(messages[-1] + "\n")
|
|
PY
|
|
then
|
|
claude_inventory_failed "stream-json parse error" "$raw_output"
|
|
return $?
|
|
fi
|
|
|
|
if ! extract_json_array "$TMP_DIR/claude-output.txt" "$ACTUAL_CLAUDE_JSON"; then
|
|
claude_inventory_failed "assistant output was not a JSON array" "$TMP_DIR/claude-output.txt"
|
|
return $?
|
|
fi
|
|
}
|
|
|
|
run_claude_validation() {
|
|
if ! command -v "$CLAUDE_BIN" >/dev/null 2>&1; then
|
|
echo "SKIP: Claude CLI not found in PATH"
|
|
return 0
|
|
fi
|
|
|
|
local compare_output
|
|
local attempt=1
|
|
while (( attempt <= INVENTORY_RETRIES )); do
|
|
if collect_claude_inventory_once; then
|
|
:
|
|
else
|
|
local rc=$?
|
|
return "$rc"
|
|
fi
|
|
if [[ "$CLAUDE_USED_FALLBACK" == "1" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if compare_output="$(compare_inventory "$EXPECTED_CLAUDE_JSON" "$ACTUAL_CLAUDE_JSON" "claude" "names-only-invocable" 2>&1)"; then
|
|
echo "claude: inventory verified"
|
|
printf '%s\n' "$compare_output"
|
|
return 0
|
|
fi
|
|
|
|
if (( attempt == INVENTORY_RETRIES )); then
|
|
printf '%s\n' "$compare_output" >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "WARN: Claude inventory mismatch on attempt $attempt/$INVENTORY_RETRIES; retrying." >&2
|
|
printf '%s\n' "$compare_output" >&2
|
|
attempt=$((attempt + 1))
|
|
done
|
|
}
|
|
|
|
run_codex_validation() {
|
|
if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then
|
|
echo "SKIP: Codex CLI not found in PATH"
|
|
return 0
|
|
fi
|
|
|
|
mkdir -p "$CODEX_USER_HOME" "$CODEX_VALIDATION_HOME"
|
|
if ! bash "$REPO_ROOT/scripts/install-codex-plugin.sh" --repo-root "$REPO_ROOT" --codex-home "$CODEX_VALIDATION_HOME" >/dev/null; then
|
|
echo "Codex plugin install into temp CODEX_HOME failed" >&2
|
|
return 1
|
|
fi
|
|
|
|
if [[ -f "$SOURCE_CODEX_HOME/auth.json" ]]; then
|
|
cp "$SOURCE_CODEX_HOME/auth.json" "$CODEX_VALIDATION_HOME/auth.json"
|
|
chmod 600 "$CODEX_VALIDATION_HOME/auth.json"
|
|
fi
|
|
|
|
local -a codex_args=(
|
|
exec
|
|
--skip-git-repo-check
|
|
--sandbox read-only
|
|
--json
|
|
-C "$WORKDIR"
|
|
)
|
|
if [[ -n "$CODEX_PROFILE" ]]; then
|
|
codex_args+=(--profile "$CODEX_PROFILE")
|
|
fi
|
|
codex_args+=("$CODEX_PROMPT")
|
|
|
|
local compare_output
|
|
local attempt=1
|
|
while (( attempt <= INVENTORY_RETRIES )); do
|
|
CODEX_USED_FALLBACK=0
|
|
|
|
if ! env HOME="$CODEX_USER_HOME" CODEX_HOME="$CODEX_VALIDATION_HOME" AGENTOPS_HOOKS_DISABLED=1 \
|
|
timeout "$TIMEOUT_SECONDS" "$CODEX_BIN" "${codex_args[@]}" >"$CODEX_STREAM_JSON"; then
|
|
if [[ -n "$CODEX_PROFILE" ]] && grep -q "config profile .* not found" "$CODEX_STREAM_JSON" 2>/dev/null; then
|
|
echo "WARN: Codex profile '$CODEX_PROFILE' not found in isolated CODEX_HOME; retrying without --profile" >&2
|
|
local -a fallback_args=(
|
|
exec
|
|
--skip-git-repo-check
|
|
--sandbox read-only
|
|
--json
|
|
-C "$WORKDIR"
|
|
"$CODEX_PROMPT"
|
|
)
|
|
if ! env HOME="$CODEX_USER_HOME" CODEX_HOME="$CODEX_VALIDATION_HOME" AGENTOPS_HOOKS_DISABLED=1 \
|
|
timeout "$TIMEOUT_SECONDS" "$CODEX_BIN" "${fallback_args[@]}" >"$CODEX_STREAM_JSON"; then
|
|
codex_inventory_failed "headless command exit after retry" "$CODEX_STREAM_JSON"
|
|
return $?
|
|
fi
|
|
else
|
|
codex_inventory_failed "headless command exit" "$CODEX_STREAM_JSON"
|
|
return $?
|
|
fi
|
|
fi
|
|
|
|
if ! python3 - "$CODEX_STREAM_JSON" "$TMP_DIR/codex-output.txt" <<'PY'
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
messages = []
|
|
for line in Path(sys.argv[1]).read_text().splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
payload = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
item = payload.get("item")
|
|
if payload.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message":
|
|
text = item.get("text", "").strip()
|
|
if text:
|
|
messages.append(text)
|
|
|
|
if not messages:
|
|
print("No agent_message found in Codex JSON stream", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
Path(sys.argv[2]).write_text(messages[-1] + "\n")
|
|
PY
|
|
then
|
|
codex_inventory_failed "stream-json parse error" "$CODEX_STREAM_JSON"
|
|
return $?
|
|
fi
|
|
|
|
if ! extract_json_array "$TMP_DIR/codex-output.txt" "$ACTUAL_CODEX_JSON"; then
|
|
codex_inventory_failed "assistant output was not a JSON array" "$TMP_DIR/codex-output.txt"
|
|
return $?
|
|
fi
|
|
|
|
if [[ "$CODEX_USED_FALLBACK" == "1" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if compare_output="$(compare_inventory "$EXPECTED_CODEX_JSON" "$ACTUAL_CODEX_JSON" "codex" "names-only" 2>&1)"; then
|
|
echo "codex: inventory verified"
|
|
printf '%s\n' "$compare_output"
|
|
return 0
|
|
fi
|
|
|
|
if (( attempt == INVENTORY_RETRIES )); then
|
|
printf '%s\n' "$compare_output" >&2
|
|
return 1
|
|
fi
|
|
|
|
echo "WARN: Codex inventory mismatch on attempt $attempt/$INVENTORY_RETRIES; retrying." >&2
|
|
printf '%s\n' "$compare_output" >&2
|
|
attempt=$((attempt + 1))
|
|
done
|
|
}
|
|
|
|
case "$RUNTIME" in
|
|
all)
|
|
run_claude_validation
|
|
run_codex_validation
|
|
;;
|
|
claude)
|
|
run_claude_validation
|
|
;;
|
|
codex)
|
|
run_codex_validation
|
|
;;
|
|
esac
|
|
|
|
echo "Headless runtime skill validation passed."
|