An undocumented tool is, from inside an agent session, indistinguishable from a tool that was never written. The framework shipped 26 git wrappers and named 6 of them in its resident index docs — 23% discoverability, with pr-review.sh among the missing. The observable consequence was an agent obeying Constitution gate 7 as best it could see it, reaching for raw curl, sending GitHub's APPROVE to a Gitea host, and getting HTTP 200 with the review silently filed PENDING. Three times. That is not a discipline failure and no amount of prose fixes it. Four changes, each converting a rule that decayed into a mechanism that cannot: - check-tools-index.sh (new, CI-blocking): every tool in an enforced suite must be named in a resident index doc, and every tool an index names must exist. The git suite is enforced now; other suites report coverage without failing, so the ratchet tightens one reviewed PR at a time instead of landing as one sweep. The enforced list is framework-owned rather than a marker inside operator-owned TOOLS.md — a doc marker would let an operator silence the gate on exactly the host where it matters most. Carries --self-test, because a checker that only ever passes is indistinguishable from one that is not running. - TOOLS-REFERENCE.md: complete 28-entry git index, plus the APPROVED/APPROVE dialect note that explains why pr-review.sh is not a formality. - mosaic-worktree.sh + wrapper-guard.sh (upstreamed): the rule "big work goes on a work filesystem" already existed in prose, and 255 GB accumulated in $HOME across 842 directories anyway, under five simultaneous placement conventions on one host. The helper therefore exposes no placement decision — given a branch name, every path is derived from `git worktree list --porcelain`. Worktrees rather than clones because enumerability is the only thing that makes reclaim safe, and reclaim is by evidence (clean tree + no unpushed commits), never by size or age. The guard blocks three mechanically-detectable mistakes and nothing else: a checkout into $HOME, a raw provider-API write to an endpoint that has a wrapper, and the literal APPROVE event. Reads pass untouched. - STANDARDS.md: model tiering as a standard, named by capability class so it survives a model generation. Start cheapest, escalate on evidence, benchmark before demoting a task class, and keep the class->model binding in operator config with the DB-backed config service as the end state. Registering the guard in runtime/claude/settings.json is the point of upstreaming it: ~/.claude/settings.json is a framework-managed copy, so a hand-added hook there is destroyed by the next upgrade. In the template it survives, and it reaches every host instead of one.
267 lines
10 KiB
Bash
Executable File
267 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# check-tools-index.sh — assert every shipped tool is discoverable from the
|
|
# resident documentation an agent actually has in context.
|
|
#
|
|
# WHY THIS GATE EXISTS
|
|
# --------------------
|
|
# The framework ships 26 git wrappers. Before this gate, 20 of them were named
|
|
# in neither `defaults/TOOLS.md` nor `guides/TOOLS-REFERENCE.md`. One of the
|
|
# undocumented ones was `pr-review.sh` — the wrapper that carries the
|
|
# APPROVED/APPROVE provider-dialect split.
|
|
#
|
|
# The observable consequence, on a live fleet host: an agent needing to place a
|
|
# review verdict reached for raw `curl`, sent GitHub's `APPROVE` to a Gitea
|
|
# host, and got HTTP 200 with the review silently filed PENDING — three times,
|
|
# because nothing about the failure pointed at the wrapper that already handled
|
|
# it correctly. The agent was not ignoring Constitution gate 7. It was obeying
|
|
# an index that said the tool did not exist.
|
|
#
|
|
# That is not a discipline problem and no amount of prose fixes it. A wrapper
|
|
# that is not in the resident index is, from inside a session, indistinguishable
|
|
# from a wrapper that was never written. So the invariant is mechanical:
|
|
#
|
|
# shipping a tool and documenting it are the same commit, or CI fails.
|
|
#
|
|
# WHAT IT CHECKS
|
|
# --------------
|
|
# forward every non-excluded tool in an ENFORCED suite is named in at least
|
|
# one index document (missing tool -> undiscoverable -> FAIL)
|
|
# reverse every `<name>.sh` an index document attributes to an enforced
|
|
# suite exists on disk (stale reference -> agent runs a ghost -> FAIL)
|
|
#
|
|
# Suites outside the enforced set are reported with a coverage percentage but do
|
|
# not fail the build, so the ratchet can be tightened one suite per PR instead of
|
|
# landing as one unreviewable sweep. `--strict` fails on those too.
|
|
#
|
|
# WHY THE ENFORCED LIST LIVES HERE AND NOT IN A MARKER INSIDE THE DOC
|
|
# -------------------------------------------------------------------
|
|
# `TOOLS.md` is operator-owned (see framework-manifest.txt). A marker inside it
|
|
# would let an operator silence this gate by editing their own copy — the gate
|
|
# would then be strongest exactly where it is least needed and absent where it
|
|
# is needed most. The list is framework-owned and changes only through a
|
|
# reviewed PR.
|
|
#
|
|
# Usage:
|
|
# check-tools-index.sh [--tools-dir DIR] [--doc FILE]... [--strict] [--self-test]
|
|
#
|
|
# Exit: 0 = every enforced suite fully discoverable · 1 = drift · 2 = bad usage
|
|
|
|
set -euo pipefail
|
|
|
|
# Suites whose coverage is a HARD requirement. Add a suite here only together
|
|
# with the doc changes that make it pass.
|
|
#
|
|
# `git` is first because it is the suite Constitution gates 6-8 make mandatory:
|
|
# an undiscoverable git wrapper converts a hard gate into a coin flip.
|
|
ENFORCED_SUITES=(git)
|
|
|
|
# Files that are not agent-callable tools and must not be required in an index.
|
|
EXCLUDE_GLOBS=(
|
|
'test-*' # hermetic regression scripts, invoked by CI not by agents
|
|
'_*' # private helpers (_lib, _scripts internals)
|
|
'*.bak' # editor/installer debris
|
|
'*.pre-*' # pre-change backups (e.g. ci-queue-wait.sh.pre-404fix-bak)
|
|
'README.md'
|
|
)
|
|
|
|
STRICT=0
|
|
SELF_TEST=0
|
|
TOOLS_DIR=""
|
|
DOCS=()
|
|
|
|
die() { printf 'check-tools-index: %s\n' "$*" >&2; exit 2; }
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--tools-dir) TOOLS_DIR="${2:-}"; shift 2 ;;
|
|
--doc) DOCS+=("${2:-}"); shift 2 ;;
|
|
--strict) STRICT=1; shift ;;
|
|
--self-test) SELF_TEST=1; shift ;;
|
|
-h|--help) sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
*) die "unknown argument: $1" ;;
|
|
esac
|
|
done
|
|
|
|
# ---- location resolution ---------------------------------------------------
|
|
# Runs from two places with different layouts, and must not silently check the
|
|
# wrong tree: a CI checkout (repo-relative) and an installed host ($MOSAIC_HOME).
|
|
resolve_locations() {
|
|
local here framework
|
|
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
# .../framework/tools/quality/scripts -> .../framework
|
|
framework="$(cd -- "$here/../../.." && pwd)"
|
|
|
|
if [ -z "$TOOLS_DIR" ]; then
|
|
if [ -d "$framework/tools" ]; then
|
|
TOOLS_DIR="$framework/tools"
|
|
else
|
|
TOOLS_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}/tools"
|
|
fi
|
|
fi
|
|
|
|
if [ ${#DOCS[@]} -eq 0 ]; then
|
|
# The two layouts are mutually exclusive on purpose. Unioning them would let
|
|
# a well-maintained operator TOOLS.md on the developer's own machine mask a
|
|
# gap in the shipped defaults — the check would pass locally and the defect
|
|
# would still install on every other host. Repo layout wins when present.
|
|
if [ -f "$framework/defaults/TOOLS.md" ]; then
|
|
DOCS+=("$framework/defaults/TOOLS.md")
|
|
[ -f "$framework/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$framework/guides/TOOLS-REFERENCE.md")
|
|
else
|
|
local mosaic_home="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
|
[ -f "$mosaic_home/TOOLS.md" ] && DOCS+=("$mosaic_home/TOOLS.md")
|
|
[ -f "$mosaic_home/guides/TOOLS-REFERENCE.md" ] && DOCS+=("$mosaic_home/guides/TOOLS-REFERENCE.md")
|
|
fi
|
|
fi
|
|
|
|
[ -d "$TOOLS_DIR" ] || die "tools dir not found: $TOOLS_DIR"
|
|
[ ${#DOCS[@]} -gt 0 ] || die "no index documents found (pass --doc FILE)"
|
|
}
|
|
|
|
is_excluded() {
|
|
local name="$1" glob
|
|
for glob in "${EXCLUDE_GLOBS[@]}"; do
|
|
# shellcheck disable=SC2254 # glob is intentionally a pattern
|
|
case "$name" in $glob) return 0 ;; esac
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# A tool counts as documented when its basename appears anywhere in the corpus.
|
|
# Deliberately permissive about *form* (table cell, code fence, prose) and strict
|
|
# about *presence*: the gate's job is "an agent can find it", not house style.
|
|
documented() { grep -qF -- "$1" "$CORPUS"; }
|
|
|
|
# ---- the check -------------------------------------------------------------
|
|
run_check() {
|
|
local rc=0 suite dir tool base enforced
|
|
|
|
CORPUS="$(mktemp)"; trap 'rm -f "$CORPUS"' RETURN
|
|
cat "${DOCS[@]}" > "$CORPUS"
|
|
|
|
printf 'tools: %s\n' "$TOOLS_DIR"
|
|
for d in "${DOCS[@]}"; do printf 'index: %s\n' "$d"; done
|
|
printf '\n'
|
|
|
|
for dir in "$TOOLS_DIR"/*/; do
|
|
[ -d "$dir" ] || continue
|
|
suite="$(basename -- "$dir")"
|
|
case " ${ENFORCED_SUITES[*]} " in *" $suite "*) enforced=1 ;; *) enforced=0 ;; esac
|
|
[ "$STRICT" -eq 1 ] && enforced=1
|
|
case "$suite" in _*) continue ;; esac
|
|
|
|
local total=0 found=0
|
|
local -a suite_missing=()
|
|
for tool in "$dir"*.sh; do
|
|
[ -e "$tool" ] || continue
|
|
base="$(basename -- "$tool")"
|
|
is_excluded "$base" && continue
|
|
total=$((total + 1))
|
|
if documented "$base"; then
|
|
found=$((found + 1))
|
|
else
|
|
suite_missing+=("$base")
|
|
fi
|
|
done
|
|
[ "$total" -eq 0 ] && continue
|
|
|
|
local pct=$(( found * 100 / total ))
|
|
if [ "$enforced" -eq 1 ] && [ ${#suite_missing[@]} -gt 0 ]; then
|
|
printf 'FAIL %-12s %3d%% (%d/%d) undocumented: %s\n' \
|
|
"$suite" "$pct" "$found" "$total" "${suite_missing[*]}"
|
|
rc=1
|
|
elif [ "$enforced" -eq 1 ]; then
|
|
printf 'ok %-12s %3d%% (%d/%d) [enforced]\n' "$suite" "$pct" "$found" "$total"
|
|
else
|
|
printf 'info %-12s %3d%% (%d/%d) not yet enforced\n' "$suite" "$pct" "$found" "$total"
|
|
fi
|
|
|
|
# Reverse: an index that names a tool this suite does not have sends agents
|
|
# after something that cannot run. Only checked for enforced suites, where
|
|
# the naming is unambiguous enough to attribute.
|
|
if [ "$enforced" -eq 1 ]; then
|
|
local -a stale=()
|
|
local ref
|
|
while read -r ref; do
|
|
[ -n "$ref" ] || continue
|
|
is_excluded "$ref" && continue
|
|
[ -e "$dir$ref" ] || stale+=("$ref")
|
|
done < <(grep -oE "$suite/[a-z0-9][a-z0-9._-]*\.sh" "$CORPUS" \
|
|
| sed "s|^$suite/||" | sort -u)
|
|
if [ ${#stale[@]} -gt 0 ]; then
|
|
printf 'FAIL %-12s stale index references (no such file): %s\n' \
|
|
"$suite" "${stale[*]}"
|
|
rc=1
|
|
fi
|
|
fi
|
|
done
|
|
|
|
printf '\n'
|
|
if [ "$rc" -ne 0 ]; then
|
|
cat <<EOF
|
|
Undocumented tools are undiscoverable. An agent cannot obey a hard gate that
|
|
tells it to use a wrapper it has no way to learn exists — it will reach for raw
|
|
curl/gh/tea instead, and the wrapper's provider-dialect handling will be lost.
|
|
|
|
Fix by naming each tool above in one of the index documents listed at the top,
|
|
in the same commit that ships it.
|
|
EOF
|
|
else
|
|
printf 'every enforced suite is fully discoverable.\n'
|
|
fi
|
|
return "$rc"
|
|
}
|
|
|
|
# ---- self-test -------------------------------------------------------------
|
|
# Proves the gate can actually fail. A checker that only ever passes is
|
|
# indistinguishable from one that is not running, which is the failure mode this
|
|
# whole file exists to prevent — so it must demonstrate a red on demand.
|
|
self_test() {
|
|
local tmp rc
|
|
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' RETURN
|
|
mkdir -p "$tmp/tools/git"
|
|
printf '#!/bin/sh\n' > "$tmp/tools/git/documented-tool.sh"
|
|
printf '#!/bin/sh\n' > "$tmp/tools/git/test-ignored.sh"
|
|
|
|
# run_check reads the TOOLS_DIR / DOCS globals; an array cannot ride in a
|
|
# command-prefix assignment, so point the globals at the fixture directly.
|
|
TOOLS_DIR="$tmp/tools"
|
|
DOCS=("$tmp/doc.md")
|
|
|
|
# Case 1: fully documented -> pass.
|
|
printf 'see tools/git/documented-tool.sh for details\n' > "$tmp/doc.md"
|
|
if run_check >/dev/null; then
|
|
printf 'self-test 1/3 ok (complete index passes)\n'
|
|
else
|
|
printf 'self-test 1/3 FAIL (complete index should pass)\n'; return 1
|
|
fi
|
|
|
|
# Case 2: an undocumented tool -> fail.
|
|
printf '#!/bin/sh\n' > "$tmp/tools/git/undocumented-tool.sh"
|
|
rc=0; run_check >/dev/null || rc=$?
|
|
if [ "$rc" -eq 1 ]; then
|
|
printf 'self-test 2/3 ok (undocumented tool fails the gate)\n'
|
|
else
|
|
printf 'self-test 2/3 FAIL (undocumented tool should fail, got rc=%s)\n' "$rc"; return 1
|
|
fi
|
|
|
|
# Case 3: a stale index reference -> fail.
|
|
rm "$tmp/tools/git/undocumented-tool.sh"
|
|
printf 'also tools/git/deleted-tool.sh\n' >> "$tmp/doc.md"
|
|
rc=0; run_check >/dev/null || rc=$?
|
|
if [ "$rc" -eq 1 ]; then
|
|
printf 'self-test 3/3 ok (stale index reference fails the gate)\n'
|
|
else
|
|
printf 'self-test 3/3 FAIL (stale reference should fail, got rc=%s)\n' "$rc"; return 1
|
|
fi
|
|
|
|
printf '\nself-test passed: the gate demonstrably reds on both drift directions.\n'
|
|
}
|
|
|
|
if [ "$SELF_TEST" -eq 1 ]; then
|
|
self_test
|
|
else
|
|
resolve_locations
|
|
run_check
|
|
fi
|