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.
* fix(#3129): replace bypassed bash regex with token-walk git-cmd.js classifier
Root cause: gsd-validate-commit.sh used:
if [[ "$CMD" =~ ^git[[:space:]]+commit ]]
This regex silently bypasses Conventional Commits enforcement for:
git -C /path commit -m ... (working-directory prefix)
GIT_AUTHOR_NAME=x git commit (env-var prefix)
/usr/bin/git commit -m ... (full-path executable)
Fix: introduces hooks/lib/git-cmd.js with isGitSubcommand(cmd, sub) —
a token-walk classifier that handles all four forms by:
1. Skipping leading VAR=VALUE env assignments
2. Validating the git executable (basename check for full-path support)
3. Consuming git global options (-C <path>, --git-dir=, -p, etc.)
4. Checking the subcommand token
The hook delegates to this classifier via node shell-out. node is
already called twice in this hook (config check + JSON parse), so no
new runtime dependency.
This becomes the single source of truth for all hooks that gate on
git subcommands (pre-commit-review-gate, post-push-verify, etc.).
Regression test: 27 assertions — tokenize correctness, 12 must-match
cases (including all 3 bypass forms), 8 must-not-match cases, 3 source
checks. All are real behavioral tests, not string comparisons.
Suite: 7035/7035. Closes#3129.
* fix(lint+hook+changeset): allow-test-rule, fix HOOK_DIR quote injection, fix changeset pr+typo
* test(#2974): migrate 8 test files to typed-IR assertions
Replaces raw stdout/stderr substring matching with structured-field
assertions per CONTRIBUTING.md "Prohibited: Raw Text Matching on Test
Outputs". Adds shared infrastructure for typed error emission so this
pattern is the easy path going forward.
Shared infrastructure:
- core.cjs: ERROR_REASON frozen enum + setJsonErrorMode/getJsonErrorMode
- gsd-tools.cjs: --json-errors CLI flag, parsed before subcommand dispatch
- config.cjs: typed reasons at all 7 error sites
- graphify.cjs: GRAPHIFY_REASON enum + reason/timeout_ms in execGraphify result
- bin/install.js: pure buildSdkFailFastReport() IR builder + renderer
- hooks/gsd-session-state.sh, gsd-phase-boundary.sh: emit Claude Code
hookSpecificOutput JSON envelope with typed state_present/config_mode/
planning_modified/file_path fields (no-op when hooks.community is off)
Test migrations (all pass, 171 tests across the 8 files):
- bug-2649-sdk-fail-fast: assert on ir.reason / ir.context / ir.fix_command
- bug-2687-config-read-warning-parity: assert.equal stderr === ''
- bug-2796-arg-parsing-regression: assert on result.json.updated/.phase
- bug-2838-summary-rescue: parse rescue footer, assert mtime invariant
- bug-2943-config-get-context-window: parse JSON, assert ERROR_REASON.CONFIG_KEY_NOT_FOUND
- graphify: assert reason === GRAPHIFY_REASON.ENOENT/TIMEOUT
- hooks-opt-in: parse hookSpecificOutput, assert typed fields
- security-scan: reclassified as source-text-is-the-product (scan label
output and CI workflow YAML ARE the deployed contract)
Verification: lint-no-source-grep clean (0 violations), full suite
6741/6741 pass.
Closes#2974
* test(#2974): address CR feedback — typed code field, robust idempotency
Two CodeRabbit findings on #3016 addressed:
1. tests/hooks-opt-in.test.cjs:355 (Minor, inline) —
parsed.reason.includes('Conventional Commits') was still substring
matching after the typed-IR migration. Fixed at the source: the
gsd-validate-commit hook now emits a typed `code` field
('CONVENTIONAL_COMMITS_VIOLATION', 'COMMIT_SUBJECT_TOO_LONG')
alongside the human-readable `reason`. Test asserts strictEqual
on the code; the prose copy is no longer part of the test contract.
2. tests/bug-2838-summary-rescue-gitignored-planning.test.cjs:224-250
(Outside-diff) — mtimeMs alone can stay unchanged on coarse-grained
filesystems (HFS+, FAT) when two rewrites land within the same
timestamp tick, falsely passing the idempotency assertion.
Replaced with a full snapshot (mtimeMs, ctimeMs, size, ino, sha256
of contents) compared via assert.deepStrictEqual — the hash
catches any rewrite the timestamp would miss.
Verification: 30/30 pass on the two affected files; lint-no-source-grep
clean (0 violations across 368 test files).
* fix(hooks): stamp gsd-hook-version in .sh hooks and fix stale detection regex (#2136, #2206)
Three-part fix for the persistent "⚠ stale hooks — run /gsd-update" false
positive that appeared on every session after a fresh install.
Root cause: the stale-hook detector (gsd-check-update.js) could only match
the JS comment syntax // in its version regex — never the bash # syntax used
in .sh hooks. And the bash hooks had no version header at all, so they always
landed in the "unknown / stale" branch regardless.
Neither partial fix (PR #2207 regex only, PR #2215 install stamping only) was
sufficient alone:
- Regex fix without install stamping: hooks install with literal
"{{GSD_VERSION}}", the {{-guard silently skips them, bash hook staleness
permanently undetectable after future updates.
- Install stamping without regex fix: hooks are stamped correctly with
"# gsd-hook-version: 1.36.0" but the detector's // regex can't read it;
still falls to the unknown/stale branch on every session.
Fix:
1. Add "# gsd-hook-version: {{GSD_VERSION}}" header to
gsd-phase-boundary.sh, gsd-session-state.sh, gsd-validate-commit.sh
2. Extend install.js (both bundled and Codex paths) to substitute
{{GSD_VERSION}} in .sh files at install time (same as .js hooks)
3. Extend gsd-check-update.js versionMatch regex to handle bash "#"
comment syntax: /(?:\/\/|#) gsd-hook-version:\s*(.+)/
Tests: 11 new assertions across 5 describe blocks covering all three fix
parts independently plus an E2E install+detect round-trip. 3885/3885 pass.
Approach credit: PR #2207 (j2h4u / Maxim Brashenko) for the regex fix;
PR #2215 (nitsan2dots) for the install.js substitution approach.
Closes#2136, #2206, #2209, #2210, #2212
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(hooks): extract check-update worker to dedicated file, eliminating template-literal regex escaping
Move stale-hook detection logic from inline `node -e '<template literal>'` subprocess
to a standalone gsd-check-update-worker.js. Benefits:
- Regex is plain JS with no double-escaping (root cause of the (?:\\/\\/|#) confusion)
- Worker is independently testable and can be read directly by tests
- Uses execFileSync (array args) to satisfy security hook that blocks execSync
- MANAGED_HOOKS now includes gsd-check-update-worker.js itself
Update tests to read worker file instead of main hook for regex/configDir assertions.
All 3886 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Port 3 community hooks from gsd-skill-creator, gated behind hooks.community config flag. All hooks are registered on install but are no-ops unless the project config has hooks: { community: true }.
gsd-session-state.sh (SessionStart): outputs STATE.md head for orientation. gsd-validate-commit.sh (PreToolUse/Bash): blocks non-Conventional-Commits messages. gsd-phase-boundary.sh (PostToolUse/Write|Edit): warns when .planning/ files are modified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>