mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
247 lines
8.4 KiB
Bash
Executable File
247 lines
8.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# lint-codex-native.sh — Lint skills-codex/ for Codex-native compliance
|
|
#
|
|
# Checks:
|
|
# 1. No slash-command invocations (must use $ prefix)
|
|
# 2. No Claude Code primitives in main execution flow (before ## References)
|
|
# 3. No ~/.claude/ paths (must use ~/.codex/)
|
|
# 4. No "Claude Code" runtime references (use "Codex" or runtime-neutral)
|
|
# 5. Required: Portability Appendix if Claude primitives exist in main flow
|
|
#
|
|
# Usage:
|
|
# scripts/lint-codex-native.sh [--strict] [--skill <name>]
|
|
#
|
|
# Exit codes:
|
|
# 0 — all checks pass
|
|
# 1 — violations found
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
SKILLS_DIR="$REPO_ROOT/skills-codex"
|
|
|
|
# Cross-runtime skills legitimately document non-Codex runtimes (cass parses
|
|
# Claude Code logs, cc-hooks is about Claude Code hooks). Shared exemption list
|
|
# with codex-sync and the other Codex gates.
|
|
CROSS_RUNTIME_FILE="$REPO_ROOT/scripts/lint/codex-cross-runtime-skills.txt"
|
|
is_cross_runtime() {
|
|
[[ -f "$CROSS_RUNTIME_FILE" ]] || return 1
|
|
grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$CROSS_RUNTIME_FILE" | grep -qxF "$1"
|
|
}
|
|
|
|
# parity_only twins are GENERATED by codex-sync and verified by its byte-exact
|
|
# drift gate (codex-sync --check) — re-checking content rules on a generated
|
|
# artifact is the whack-a-mole this fix removes. Content lint runs on BESPOKE
|
|
# (hand-authored) twins only. (codex-sync runs in regen-all ahead of this gate.)
|
|
BESPOKE_SKILLS="$(python3 -c "import json; d=json.load(open('$REPO_ROOT/skills-codex-overrides/catalog.json')); print(chr(10).join(e['name'] for e in d.get('skills',[]) if e.get('treatment')=='bespoke'))" 2>/dev/null || true)"
|
|
is_bespoke() { grep -qxF "$1" <<<"$BESPOKE_SKILLS"; }
|
|
|
|
STRICT=false
|
|
FILTER_SKILL=""
|
|
ERRORS=0
|
|
WARNINGS=0
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--strict) STRICT=true; shift ;;
|
|
--skill) FILTER_SKILL="$2"; shift 2 ;;
|
|
*) echo "Unknown flag: $1"; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[0;33m'
|
|
GREEN='\033[0;32m'
|
|
NC='\033[0m'
|
|
|
|
error() {
|
|
echo -e "${RED} FAIL${NC}: $1"
|
|
ERRORS=$((ERRORS + 1))
|
|
}
|
|
|
|
warn() {
|
|
echo -e "${YELLOW} WARN${NC}: $1"
|
|
WARNINGS=$((WARNINGS + 1))
|
|
}
|
|
|
|
pass() {
|
|
if $STRICT; then
|
|
echo -e "${GREEN} PASS${NC}: $1"
|
|
fi
|
|
}
|
|
|
|
# Current source metadata owns the slash-invocation vocabulary.
|
|
SKILL_NAMES="$(find "$REPO_ROOT/skills" -mindepth 2 -maxdepth 2 -name SKILL.md -print | sed 's#/SKILL.md$##; s#^.*/##' | LC_ALL=C sort | paste -sd '|' -)"
|
|
|
|
# Claude-only primitives (should not appear in main execution flow)
|
|
CLAUDE_PRIMITIVES="TeamCreate|SendMessage|EnterPlanMode|ExitPlanMode|EnterWorktree"
|
|
|
|
# Find the line number where a section starts (0 if not found)
|
|
find_section_line() {
|
|
local file="$1"
|
|
local pattern="$2"
|
|
local line
|
|
line=$(grep -n "$pattern" "$file" | head -1 | cut -d: -f1)
|
|
echo "${line:-0}"
|
|
}
|
|
|
|
# Count matches in a string (handling empty input)
|
|
count_lines() {
|
|
local input="$1"
|
|
if [[ -z "$input" ]]; then
|
|
echo 0
|
|
else
|
|
echo "$input" | wc -l | tr -d ' '
|
|
fi
|
|
}
|
|
|
|
# Check a single skill
|
|
check_skill() {
|
|
local skill_name="$1"
|
|
local skill_file="$SKILLS_DIR/$skill_name/SKILL.md"
|
|
|
|
# parity_only twins are generator-verified (codex-sync drift gate); only lint
|
|
# hand-authored bespoke twins. An explicit --skill request is always honored.
|
|
if [[ -z "$FILTER_SKILL" ]] && ! is_bespoke "$skill_name"; then
|
|
return
|
|
fi
|
|
|
|
if [[ ! -f "$skill_file" ]]; then
|
|
warn "$skill_name: SKILL.md not found"
|
|
return
|
|
fi
|
|
|
|
local refs_line
|
|
refs_line=$(find_section_line "$skill_file" '^## Reference')
|
|
local port_line
|
|
port_line=$(find_section_line "$skill_file" '^## Portability')
|
|
local total_lines
|
|
total_lines=$(wc -l < "$skill_file" | tr -d ' ')
|
|
|
|
# Determine the "main flow" boundary (before References or Portability)
|
|
local main_end=$total_lines
|
|
if [[ $refs_line -gt 0 ]]; then
|
|
main_end=$refs_line
|
|
fi
|
|
if [[ $port_line -gt 0 && $port_line -lt $main_end ]]; then
|
|
main_end=$port_line
|
|
fi
|
|
|
|
# --- Check 1: Slash-command invocations ---
|
|
# Use perl lookbehind for accurate detection (avoids false positives from file paths)
|
|
# Real slash-commands: ` /research`, `"/council`, backtick-/skill
|
|
# False positives: `.agents/council/`, `skills/research/`, `merge/release`
|
|
local slash_hits
|
|
slash_hits=$(perl -ne "print \"$.: \$_\" if m{(?<![A-Za-z0-9_/.=\\\$-])/(${SKILL_NAMES})(?![A-Za-z0-9-])}" "$skill_file" 2>/dev/null || true)
|
|
if [[ -n "$slash_hits" ]]; then
|
|
local count
|
|
count=$(count_lines "$slash_hits")
|
|
error "$skill_name: $count slash-command invocation(s) — must use \$ prefix"
|
|
if $STRICT; then
|
|
echo "$slash_hits" | head -5 | sed 's/^/ /'
|
|
fi
|
|
else
|
|
pass "$skill_name: no slash-command invocations"
|
|
fi
|
|
|
|
# --- Check 2: Claude primitives in main execution flow ---
|
|
if [[ $main_end -gt 1 ]]; then
|
|
local prim_hits
|
|
prim_hits=$(head -n "$main_end" "$skill_file" | grep -En "(${CLAUDE_PRIMITIVES})" 2>/dev/null || true)
|
|
if [[ -n "$prim_hits" ]]; then
|
|
local count
|
|
count=$(count_lines "$prim_hits")
|
|
error "$skill_name: $count Claude primitive(s) in main execution flow (before line $main_end)"
|
|
if $STRICT; then
|
|
echo "$prim_hits" | head -5 | sed 's/^/ /'
|
|
fi
|
|
else
|
|
pass "$skill_name: no Claude primitives in main flow"
|
|
fi
|
|
fi
|
|
|
|
# --- Check 3: ~/.claude/ paths ---
|
|
# Cross-runtime skills may reference ~/.claude accurately (cc-hooks documents
|
|
# the Claude Code hook config path); skip this check for them.
|
|
if is_cross_runtime "$skill_name"; then
|
|
pass "$skill_name: cross-runtime skill — ~/.claude/ check skipped"
|
|
return
|
|
fi
|
|
local path_hits
|
|
# The tilde here is a literal grep PATTERN (matching the string "~/.claude/"
|
|
# in skill files), not a path meant to expand — SC2088 is a false positive.
|
|
# shellcheck disable=SC2088
|
|
path_hits=$(grep -n '~/\.claude/' "$skill_file" | grep -v 'Portability\|non-Codex\|appendix' || true)
|
|
if [[ -n "$path_hits" ]]; then
|
|
local count
|
|
count=$(count_lines "$path_hits")
|
|
if $STRICT; then
|
|
error "$skill_name: $count ~/.claude/ path reference(s) — use ~/.codex/"
|
|
else
|
|
warn "$skill_name: $count ~/.claude/ path reference(s)"
|
|
fi
|
|
else
|
|
pass "$skill_name: no ~/.claude/ paths"
|
|
fi
|
|
|
|
# --- Check 4: "Claude Code" runtime reference ---
|
|
local runtime_hits
|
|
runtime_hits=$(grep -in 'Claude Code' "$skill_file" | grep -vi 'Portability\|non-Codex\|appendix\|backend-claude\|claude-code-latest' || true)
|
|
if [[ -n "$runtime_hits" ]]; then
|
|
local count
|
|
count=$(count_lines "$runtime_hits")
|
|
warn "$skill_name: $count 'Claude Code' runtime reference(s)"
|
|
else
|
|
pass "$skill_name: no 'Claude Code' runtime references"
|
|
fi
|
|
|
|
# --- Check 5: Claude primitives anywhere + no Portability Appendix ---
|
|
local total_prims
|
|
total_prims=$(grep -cE "(${CLAUDE_PRIMITIVES})" "$skill_file" 2>/dev/null) || total_prims=0
|
|
if [[ "$total_prims" -gt 0 && "$port_line" -eq 0 ]]; then
|
|
if [[ "$refs_line" -gt 0 ]]; then
|
|
local main_prims
|
|
main_prims=$(head -n "$refs_line" "$skill_file" | grep -cE "(${CLAUDE_PRIMITIVES})" 2>/dev/null) || main_prims=0
|
|
if [[ "$main_prims" -gt 0 ]]; then
|
|
warn "$skill_name: $total_prims Claude primitive(s) total ($main_prims in main flow) — needs Portability Appendix"
|
|
fi
|
|
else
|
|
warn "$skill_name: $total_prims Claude primitive(s) but no Portability Appendix or References section"
|
|
fi
|
|
fi
|
|
|
|
}
|
|
|
|
echo "Codex-Native Skill Lint"
|
|
echo "======================="
|
|
echo "Directory: $SKILLS_DIR"
|
|
echo ""
|
|
|
|
if [[ -n "$FILTER_SKILL" ]]; then
|
|
echo "Checking: $FILTER_SKILL"
|
|
echo ""
|
|
check_skill "$FILTER_SKILL"
|
|
else
|
|
for skill_dir in "$SKILLS_DIR"/*/; do
|
|
skill_name=$(basename "$skill_dir")
|
|
check_skill "$skill_name"
|
|
done
|
|
fi
|
|
|
|
echo ""
|
|
echo "======================="
|
|
echo "Errors: $ERRORS | Warnings: $WARNINGS"
|
|
|
|
if [[ $ERRORS -gt 0 ]]; then
|
|
echo -e "${RED}FAIL${NC}: $ERRORS error(s) found"
|
|
exit 1
|
|
elif [[ $WARNINGS -gt 0 ]]; then
|
|
echo -e "${YELLOW}WARN${NC}: $WARNINGS warning(s) (pass with warnings)"
|
|
exit 0
|
|
else
|
|
echo -e "${GREEN}PASS${NC}: all checks clean"
|
|
exit 0
|
|
fi
|