Merge pull request #1564 from Tibsfox/feat/hooks-opt-in-1473d

feat(hooks): opt-in session-state, commit-validation, phase-boundary hooks
This commit is contained in:
Tom Boucher
2026-04-02 17:49:08 -04:00
committed by GitHub
5 changed files with 684 additions and 1 deletions

View File

@@ -3709,7 +3709,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// 4. Remove GSD hooks
const hooksDir = path.join(targetDir, 'hooks');
if (fs.existsSync(hooksDir)) {
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js'];
const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js', 'gsd-prompt-guard.js', 'gsd-session-state.sh', 'gsd-validate-commit.sh', 'gsd-phase-boundary.sh'];
let hookCount = 0;
for (const hook of gsdHooks) {
const hookPath = path.join(hooksDir, hook);
@@ -4809,6 +4809,75 @@ function install(isGlobal, runtime = 'claude') {
});
console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
}
// Community hooks — registered on install but opt-in at runtime.
// Each hook checks .planning/config.json for hooks.community: true
// and exits silently (no-op) if not enabled. This lets users enable
// them per-project by adding: "hooks": { "community": true }
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
const validateCommitCommand = isGlobal
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-validate-commit.sh'
: 'bash ' + dirName + '/hooks/gsd-validate-commit.sh';
const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit'))
);
if (!hasValidateCommitHook) {
settings.hooks[preToolEvent].push({
matcher: 'Bash',
hooks: [
{
type: 'command',
command: validateCommitCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
}
// Configure session state orientation hook (opt-in)
const sessionStateCommand = isGlobal
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-session-state.sh'
: 'bash ' + dirName + '/hooks/gsd-session-state.sh';
const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state'))
);
if (!hasSessionStateHook) {
settings.hooks.SessionStart.push({
hooks: [
{
type: 'command',
command: sessionStateCommand
}
]
});
console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
}
// Configure phase boundary detection hook (opt-in)
const phaseBoundaryCommand = isGlobal
? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-phase-boundary.sh'
: 'bash ' + dirName + '/hooks/gsd-phase-boundary.sh';
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary'))
);
if (!hasPhaseBoundaryHook) {
settings.hooks[postToolEvent].push({
matcher: 'Write|Edit',
hooks: [
{
type: 'command',
command: phaseBoundaryCommand,
timeout: 5
}
]
});
console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
}
}
return { settingsPath, settings, statuslineCommand, runtime };

27
hooks/gsd-phase-boundary.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# gsd-phase-boundary.sh — PostToolUse hook: detect .planning/ file writes
# Outputs a reminder when planning files are modified outside normal workflow.
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
#
# 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
INPUT=$(cat)
# Extract file_path from JSON using Node (handles escaping correctly)
FILE=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.file_path||'')}catch{}})" 2>/dev/null)
if [[ "$FILE" == *.planning/* ]] || [[ "$FILE" == .planning/* ]]; then
echo ".planning/ file modified: $FILE"
echo "Check: Should STATE.md be updated to reflect this change?"
fi
exit 0

33
hooks/gsd-session-state.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# 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
echo '## Project State Reminder'
echo ''
if [ -f .planning/STATE.md ]; then
echo 'STATE.md exists - check for blockers and current phase.'
head -20 .planning/STATE.md
else
echo 'No .planning/ found - suggest /gsd:new-project if starting new work.'
fi
echo ''
if [ -f .planning/config.json ]; then
MODE=$(grep -o '"mode"[[:space:]]*:[[:space:]]*"[^"]*"' .planning/config.json 2>/dev/null || echo '"mode": "unknown"')
echo "Config: $MODE"
fi
exit 0

47
hooks/gsd-validate-commit.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
# Blocks git commit commands with non-conforming messages (exit 2).
# Allows conforming messages and all non-commit commands (exit 0).
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
#
# 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
INPUT=$(cat)
# Extract command from JSON using Node (handles escaping correctly, no jq needed)
CMD=$(echo "$INPUT" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{process.stdout.write(JSON.parse(d).tool_input?.command||'')}catch{}})" 2>/dev/null)
# Only check git commit commands
if [[ "$CMD" =~ ^git[[:space:]]+commit ]]; then
# Extract message from -m flag
MSG=""
if [[ "$CMD" =~ -m[[:space:]]+\"([^\"]+)\" ]]; then
MSG="${BASH_REMATCH[1]}"
elif [[ "$CMD" =~ -m[[:space:]]+\'([^\']+)\' ]]; then
MSG="${BASH_REMATCH[1]}"
fi
if [ -n "$MSG" ]; then
SUBJECT=$(echo "$MSG" | head -1)
# Validate Conventional Commits format
if ! [[ "$SUBJECT" =~ ^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?:[[:space:]].+ ]]; then
echo '{"decision": "block", "reason": "Commit message must follow Conventional Commits: <type>(<scope>): <subject>. Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore. Subject must be <=72 chars, lowercase, imperative mood, no trailing period."}'
exit 2
fi
if [ ${#SUBJECT} -gt 72 ]; then
echo '{"decision": "block", "reason": "Commit subject must be 72 characters or less."}'
exit 2
fi
fi
fi
exit 0

507
tests/hooks-opt-in.test.cjs Normal file
View File

@@ -0,0 +1,507 @@
/**
* GSD Tools Tests - Community Hooks (opt-in)
*
* Tests for feat/hooks-opt-in-1473d:
* - Hook file existence and permissions
* - Installer hook registration in install.js
* - Hook execution with opt-in enabled and disabled
* - Negative security tests for hooks
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const isWindows = process.platform === 'win32';
// ─── Helpers ────────────────────────────────────────────────────────────────
function createTempProject(prefix = 'gsd-hook-test-') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
return tmpDir;
}
function cleanup(tmpDir) {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
}
function writeConfigWithHooks(tmpDir, enabled) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({
model_profile: 'balanced',
hooks: { community: enabled }
}, null, 2)
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || '# Session State\n\n**Current Phase:** 01\n**Status:** Active\n';
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Hook file existence and permissions
// ─────────────────────────────────────────────────────────────────────────────
describe('hook file validation', () => {
test('gsd-session-state.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-session-state.sh should exist');
});
test('gsd-validate-commit.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-validate-commit.sh should exist');
});
test('gsd-phase-boundary.sh exists', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
assert.ok(fs.existsSync(hookPath), 'gsd-phase-boundary.sh should exist');
});
test('gsd-session-state.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-session-state.sh should be executable');
});
test('gsd-validate-commit.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-validate-commit.sh should be executable');
});
test('gsd-phase-boundary.sh is executable', { skip: isWindows ? 'Windows has no POSIX file permissions' : false }, () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const stat = fs.statSync(hookPath);
assert.ok((stat.mode & 0o111) !== 0, 'gsd-phase-boundary.sh should be executable');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. Installer hook registration
// ─────────────────────────────────────────────────────────────────────────────
describe('installer hook registration', () => {
const installJsPath = path.join(__dirname, '..', 'bin', 'install.js');
let installSource;
beforeEach(() => {
installSource = fs.readFileSync(installJsPath, 'utf-8');
});
test('install.js contains gsd-validate-commit registration block', () => {
assert.ok(
installSource.includes('gsd-validate-commit'),
'install.js should contain gsd-validate-commit hook registration'
);
assert.ok(
installSource.includes('validateCommitCommand'),
'install.js should define validateCommitCommand variable'
);
assert.ok(
installSource.includes('hasValidateCommitHook'),
'install.js should check for existing validate-commit hook'
);
});
test('install.js contains gsd-session-state registration block', () => {
assert.ok(
installSource.includes('gsd-session-state'),
'install.js should contain gsd-session-state hook registration'
);
assert.ok(
installSource.includes('sessionStateCommand'),
'install.js should define sessionStateCommand variable'
);
assert.ok(
installSource.includes('hasSessionStateHook'),
'install.js should check for existing session-state hook'
);
});
test('install.js contains gsd-phase-boundary registration block', () => {
assert.ok(
installSource.includes('gsd-phase-boundary'),
'install.js should contain gsd-phase-boundary hook registration'
);
assert.ok(
installSource.includes('phaseBoundaryCommand'),
'install.js should define phaseBoundaryCommand variable'
);
assert.ok(
installSource.includes('hasPhaseBoundaryHook'),
'install.js should check for existing phase-boundary hook'
);
});
test('install.js registers validate-commit with PreToolUse event and Bash matcher', () => {
assert.ok(
installSource.includes("settings.hooks[preToolEvent].push"),
'validate-commit should be pushed to preToolEvent hooks array'
);
const validateCommitBlock = installSource.substring(
installSource.indexOf('// Configure commit validation hook'),
installSource.indexOf('// Configure session state orientation hook')
);
assert.ok(
validateCommitBlock.includes("matcher: 'Bash'"),
'validate-commit hook should use Bash matcher'
);
assert.ok(
validateCommitBlock.includes('preToolEvent'),
'validate-commit hook should register on preToolEvent (PreToolUse)'
);
});
test('install.js adds all 3 new hooks to the uninstall cleanup list', () => {
const gsdHooksMatch = installSource.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
assert.ok(gsdHooksMatch, 'install.js should define gsdHooks array for uninstall cleanup');
const gsdHooksContent = gsdHooksMatch[1];
assert.ok(
gsdHooksContent.includes('gsd-session-state.sh'),
'gsdHooks should include gsd-session-state.sh'
);
assert.ok(
gsdHooksContent.includes('gsd-validate-commit.sh'),
'gsdHooks should include gsd-validate-commit.sh'
);
assert.ok(
gsdHooksContent.includes('gsd-phase-boundary.sh'),
'gsdHooks should include gsd-phase-boundary.sh'
);
});
test('install.js log messages indicate opt-in behavior', () => {
assert.ok(
installSource.includes('opt-in via config'),
'install.js should mention opt-in in log messages'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Opt-in gating behavior
// ─────────────────────────────────────────────────────────────────────────────
describe('opt-in gating behavior', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (no-op) even with a bad commit message
assert.strictEqual(result.status, 0, `Should be no-op when disabled, got ${result.status}`);
});
test('validate-commit is a no-op when config.json is absent', () => {
// No config.json at all
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-bare-'));
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
try {
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: bareDir,
});
assert.strictEqual(result.status, 0, `Should be no-op without config.json, got ${result.status}`);
} finally {
fs.rmSync(bareDir, { recursive: true, force: true });
}
});
test('session-state is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
// Should NOT output state info when disabled
assert.ok(
!result.stdout.includes('Project State Reminder'),
`Should not output state reminder when disabled: ${result.stdout}`
);
});
test('phase-boundary is a no-op when hooks.community is false', () => {
writeConfigWithHooks(tmpDir, false);
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
!result.stdout.includes('.planning/ file modified'),
`Should not output warning when disabled: ${result.stdout}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. Hook execution when enabled
// ─────────────────────────────────────────────────────────────────────────────
describe('hook execution when enabled', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit allows valid conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(core): add locking mechanism"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit should exit 0, got ${result.status}. stderr: ${result.stderr}`);
});
test('validate-commit blocks non-conventional commit', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Non-conventional commit should exit 2, got ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
assert.ok(result.stdout.includes('Conventional Commits'), `stdout should mention "Conventional Commits": ${result.stdout}`);
});
test('validate-commit allows non-commit commands', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git push origin main' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Non-commit command should exit 0, got ${result.status}`);
});
test('session-state outputs state info when enabled', () => {
writeMinimalStateMd(tmpDir);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('STATE.md exists'),
`stdout should contain "STATE.md exists": ${result.stdout}`
);
});
test('session-state exits 0 without .planning/ (in enabled project)', () => {
// Create a dir with config but no STATE.md
const noStateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-nostate-'));
fs.mkdirSync(path.join(noStateDir, '.planning'), { recursive: true });
writeConfigWithHooks(noStateDir, true);
const hookPath = path.join(HOOKS_DIR, 'gsd-session-state.sh');
try {
const result = spawnSync('bash', [hookPath], {
input: '',
encoding: 'utf-8',
cwd: noStateDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('No .planning/ found') || result.stdout.includes('Project State'),
`Should handle missing STATE.md gracefully: ${result.stdout}`
);
} finally {
fs.rmSync(noStateDir, { recursive: true, force: true });
}
});
test('phase-boundary detects .planning/ writes when enabled', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = JSON.stringify({
tool_input: { file_path: '.planning/STATE.md' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should exit 0: ${result.stderr}`);
assert.ok(
result.stdout.includes('.planning/ file modified'),
`stdout should contain ".planning/ file modified": ${result.stdout}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 5. Negative security tests for hooks
// ─────────────────────────────────────────────────────────────────────────────
describe('hook security tests', { skip: isWindows ? 'bash hooks require unix shell' : false }, () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
writeConfigWithHooks(tmpDir, true);
});
afterEach(() => {
cleanup(tmpDir);
});
test('validate-commit blocks message with shell metacharacters', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "$(rm -rf /)"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Shell metacharacter message should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
});
test('validate-commit blocks message with backtick injection', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "`whoami`"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 2, `Backtick injection should be blocked: ${result.status}`);
assert.ok(result.stdout.includes('block'), `stdout should contain "block": ${result.stdout}`);
});
test('validate-commit allows commit with scope containing special chars', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "fix(api/v2): handle edge case"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Valid commit with / in scope should be allowed: ${result.status}`);
});
test('phase-boundary handles malformed JSON input gracefully', () => {
const hookPath = path.join(HOOKS_DIR, 'gsd-phase-boundary.sh');
const input = 'not json at all';
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
assert.strictEqual(result.status, 0, `Should not crash on malformed JSON: ${result.stderr}`);
});
test('hooks handle config.json with broken JSON gracefully', () => {
// Write malformed JSON config
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{ broken json'
);
const hookPath = path.join(HOOKS_DIR, 'gsd-validate-commit.sh');
const input = JSON.stringify({
tool_input: { command: 'git commit -m "WIP save"' }
});
const result = spawnSync('bash', [hookPath], {
input,
encoding: 'utf-8',
cwd: tmpDir,
});
// Should exit 0 (treat malformed config as disabled)
assert.strictEqual(result.status, 0, `Malformed config should be treated as disabled: ${result.status}`);
});
});