Skip to content

Commit 857b804

Browse files
committed
feat(hooks): SessionStart hook prints the pm-dispatch pointer, its touch sha and the lane's seat-post line
A multi-repo project session has the parent of the checkouts as its working directory, so a nested checkout's .claude/skills never reaches the session's skill list and /pm-dispatch cannot be invoked there. session-start-fleet.sh prints the pointer to SKILL.md into the session's first context, the latest touch of SKILL.md on HEAD (a shallow clone's graft boundary is labelled as one, never presented as a measured touch), and — when OS_FLEET_LANE names a lane — the lane's seat-post number read from references/lanes/<lane>.md plus the stub reminder. No network, no GitHub read, exit 0 always; located by its own path, never the cwd. Registered beside the PreToolUse guards; its self-test builds a hermetic fixture checkout. seat-lifecycle.md gains one line in 〈开轮互斥〉 naming the hook, paid by the reader-routing line the SKILL.md stage index already carries. Claude-Session: https://claude.ai/code/session_01SSWEKCxzY8HYr8EsFpR8VG Co-authored-by: Claude <noreply@anthropic.com>
1 parent d12e650 commit 857b804

4 files changed

Lines changed: 390 additions & 2 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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

Comments
 (0)