diff --git a/scripts/lint-skill-frontmatter.sh b/scripts/lint-skill-frontmatter.sh new file mode 100755 index 000000000..6eeea6618 --- /dev/null +++ b/scripts/lint-skill-frontmatter.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# lint-skill-frontmatter.sh — enforce required keys on skills/*/SKILL.md. +# +# The hexagonal-architecture model (BC1-BC5) generates docs/contracts/ +# context-map.md from each skill's YAML frontmatter. When a SKILL.md is +# missing `consumes`, `produces`, `context_rel`, or `hexagonal_role`, the +# generated map drifts silently and the next consumer of the catalog hits +# downstream surprises. +# +# This script walks every skills/*/SKILL.md and validates: +# - name: non-empty string +# - description: non-empty string +# - hexagonal_role: one of {domain, driving-adapter, driven-adapter, +# supporting, generic} +# - consumes: key present (value may be empty list) +# - produces: key present (value may be empty list) +# - context_rel: key present (value may be empty list) +# +# Modes: +# --check exit 1 on any miss (CI gate) +# --list emit one line per missing key, sorted by skill +# --json machine-readable summary +# --skill limit to a single skill +# +# Exit codes: +# 0 — all skills clean (or no skills found) +# 1 — at least one missing required key +# 2 — usage error +# 3 — execution error (missing awk, no skills dir, etc.) + +set -euo pipefail + +MODE="check" +ONE_SKILL="" +JSON=0 + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --check) MODE="check" ;; + --list) MODE="list" ;; + --json) MODE="json"; JSON=1 ;; + --skill) shift; ONE_SKILL="${1:-}" ;; + -h|--help) usage 0 ;; + *) echo "lint-skill-frontmatter: unknown arg: $1" >&2; usage 2 ;; + esac + shift || true +done + +# Resolve repo root so the script works from any subdir. +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +SKILLS_DIR="$ROOT/skills" +if [ ! -d "$SKILLS_DIR" ]; then + echo "lint-skill-frontmatter: skills/ directory not found at $SKILLS_DIR" >&2 + exit 3 +fi + +VALID_HEX_ROLES=" domain driving-adapter driven-adapter supporting generic " + +# Extract YAML frontmatter block (between first two `---` lines) of a file. +extract_frontmatter() { + awk '/^---$/{c++; if(c==2) exit; next} c==1' "$1" +} + +# Check a single SKILL.md, emit findings to stdout (one per line). +check_one() { + local file="$1" + local skill + skill="$(basename "$(dirname "$file")")" + local fm + fm="$(extract_frontmatter "$file")" + if [ -z "$fm" ]; then + echo "$skill|missing|no-frontmatter|file=$file" + return 1 + fi + + local rc=0 + + # Helper: does the frontmatter have key X (any form)? + has_key() { + printf '%s\n' "$fm" | grep -qE "^${1}:[[:space:]]*" + } + # Helper: scalar value (everything after `:` on the same line, trimmed). + get_scalar() { + printf '%s\n' "$fm" | sed -n "s/^${1}:[[:space:]]*//p" | head -1 + } + + if ! has_key name; then + echo "$skill|missing|name|file=$file"; rc=1 + elif [ -z "$(get_scalar name)" ]; then + echo "$skill|empty|name|file=$file"; rc=1 + fi + + if ! has_key description; then + echo "$skill|missing|description|file=$file"; rc=1 + elif [ -z "$(get_scalar description)" ]; then + echo "$skill|empty|description|file=$file"; rc=1 + fi + + if ! has_key hexagonal_role; then + echo "$skill|missing|hexagonal_role|file=$file"; rc=1 + else + local role + role="$(get_scalar hexagonal_role)" + if [ -z "$role" ]; then + echo "$skill|empty|hexagonal_role|file=$file"; rc=1 + elif ! printf '%s' "$VALID_HEX_ROLES" | grep -q " $role "; then + echo "$skill|invalid|hexagonal_role|value=$role|file=$file"; rc=1 + fi + fi + + for k in consumes produces context_rel; do + if ! has_key "$k"; then + echo "$skill|missing|$k|file=$file"; rc=1 + fi + done + + return $rc +} + +# Collect SKILL.md paths. +declare -a skills_paths=() +if [ -n "$ONE_SKILL" ]; then + one="$SKILLS_DIR/$ONE_SKILL/SKILL.md" + if [ ! -r "$one" ]; then + echo "lint-skill-frontmatter: skill not found: $ONE_SKILL" >&2 + exit 3 + fi + skills_paths+=("$one") +else + while IFS= read -r p; do + skills_paths+=("$p") + done < <(find "$SKILLS_DIR" -mindepth 2 -maxdepth 2 -name SKILL.md | sort) +fi + +if [ "${#skills_paths[@]}" -eq 0 ]; then + if [ "$JSON" -eq 1 ]; then + echo '{"total":0,"clean":0,"violations":0,"findings":[]}' + else + echo "lint-skill-frontmatter: no SKILL.md files found" + fi + exit 0 +fi + +# Process all skills, collect findings. +findings_file="$(mktemp)" +trap 'rm -f "$findings_file"' EXIT +violations=0 +clean=0 +total=0 + +for p in "${skills_paths[@]}"; do + total=$((total + 1)) + if check_one "$p" >> "$findings_file"; then + clean=$((clean + 1)) + else + violations=$((violations + 1)) + fi +done + +if [ "$MODE" = "json" ]; then + # Build JSON findings array. + findings_json="$(awk -F'|' '{ + file=""; value="" + for (i=4; i<=NF; i++) { + if ($i ~ /^file=/) { file=substr($i,6) } + else if ($i ~ /^value=/) { value=substr($i,7) } + } + printf "{\"skill\":\"%s\",\"status\":\"%s\",\"key\":\"%s\",\"value\":\"%s\",\"file\":\"%s\"}\n", $1, $2, $3, value, file + }' "$findings_file" | paste -sd, -)" + printf '{"total":%d,"clean":%d,"violations":%d,"findings":[%s]}\n' \ + "$total" "$clean" "$violations" "${findings_json}" +elif [ "$MODE" = "list" ]; then + if [ -s "$findings_file" ]; then + sort "$findings_file" + fi + echo + echo "lint-skill-frontmatter: $total skill(s); $clean clean, $violations with missing/invalid keys" +else + # check mode (default) + if [ -s "$findings_file" ]; then + echo "lint-skill-frontmatter: FAIL — $violations skill(s) with frontmatter issues:" + sort "$findings_file" | sed 's/^/ /' + else + echo "lint-skill-frontmatter: OK — $total skill(s) clean" + fi +fi + +if [ "$violations" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/skills-codex/.agentops-manifest.json b/skills-codex/.agentops-manifest.json index af75a09e3..7f8df78f0 100644 --- a/skills-codex/.agentops-manifest.json +++ b/skills-codex/.agentops-manifest.json @@ -727,7 +727,7 @@ { "name": "council", "source_skill": "skills/council", - "source_hash": "b1ea8ee2e039dba422beee52f621e67542bf1084f808680ec5c5ad6e386a8914", + "source_hash": "bab6198e85ed06e27ce5a09c6ef6e41f8928c1739dc993fc8c42e33fcec17754", "generated_hash": "6b5ce56f36cb10393e921e524539c3dd41d28a5bdce5eac645075050864222ef" }, { @@ -787,7 +787,7 @@ { "name": "expert-council", "source_skill": "skills/expert-council", - "source_hash": "086b2e0ecf603e711ad0f1edf09197e6ee7e96af1a7a8f69feb171383d4678fd", + "source_hash": "d8659243e6987adc925926cd3dcb05c158fc1e24e0e944aea7f9cf5011ab2bb6", "generated_hash": "2db9315d89a07bee38b8975a58be99e1c8aca853f9a0987d5e5286acc57c84b5" }, { diff --git a/skills-codex/council/.agentops-generated.json b/skills-codex/council/.agentops-generated.json index aebee2135..a2569ee9e 100644 --- a/skills-codex/council/.agentops-generated.json +++ b/skills-codex/council/.agentops-generated.json @@ -2,6 +2,6 @@ "generator": "manual-maintained", "source_skill": "skills/council", "layout": "modular", - "source_hash": "b1ea8ee2e039dba422beee52f621e67542bf1084f808680ec5c5ad6e386a8914", + "source_hash": "bab6198e85ed06e27ce5a09c6ef6e41f8928c1739dc993fc8c42e33fcec17754", "generated_hash": "6b5ce56f36cb10393e921e524539c3dd41d28a5bdce5eac645075050864222ef" } diff --git a/skills-codex/expert-council/.agentops-generated.json b/skills-codex/expert-council/.agentops-generated.json index a19b9ba3a..ec4ee8a04 100644 --- a/skills-codex/expert-council/.agentops-generated.json +++ b/skills-codex/expert-council/.agentops-generated.json @@ -2,6 +2,6 @@ "generator": "manual-maintained", "source_skill": "skills/expert-council", "layout": "modular", - "source_hash": "086b2e0ecf603e711ad0f1edf09197e6ee7e96af1a7a8f69feb171383d4678fd", + "source_hash": "d8659243e6987adc925926cd3dcb05c158fc1e24e0e944aea7f9cf5011ab2bb6", "generated_hash": "2db9315d89a07bee38b8975a58be99e1c8aca853f9a0987d5e5286acc57c84b5" } diff --git a/skills/expert-council/SKILL.md b/skills/expert-council/SKILL.md index a1a7eb02a..503d114bd 100644 --- a/skills/expert-council/SKILL.md +++ b/skills/expert-council/SKILL.md @@ -4,6 +4,12 @@ description: 'Alias for /council --mode=debate — adversarial named-persona deb practices: - llm-eval-harness hexagonal_role: domain +consumes: +- council +produces: [] +context_rel: +- kind: alias-of + with: council skill_api_version: 1 user-invocable: true context: diff --git a/tests/scripts/lint-skill-frontmatter.bats b/tests/scripts/lint-skill-frontmatter.bats new file mode 100644 index 000000000..14bb067b3 --- /dev/null +++ b/tests/scripts/lint-skill-frontmatter.bats @@ -0,0 +1,205 @@ +#!/usr/bin/env bats +# Regression tests for scripts/lint-skill-frontmatter.sh (soc-e8pj). +# +# Each test builds an isolated fixture repo with a small skills/ tree and +# runs the script with HOME-relative repo overrides. The script resolves +# the skills dir via `git rev-parse --show-toplevel`, so cd'ing into the +# fixture repo is sufficient. + +setup() { + REPO_ROOT="$(git rev-parse --show-toplevel)" + SCRIPT="$REPO_ROOT/scripts/lint-skill-frontmatter.sh" + TMP="$(mktemp -d)" + ORIG_DIR="$PWD" + + git init --quiet --initial-branch=main "$TMP/repo" + cd "$TMP/repo" + git config user.email t@t.test + git config user.name tester + git commit --quiet --allow-empty -m "initial" + mkdir -p skills + cd "$ORIG_DIR" +} + +teardown() { + cd "$ORIG_DIR" 2>/dev/null || true + rm -rf "$TMP" +} + +# Helper: write a skill SKILL.md with given frontmatter body. +write_skill() { + local name="$1" fm="$2" + mkdir -p "$TMP/repo/skills/$name" + { + echo "---" + printf '%s\n' "$fm" + echo "---" + echo + echo "# $name" + echo + echo "skill body." + } > "$TMP/repo/skills/$name/SKILL.md" +} + +# Helper: invoke the script from inside the fixture repo. +run_lint() { + cd "$TMP/repo" + run "$SCRIPT" "$@" +} + +@test "passes a clean skill with all keys present" { + write_skill clean "name: clean +description: a clean skill +hexagonal_role: domain +consumes: [] +produces: [] +context_rel: []" + run_lint + [ "$status" -eq 0 ] + [[ "$output" == *"1 skill(s) clean"* ]] || [[ "$output" == *"OK"* ]] +} + +@test "fails when consumes is missing" { + write_skill nocons "name: nocons +description: no consumes key +hexagonal_role: domain +produces: [] +context_rel: []" + run_lint + [ "$status" -eq 1 ] + [[ "$output" == *"nocons|missing|consumes"* ]] +} + +@test "fails when produces is missing" { + write_skill noprod "name: noprod +description: no produces key +hexagonal_role: domain +consumes: [] +context_rel: []" + run_lint + [ "$status" -eq 1 ] + [[ "$output" == *"noprod|missing|produces"* ]] +} + +@test "fails when context_rel is missing" { + write_skill noctx "name: noctx +description: no context_rel key +hexagonal_role: domain +consumes: [] +produces: []" + run_lint + [ "$status" -eq 1 ] + [[ "$output" == *"noctx|missing|context_rel"* ]] +} + +@test "fails when hexagonal_role is missing" { + write_skill norole "name: norole +description: no role +consumes: [] +produces: [] +context_rel: []" + run_lint + [ "$status" -eq 1 ] + [[ "$output" == *"norole|missing|hexagonal_role"* ]] +} + +@test "fails when hexagonal_role has an invalid value" { + write_skill badrole "name: badrole +description: bad role +hexagonal_role: weasel +consumes: [] +produces: [] +context_rel: []" + run_lint + [ "$status" -eq 1 ] + [[ "$output" == *"badrole|invalid|hexagonal_role"* ]] + [[ "$output" == *"weasel"* ]] +} + +@test "fails when name is empty" { + write_skill noname "name: +description: x +hexagonal_role: domain +consumes: [] +produces: [] +context_rel: []" + run_lint + [ "$status" -eq 1 ] + [[ "$output" == *"noname|empty|name"* ]] +} + +@test "accepts all 5 documented hexagonal_role enum values" { + for role in domain driving-adapter driven-adapter supporting generic; do + write_skill "ok-$role" "name: ok-$role +description: testing $role +hexagonal_role: $role +consumes: [] +produces: [] +context_rel: []" + done + run_lint + [ "$status" -eq 0 ] +} + +@test "reports a skill missing frontmatter entirely" { + mkdir -p "$TMP/repo/skills/empty" + echo "no frontmatter here" > "$TMP/repo/skills/empty/SKILL.md" + run_lint + [ "$status" -eq 1 ] + [[ "$output" == *"empty|missing|no-frontmatter"* ]] +} + +@test "--skill scopes to one skill only" { + write_skill bad "name: bad +description: missing keys" + write_skill good "name: good +description: ok +hexagonal_role: domain +consumes: [] +produces: [] +context_rel: []" + run_lint --skill good + [ "$status" -eq 0 ] + [[ "$output" != *"bad|"* ]] +} + +@test "--skill rejects unknown skill name with exit 3" { + run_lint --skill does-not-exist + [ "$status" -eq 3 ] + [[ "$output" == *"not found"* ]] +} + +@test "--list mode prints findings + summary line" { + write_skill miss "name: miss +description: x +hexagonal_role: domain +consumes: []" + run_lint --list + [ "$status" -eq 1 ] + [[ "$output" == *"miss|missing|produces"* ]] + [[ "$output" == *"miss|missing|context_rel"* ]] + [[ "$output" == *"1 skill(s);"* ]] || [[ "$output" == *"clean,"* ]] +} + +@test "--json produces a parseable summary" { + write_skill miss "name: miss +description: x +hexagonal_role: domain +consumes: []" + run_lint --json + [ "$status" -eq 1 ] + echo "$output" | jq -e '.total == 1 and .violations == 1' >/dev/null + echo "$output" | jq -e '.findings | length >= 2' >/dev/null +} + +@test "no skills present = exit 0 with informational message" { + run_lint + [ "$status" -eq 0 ] + [[ "$output" == *"no SKILL.md files"* ]] +} + +@test "rejects unknown flag with usage error" { + run_lint --weasel + [ "$status" -eq 2 ] + [[ "$output" == *"unknown"* ]] +}