mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
d4d92c8dfb
Three low-severity **ag-lk80** post-mortem papercuts, one chore arc (single rollback semantic). ## 1. ASCII-diagram skill-count drift guard — `sync-skill-counts.sh` `docs/agentops-system-map.md` and `docs/agentops-brief.md` hardcode the skill count inside box-drawing borders whose column widths are alignment-sensitive. `patch_file` deliberately won't auto-rewrite them (a digit-width change, e.g. `99→100`, silently breaks the box). Added the sanctioned **tolerant check**: every `<N> [shared] skills` token must equal the on-disk skill TOTAL, else FAIL so a human re-pads the box. Closes the drift class auto-patch left open. Works in both `--check` and apply modes (reuses the existing `errors` counter). ## 2. `bd hooks` commit noise — not repo-actionable The help-text dumped on every commit comes from `.git/hooks/*` bd-installed shims (**local, untracked git state**) — fixable only by a bd-tool change or a local `bd hooks` reinstall, nothing committable in agentops. Documented as resolved-here-not-applicable (the dump even appears in this PR's own commit step). ## 3. Drop dead `hooks/` scan — `check-agents-write-surfaces.sh` `hooks/` doesn't exist post-3.0-teardown, so the `[[ -d hooks ]]` scan was a guaranteed no-op. Removed it + its comment. **Kept** `writer["hooks"]` — `hooks` remains a valid *opt-in* writer-surface label per `docs/contracts/agents-write-surfaces.md` (only the dead directory scan was vestigial, not the classifier vocabulary). ## Verification - `sync-skill-counts.sh --check` → PASS; guard FAILs on an injected `82→99` drift (confirmed it bites) - `check-agents-write-surfaces.sh` → PASS (68 referenced subdirs, all documented); **24/24 bats green** - `shellcheck` clean on both scripts Closes-scenario: ag-c4wn#lk80-papercuts Bounded-context: BC2-Validation Evidence: scripts/sync-skill-counts.sh
332 lines
12 KiB
Bash
Executable File
332 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# check-agents-write-surfaces.sh — guard the .agents/ write-surface contract.
|
|
#
|
|
# Every top-level subdir under .agents/ that production code (cli/**/*.go
|
|
# non-test, scripts/, hooks/, lib/) writes to must be catalogued in
|
|
# docs/contracts/agents-write-surfaces.md. The catalog has an explicit
|
|
# allowlist between BEGIN/END markers and a typed surfaces table; this script
|
|
# parses both and fails when production code references a subdir that isn't
|
|
# documented or when documented surfaces lack valid lifecycle/writer metadata.
|
|
#
|
|
# Skill-owned subdirs (.agents/<skill-name>/) are auto-allowed when an
|
|
# active skill exists at skills/<skill-name>/SKILL.md. New skills don't
|
|
# need a doc edit; new CLI/script/hook write surfaces do.
|
|
|
|
usage() {
|
|
cat <<USAGE
|
|
Usage: $0 [--json]
|
|
|
|
Options:
|
|
--json Emit a machine-readable summary instead of human prose.
|
|
|
|
Exit codes:
|
|
0 OK (all referenced subdirs documented or skill-owned, classifications valid)
|
|
1 Undocumented top-level subdirs or classification drift found
|
|
2 Bad invocation or missing contract doc
|
|
USAGE
|
|
}
|
|
|
|
JSON=false
|
|
case "${1:-}" in
|
|
--json) JSON=true ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
'') ;;
|
|
*) usage; exit 2 ;;
|
|
esac
|
|
|
|
if [[ -n "${AGENTS_WRITE_SURFACES_REPO_ROOT:-}" ]]; then
|
|
REPO_ROOT="$(cd "$AGENTS_WRITE_SURFACES_REPO_ROOT" && pwd)"
|
|
else
|
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
fi
|
|
CONTRACT_DOC="$REPO_ROOT/docs/contracts/agents-write-surfaces.md"
|
|
SKILLS_DIR="$REPO_ROOT/skills"
|
|
|
|
json_escape() {
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"
|
|
s="${s//\"/\\\"}"
|
|
s="${s//$'\n'/\\n}"
|
|
s="${s//$'\r'/\\r}"
|
|
s="${s//$'\t'/\\t}"
|
|
printf '%s' "$s"
|
|
}
|
|
|
|
if [[ ! -f "$CONTRACT_DOC" ]]; then
|
|
echo "ERROR: contract doc missing: $CONTRACT_DOC" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# Parse allowlist between markers. Lines starting with '#' are treated as
|
|
# comments. Empty lines are ignored. Each remaining line must be a single
|
|
# top-level subdir name with no slashes.
|
|
allowlist_tmp="$(mktemp)"
|
|
trap 'rm -f "$allowlist_tmp"' EXIT
|
|
awk '
|
|
/^[[:space:]]*<!-- BEGIN agents-write-surfaces-allowlist -->[[:space:]]*$/ { inside=1; next }
|
|
/^[[:space:]]*<!-- END agents-write-surfaces-allowlist -->[[:space:]]*$/ { inside=0; next }
|
|
inside { print }
|
|
' "$CONTRACT_DOC" \
|
|
| sed -E 's/[[:space:]]+#.*$//' \
|
|
| sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \
|
|
| awk 'NF && $1 !~ /^#/' \
|
|
| sort -u > "$allowlist_tmp"
|
|
|
|
if [[ ! -s "$allowlist_tmp" ]]; then
|
|
echo "ERROR: allowlist block is empty or markers missing in $CONTRACT_DOC" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# Reject malformed entries (must be lowercase letters, digits, '-' or '_').
|
|
malformed="$(grep -vE '^[a-z0-9][a-z0-9_-]*$' "$allowlist_tmp" || true)"
|
|
if [[ -n "$malformed" ]]; then
|
|
echo "ERROR: malformed allowlist entries in $CONTRACT_DOC:" >&2
|
|
echo "$malformed" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# Parse the typed surfaces table. Each row must use this shape:
|
|
# | `surface` | `lifecycle` | `writer`, `writer` | mutation lane | purpose |
|
|
# Lifecycle and writer values are intentionally restricted to the vocabulary
|
|
# documented in the contract so new states cannot appear without policy review.
|
|
contract_tmp="$(mktemp)"
|
|
classification_errors_tmp="$(mktemp)"
|
|
trap 'rm -f "$allowlist_tmp" "$contract_tmp" "$classification_errors_tmp"' EXIT
|
|
awk -v out="$contract_tmp" -v err="$classification_errors_tmp" '
|
|
function trim(s) {
|
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", s)
|
|
return s
|
|
}
|
|
function clean(s) {
|
|
s = trim(s)
|
|
gsub(/`/, "", s)
|
|
return trim(s)
|
|
}
|
|
BEGIN {
|
|
lifecycle["persistent"] = 1
|
|
lifecycle["rolling"] = 1
|
|
lifecycle["regenerated"] = 1
|
|
lifecycle["runtime-only"] = 1
|
|
lifecycle["ignored"] = 1
|
|
writer["cli"] = 1
|
|
writer["hooks"] = 1
|
|
writer["scripts"] = 1
|
|
writer["skills"] = 1
|
|
writer["operators"] = 1
|
|
writer["tests"] = 1
|
|
}
|
|
/^\|[[:space:]]*`/ {
|
|
n = split($0, cells, "|")
|
|
if (n < 7) {
|
|
next
|
|
}
|
|
surface = clean(cells[2])
|
|
if (surface == "Surface" || surface == "") {
|
|
next
|
|
}
|
|
life = clean(cells[3])
|
|
writers = clean(cells[4])
|
|
lane = trim(cells[5])
|
|
if (surface !~ /^[A-Za-z0-9][A-Za-z0-9._-]*$/) {
|
|
print "surface " surface " has malformed surface name" >> err
|
|
next
|
|
}
|
|
if (!(life in lifecycle)) {
|
|
print surface " has unknown lifecycle: " life >> err
|
|
}
|
|
if (writers == "") {
|
|
print surface " has missing allowed writers" >> err
|
|
} else {
|
|
writer_count = split(writers, writer_cells, ",")
|
|
for (i = 1; i <= writer_count; i++) {
|
|
w = clean(writer_cells[i])
|
|
if (!(w in writer)) {
|
|
print surface " has unknown writer: " w >> err
|
|
}
|
|
}
|
|
}
|
|
if (lane == "" || lane ~ /TBD|TODO|FIXME/) {
|
|
print surface " has missing mutation lane" >> err
|
|
}
|
|
print surface "\t" life "\t" writers "\t" lane >> out
|
|
}
|
|
' "$CONTRACT_DOC"
|
|
|
|
if [[ ! -s "$contract_tmp" ]]; then
|
|
echo "ERROR: surfaces table is empty or malformed in $CONTRACT_DOC" >&2
|
|
exit 2
|
|
fi
|
|
|
|
# Extract referenced top-level subdirs from production code.
|
|
# - cli/**/*.go excluding _test.go
|
|
# - scripts/*.sh, lib/*.sh
|
|
referenced_tmp="$(mktemp)"
|
|
references_tmp="$(mktemp)"
|
|
trap 'rm -f "$allowlist_tmp" "$contract_tmp" "$classification_errors_tmp" "$referenced_tmp" "$references_tmp"' EXIT
|
|
|
|
scan_agent_references() {
|
|
local file rel match subdir
|
|
while IFS= read -r -d '' file; do
|
|
rel="${file#"$REPO_ROOT"/}"
|
|
while IFS= read -r match; do
|
|
[[ -z "$match" ]] && continue
|
|
subdir="${match#.agents/}"
|
|
printf '%s\t%s\n' "$subdir" "$rel"
|
|
done < <(grep -Eo '\.agents/[a-z][a-zA-Z0-9_-]*' "$file" 2>/dev/null || true)
|
|
while IFS= read -r match; do
|
|
[[ -z "$match" ]] && continue
|
|
subdir="$(sed -E 's/^.*"[[:space:]]*,[[:space:]]*"([a-z][a-zA-Z0-9_-]*).*$/\1/' <<<"$match")"
|
|
printf '%s\t%s\n' "$subdir" "$rel"
|
|
done < <(grep -Eo 'filepath\.Join\([^)]*\.agents"[[:space:]]*,[[:space:]]*"[a-z][a-zA-Z0-9_-]*' "$file" 2>/dev/null || true)
|
|
done
|
|
}
|
|
|
|
scan_dirs=()
|
|
[[ -d "$REPO_ROOT/scripts" ]] && scan_dirs+=("$REPO_ROOT/scripts")
|
|
[[ -d "$REPO_ROOT/lib" ]] && scan_dirs+=("$REPO_ROOT/lib")
|
|
|
|
{
|
|
if [[ -d "$REPO_ROOT/cli" ]]; then
|
|
scan_agent_references < <(find "$REPO_ROOT/cli" -type f -name '*.go' ! -name '*_test.go' -print0 2>/dev/null)
|
|
fi
|
|
if [[ ${#scan_dirs[@]} -gt 0 ]]; then
|
|
scan_agent_references < <(find "${scan_dirs[@]}" -type f \( -name '*.sh' -o -name '*.bash' \) -print0 2>/dev/null)
|
|
fi
|
|
} | sort -u > "$references_tmp"
|
|
cut -f1 "$references_tmp" | sort -u > "$referenced_tmp"
|
|
|
|
# Compute active skill names — skill-owned subdirs are auto-allowed.
|
|
skills_tmp="$(mktemp)"
|
|
trap 'rm -f "$allowlist_tmp" "$contract_tmp" "$classification_errors_tmp" "$referenced_tmp" "$references_tmp" "$skills_tmp"' EXIT
|
|
: > "$skills_tmp"
|
|
if [[ -d "$SKILLS_DIR" ]]; then
|
|
shopt -s nullglob
|
|
for d in "$SKILLS_DIR"/*/; do
|
|
[[ -f "${d}SKILL.md" ]] && basename "$d" >> "$skills_tmp"
|
|
done
|
|
shopt -u nullglob
|
|
if [[ -s "$skills_tmp" ]]; then
|
|
sort -u "$skills_tmp" -o "$skills_tmp"
|
|
fi
|
|
fi
|
|
|
|
# undocumented = referenced - allowlist - skills
|
|
undocumented_tmp="$(mktemp)"
|
|
tracked_surfaces_tmp="$(mktemp)"
|
|
needed_classifications_tmp="$(mktemp)"
|
|
policy_classifications_tmp="$(mktemp)"
|
|
contracted_surfaces_tmp="$(mktemp)"
|
|
missing_classifications_tmp="$(mktemp)"
|
|
trap 'rm -f "$allowlist_tmp" "$contract_tmp" "$classification_errors_tmp" "$referenced_tmp" "$references_tmp" "$skills_tmp" "$undocumented_tmp" "$tracked_surfaces_tmp" "$needed_classifications_tmp" "$policy_classifications_tmp" "$contracted_surfaces_tmp" "$missing_classifications_tmp"' EXIT
|
|
comm -23 "$referenced_tmp" "$allowlist_tmp" \
|
|
| comm -23 - "$skills_tmp" > "$undocumented_tmp"
|
|
|
|
# Repo-tracked top-level .agents surfaces must also be classified. In a real
|
|
# repo, use git so ignored runtime scratch does not become policy by accident.
|
|
# In tests or unpacked source trees without git metadata, fall back to existing
|
|
# top-level entries under .agents/.
|
|
: > "$tracked_surfaces_tmp"
|
|
if [[ -d "$REPO_ROOT/.agents" ]]; then
|
|
if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
git -C "$REPO_ROOT" ls-files .agents 2>/dev/null \
|
|
| awk -F / '$1 == ".agents" && NF >= 2 { print $2 }' \
|
|
| sort -u > "$tracked_surfaces_tmp"
|
|
else
|
|
find "$REPO_ROOT/.agents" -mindepth 1 -maxdepth 1 -print 2>/dev/null \
|
|
| sed -E 's#^.*/##' \
|
|
| sort -u > "$tracked_surfaces_tmp"
|
|
fi
|
|
fi
|
|
|
|
cat "$allowlist_tmp" "$tracked_surfaces_tmp" | sort -u > "$needed_classifications_tmp"
|
|
comm -23 "$needed_classifications_tmp" "$skills_tmp" > "$policy_classifications_tmp"
|
|
cut -f1 "$contract_tmp" | sort -u > "$contracted_surfaces_tmp"
|
|
comm -23 "$policy_classifications_tmp" "$contracted_surfaces_tmp" > "$missing_classifications_tmp"
|
|
|
|
UNDOC_COUNT=$(wc -l < "$undocumented_tmp" | tr -d ' ')
|
|
MISSING_CLASSIFICATION_COUNT=$(wc -l < "$missing_classifications_tmp" | tr -d ' ')
|
|
CLASSIFICATION_ERROR_COUNT=$(wc -l < "$classification_errors_tmp" | tr -d ' ')
|
|
ALLOW_COUNT=$(wc -l < "$allowlist_tmp" | tr -d ' ')
|
|
REF_COUNT=$(wc -l < "$referenced_tmp" | tr -d ' ')
|
|
|
|
if [[ "$JSON" == "true" ]]; then
|
|
printf '{"contract":"%s","allowlist_size":%s,"referenced":%s,"undocumented":[' \
|
|
"$(json_escape "${CONTRACT_DOC#"$REPO_ROOT"/}")" "$ALLOW_COUNT" "$REF_COUNT"
|
|
first=1
|
|
while IFS= read -r entry; do
|
|
[[ -z "$entry" ]] && continue
|
|
if [[ "$first" -eq 1 ]]; then first=0; else printf ','; fi
|
|
printf '"%s"' "$(json_escape "$entry")"
|
|
done < "$undocumented_tmp"
|
|
printf '],"missing_classifications":['
|
|
first=1
|
|
while IFS= read -r entry; do
|
|
[[ -z "$entry" ]] && continue
|
|
if [[ "$first" -eq 1 ]]; then first=0; else printf ','; fi
|
|
printf '"%s"' "$(json_escape "$entry")"
|
|
done < "$missing_classifications_tmp"
|
|
printf '],"classification_errors":['
|
|
first=1
|
|
while IFS= read -r entry; do
|
|
[[ -z "$entry" ]] && continue
|
|
if [[ "$first" -eq 1 ]]; then first=0; else printf ','; fi
|
|
printf '"%s"' "$(json_escape "$entry")"
|
|
done < "$classification_errors_tmp"
|
|
printf '],"source_locations":{'
|
|
first=1
|
|
while IFS= read -r entry; do
|
|
[[ -z "$entry" ]] && continue
|
|
if [[ "$first" -eq 1 ]]; then first=0; else printf ','; fi
|
|
printf '"%s":[' "$(json_escape "$entry")"
|
|
file_first=1
|
|
while IFS= read -r source_file; do
|
|
[[ -z "$source_file" ]] && continue
|
|
if [[ "$file_first" -eq 1 ]]; then file_first=0; else printf ','; fi
|
|
printf '"%s"' "$(json_escape "$source_file")"
|
|
done < <(awk -F '\t' -v key="$entry" '$1 == key { print $2 }' "$references_tmp" | sort -u)
|
|
printf ']'
|
|
done < "$referenced_tmp"
|
|
printf '},"status":"%s"}\n' "$([ "$UNDOC_COUNT" -gt 0 ] || [ "$MISSING_CLASSIFICATION_COUNT" -gt 0 ] || [ "$CLASSIFICATION_ERROR_COUNT" -gt 0 ] && echo fail || echo ok)"
|
|
fi
|
|
|
|
if [[ "$UNDOC_COUNT" -gt 0 || "$MISSING_CLASSIFICATION_COUNT" -gt 0 || "$CLASSIFICATION_ERROR_COUNT" -gt 0 ]]; then
|
|
if [[ "$JSON" != "true" ]]; then
|
|
if [[ "$UNDOC_COUNT" -gt 0 ]]; then
|
|
echo "ERROR: $UNDOC_COUNT undocumented .agents/ write surface(s) found in production code." >&2
|
|
echo "Add an entry under '<!-- BEGIN agents-write-surfaces-allowlist -->' in:" >&2
|
|
echo " $CONTRACT_DOC" >&2
|
|
echo "Undocumented subdirs:" >&2
|
|
while IFS= read -r entry; do
|
|
[[ -z "$entry" ]] && continue
|
|
echo " - $entry" >&2
|
|
while IFS= read -r source_file; do
|
|
[[ -z "$source_file" ]] && continue
|
|
echo " $source_file" >&2
|
|
done < <(awk -F '\t' -v key="$entry" '$1 == key { print $2 }' "$references_tmp" | sort -u | head -5)
|
|
done < "$undocumented_tmp"
|
|
fi
|
|
if [[ "$MISSING_CLASSIFICATION_COUNT" -gt 0 ]]; then
|
|
echo "ERROR: $MISSING_CLASSIFICATION_COUNT .agents/ surface(s) missing table classifications in $CONTRACT_DOC:" >&2
|
|
while IFS= read -r entry; do
|
|
[[ -z "$entry" ]] && continue
|
|
echo " - $entry" >&2
|
|
done < "$missing_classifications_tmp"
|
|
fi
|
|
if [[ "$CLASSIFICATION_ERROR_COUNT" -gt 0 ]]; then
|
|
echo "ERROR: $CLASSIFICATION_ERROR_COUNT invalid .agents/ classification(s) in $CONTRACT_DOC:" >&2
|
|
while IFS= read -r entry; do
|
|
[[ -z "$entry" ]] && continue
|
|
echo " - $entry" >&2
|
|
done < "$classification_errors_tmp"
|
|
fi
|
|
fi
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$JSON" != "true" ]]; then
|
|
echo "agents-write-surfaces contract OK: $REF_COUNT referenced subdirs, $ALLOW_COUNT allowlisted, all documented/classified or skill-owned."
|
|
fi
|
|
exit 0
|