|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Self-test for session-start-fleet.sh — run it after touching that hook: |
| 3 | +# |
| 4 | +# .claude/hooks/session-start-fleet.selftest.sh |
| 5 | +# |
| 6 | +# Runs the hook the way the harness does on SessionStart (a JSON envelope on stdin, stdout |
| 7 | +# collected) and asserts, case by case, what it prints and that it exits 0. Hermetic: it |
| 8 | +# builds its OWN throwaway checkout under $TMPDIR — a git repo carrying a copy of the hook at |
| 9 | +# the hook's real relative position, a `SKILL.md`, and two lane files (one with a `Seat post:` |
| 10 | +# line, one without) — so the matrix never depends on which machine or checkout it runs from, |
| 11 | +# nor on how deep this clone is. Needs jq (to read settings.json) and git; no install, no |
| 12 | +# build, no network. Exit 0 = all cases hold. |
| 13 | +# |
| 14 | +# Every case is a line the hook PRINTS, so the exit-0 contract is asserted on each of them |
| 15 | +# rather than once: the one failure this hook must never have is a non-zero exit, in any |
| 16 | +# shape of input. NON-VACUITY at the foot proves the matrix can fail — a mutated copy that |
| 17 | +# prints the wrong pointer is caught by the same `says` the real hook passes. |
| 18 | + |
| 19 | +set -uo pipefail |
| 20 | + |
| 21 | +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 22 | +hook="${SESSION_START_FLEET_HOOK:-$here/session-start-fleet.sh}" |
| 23 | +settings="${SESSION_START_FLEET_SETTINGS:-$here/../settings.json}" |
| 24 | +pass=0 |
| 25 | +fail=0 |
| 26 | + |
| 27 | +command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to read settings.json" >&2; exit 1; } |
| 28 | +command -v git >/dev/null 2>&1 || { echo "selftest needs git to build the fixture" >&2; exit 1; } |
| 29 | +[ -x "$hook" ] || { echo "hook not executable: $hook" >&2; exit 1; } |
| 30 | + |
| 31 | +short() { printf '%s' "${1/#$HOME/~}"; } |
| 32 | + |
| 33 | +# --- fixture ----------------------------------------------------------------------------- |
| 34 | +# REPO a checkout: .claude/hooks/<hook copy>, SKILL.md, lanes/skills.md (Seat post: #19287), |
| 35 | +# lanes/devx.md (no seat-post line), one commit. |
| 36 | +# BARE the same tree with NO SKILL.md, no git. |
| 37 | +# PLAIN the hook copy alone in a directory that is no checkout at all. |
| 38 | +tmp="$(mktemp -d)" |
| 39 | +trap 'rm -rf "$tmp"' EXIT INT TERM |
| 40 | +REPO="$tmp/repo" |
| 41 | +BARE="$tmp/bare" |
| 42 | +PLAIN="$tmp/plain" |
| 43 | +SKILL_REL='.claude/skills/pm-dispatch/SKILL.md' |
| 44 | +LANES_REL='.claude/skills/pm-dispatch/references/lanes' |
| 45 | +for r in "$REPO" "$BARE" "$PLAIN"; do mkdir -p "$r/.claude/hooks"; cp "$hook" "$r/.claude/hooks/session-start-fleet.sh"; done |
| 46 | +mkdir -p "$REPO/$LANES_REL" "$BARE/$LANES_REL" |
| 47 | +printf '# pm-dispatch (fixture)\n\nrules live here.\n' > "$REPO/$SKILL_REL" |
| 48 | +printf '# lane: skills\n\n- Seat post: #19287 — the stub on the board.\n- posture lives in the project.\n' > "$REPO/$LANES_REL/skills.md" |
| 49 | +printf '# lane: devx\n\n- talks about the seat post but names no number.\n' > "$REPO/$LANES_REL/devx.md" |
| 50 | +printf '# lane: skills\n\n- Seat post: #19287\n' > "$BARE/$LANES_REL/skills.md" |
| 51 | +( |
| 52 | + cd "$REPO" || exit 1 |
| 53 | + git init -q . |
| 54 | + git config user.email selftest@example.com |
| 55 | + git config user.name selftest |
| 56 | + git add -A |
| 57 | + git commit -qm 'fixture: hook, skill, lanes' |
| 58 | +) || { echo "fixture build failed" >&2; exit 1; } |
| 59 | +REPO_TOUCH="$(git -C "$REPO" log -1 --format=%h -- "$SKILL_REL")" |
| 60 | +REPO_TOUCH_FULL="$(git -C "$REPO" log -1 --format=%H -- "$SKILL_REL")" |
| 61 | + |
| 62 | +# run <hook-path> [env…] -> stdout of the hook, with the harness's envelope on stdin; |
| 63 | +# the exit code is kept in RC (captured BEFORE any pipe, so it is the hook's own). |
| 64 | +RC="" |
| 65 | +OUT="" |
| 66 | +run() { |
| 67 | + local h="$1"; shift |
| 68 | + local payload='{"session_id":"selftest","hook_event_name":"SessionStart","source":"startup","cwd":"/nowhere"}' |
| 69 | + OUT="$(printf '%s' "$payload" | env "$@" "$h" 2>/dev/null)" |
| 70 | + RC=$? |
| 71 | +} |
| 72 | + |
| 73 | +ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } |
| 74 | +nope() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; } |
| 75 | + |
| 76 | +exit0() { # exit0 <label> — RC of the last run must be 0 |
| 77 | + if [ "$RC" = 0 ]; then ok "exit 0 $1"; else nope "exit $RC $1 (must be 0: a hook that fails must never block a session)"; fi |
| 78 | +} |
| 79 | +says() { # says <needle> <label> |
| 80 | + case "$OUT" in |
| 81 | + *"$1"*) ok "says $2" ;; |
| 82 | + *) nope "missing \"$1\" $2" ;; |
| 83 | + esac |
| 84 | +} |
| 85 | +lacks() { # lacks <needle> <label> |
| 86 | + case "$OUT" in |
| 87 | + *"$1"*) nope "still says \"$1\" $2" ;; |
| 88 | + *) ok "lacks $2" ;; |
| 89 | + esac |
| 90 | +} |
| 91 | +lines_le() { # lines_le <n> <label> |
| 92 | + local n; n="$(printf '%s\n' "$OUT" | grep -c '' )" |
| 93 | + if [ "$n" -le "$1" ]; then ok "lines $n ≤ $1 $2"; else nope "lines $n > $1 $2"; fi |
| 94 | +} |
| 95 | + |
| 96 | +POINTER='Fleet protocol: read .claude/skills/pm-dispatch/SKILL.md (and the stage references its index names) before any dispatch, claim, review or landing act.' |
| 97 | +STUB='the seat post is a stub; posture and ledger live in the project' |
| 98 | +H="$REPO/.claude/hooks/session-start-fleet.sh" |
| 99 | + |
| 100 | +echo "== the pointer, the touch, and the lane line without OS_FLEET_LANE ==" |
| 101 | +run "$H" |
| 102 | +exit0 'plain run' |
| 103 | +says "$POINTER" 'the pointer line, verbatim' |
| 104 | +says "SKILL.md: $REPO/$SKILL_REL" 'the skill path, resolved from the HOOK FILE, never the cwd' |
| 105 | +says "touch: $REPO_TOUCH " "the latest touch of SKILL.md ($REPO_TOUCH), as git log -1 -- <path> names it" |
| 106 | +says 'compare with the opening mark' 'the touch line says what the sha is for' |
| 107 | +says 'lane: not set' 'no OS_FLEET_LANE ⇒ lane: not set' |
| 108 | +lacks 'SHALLOW' 'a full fixture clone is not called shallow' |
| 109 | +lacks 'note: this session' 'no project-dir note when CLAUDE_PROJECT_DIR is unset' |
| 110 | +lines_le 15 'the whole print stays within the budget' |
| 111 | + |
| 112 | +echo "== the hook is located by its own path: the cwd and CLAUDE_PROJECT_DIR are not inputs ==" |
| 113 | +( cd "$tmp" && run "$H" ); # subshell cwd = the parent of the checkout, the project-session shape |
| 114 | +run "$H" CLAUDE_PROJECT_DIR="$tmp" |
| 115 | +exit0 'run with CLAUDE_PROJECT_DIR = the PARENT of the checkout' |
| 116 | +says "SKILL.md: $REPO/$SKILL_REL" 'the skill path still resolves to the checkout the hook lives in' |
| 117 | +says 'note: this session' 'a project dir that is not this checkout is named as the reason for the pointer' |
| 118 | +run "$H" CLAUDE_PROJECT_DIR="$REPO" |
| 119 | +exit0 'run with CLAUDE_PROJECT_DIR = this checkout' |
| 120 | +lacks 'note: this session' 'no note when the project dir IS this checkout' |
| 121 | +run "$H" CLAUDE_PROJECT_DIR="$REPO/" |
| 122 | +lacks 'note: this session' 'a trailing slash on the project dir is the same checkout' |
| 123 | + |
| 124 | +echo "== OS_FLEET_LANE names a lane ==" |
| 125 | +run "$H" OS_FLEET_LANE=skills |
| 126 | +exit0 'lane with a Seat post: line' |
| 127 | +says 'lane: skills · seat post #19287' 'the seat-post number is read from references/lanes/skills.md' |
| 128 | +says "$STUB" 'the stub reminder rides the lane line' |
| 129 | +run "$H" OS_FLEET_LANE=devx |
| 130 | +exit0 'lane whose file carries no Seat post: line' |
| 131 | +says 'seat post: not recorded in' 'no number is invented when the lane file names none' |
| 132 | +says "$STUB" 'the stub reminder still rides the line' |
| 133 | +lacks 'seat post #' 'a sentence that merely mentions the seat post passes no number' |
| 134 | +run "$H" OS_FLEET_LANE=nope |
| 135 | +exit0 'lane with no file at all' |
| 136 | +says "lane: nope · no $LANES_REL/nope.md on this checkout" 'a lane with no file says so, with the path looked for' |
| 137 | +run "$H" OS_FLEET_LANE='../skills' |
| 138 | +exit0 'a lane value with path characters' |
| 139 | +says 'OS_FLEET_LANE refused' 'a value that is not a lane name is refused before it reaches a path' |
| 140 | +lacks '../skills' 'the refused value is not echoed into the context' |
| 141 | +run "$H" OS_FLEET_LANE='skills; echo pwned' |
| 142 | +exit0 'a lane value with shell metacharacters' |
| 143 | +says 'OS_FLEET_LANE refused' 'refused the same way' |
| 144 | +lacks 'pwned' 'and never spelled into the output' |
| 145 | +run "$H" OS_FLEET_LANE='' |
| 146 | +says 'lane: not set' 'an EMPTY OS_FLEET_LANE reads as not set' |
| 147 | + |
| 148 | +echo "== a checkout with no SKILL.md, and a directory that is no checkout ==" |
| 149 | +run "$BARE/.claude/hooks/session-start-fleet.sh" OS_FLEET_LANE=skills |
| 150 | +exit0 'no SKILL.md, no git' |
| 151 | +says "$POINTER" 'the pointer still prints' |
| 152 | +says 'SKILL.md: MISSING at' 'the missing skill is named, with the path looked for' |
| 153 | +says 'lane: skills · seat post #19287' 'the lane file is still read on its own' |
| 154 | +run "$PLAIN/.claude/hooks/session-start-fleet.sh" |
| 155 | +exit0 'hook copy in a directory that is no checkout' |
| 156 | +says 'SKILL.md: MISSING at' 'a plain directory has no skill either' |
| 157 | +says 'lane: not set' 'and the lane line still prints' |
| 158 | + |
| 159 | +echo "== a shallow clone's boundary is labelled, never presented as a measured touch ==" |
| 160 | +shallow_file="$(git -C "$REPO" rev-parse --git-path shallow)" |
| 161 | +case "$shallow_file" in /*) ;; *) shallow_file="$REPO/$shallow_file" ;; esac |
| 162 | +printf '%s\n' "$REPO_TOUCH_FULL" > "$shallow_file" |
| 163 | +run "$H" |
| 164 | +exit0 'run on a checkout whose touch commit is the shallow boundary' |
| 165 | +says "touch: $REPO_TOUCH is this SHALLOW clone's boundary" 'the boundary sha is named AS a boundary' |
| 166 | +lacks 'latest commit touching SKILL.md' 'and is not called the latest touch' |
| 167 | +says 'deepen' 'the line says what to do about it' |
| 168 | +rm -f "$shallow_file" |
| 169 | +run "$H" |
| 170 | +says "touch: $REPO_TOUCH — latest commit touching SKILL.md" 'with the shallow file gone the same sha is a measured touch again' |
| 171 | + |
| 172 | +echo "== inputs the hook must survive at exit 0 ==" |
| 173 | +OUT="$("$H" </dev/null 2>/dev/null)"; RC=$? |
| 174 | +exit0 'stdin closed (no envelope at all)' |
| 175 | +says "$POINTER" 'and it still prints the pointer' |
| 176 | +OUT="$(printf 'not json at all' | "$H" 2>/dev/null)"; RC=$? |
| 177 | +exit0 'malformed stdin' |
| 178 | +OUT="$(printf '' | "$H" 2>/dev/null)"; RC=$? |
| 179 | +exit0 'empty stdin' |
| 180 | +nogit="$(mktemp -d)" |
| 181 | +for b in bash env cat sed grep head dirname; do p="$(command -v "$b")" && ln -s "$p" "$nogit/$b"; done |
| 182 | +OUT="$(printf '{}' | PATH="$nogit" "$H" 2>/dev/null)"; RC=$? |
| 183 | +rm -rf "$nogit" |
| 184 | +exit0 'git not on PATH' |
| 185 | +says 'touch: unavailable' 'without git the touch is reported unavailable, not invented' |
| 186 | +says "$POINTER" 'and the pointer still prints' |
| 187 | + |
| 188 | +echo "== syntax, mode, and the bash-3.2 floor's loudest constructs ==" |
| 189 | +if bash -n "$hook" 2>/dev/null; then ok 'bash -n parses the hook'; else nope 'bash -n rejects the hook'; fi |
| 190 | +if [ -x "$hook" ]; then ok 'the hook is executable'; else nope 'the hook is not executable'; fi |
| 191 | +# Comment lines are stripped first: the header NAMES these constructs in order to forbid them. |
| 192 | +if grep -vE '^[[:space:]]*#' "$hook" | grep -nE '\bmapfile\b|\breadarray\b|declare -A|\$\{[A-Za-z_]+(,,|\^\^)\}|EPOCHSECONDS|&>>?|\|&' >/dev/null; then |
| 193 | + nope 'a bash-4+ construct is in the hook (mapfile/readarray/declare -A/${x,,}/EPOCHSECONDS/&>/|&)' |
| 194 | +else |
| 195 | + ok 'none of the loudest bash-4+ constructs appear in the hook' |
| 196 | +fi |
| 197 | + |
| 198 | +echo "== wiring: settings.json registers this hook on SessionStart ==" |
| 199 | +if [ -f "$settings" ]; then |
| 200 | + cmds="$(jq -r '.hooks.SessionStart[]?.hooks[]? | select(.type == "command") | .command' "$settings" 2>/dev/null || true)" |
| 201 | + case "$cmds" in |
| 202 | + *session-start-fleet.sh*) ok "settings.json runs session-start-fleet.sh on SessionStart ($(short "$settings"))" ;; |
| 203 | + *) nope "settings.json has no SessionStart command naming session-start-fleet.sh ($(short "$settings"))" ;; |
| 204 | + esac |
| 205 | + case "$cmds" in |
| 206 | + *'$CLAUDE_PROJECT_DIR/.claude/hooks/session-start-fleet.sh'*) ok 'the registration is spelled like its siblings ($CLAUDE_PROJECT_DIR/.claude/hooks/…)' ;; |
| 207 | + *) nope 'the registration does not use the sibling spelling $CLAUDE_PROJECT_DIR/.claude/hooks/session-start-fleet.sh' ;; |
| 208 | + esac |
| 209 | + pre="$(jq -r '.hooks.PreToolUse[]?.hooks[]? | .command' "$settings" 2>/dev/null || true)" |
| 210 | + case "$pre" in |
| 211 | + *session-start-fleet.sh*) nope 'the hook is ALSO wired as a PreToolUse guard — it prints context, it judges no tool call' ;; |
| 212 | + *) ok 'the hook is not wired as a PreToolUse guard' ;; |
| 213 | + esac |
| 214 | +else |
| 215 | + nope "settings.json not found at $(short "$settings")" |
| 216 | +fi |
| 217 | + |
| 218 | +echo "== NON-VACUITY: the matrix can fail ==" |
| 219 | +# A copy that prints a different pointer must be caught by the same `says` the real hook |
| 220 | +# passes; a copy that exits 1 must be caught by `exit0`. Both are run against the fixture. |
| 221 | +MUT="$tmp/mut/.claude/hooks"; mkdir -p "$MUT" |
| 222 | +sed 's/^printf .%s.n. "Fleet protocol: /printf "%s\\n" "Wrong pointer: /' "$hook" > "$MUT/session-start-fleet.sh"; chmod +x "$MUT/session-start-fleet.sh" |
| 223 | +run "$MUT/session-start-fleet.sh" |
| 224 | +case "$OUT" in |
| 225 | + *"$POINTER"*) nope 'a mutated pointer was NOT caught — the pointer assertion is vacuous' ;; |
| 226 | + *'Wrong pointer:'*) ok 'a mutated pointer is caught (the mutation really applied and `says` sees the difference)' ;; |
| 227 | + *) nope 'the mutation did not apply (sed anchor missed); the non-vacuity run proved nothing' ;; |
| 228 | +esac |
| 229 | +printf '#!/usr/bin/env bash\nexit 1\n' > "$MUT/session-start-fleet.sh" |
| 230 | +run "$MUT/session-start-fleet.sh" |
| 231 | +if [ "$RC" = 1 ]; then ok 'a copy that exits 1 is seen as exit 1 by `run` (so exit0 can fail)'; else nope "a copy that exits 1 read as exit $RC — exit0 cannot fail"; fi |
| 232 | +# The trap is what makes an `-u` abort exit 0: a copy that references an unset variable |
| 233 | +# under `set -u` must still exit 0, which the trap alone provides. |
| 234 | +sed 's/^SKILL_REL=/: "$UNSET_ON_PURPOSE"\nSKILL_REL=/' "$hook" > "$MUT/session-start-fleet.sh"; chmod +x "$MUT/session-start-fleet.sh" |
| 235 | +if grep -q 'UNSET_ON_PURPOSE' "$MUT/session-start-fleet.sh"; then |
| 236 | + run "$MUT/session-start-fleet.sh" |
| 237 | + exit0 'an unset-variable abort under set -u still exits 0 (the EXIT trap holds the contract)' |
| 238 | +else |
| 239 | + nope 'the set -u mutation did not apply (sed anchor missed)' |
| 240 | +fi |
| 241 | + |
| 242 | +printf '\nsession-start-fleet selftest: %s passed, %s failed\n' "$pass" "$fail" |
| 243 | +[ "$fail" -eq 0 ] || exit 1 |
| 244 | +exit 0 |
0 commit comments