From 857b804dec1793c790cc9ce417d6af122f10b0ec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 05:05:36 +0000 Subject: [PATCH 1/2] feat(hooks): SessionStart hook prints the pm-dispatch pointer, its touch sha and the lane's seat-post line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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 --- .claude/hooks/session-start-fleet.selftest.sh | 244 ++++++++++++++++++ .claude/hooks/session-start-fleet.sh | 135 ++++++++++ .claude/settings.json | 10 + .../pm-dispatch/references/seat-lifecycle.md | 3 +- 4 files changed, 390 insertions(+), 2 deletions(-) create mode 100755 .claude/hooks/session-start-fleet.selftest.sh create mode 100755 .claude/hooks/session-start-fleet.sh diff --git a/.claude/hooks/session-start-fleet.selftest.sh b/.claude/hooks/session-start-fleet.selftest.sh new file mode 100755 index 00000000000..50892e1aa4b --- /dev/null +++ b/.claude/hooks/session-start-fleet.selftest.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# Self-test for session-start-fleet.sh — run it after touching that hook: +# +# .claude/hooks/session-start-fleet.selftest.sh +# +# Runs the hook the way the harness does on SessionStart (a JSON envelope on stdin, stdout +# collected) and asserts, case by case, what it prints and that it exits 0. Hermetic: it +# builds its OWN throwaway checkout under $TMPDIR — a git repo carrying a copy of the hook at +# the hook's real relative position, a `SKILL.md`, and two lane files (one with a `Seat post:` +# line, one without) — so the matrix never depends on which machine or checkout it runs from, +# nor on how deep this clone is. Needs jq (to read settings.json) and git; no install, no +# build, no network. Exit 0 = all cases hold. +# +# Every case is a line the hook PRINTS, so the exit-0 contract is asserted on each of them +# rather than once: the one failure this hook must never have is a non-zero exit, in any +# shape of input. NON-VACUITY at the foot proves the matrix can fail — a mutated copy that +# prints the wrong pointer is caught by the same `says` the real hook passes. + +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="${SESSION_START_FLEET_HOOK:-$here/session-start-fleet.sh}" +settings="${SESSION_START_FLEET_SETTINGS:-$here/../settings.json}" +pass=0 +fail=0 + +command -v jq >/dev/null 2>&1 || { echo "selftest needs jq to read settings.json" >&2; exit 1; } +command -v git >/dev/null 2>&1 || { echo "selftest needs git to build the fixture" >&2; exit 1; } +[ -x "$hook" ] || { echo "hook not executable: $hook" >&2; exit 1; } + +short() { printf '%s' "${1/#$HOME/~}"; } + +# --- fixture ----------------------------------------------------------------------------- +# REPO a checkout: .claude/hooks/, SKILL.md, lanes/skills.md (Seat post: #19287), +# lanes/devx.md (no seat-post line), one commit. +# BARE the same tree with NO SKILL.md, no git. +# PLAIN the hook copy alone in a directory that is no checkout at all. +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM +REPO="$tmp/repo" +BARE="$tmp/bare" +PLAIN="$tmp/plain" +SKILL_REL='.claude/skills/pm-dispatch/SKILL.md' +LANES_REL='.claude/skills/pm-dispatch/references/lanes' +for r in "$REPO" "$BARE" "$PLAIN"; do mkdir -p "$r/.claude/hooks"; cp "$hook" "$r/.claude/hooks/session-start-fleet.sh"; done +mkdir -p "$REPO/$LANES_REL" "$BARE/$LANES_REL" +printf '# pm-dispatch (fixture)\n\nrules live here.\n' > "$REPO/$SKILL_REL" +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" +printf '# lane: devx\n\n- talks about the seat post but names no number.\n' > "$REPO/$LANES_REL/devx.md" +printf '# lane: skills\n\n- Seat post: #19287\n' > "$BARE/$LANES_REL/skills.md" +( + cd "$REPO" || exit 1 + git init -q . + git config user.email selftest@example.com + git config user.name selftest + git add -A + git commit -qm 'fixture: hook, skill, lanes' +) || { echo "fixture build failed" >&2; exit 1; } +REPO_TOUCH="$(git -C "$REPO" log -1 --format=%h -- "$SKILL_REL")" +REPO_TOUCH_FULL="$(git -C "$REPO" log -1 --format=%H -- "$SKILL_REL")" + +# run [env…] -> stdout of the hook, with the harness's envelope on stdin; +# the exit code is kept in RC (captured BEFORE any pipe, so it is the hook's own). +RC="" +OUT="" +run() { + local h="$1"; shift + local payload='{"session_id":"selftest","hook_event_name":"SessionStart","source":"startup","cwd":"/nowhere"}' + OUT="$(printf '%s' "$payload" | env "$@" "$h" 2>/dev/null)" + RC=$? +} + +ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } +nope() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; } + +exit0() { # exit0