A run on 2026-09-10 burned most of a session on three failures, all
mechanical and all preventable by one check at the start.
The expensive one was not the wrong branch. An executor stashed its own
partial work with a descriptive message, came back to a clean tree,
concluded "subagent edits are not persisting", and re-fired subagents
twice against that theory. The work was in stash@{0} the whole time.
So preflight.sh prints the stash stack UNCONDITIONALLY and first, pass or
fail. An agent that sees the stack cannot invent the tooling theory. The
checks are ordered by how much time each failure actually cost, not by
severity:
0. stash stack, always printed
1. not the shared primary checkout (git-dir vs git-common-dir)
2. on a branch that contains origin/<base>
3. clean working tree
4. not behind origin/<base>
Check 2 is deliberately not "must equal main". A feature branch cut from
an up-to-date base is fine; what caused the incident was a LEFTOVER
branch from a merged PR (docs/admin-screenshots), which fails the
ancestor test while a fresh feature branch passes. Verified against the
real commit: `git merge-base --is-ancestor origin/main 5b7ae6d` fails.
AGENTS.md gains the rules the incident earned, the first of which is the
one that matters:
- If the tree looks unexpectedly clean, run `git stash list` BEFORE
concluding anything about your tooling.
- Never bare `git stash` / `pop`. ~20 worktrees on this machine share
ONE stack; another session pushing an entry shifts every index, and
pop deletes on success, so a wrong index is unrecoverable.
- Work in a worktree, not the shared checkout the operator is using.
- The plan is the contract. If an approved item looks wrong, STOP and
say so rather than implementing the alternative -- the same run
re-derived a design that had been explicitly reviewed and rejected,
because the rejected version sounded more reasonable in isolation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
103 lines
4.2 KiB
Bash
Executable File
103 lines
4.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Refuse to start agent work in a state that will waste an hour.
|
|
#
|
|
# preflight.sh [--allow-shared] [--base main]
|
|
#
|
|
# Exit 0 = safe to proceed. Exit 1 = blocked, with the reason and the fix.
|
|
#
|
|
# Written after a real run on 2026-09-10 that burned most of a session. The
|
|
# checks are in order of how much time each failure actually cost, not in
|
|
# order of severity:
|
|
#
|
|
# 1. An executor stashed its own partial work with a descriptive message,
|
|
# then found a clean tree, concluded "subagent edits are not persisting",
|
|
# and re-fired subagents twice against that theory. `git stash list`
|
|
# would have ended it immediately -- so this script prints the stash
|
|
# stack UNCONDITIONALLY, before anything else, even when it passes.
|
|
# 2. The checkout was still on a leftover branch from a merged PR, so every
|
|
# edit landed there and `git pull origin main` failed against it.
|
|
# 3. Work ran directly in the shared checkout, which collided with the
|
|
# operator's own git commands in another terminal.
|
|
set -euo pipefail
|
|
|
|
BASE="main"
|
|
ALLOW_SHARED=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--allow-shared) ALLOW_SHARED=1; shift ;;
|
|
--base) BASE="${2:?--base needs a branch name}"; shift 2 ;;
|
|
-h|--help) sed -n '2,6p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
git rev-parse --git-dir >/dev/null 2>&1 || {
|
|
echo "preflight: not inside a git repository" >&2; exit 1; }
|
|
|
|
fail() { echo "BLOCKED: $1" >&2; echo " fix: $2" >&2; exit 1; }
|
|
|
|
# --- 0. The stash stack, always, pass or fail ------------------------------
|
|
#
|
|
# Unconditional and first. The whole point is that an agent seeing an
|
|
# unexpectedly clean tree reads this before it can invent a tooling theory.
|
|
# It is also shared across every worktree of this repo on this machine, which
|
|
# is why `git stash pop` is unsafe here -- another session pushing a stash
|
|
# shifts every index, and pop DROPS on success, so a wrong index is
|
|
# destructive. Address a stash by SHA and use `apply`.
|
|
echo "--- git stash stack (shared across all worktrees) ---"
|
|
if [ -z "$(git stash list)" ]; then
|
|
echo "(empty)"
|
|
else
|
|
git stash list --format='%gd %H %gs'
|
|
echo
|
|
echo "NOTE: entries above are shared. If you are missing work, it may be"
|
|
echo " here. Restore with: git stash apply <SHA> (never pop)"
|
|
fi
|
|
echo
|
|
|
|
# --- 1. Not the shared primary checkout ------------------------------------
|
|
#
|
|
# In a linked worktree these two differ; in the primary checkout they are the
|
|
# same path. Work in the primary mutates the tree the operator is using.
|
|
if [ "$ALLOW_SHARED" -eq 0 ]; then
|
|
if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then
|
|
fail "running in the shared primary checkout, not a worktree" \
|
|
"create one (git worktree add) or pass --allow-shared if you mean it"
|
|
fi
|
|
fi
|
|
|
|
# --- 2. On the expected base branch ----------------------------------------
|
|
branch="$(git rev-parse --abbrev-ref HEAD)"
|
|
if [ "$branch" != "$BASE" ]; then
|
|
# A feature branch cut from an up-to-date base is fine; a leftover branch
|
|
# from a merged PR is what caused the incident. Distinguish them by asking
|
|
# whether the base is an ancestor.
|
|
if git merge-base --is-ancestor "origin/$BASE" HEAD 2>/dev/null; then
|
|
echo "note: on '$branch', which already contains origin/$BASE. OK."
|
|
else
|
|
fail "on '$branch', which does NOT contain origin/$BASE" \
|
|
"git checkout $BASE && git pull origin $BASE"
|
|
fi
|
|
fi
|
|
|
|
# --- 3. Clean working tree -------------------------------------------------
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
echo "--- uncommitted changes ---" >&2
|
|
git status --short >&2
|
|
fail "working tree is dirty" \
|
|
"commit them, or stash with: git stash push -u -m '<unique-tag>'"
|
|
fi
|
|
|
|
# --- 4. Base is current ----------------------------------------------------
|
|
git fetch --quiet origin "$BASE" 2>/dev/null || {
|
|
echo "note: could not fetch origin/$BASE; skipping the freshness check"; }
|
|
if git rev-parse --verify --quiet "origin/$BASE" >/dev/null; then
|
|
behind="$(git rev-list --count "HEAD..origin/$BASE")"
|
|
if [ "$behind" -gt 0 ]; then
|
|
fail "$behind commit(s) behind origin/$BASE" \
|
|
"git pull origin $BASE"
|
|
fi
|
|
fi
|
|
|
|
echo "preflight OK: $branch, clean, current with origin/$BASE"
|