Skip to content

Commit 9c79d92

Browse files
feat(hooks): SessionStart hook prints the pm-dispatch pointer, its SKILL.md touch sha and the lane’s seat-post line (#25386)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DM24jRuzJ1rYKwcydPYYtYc)_ Fixes #25380 Clause-②: no Metadata-protocol: none ## 维护者速读(草稿) - **改了什么**:新增一个 `SessionStart` 钩子 `.claude/hooks/session-start-fleet.sh`,会话一开始就把三行话印进上下文:① 「先读 `.claude/skills/pm-dispatch/SKILL.md` 与阶段 references 再派发/认领/复核/落地」的指针;② 本检出上 SKILL.md 最近一次触碰的 sha(供席位与开轮标记比对;浅克隆边界会被明确标出、不冒充真触碰);③ 车道行:环境里有 `OS_FLEET_LANE` 就印该车道的席位贴号与「席位贴只是存根,姿态与台账住项目里」的提醒,没有就印 `lane: not set`。配套 selftest(58 例)、`settings.json` 注册一条、`seat-lifecycle.md` 加一行点名钩子(删一行已由 SKILL.md 索引承载的读者路由句付账,96→95 行,上限不动)。 - **为什么改**:您 2026-10-23 的原话:「包括Claude code projects 我打开之后怎么让他自动加载相关的技能要求。」「同意,你现在就立卡派发处理。」多仓项目线程的工作目录是三个检出的父目录,嵌套检出的 `.claude/skills` 进不了技能列表,两天来每条席位线程都是靠项目指令手读 SKILL.md。规则仍只住仓库里一份,钩子只印指针。 - **风险与代价(含回滚)**:钩子不联网、不读 GitHub、不写文件,恒 exit 0(连 `set -u` 中断都被 trap 收成 0),失败最多少印几行,不会挡会话;每次会话开始多 3–5 行上下文。一次 revert 可回(删钩子两文件 + settings 一段 + seat-lifecycle 一行)。两处实测要您知道:(a) 本卡线程里 `CLAUDE_PROJECT_DIR` 未设、已有的 PreToolUse 守卫对一条已知应拦的命令没有拦——即 objectstack 的 `settings.json` 在这种「父目录开会话」的线程里很可能根本没被加载(或 `$CLAUDE_PROJECT_DIR` 指向父目录),那样本钩子在项目线程里同样不会触发;单仓会话与 CI 自检不受影响。(b) `references/lanes/*.md` 目前没有任何一份写了席位贴号,钩子如实印「not recorded」;补号是各车道文件各加一行 `Seat post: #N`,不在本卡范围。 - **席位意见**:(席位复核后填) - **你要做的(一个动作)**:席位复审 PASS 后,一句「可以落地」;若要让项目线程真的触发它,还需确认平台是否加载嵌套检出的 `settings.json`(见 A1)。 ## What changed | File | Change | |:--|:--| | `.claude/hooks/session-start-fleet.sh` (new) | `SessionStart` hook. Prints the pointer line, the SKILL.md path and its latest touch on HEAD, an optional one-line note when `CLAUDE_PROJECT_DIR` is not this checkout, and the lane line. Dependency-free bash (3.2 floor), no jq, no network, no GitHub read, no write; `trap 'exit 0' EXIT` so it exits 0 under every input measured (closed/empty/malformed stdin, no git on PATH, no checkout, no SKILL.md, a refused lane value, an `-u` abort). Everything is resolved from the hook's own path, never the cwd. | | `.claude/hooks/session-start-fleet.selftest.sh` (new) | 58 cases on a hermetic fixture checkout (git repo + SKILL.md + two lane files, one with `Seat post: #19287`, one without): pointer verbatim, touch sha = `git log -1 -- SKILL.md`, shallow-boundary labelling (the fixture's touch commit written into the git dir's `shallow` file flips the wording and removing it flips it back), lane set / unset / empty / no file / no number / path characters / shell metacharacters, cwd and `CLAUDE_PROJECT_DIR` not being inputs, ≤15 lines, `bash -n` and the executable bit (the bash-3.2 floor itself is `check:bash32-floor`'s, repo-wide), the `settings.json` wiring (registered on `SessionStart`, sibling spelling, not also a `PreToolUse` guard), and a NON-VACUITY tail (a mutated pointer, an `exit 1` copy and a `set -u` abort are each caught or held). Discovered by the existing `find .claude/hooks -name '*.selftest.sh'` step in `lint.yml`, so CI runs it with no workflow edit. | | `.claude/settings.json` | one `hooks.SessionStart` entry: `"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start-fleet.sh"`, the sibling spelling. `permissions` untouched (`check:pm-settings-deny-roster` green). | | `.claude/skills/pm-dispatch/references/seat-lifecycle.md` | +1 line in 〈开轮互斥〉 under the harness-current line: `- \`session-start-fleet.sh\` 钩子开会话即印 SKILL.md 触碰 sha 与车道席位贴行,供比对开轮标记。` (115 bytes). −1 line: the opening reader-routing sentence 「每个座位每次 fire 读本文:…」, whose content the SKILL.md 〈阶段细则索引〉 row for this file already carries (reader + both read moments). 96 → 95 lines; the ceiling row stays 96 (headroom 1, no ratchet edit). Placed away from L69 (PR #25348) and from 〈座位贴协议〉 (#25379). ⛔ Not a second copy of the pointer text. | ## The exact lines the hook prints On this branch's checkout (HEAD `e75a6b53`, a shallow clone), no `OS_FLEET_LANE`: ```text Fleet protocol: read .claude/skills/pm-dispatch/SKILL.md (and the stage references its index names) before any dispatch, claim, review or landing act. SKILL.md: /home/claude/objectstack/.claude/skills/pm-dispatch/SKILL.md touch: 8010b64 is this SHALLOW clone's boundary, not a measured touch of SKILL.md (HEAD e75a6b5); the real touch is older than the clone holds — deepen (git fetch --deepen=N origin) or read it from a full checkout before comparing with an opening mark. lane: not set (OS_FLEET_LANE names the lane; set it in the environment this hook runs in — the project's instructions or the agent's launch environment). ``` With `OS_FLEET_LANE=skills`, the last line becomes: ```text lane: skills · seat post: not recorded in .claude/skills/pm-dispatch/references/lanes/skills.md (no `Seat post: #N` line); read the pm:seat index for it; the seat post is a stub; posture and ledger live in the project. ``` On a full clone the touch line reads `touch: SHA — latest commit touching SKILL.md on HEAD SHA; compare with the opening mark (seat-lifecycle.md 〈开轮互斥〉) before the round.`; with a lane file carrying `Seat post: #19287` the lane line reads `lane: skills · seat post #19287 (.claude/skills/pm-dispatch/references/lanes/skills.md); the seat post is a stub; posture and ledger live in the project.` (both pinned by the self-test). When `CLAUDE_PROJECT_DIR` is set and is not this checkout, one more line is printed: ` note: this session's project dir is not this checkout, so the harness's skill list does not include this checkout's .claude/skills — hence this pointer.` The shallow-boundary wording is not decoration: `8010b64` is in this clone's `.git/shallow`, and `git log -1 -- SKILL.md` names it only because the graft makes the boundary look like it touched every path — the exact fabrication `check-harness-current.mjs`'s header documents. On `origin/main` from a full checkout the real touch is older; the hook refuses to present the boundary as a reading. ## Mechanism assumptions, measured (dispatch A1–A4) - **A1 — does a hook registered in `objectstack/.claude/settings.json` fire in a project thread, and what does it see?** Measured from this card thread (working directory `/home/claude` at start, the parent of the three checkouts): `CLAUDE_PROJECT_DIR` is **unset** in the Bash tool's environment, and the registered PreToolUse guard `guard-tree-enum.sh` did **not** block `for f in .github/workflows/*.yml; do git show "origin/main:$f" | head -1; done` — its own self-test's first measured block case — the command ran and printed. So in this thread shape either the nested checkout's `settings.json` is not loaded at all, or `$CLAUDE_PROJECT_DIR` resolves to the parent, where `$CLAUDE_PROJECT_DIR/.claude/hooks/…` does not exist and the hook command fails non-blocking. This contradicts the card's premise that the guards fire in card threads; it may differ by how a thread is rooted. Consequence: the hook is correct and self-tested, but whether a project thread ever runs it is a platform question, not something this PR can settle. The hook locates SKILL.md from its own path, so wherever it IS run it prints the right checkout. A registration that also looks under `$CLAUDE_PROJECT_DIR/objectstack/…` would not help if the file is not loaded, so the sibling spelling is kept rather than guessing. - **A2 — "verified once from a project thread".** Not observable before merge: the harness reads `settings.json` from the primary checkout at session start and does not reload it, and the measured thread does not run the registered hooks at all (A1). ⛔ Not claimed. The self-test evidence and the exact lines above stand in; the first post-merge project thread that shows the pointer in its first context is the acceptance reading, recorded by the seat. - **A3 — `seat-lifecycle.md` placement.** One line in 〈开轮互斥〉 (L21 after the edit), away from L69 (#25348, which only rewords that line and moves no ceiling) and from 〈座位贴协议〉 (#25379). `origin/main` merged when the seat says so. - **A4 — `OS_FLEET_LANE` spelling.** `git grep` over `scripts/`, `.claude/`, `.github/`: no script reads a lane name from the environment under any spelling (`OS_LANE_STEPS` / `OS_LANE_JOB_STATUS` in `lint.yml` are the CI lane-verdict inputs, a different thing). `OS_FLEET_LANE` is new and collides with nothing; it follows the `OS_FLEET_*` family (`fleet-token.mjs`, `fleet-write/**`). - **A5 (found, not assumed) — the seat-post number is not in any lane file.** `references/lanes/*.md` carry no `#N` at all (`grep -rn '19287\|6023\|1353' .claude/` is empty); seat posts are found by the `pm:seat` label index. The hook reads an optional line `Seat post: #N` from the lane file as the card specifies and says `not recorded` when absent. Writing those lines is one line per lane file, outside this card's file surface — carrier: the skills seat (a follow-up card or a rider on #25379's reference edit). ## Verification - `bash .claude/hooks/session-start-fleet.selftest.sh` → `58 passed, 0 failed`, exit 0 (log in the PR's verification comment). Hook exits 0 with and without `OS_FLEET_LANE`, on a checkout with no SKILL.md, in a directory that is no checkout, with no git on PATH, with closed, empty and malformed stdin. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the actual changed set (4 paths, 392 changed lines, under the 3000 threshold): 19 commands derived (`--commands`, no paths — the tool took the change set off the merge base itself), every one run on head `e75a6b53` with its exit code captured before any pipe and reconciled with `--ran`: `19 derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED`, all exit 0. The list: check-closing-keyword-parity (+ --self-test), check-comment-mask-corpus, check-harness-current --self-test, lint `check:doc-formula-expressions` (exit 3 PREREQUISITE NOT MET on the first sweep until `@objectstack/formula` and `@objectstack/lint` were built; exit 0 on the final sweep), check:agent-test-spelling, check:bash32-floor (red on the first sweep — the selftest had spelled the forbidden tokens into a grep pattern; fixed in the second commit, green since), check:cross-package-test-inputs, check:doc-authoring, check:driver-memory-census, check:gitlink-declared, check:nul-bytes, check:pm-governed-merges, check:pm-settings-deny-roster, check:pm-skill-id-lint, check:pm-skill-ratchet, check:refd-timer-probe, check:skill-frame-sync, check:watch-hint-literal. Repo-wide scans (`pnpm lint`, the hook self-test discovery step, the type-check lanes) are CI's; this PR's check-runs are the reading for those. - `pnpm check:pm-skill-ratchet`: green — `seat-lifecycle.md` 95 lines against its ceiling 96 (headroom 1; the ceiling row is not moved), every edited line ≤120 bytes (the new line is 115), no cross-file move declared. - The hook's own text carries no issue number (`check:pm-skill-id-lint` covers the skill files; the hook is read by `check:doc-authoring` and `check:bash32-floor`, both in the derived list). ## Acceptance notes - `references/lanes/*.md` carry no `Seat post: #N` line (A5) — carrier: skills seat; noted, not filed. - The project-thread hook question (A1): this thread's registered PreToolUse guards do not fire, with `CLAUDE_PROJECT_DIR` unset in the tool environment — a platform reading for the seat's `platform-readings.md` fact table (reference edits are outside this card). Carrier: skills seat; noted, not filed. Governed surface `.claude/**` (Tier S): draft, awaiting the seat's `CONTRACT_REVIEW_TIER` review and the maintainer's word; ⛔ not to be flipped ready, queued or armed by this thread. `skip-changeset`: no published package is touched (`.claude/**` and a reference file only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SSWEKCxzY8HYr8EsFpR8VG --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 119a338 commit 9c79d92

4 files changed

Lines changed: 388 additions & 2 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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 and mode =="
189+
# The bash-3.2 floor itself is held repo-wide over `.claude/hooks/**` by
190+
# `scripts/check-bash32-floor.mjs` (`pnpm check:bash32-floor`), whose scan has no filename
191+
# allowlist and flags a construct's TOKEN wherever it stands; it is deliberately not re-scanned
192+
# here, because spelling the forbidden tokens into a grep pattern is itself a finding there.
193+
if bash -n "$hook" 2>/dev/null; then ok 'bash -n parses the hook'; else nope 'bash -n rejects the hook'; fi
194+
if [ -x "$hook" ]; then ok 'the hook is executable'; else nope 'the hook is not executable'; fi
195+
196+
echo "== wiring: settings.json registers this hook on SessionStart =="
197+
if [ -f "$settings" ]; then
198+
cmds="$(jq -r '.hooks.SessionStart[]?.hooks[]? | select(.type == "command") | .command' "$settings" 2>/dev/null || true)"
199+
case "$cmds" in
200+
*session-start-fleet.sh*) ok "settings.json runs session-start-fleet.sh on SessionStart ($(short "$settings"))" ;;
201+
*) nope "settings.json has no SessionStart command naming session-start-fleet.sh ($(short "$settings"))" ;;
202+
esac
203+
case "$cmds" in
204+
*'$CLAUDE_PROJECT_DIR/.claude/hooks/session-start-fleet.sh'*) ok 'the registration is spelled like its siblings ($CLAUDE_PROJECT_DIR/.claude/hooks/…)' ;;
205+
*) nope 'the registration does not use the sibling spelling $CLAUDE_PROJECT_DIR/.claude/hooks/session-start-fleet.sh' ;;
206+
esac
207+
pre="$(jq -r '.hooks.PreToolUse[]?.hooks[]? | .command' "$settings" 2>/dev/null || true)"
208+
case "$pre" in
209+
*session-start-fleet.sh*) nope 'the hook is ALSO wired as a PreToolUse guard — it prints context, it judges no tool call' ;;
210+
*) ok 'the hook is not wired as a PreToolUse guard' ;;
211+
esac
212+
else
213+
nope "settings.json not found at $(short "$settings")"
214+
fi
215+
216+
echo "== NON-VACUITY: the matrix can fail =="
217+
# A copy that prints a different pointer must be caught by the same `says` the real hook
218+
# passes; a copy that exits 1 must be caught by `exit0`. Both are run against the fixture.
219+
MUT="$tmp/mut/.claude/hooks"; mkdir -p "$MUT"
220+
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"
221+
run "$MUT/session-start-fleet.sh"
222+
case "$OUT" in
223+
*"$POINTER"*) nope 'a mutated pointer was NOT caught — the pointer assertion is vacuous' ;;
224+
*'Wrong pointer:'*) ok 'a mutated pointer is caught (the mutation really applied and `says` sees the difference)' ;;
225+
*) nope 'the mutation did not apply (sed anchor missed); the non-vacuity run proved nothing' ;;
226+
esac
227+
printf '#!/usr/bin/env bash\nexit 1\n' > "$MUT/session-start-fleet.sh"
228+
run "$MUT/session-start-fleet.sh"
229+
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
230+
# The trap is what makes an `-u` abort exit 0: a copy that references an unset variable
231+
# under `set -u` must still exit 0, which the trap alone provides.
232+
sed 's/^SKILL_REL=/: "$UNSET_ON_PURPOSE"\nSKILL_REL=/' "$hook" > "$MUT/session-start-fleet.sh"; chmod +x "$MUT/session-start-fleet.sh"
233+
if grep -q 'UNSET_ON_PURPOSE' "$MUT/session-start-fleet.sh"; then
234+
run "$MUT/session-start-fleet.sh"
235+
exit0 'an unset-variable abort under set -u still exits 0 (the EXIT trap holds the contract)'
236+
else
237+
nope 'the set -u mutation did not apply (sed anchor missed)'
238+
fi
239+
240+
printf '\nsession-start-fleet selftest: %s passed, %s failed\n' "$pass" "$fail"
241+
[ "$fail" -eq 0 ] || exit 1
242+
exit 0

0 commit comments

Comments
 (0)