Files
msd-core/hooks/gsd-session-state.sh
Otavio Salvador 8ca86b5e24 fix: use #!/usr/bin/env bash in community .sh hooks for distro portability
The three opt-in bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
gsd-validate-commit.sh) shipped with #!/bin/bash, which fails on distros
that don't ship bash at /bin/bash (NixOS, minimal Alpine images, some
container runtimes). POSIX guarantees /bin/sh but not /bin/bash.

This is latent in the default install path because Claude Code wires the
hooks as `bash <path>` from settings.json (PATH-resolved — the script's
own shebang is read as a comment by bash). The fix matters when scripts
are run directly: tests, future installer changes, or manual debugging.

Changes:
- hooks/gsd-{phase-boundary,session-state,validate-commit}.sh: shebang
  switched to #!/usr/bin/env bash, matching the convention already used
  in scripts/*.sh.
- tests/bug-2136-sh-hook-version.test.cjs: assertion updated to expect
  the new shebang; comment updated to spell out the rationale.
- tests/bug-2979-hook-absolute-node.test.cjs: doc-comment updated — the
  prior wording cited "POSIX std PATH always has /bin" as the reason
  bare `bash` is OK. The actual reason is that bare `bash` is
  PATH-resolved, which is portable across distros that don't ship
  /bin/bash. POSIX std PATH guarantees /bin/sh, not /bin/bash.
- bin/install.js::buildHookCommand: comment block clarifying the same.
  No behavior change in this file — bare `bash` was already correct.
- .changeset/portable-bash-shebang-hooks.md: changeset entry.

Verified locally on NixOS:
- npm run build:hooks: hooks/dist/*.sh shebangs propagate correctly.
- node --test tests/bug-2136-*.cjs tests/bug-2979-*.cjs
  tests/bug-1817-*.cjs tests/bug-1834-*.cjs tests/bug-1906-*.cjs
  tests/bug-2557-*.cjs tests/bug-3017-*.cjs tests/security-scan.test.cjs
  tests/hooks-doc-parity.test.cjs: 126/126 pass.
- node scripts/run-tests.cjs (full suite): 6944 pass / 0 fail / 5 skip.
2026-05-06 15:41:27 -04:00

60 lines
2.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# gsd-hook-version: {{GSD_VERSION}}
# gsd-session-state.sh — SessionStart hook: inject project state reminder
# Outputs STATE.md head on every session start for orientation.
#
# OPT-IN: This hook is a no-op unless config.json has hooks.community: true.
# Enable with: "hooks": { "community": true } in .planning/config.json
# Check opt-in config — exit silently if not enabled
if [ -f .planning/config.json ]; then
ENABLED=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(c.hooks?.community===true?'1':'0')}catch{process.stdout.write('0')}" 2>/dev/null)
if [ "$ENABLED" != "1" ]; then exit 0; fi
else
exit 0
fi
# Build the additionalContext text and emit it as a structured JSON
# envelope per the Claude Code SessionStart hook protocol (#2974). Tests
# parse the JSON and assert on typed fields (state_present: bool,
# config_mode: string, etc) rather than substring-matching free-form text.
STATE_PRESENT="false"
STATE_HEAD=""
if [ -f .planning/STATE.md ]; then
STATE_PRESENT="true"
STATE_HEAD=$(head -20 .planning/STATE.md)
fi
CONFIG_MODE="unknown"
if [ -f .planning/config.json ]; then
CONFIG_MODE=$(node -e "try{const c=require('./.planning/config.json');process.stdout.write(String(c.mode||'unknown'))}catch{process.stdout.write('unknown')}" 2>/dev/null)
fi
# Use Node for JSON encoding so embedded newlines/quotes are escaped correctly.
# additionalContext is the text Claude Code injects at session start; the
# typed fields (state_present, config_mode) let tests assert on the
# structured contract without grepping the prose.
node -e '
const [statePresent, stateHead, configMode] = process.argv.slice(1);
const headerLines = ["## Project State Reminder", ""];
if (statePresent === "true") {
headerLines.push("STATE.md exists - check for blockers and current phase.");
if (stateHead) headerLines.push(stateHead);
} else {
headerLines.push("No .planning/ found - suggest /gsd-new-project if starting new work.");
}
headerLines.push("");
headerLines.push("Config: \"mode\": \"" + configMode + "\"");
const additionalContext = headerLines.join("\n");
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext,
state_present: statePresent === "true",
config_mode: configMode,
},
}));
' "$STATE_PRESENT" "$STATE_HEAD" "$CONFIG_MODE"
exit 0