Files
boshu2__agentops/scripts/validate-skill-schema.sh
Bo 8cdcb5a903 Train 1: measurement substrate, context diet, retrieval-eval contract (instrument-panel roadmap) (#1087)
> **Residues closed on the caller's merge instruction** (`499d916a6`):
the round-2 findings were the same failure shape — round-1 repairs
patched cited lines instead of sweeping the class — so this commit
sweeps each file whole: every remaining SATURATED-row-append site in
skill-eval now routes to RUNBOOK retirement, the human-only-skills
*description* is runtime-conditional, premortem's "(MEASURED)" label is
gone, SKILL-API's context table carries all 25 rows and the enforcement
table gains `disable-model-invocation`, and the fixture-identity claim
is stated precisely (probe id, honesty note, and control arm are the
only differing fields — as the acceptance permits). Post-sweep:
validators, full Go suite, 68/68 gates, goldens + headroom bats green,
projections current, gemini in sync. Merging per Bo's instruction.

## What

Train 1 of the accepted [instrument-panel
roadmap](docs/plans/2026-08-26-instrument-panel-roadmap.md) (intent
landed at `986a4feaf`): the measurement substrate, the skill-context
diet, and the retrieval-eval contract. Three worktree-isolated lanes,
each independently validated by a fresh context, plus one integration
commit. 103 files, +5,510/−76.

**L1 — measurement substrate** (`instrument/measurement-substrate`)
- Gate `skill.probe-headroom` (advisory, Fast|Full): answers the
question `skill.probe-coverage` cannot — not "does a probe result exist"
but "could one have existed at all". The rule, ported to Go
(`cli/internal/probeheadroom` + `cli/cmd/probe-headroom` behind a thin
check script — the witness-crosscheck pattern, **no new `ao` root
command**): control arm ≥ 0.75 with ≥ 2 usable reps at ≥ 2 effort levels
⇒ SATURATED (void row, not an honest null); UNMEASURED outranks it;
treatment-silent ⇒ FLOOR; else SEPARATED. RED first: both committed
fixture pairs read `INERT` to everything else in the repo; the failing
separation test predates the implementation, and a bats negative-control
swaps fixture bytes and asserts the gate flips.
- **First reading on real data: 7 of 11 historical probe groups are
SATURATED** — including both `validate-not-proven` runs. Those INERT
rows were never honest nulls; they were void. The 0/12 ledger number now
argues itself.
- Declared denominator for probe-coverage:
`scripts/.skill-probe-denominator-exclusions`, fail-closed parser (entry
without an argument, stale slug, or duplicate ⇒ exit 2). One entry
(`goals`, a pure alias-of `fitness`). Net effect deliberately zero (0/12
→ 0/12: alias left, `one-way-door` entered) — the gain is a declared
number, not a better-looking one.
- Re-landed from the recovered clean-room commit (`9872483bd`),
re-validated against *current* main: `skill-eval` (defers saturation to
the gate id; its shell scripts dropped, not shipped — ratchet intent),
`route`, `one-way-door`, premortem reversibility check, council
`caller_challenge` (schema + validator, per the agent-core boundary that
the panel may challenge, never overrule).

**L4 — context diet** (`instrument/context-diet`)
- `disable-model-invocation: true` on 4 human-only skills (key verified
verbatim against Anthropic's docs). The plan guessed 35 candidates; the
graph said otherwise — 23 carry `user-invocable: true`, and 19 of those
are excluded on cited evidence (rpi consumes
anti-ceremony/implement/plan/validate; workflow scripts reach others;
`goals` is a live migration tombstone). The exclusion evidence is
retained in the lane report.
- One router skill (`human-only-skills`) — the single always-loaded
description that replaces four; it hints, never fires.
- `.out-of-scope/` formalized with this week's three refusals
(checked-in knowledge corpus; ee self-improvement loops; whole-skill A/B
as the measurement unit), each citing its evidence.
- Deterministic proof, no model eval: before/after bytes of
always-loaded description load reported in the lane summary.

**L5 — retrieval-eval contract** (`instrument/retrieval-contract`, lane
verdict PASS 10/10)
- `AGENTS.md` federated row now names **ee (eidetic-engine)** as a
concrete caller-selected memory system — consume, never build; symlink
intact.
- `schemas/pack-quality-expectations.v1.schema.json` + 4 routing goldens
+ `scripts/check-routing-probe-goldens.sh` graded against `ao skills
find`, wired as an **advisory** nightly job. Zero goldens is a failing
state — no new zero-denominator green.
- **The instrument caught a real miss on day one — and its own
prescription fixed it.** Golden `rq-04` expects `validate` for "judge
whether this finished change is actually proven before I merge it"; at
authoring, `ao skills find` ranked the *forbidden* `premortem` first and
`validate` nowhere in six natural phrasings. The pointer-wording-first
repair (validate's description gained the caller's own words: finished,
proven, verdict, merge) now ranks it #1 at 0.333; grader 6/6, and the
golden pins the repair — a description regression reopens it.

## Integration

`regen-all.sh` once over the merged lanes (catalog 52 → 56, four new
codex twins, mesh, router, manifests); `skills/route/SKILL.md`
catalog/router links became prose repo-root references (the projected
twin cannot resolve `../catalog.json` — this was both the
portable-conformance failure and the sole broken doc link);
`codex-portable-conformance.bats` pin 52 → 56.

## Evidence

- `cd cli && go build ./... && go vet ./... && go test ./...` exit 0 ·
`ao gate check --full` **68/68** · four skill validators PASS ·
probe-headroom / routing-goldens / probe-coverage bats PASS ·
`regen-all.sh --check` all current.
- Per-lane fresh validators re-ran every suite on detached content; L5
PASS; L1/L4 NOT_PROVEN solely on the projection-regen clause reserved
for integration (their remaining acceptance observed green), settled
above. Cross-family (Codex) review of the integrated diff recorded in
the session report.
- Two disclosed scope stretches accepted at integration: a one-line
`.gitignore` entry mirroring the witness-crosscheck precedent, and the
probe LEDGER.md fact-correction L1's own change made necessary (noted
for Train 2's L2, which owns that file next).

## Cross-family review (Codex, fresh context)

Round 1: **FAIL** — two blockers (the RED fixtures didn't isolate the
control arm; the goldens grader was red where the plan's acceptance says
green) and eight majors (contract contradictions in the re-landed
skills, a converter-substitution false claim in the codex router twin,
two overreaching `.out-of-scope` entries, stale SKILL-API counts). All
repaired in one bounded round (`db68935a3`): fixtures now byte-identical
outside the control arm, the routing miss actually fixed rather than
tolerated, every cited contradiction reconciled at the source and
re-projected. Post-repair: full Go suite exit 0, `gate check --full`
68/68, all validators and probe/goldens bats green, projections current,
gemini byte-identity restored. Focused re-check verdict recorded in the
session report.

## Follow-ups (Train 2, already planned)

Seeded-defect probes for the judgment spine (every ledger row citing a
passing headroom pre-screen) and the gate-hardening pair
(`Gate-Loosen-Reason` tightening ratchet; mechanical
grounding-validation over evidence docs). Plus, surfaced by this train:
a latent `valid_keys`/schema divergence in `validate-skill-schema.sh`
(two keys the schema defines are absent from the script's allowlist —
pre-existing).
2026-08-26 23:16:51 +00:00

327 lines
9.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# validate-skill-schema.sh — Validate SKILL.md YAML frontmatter against JSON schema
#
# Finds all skills/*/SKILL.md files, extracts YAML frontmatter, and validates
# each against schemas/skill-frontmatter.v1.schema.json.
#
# Validation tiers (best available wins):
# 1. yq + python3 jsonschema — full JSON Schema Draft-07 validation
# 2. yq + jq — structural checks (required fields, types, enum values)
# 3. grep — basic required-field presence check
#
# Usage: scripts/validate-skill-schema.sh [--verbose]
# Exit: 0 = all pass, 1 = failures found
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SCHEMA="$REPO_ROOT/schemas/skill-frontmatter.v1.schema.json"
SKILLS_DIR="$REPO_ROOT/skills"
VERBOSE=0
if [[ "${1:-}" == "--verbose" ]]; then
VERBOSE=1
fi
# --- Colors (disabled in CI / non-tty) ---
if [[ -t 1 ]]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; NC=''
fi
pass=0
fail=0
warn=0
total=0
failures=""
# --- Extract YAML frontmatter from a SKILL.md file ---
# Outputs everything between the first pair of --- delimiters (exclusive).
extract_frontmatter() {
local file="$1"
awk 'BEGIN{found=0} /^---$/{if(found){exit}else{found=1;next}} found{print}' "$file"
}
# --- Detect validation tier ---
HAS_YQ=0
HAS_PYTHON_JSONSCHEMA=0
HAS_JQ=0
YQ_VARIANT=""
if command -v yq &>/dev/null; then
HAS_YQ=1
# Detect yq variant: "go" (Mike Farah, supports `-o json`) vs "python" (kislyuk,
# jq wrapper that emits JSON by default from YAML). Both are packaged under the
# same binary name in different distros.
if yq --help 2>&1 | grep -q 'jq filter'; then
YQ_VARIANT="python"
else
YQ_VARIANT="go"
fi
fi
if command -v jq &>/dev/null; then
HAS_JQ=1
fi
# yq_to_json: run a jq filter over YAML on stdin and emit JSON, regardless of
# which yq variant is installed.
yq_to_json() {
local filter="${1:-.}"
case "$YQ_VARIANT" in
go) yq -o json "$filter" ;;
python) yq "$filter" ;;
*) return 1 ;;
esac
}
if command -v python3 &>/dev/null; then
if python3 -c "import jsonschema, json, yaml" &>/dev/null; then
HAS_PYTHON_JSONSCHEMA=1
fi
fi
if [[ $HAS_YQ -eq 1 && $HAS_PYTHON_JSONSCHEMA -eq 1 ]]; then
TIER="full"
echo "=== SKILL.md Schema Validation (full: yq + python3 jsonschema) ==="
elif [[ $HAS_YQ -eq 1 && $HAS_JQ -eq 1 ]]; then
TIER="structural"
echo "=== SKILL.md Schema Validation (structural: yq + jq) ==="
else
TIER="basic"
echo "=== SKILL.md Schema Validation (basic: grep) ==="
fi
# --- Full validation via python3 jsonschema ---
validate_full() {
local skill_name="$1"
local frontmatter="$2"
local json_data
json_data=$(echo "$frontmatter" | yq_to_json '.' 2>&1) || {
echo -e " ${RED}FAIL${NC} $skill_name: invalid YAML frontmatter"
[[ $VERBOSE -eq 1 ]] && echo " yq error: $json_data"
return 1
}
local result
result=$(python3 -c "
import json, sys
from jsonschema import validate, ValidationError
schema = json.load(open('$SCHEMA'))
data = json.loads(sys.stdin.read())
try:
validate(instance=data, schema=schema)
print('OK')
except ValidationError as e:
print(f'FAIL: {e.message}')
" <<< "$json_data" 2>&1) || true
if [[ "$result" == "OK" ]]; then
[[ $VERBOSE -eq 1 ]] && echo -e " ${GREEN}PASS${NC} $skill_name"
return 0
else
echo -e " ${RED}FAIL${NC} $skill_name: ${result#FAIL: }"
return 1
fi
}
# --- Structural validation via yq + jq ---
validate_structural() {
local skill_name="$1"
local frontmatter="$2"
local errors=""
local json_data
json_data=$(echo "$frontmatter" | yq_to_json '.' 2>&1) || {
echo -e " ${RED}FAIL${NC} $skill_name: invalid YAML frontmatter"
return 1
}
# Check required fields
for field in name description skill_api_version; do
if ! echo "$json_data" | jq -e ".[\"$field\"]" &>/dev/null; then
errors="${errors}missing required field '$field'; "
fi
done
# Check name is string
local name_type
name_type=$(echo "$json_data" | jq -r '.name | type' 2>/dev/null)
if [[ "$name_type" != "string" && -z "$errors" ]]; then
errors="${errors}'name' must be a string (got $name_type); "
fi
# Check description is string
local desc_type
desc_type=$(echo "$json_data" | jq -r '.description | type' 2>/dev/null)
if [[ "$desc_type" != "string" ]]; then
errors="${errors}'description' must be a string (got $desc_type); "
fi
# Check skill_api_version is 1
local api_ver
api_ver=$(echo "$json_data" | jq -r '.skill_api_version' 2>/dev/null)
if [[ "$api_ver" != "1" ]]; then
errors="${errors}'skill_api_version' must be 1 (got $api_ver); "
fi
# Check metadata.tier enum if present
local tier
tier=$(echo "$json_data" | jq -r '.metadata.tier // empty' 2>/dev/null)
if [[ -n "$tier" ]]; then
local valid_tiers="judgment execution library session product contribute meta background orchestration cross-vendor knowledge experimental"
if ! echo "$valid_tiers" | grep -qw "$tier"; then
errors="${errors}'metadata.tier' invalid value '$tier'; "
fi
fi
# Check for unknown top-level keys (additionalProperties: false)
local valid_keys="name description skill_api_version metadata user-invocable disable-model-invocation context allowed-tools license compatibility model output_contract practices hexagonal_role consumes produces context_rel"
local actual_keys
actual_keys=$(echo "$json_data" | jq -r 'keys[]' 2>/dev/null)
for key in $actual_keys; do
if ! echo "$valid_keys" | grep -qw "$key"; then
errors="${errors}unknown top-level property '$key'; "
fi
done
if [[ -n "$errors" ]]; then
echo -e " ${RED}FAIL${NC} $skill_name: $errors"
return 1
else
[[ $VERBOSE -eq 1 ]] && echo -e " ${GREEN}PASS${NC} $skill_name"
return 0
fi
}
# --- Basic validation via grep ---
validate_basic() {
local skill_name="$1"
local frontmatter="$2"
local errors=""
# Check required fields exist
if ! echo "$frontmatter" | grep -q "^name:"; then
errors="${errors}missing 'name'; "
fi
if ! echo "$frontmatter" | grep -q "^description:"; then
errors="${errors}missing 'description'; "
fi
if ! echo "$frontmatter" | grep -q "^skill_api_version:"; then
errors="${errors}missing 'skill_api_version'; "
fi
# Check skill_api_version value
if echo "$frontmatter" | grep -q "^skill_api_version:"; then
local ver
ver=$(echo "$frontmatter" | grep "^skill_api_version:" | sed 's/skill_api_version:[[:space:]]*//')
if [[ "$ver" != "1" ]]; then
errors="${errors}'skill_api_version' must be 1 (got '$ver'); "
fi
fi
if [[ -n "$errors" ]]; then
echo -e " ${RED}FAIL${NC} $skill_name: $errors"
return 1
else
[[ $VERBOSE -eq 1 ]] && echo -e " ${GREEN}PASS${NC} $skill_name"
return 0
fi
}
# --- Validate schema file exists ---
if [[ ! -f "$SCHEMA" ]]; then
echo -e "${YELLOW}WARNING${NC}: Schema file not found at $SCHEMA"
echo "Falling back to basic validation without schema."
TIER="basic"
fi
# --- Main loop ---
for skill_dir in "$SKILLS_DIR"/*/; do
[[ ! -d "$skill_dir" ]] && continue
skill_name=$(basename "$skill_dir")
# Leading-underscore dirs (e.g. skills/_fixtures/) are non-skill scaffolding
# (planted test fixtures); they are not real skills, so don't schema-validate.
[[ "$skill_name" == _* ]] && continue
skill_file="$skill_dir/SKILL.md"
if [[ ! -f "$skill_file" ]]; then
echo -e " ${YELLOW}SKIP${NC} $skill_name: no SKILL.md"
warn=$((warn + 1))
continue
fi
# Runtime compatibility pointers are redirect packages, not independent
# implementations. The redirect gate owns their compact schema.
if grep -Eq '^implementation:[[:space:]]+false([[:space:]]|$)' "$skill_file"; then
continue
fi
# Verify frontmatter delimiters exist
if ! head -1 "$skill_file" | grep -q "^---$"; then
echo -e " ${RED}FAIL${NC} $skill_name: SKILL.md does not start with ---"
fail=$((fail + 1))
failures="${failures} - $skill_name\n"
total=$((total + 1))
continue
fi
frontmatter=$(extract_frontmatter "$skill_file")
if [[ -z "$frontmatter" ]]; then
echo -e " ${RED}FAIL${NC} $skill_name: empty frontmatter"
fail=$((fail + 1))
failures="${failures} - $skill_name\n"
total=$((total + 1))
continue
fi
total=$((total + 1))
case "$TIER" in
full)
if validate_full "$skill_name" "$frontmatter"; then
pass=$((pass + 1))
else
fail=$((fail + 1))
failures="${failures} - $skill_name\n"
fi
;;
structural)
if validate_structural "$skill_name" "$frontmatter"; then
pass=$((pass + 1))
else
fail=$((fail + 1))
failures="${failures} - $skill_name\n"
fi
;;
basic)
if validate_basic "$skill_name" "$frontmatter"; then
pass=$((pass + 1))
else
fail=$((fail + 1))
failures="${failures} - $skill_name\n"
fi
;;
esac
done
# --- Summary ---
echo ""
echo "--- Results ---"
echo "Total: $total | Pass: $pass | Fail: $fail | Warn: $warn"
if [[ $fail -gt 0 ]]; then
echo ""
echo "Failed skills:"
echo -e "$failures"
echo "FAIL: $fail skill(s) failed schema validation"
exit 1
fi
echo ""
echo "All $pass skill(s) passed schema validation"
exit 0