Files
boshu2__agentops/scripts/validate-skill-flow.sh
T
Bo 6d5d111faf feat(skills): land four-vendor skill corpus (#803)
## Summary
- Lands the AgentOps skill corpus across source and Codex wrappers,
including Gemini image targets.
- Integrates the skill branch onto current origin/main and refreshes
generated manifests/catalogs.
- Normalizes local next-work queue enum drift and fixes shellcheck in
alias-collision validation.

## Validation
- bash scripts/regen-all.sh --check
- bash scripts/validate-skill-schema.sh
- bash tests/skills/test-token-budgets.sh
- bash tests/skills/run-all.sh
- bash scripts/audit-codex-parity.sh && bash
scripts/validate-codex-override-coverage.sh && bash
scripts/validate-codex-generated-artifacts.sh --scope worktree && bash
scripts/validate-codex-api-conformance.sh && bash
scripts/validate-codex-runtime-sections.sh && bash
scripts/lint-codex-native.sh
- bash skills/heal-skill/scripts/heal.sh --strict
- PRE_PUSH_SKIP_MKDOCS=1 bash scripts/pre-push-gate.sh --fast

MkDocs strict remains skipped locally via the documented
PRE_PUSH_SKIP_MKDOCS=1 override because the repo currently emits
repo-wide strict-link warnings unrelated to this landing; CI remains
authoritative.
2026-06-06 23:44:42 -04:00

388 lines
13 KiB
Bash
Executable File

#!/usr/bin/env bash
# validate-skill-flow.sh — Enforce skill-flow connectivity across every
# skills/<name>/SKILL.md.
#
# Motivation (follow-up to scripts/audit-skill-metadata.sh, ag-f0i):
# audit-skill-metadata.sh owns `context_rel.with` resolution. It explicitly
# deferred two checks as "discovered follow-ups, not enforced":
# 1. `consumes` vocabulary canonicality (open vocabulary, no registry).
# 2. skill-to-skill connectivity ("do all skills flow together?").
# This gate closes both, plus checks `metadata.dependencies` resolution.
#
# What it checks (FAIL = exit 1):
# 1. CLOSED CONSUMES VOCABULARY. Every `consumes` token must be either a real
# skill slug or one of the whitelisted EXTERNAL_INPUTS (see below). This
# turns `consumes` from an open free-text field into a closed contract so
# the producer->consumer graph can be reasoned about.
# 2. metadata.dependencies RESOLUTION. Every `metadata.dependencies` entry
# must name an existing skill slug.
# 3. ORPHAN DETECTION. A skill is "connected" if it shares at least one
# skill-to-skill edge with another skill, counting ALL THREE edge layers
# (consumes skill-slugs, context_rel.with skill-slugs, metadata.dependencies).
# Orphans must be listed in the standalone allowlist
# (scripts/skill-flow-standalone.txt) — intentionally-standalone meta /
# utility / boundary skills. An un-allowlisted orphan FAILS.
#
# What it REPORTS (informational, never fails):
# - Cross-layer disagreement: `consumes` skill-slugs vs `metadata.dependencies`
# (the two fields drifted historically; surfaced so they can be reconciled).
# - Dead-end produced artifacts (produced but consumed by no skill).
#
# context_rel.with resolution stays owned by audit-skill-metadata.sh — this gate
# does not re-litigate it (and tolerates `*.md` doc targets used by entry-point
# skills such as session-bootstrap).
#
# Usage:
# bash scripts/validate-skill-flow.sh [--json] [--skills-root DIR] [--allowlist FILE]
#
# --json emit a machine-readable verdict on stdout (stdout = data only)
# --skills-root directory holding skills/<name>/SKILL.md
# (default: <repo>/skills, or $SKILL_FLOW_SKILLS_ROOT)
# --allowlist standalone-skill allowlist file
# (default: <repo>/scripts/skill-flow-standalone.txt)
# -h, --help show this help
#
# Exit codes: 0 = clean, 1 = findings, 2 = usage / environment error.
#
# Contract reference: docs/contracts/skill-flow.md
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
JSON=0
SKILLS_ROOT="${SKILL_FLOW_SKILLS_ROOT:-${REPO_ROOT}/skills}"
ALLOWLIST="${SKILL_FLOW_ALLOWLIST:-${REPO_ROOT}/scripts/skill-flow-standalone.txt}"
usage() {
cat <<'USAGE'
validate-skill-flow.sh — enforce skill-flow connectivity across all SKILL.md.
Checks (fail): closed consumes vocabulary, metadata.dependencies resolution,
and orphan detection (un-allowlisted skill with zero skill-to-skill edges).
Reports (advisory): consumes vs metadata.dependencies disagreement, dead-end
produced artifacts.
Usage:
bash scripts/validate-skill-flow.sh [--json] [--skills-root DIR] [--allowlist FILE]
--json emit a machine-readable verdict on stdout (stdout = data only)
--skills-root directory holding skills/<name>/SKILL.md
(default: <repo>/skills, or $SKILL_FLOW_SKILLS_ROOT)
--allowlist standalone-skill allowlist file
(default: <repo>/scripts/skill-flow-standalone.txt)
-h, --help show this help
Exit codes: 0 = clean, 1 = findings, 2 = usage / environment error.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--json) JSON=1; shift ;;
--skills-root) SKILLS_ROOT="${2:?--skills-root needs a value}"; shift 2 ;;
--allowlist) ALLOWLIST="${2:?--allowlist needs a value}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--*) echo "ERROR: unknown flag: $1 (try: bash scripts/validate-skill-flow.sh --help)" >&2; exit 2 ;;
*) echo "ERROR: unexpected argument: $1 (try: bash scripts/validate-skill-flow.sh --help)" >&2; exit 2 ;;
esac
done
if [[ ! -d "${SKILLS_ROOT}" ]]; then
echo "ERROR: skills directory not found at ${SKILLS_ROOT}" >&2
exit 2
fi
SKILLS_ROOT="${SKILLS_ROOT}" ALLOWLIST="${ALLOWLIST}" JSON="${JSON}" python3 - <<'PYEOF'
import json
import os
import re
import sys
from pathlib import Path
try:
import yaml # type: ignore
except Exception as e: # pragma: no cover - environment guard
sys.stderr.write("ERROR: PyYAML is required (pip install pyyaml). underlying: %s\n" % e)
sys.exit(2)
SKILLS_ROOT = Path(os.environ["SKILLS_ROOT"])
ALLOWLIST = Path(os.environ["ALLOWLIST"])
JSON = os.environ.get("JSON") == "1"
# Closed vocabulary for non-skill `consumes` tokens. These are the *external
# inputs* a skill may read that are not themselves produced by a peer skill
# (VCS state, the bd issue store, an upstream API, the repo working tree, the
# onboarding handshake). Adding a new external input is a deliberate act: extend
# this list AND document it in docs/contracts/skill-flow.md.
EXTERNAL_INPUTS = {
"Cargo.lock",
"Cargo.toml",
"bd",
"build-config",
"cargo-metadata",
"cli-source",
"closed-beads",
"code",
"code-under-review",
"codebase",
"codex-plugin",
"command-help",
"command-map",
"convention-target",
"crate-docs",
"crate-source",
"data-model",
"environment-contract",
"error-reports",
"evidence",
"existing-docs",
"existing-tracked-work",
"external-api",
"external-source-candidates",
"failure-report",
"ffi-bindings",
"ffi-contracts",
"gemini-extension",
"git",
"git-worktree",
"github-pr",
"hook-policy",
"implementation-examples",
"installation-docs",
"manifest-and-lockfile",
"mcp-server",
"onboard",
"operational-constraints",
"package-metadata",
"product-requirements",
"profiler-output",
"project-context",
"project-goals",
"project-source",
"release-notes",
"repo-context",
"repo-tree",
"repository",
"runtime-configuration",
"runtime-metrics",
"rust-source",
"service-contract",
"skill-bundle",
"source-code",
"specification",
"support-history",
"task-intent",
"task-question",
"test-plan",
"test-results",
"test-suite",
"test-target",
"tests",
}
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def parse_frontmatter(skill_md):
try:
text = skill_md.read_text(encoding="utf-8")
except Exception:
return {}
m = FRONTMATTER_RE.match(text)
if not m:
return {}
try:
data = yaml.safe_load(m.group(1))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def str_list(value):
if not isinstance(value, list):
return []
return [str(x) for x in value if x is not None]
def load_allowlist(path):
"""Return set of allowlisted standalone skill slugs (# comments allowed)."""
if not path.is_file():
return set()
out = set()
for line in path.read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
out.add(line)
return out
# 1. Load every skill's frontmatter.
skill_dirs = sorted(
p for p in SKILLS_ROOT.iterdir() if p.is_dir() and (p / "SKILL.md").is_file()
)
names = {p.name for p in skill_dirs}
skills = {}
for sd in skill_dirs:
fm = parse_frontmatter(sd / "SKILL.md")
consumes = str_list(fm.get("consumes"))
produces = str_list(fm.get("produces"))
ctx = []
for e in (fm.get("context_rel") or []):
if isinstance(e, dict) and isinstance(e.get("with"), str):
ctx.append(e["with"].strip())
md = fm.get("metadata") or {}
mdeps = str_list(md.get("dependencies")) if isinstance(md, dict) else []
skills[sd.name] = {
"consumes": consumes,
"produces": produces,
"ctx": ctx,
"mdeps": mdeps,
}
allowlist = load_allowlist(ALLOWLIST)
# Every artifact produced by some skill — a `consumes` token may legitimately
# name one (e.g. push consumes git-changes; beads consumes bd-issue).
produced_artifacts = set()
for d in skills.values():
produced_artifacts.update(d["produces"])
failures = [] # list of (kind, slug, detail)
# CHECK 1: closed consumes vocabulary. A consumes token must resolve to one of:
# a peer skill slug, a whitelisted external input, or an artifact produced by
# some skill. Anything else is a typo or an undeclared dependency.
for slug in sorted(skills):
for tok in skills[slug]["consumes"]:
if tok in names or tok in EXTERNAL_INPUTS or tok in produced_artifacts:
continue
failures.append((
"consumes-vocabulary",
slug,
"consumes '%s' resolves to nothing: not a skill slug, not a "
"whitelisted external input (%s), and not an artifact any skill "
"produces" % (tok, ", ".join(sorted(EXTERNAL_INPUTS))),
))
# CHECK 2: metadata.dependencies resolution.
for slug in sorted(skills):
for tok in skills[slug]["mdeps"]:
if tok not in names:
failures.append((
"metadata-dependencies",
slug,
"metadata.dependencies '%s' does not resolve to a skill slug" % tok,
))
# Build undirected skill-to-skill edge set across all three layers.
edges = set()
for slug, d in skills.items():
for tok in d["consumes"]:
if tok in names and tok != slug:
edges.add(frozenset((slug, tok)))
for tok in d["ctx"]:
if tok in names and tok != slug:
edges.add(frozenset((slug, tok)))
for tok in d["mdeps"]:
if tok in names and tok != slug:
edges.add(frozenset((slug, tok)))
degree = {slug: 0 for slug in skills}
for e in edges:
for slug in e:
degree[slug] += 1
orphans = sorted(s for s in skills if degree[s] == 0)
# CHECK 3: orphans must be allowlisted.
unallowed_orphans = [s for s in orphans if s not in allowlist]
for slug in unallowed_orphans:
failures.append((
"orphan",
slug,
"no skill-to-skill edge in consumes/context_rel/metadata.dependencies; "
"wire an edge or add to scripts/skill-flow-standalone.txt with a rationale",
))
# Stale allowlist entries (allowlisted but actually connected, or not a skill).
stale_allowlist = sorted(
s for s in allowlist if s not in skills or (s in skills and degree[s] > 0)
)
# Informational: consumes-skill vs metadata.dependencies disagreement.
disagreements = []
for slug in sorted(skills):
cs = {t for t in skills[slug]["consumes"] if t in names}
md = set(skills[slug]["mdeps"])
if (cs or md) and cs != md:
disagreements.append((slug, sorted(cs), sorted(md)))
# Informational: dead-end produced artifacts (produced, consumed by no skill).
consumed_tokens = set()
for d in skills.values():
consumed_tokens.update(d["consumes"])
produced = {}
for slug, d in skills.items():
for art in d["produces"]:
produced.setdefault(art, []).append(slug)
dead_end = sorted(a for a in produced if a not in consumed_tokens)
verdict = "PASS" if not failures else "FAIL"
if JSON:
print(json.dumps({
"verdict": verdict,
"skills_checked": len(skills),
"edges": len(edges),
"orphans": orphans,
"failures": [
{"kind": k, "skill": s, "detail": d} for (k, s, d) in failures
],
"stale_allowlist": stale_allowlist,
"disagreements": [
{"skill": s, "consumes": cs, "metadata_dependencies": md}
for (s, cs, md) in disagreements
],
"dead_end_artifacts": dead_end,
}, indent=2, sort_keys=True))
else:
print("validate-skill-flow: %d skill(s), %d skill-to-skill edge(s)" % (
len(skills), len(edges)))
print(" orphans: %d (allowlisted standalone: %d)" % (
len(orphans), len([o for o in orphans if o in allowlist])))
if disagreements:
print("")
print("INFO: %d skill(s) where consumes(skills) != metadata.dependencies "
"(reconcile, not fatal):" % len(disagreements))
for slug, cs, md in disagreements:
print(" - %-26s consumes=%s metadata.deps=%s" % (
slug, cs or "-", md or "-"))
if dead_end:
print("")
print("INFO: %d produced artifact(s) consumed by no skill "
"(output-type annotation, not fatal):" % len(dead_end))
for art in dead_end:
print(" - %s (from: %s)" % (art, ", ".join(produced[art])))
if stale_allowlist:
print("")
print("WARN: %d stale allowlist entry/entries (now connected or not a "
"skill — remove from scripts/skill-flow-standalone.txt):"
% len(stale_allowlist))
for slug in stale_allowlist:
print(" - %s" % slug)
if failures:
print("")
print("FAIL: %d finding(s):" % len(failures))
for kind, slug, detail in failures:
print(" [%s] %s/SKILL.md: %s" % (kind, slug, detail))
print("")
print("fix: see docs/contracts/skill-flow.md")
else:
print("")
print("OK: skill flow is connected and the consumes vocabulary is closed.")
sys.exit(0 if verdict == "PASS" else 1)
PYEOF