framework: make tool discoverability, workspace placement and model tiering mechanical
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.
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
||||
# worktree on a fleet host.
|
||||
#
|
||||
# Why this exists as a helper and not as a rule: the rule already existed, in
|
||||
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
||||
# $HOME across 842 directories anyway. Five placement conventions were live on
|
||||
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
||||
# and a decision an agent has to make is a decision that drifts.
|
||||
#
|
||||
# So this script makes NO placement decision available. The caller supplies a
|
||||
# branch name. Every path is DERIVED:
|
||||
#
|
||||
# main worktree <- git worktree list --porcelain (never cwd, which may
|
||||
# itself already be a worktree)
|
||||
# REPO_NAME <- basename of the main worktree
|
||||
# REPO_PARENT <- dirname of the main worktree
|
||||
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
||||
# SLUG <- branch with '/' replaced by '-'
|
||||
# WT_PATH <- $WT_ROOT/$SLUG
|
||||
#
|
||||
# The derivation puts the worktree on the same filesystem as the object store
|
||||
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
||||
# properties that make the checkout cheap and — via `git worktree list` —
|
||||
# enumerable, which is the only reason automated cleanup can ever be safe.
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
||||
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
||||
# mosaic-worktree.sh list this repo's worktrees + state
|
||||
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
||||
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
||||
#
|
||||
# `rm` and `gc` refuse to delete a worktree with uncommitted changes or with
|
||||
# commits absent from every remote. That check is by EVIDENCE, never by size or
|
||||
# age. --force overrides it and is yours to type deliberately.
|
||||
#
|
||||
# Run from anywhere inside the repo, or pass --repo <path>.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
REPO_HINT=""
|
||||
ARGS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
CMD="${1:-}"
|
||||
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
||||
shift || true
|
||||
|
||||
# ---- mechanical derivation -------------------------------------------------
|
||||
# The FIRST entry of `git worktree list --porcelain` is always the main
|
||||
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
||||
# would nest worktrees inside worktrees.
|
||||
resolve_repo() {
|
||||
local start="${REPO_HINT:-$PWD}"
|
||||
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
||||
|| die "not inside a git repository: $start"
|
||||
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /{print substr($0,10); exit}')"
|
||||
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
||||
REPO_NAME="$(basename -- "$MAIN_WT")"
|
||||
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
||||
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
||||
}
|
||||
|
||||
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
||||
|
||||
derive_path() {
|
||||
local branch="$1"
|
||||
[ -n "$branch" ] || die "branch name required"
|
||||
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
||||
}
|
||||
|
||||
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
||||
# $HOME is for configuration and state, not work products. Refuse rather than
|
||||
# silently produce the layout we are trying to eliminate.
|
||||
assert_not_home() {
|
||||
local p="$1" home_real repo_real
|
||||
home_real="$(cd "$HOME" && pwd -P)"
|
||||
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
||||
case "$repo_real/" in
|
||||
"$home_real"/*)
|
||||
die "refusing: derived path is under \$HOME ($p).
|
||||
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
||||
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
||||
configuration, credentials, state and caches — not checkouts." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- work-loss evidence ----------------------------------------------------
|
||||
# Two independent questions, both answered from git, neither from size or age:
|
||||
# dirty — anything uncommitted in the tree
|
||||
# unpushed — commits reachable from HEAD that no remote ref contains
|
||||
wt_dirty() { git -C "$1" status --porcelain 2>/dev/null | head -200 | wc -l; }
|
||||
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || echo "?"; }
|
||||
|
||||
wt_state() {
|
||||
local wt="$1" d u
|
||||
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"
|
||||
if [ "$d" -eq 0 ] && [ "$u" = "0" ]; then
|
||||
printf 'SAFE\tclean; 0 unpushed'
|
||||
else
|
||||
printf 'PRESERVE\t%s uncommitted; %s unpushed' "$d" "$u"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- commands --------------------------------------------------------------
|
||||
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
||||
|
||||
cmd_new() {
|
||||
local branch="${1:-}" base=""
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
assert_not_home "$path"
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo "exists: $path"
|
||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$WT_ROOT"
|
||||
|
||||
# Existing branch -> check it out. New branch -> create from base (default:
|
||||
# the remote's default branch if resolvable, else current HEAD).
|
||||
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
||||
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
||||
else
|
||||
if [ -z "$base" ]; then
|
||||
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||
[ -n "$base" ] || base="HEAD"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
worktree: $path
|
||||
branch: $branch
|
||||
|
||||
Removal is part of this task, not a later chore. When the work is pushed:
|
||||
mosaic-worktree.sh rm $branch
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
resolve_repo
|
||||
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
||||
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
local branch="${1:-}" force=0
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
[ -d "$path" ] || die "no worktree at $path"
|
||||
|
||||
local d u
|
||||
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"
|
||||
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ]; }; then
|
||||
die "refusing to remove $path
|
||||
uncommitted files: $d
|
||||
unpushed commits: $u
|
||||
Commit and push first — that is the contract. If this work is genuinely
|
||||
disposable, re-run with --force."
|
||||
fi
|
||||
|
||||
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
||||
if [ "$force" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove --force "$path"
|
||||
else
|
||||
git -C "$MAIN_WT" worktree remove "$path"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
echo "removed: $path"
|
||||
rmdir "$WT_ROOT" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_gc() {
|
||||
local apply=0
|
||||
[ "${1:-}" = "--apply" ] && apply=1
|
||||
resolve_repo
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && continue
|
||||
local_state="$(wt_state "$wt")"
|
||||
case "$local_state" in
|
||||
SAFE*)
|
||||
if [ "$apply" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
||||
else
|
||||
echo "reclaimable (clean + fully pushed): $wt"
|
||||
fi ;;
|
||||
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
||||
esac
|
||||
done
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
new) cmd_new "$@" ;;
|
||||
path) cmd_path "$@" ;;
|
||||
list) cmd_list "$@" ;;
|
||||
rm) cmd_rm "$@" ;;
|
||||
gc) cmd_gc "$@" ;;
|
||||
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
||||
esac
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# wrapper-guard.sh — PreToolUse hook on Bash.
|
||||
#
|
||||
# Blocks three specific, mechanically-detectable mistakes that prose has
|
||||
# repeatedly failed to prevent:
|
||||
#
|
||||
# 1. A checkout (git clone / git worktree add) targeting $HOME.
|
||||
# Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
|
||||
#
|
||||
# 2. A raw provider API WRITE against an endpoint that already has a Mosaic
|
||||
# wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
|
||||
# provider dialect, identity, and queue-guard ordering that raw curl does
|
||||
# not. Reads are untouched — they are how you gather evidence.
|
||||
#
|
||||
# 3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
|
||||
# it accepts APPROVE with HTTP 200, silently files the review PENDING,
|
||||
# and then 422s on submit. This one is unconditionally wrong on Gitea and
|
||||
# is what a verdict silently failing to land looks like.
|
||||
#
|
||||
# Design constraint: this hook must not become something agents route around.
|
||||
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
|
||||
# curl for reads, for registry/manifest calls, and for endpoints with no
|
||||
# wrapper (there are many) all pass untouched.
|
||||
#
|
||||
# Break-glass, for a genuine gap where no wrapper can express the call:
|
||||
# MOSAIC_WRAPPER_OVERRIDE=1 <command>
|
||||
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
|
||||
# extend the wrapper, not to keep typing the override.
|
||||
#
|
||||
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT="$(cat)"
|
||||
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# Honour the override only when it is set in the command itself or the env.
|
||||
case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac
|
||||
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0
|
||||
|
||||
W="$HOME/.config/mosaic/tools/git"
|
||||
|
||||
# ---- 1. checkout into $HOME ------------------------------------------------
|
||||
if printf '%s' "$CMD" | grep -Eq 'git[^|;&]*(clone|worktree[[:space:]]+add)'; then
|
||||
# Any argument that resolves under $HOME and is not under a work filesystem.
|
||||
if printf '%s' "$CMD" | grep -Eq "(^|[[:space:]=\"'])(~|\\\$HOME|$HOME)/"; then
|
||||
cat <<EOF
|
||||
BLOCKED: this checks a repository out under \$HOME.
|
||||
|
||||
\$HOME holds configuration, credentials, state and caches. It does not hold
|
||||
checkouts, worktrees, scratch files, or build output. One fleet host's /home hit
|
||||
100% (394 G) with 255 GB of agent workspaces accumulated exactly this way.
|
||||
|
||||
Use the helper, which derives the path so you do not have to choose one:
|
||||
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> # /src/<repo>-worktrees/<slug>
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # show where it would go
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||
|
||||
Worktrees, not clones: they share the object store, and \`git worktree list\`
|
||||
makes every one of them enumerable — which is the only reason cleanup can
|
||||
ever be safe.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 2/3. provider API writes ---------------------------------------------
|
||||
# Only consider calls that are (a) to a provider API path and (b) mutating.
|
||||
is_api=0
|
||||
printf '%s' "$CMD" | grep -Eq '/api/v1/repos/|api\.github\.com/repos/' && is_api=1
|
||||
if [ "$is_api" -eq 1 ]; then
|
||||
is_write=0
|
||||
printf '%s' "$CMD" | grep -Eq -- '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]]*(POST|PATCH|PUT|DELETE)' && is_write=1
|
||||
# curl sends POST implicitly when given a body.
|
||||
printf '%s' "$CMD" | grep -Eq -- '--data|-d[[:space:]]' && is_write=1
|
||||
|
||||
if [ "$is_write" -eq 1 ]; then
|
||||
endpoint=""; wrapper=""
|
||||
case "$CMD" in
|
||||
*"/pulls/"*"/reviews"*|*"/pulls/"*"/requested_reviewers"*)
|
||||
endpoint="pull-request review"; wrapper="$W/pr-review.sh" ;;
|
||||
*"/pulls/"*"/merge"*) endpoint="pull-request merge"; wrapper="$W/pr-merge.sh" ;;
|
||||
*"/issues/"*"/comments"*) endpoint="issue comment"; wrapper="$W/issue-comment.sh" ;;
|
||||
*"/pulls"*) endpoint="pull request"; wrapper="$W/pr-create.sh" ;;
|
||||
*"/issues"*) endpoint="issue"; wrapper="$W/issue-create.sh" ;;
|
||||
*"/milestones"*) endpoint="milestone"; wrapper="$W/milestone-create.sh" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$wrapper" ] && [ -x "$wrapper" ]; then
|
||||
cat <<EOF
|
||||
BLOCKED: raw provider API write to the $endpoint endpoint.
|
||||
|
||||
A Mosaic wrapper already covers this and the Constitution (gate 7) requires it
|
||||
before any raw provider call:
|
||||
|
||||
$wrapper
|
||||
|
||||
The wrappers are not a formality. They carry provider-dialect differences that
|
||||
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
|
||||
APPROVE, and Gitea accepts the wrong one with HTTP 200 while filing the review
|
||||
as PENDING. They also resolve identity explicitly, which matters on a host
|
||||
where the default login is an admin account.
|
||||
|
||||
Run \`$(basename "$wrapper") --help\` for the flags.
|
||||
|
||||
If no wrapper flag can express this call, that is a wrapper gap: extend the
|
||||
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 3. the APPROVE/APPROVED trap, wherever it appears ---------------------
|
||||
if printf '%s' "$CMD" | grep -Eq '"event"[[:space:]]*:[[:space:]]*"APPROVE"'; then
|
||||
cat <<EOF
|
||||
BLOCKED: review event "APPROVE" is not valid on Gitea.
|
||||
|
||||
Gitea's vocabulary is "APPROVED". It accepts "APPROVE" with HTTP 200, silently
|
||||
files the review as PENDING, and then fails the submit endpoint with
|
||||
422 "review stay pending" — so the verdict looks placed and is not.
|
||||
|
||||
("REQUEST_CHANGES" is spelled identically on both providers; only the approve
|
||||
path carries this trap.)
|
||||
|
||||
Use $W/pr-review.sh, which sends the correct token for the detected provider.
|
||||
Whatever you use, re-read GET /pulls/{n}/reviews and assert state==APPROVED
|
||||
before reporting a verdict placed.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user