diff --git a/README.md b/README.md
index 8233848d6..8f191f445 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ From inside your repo, paste this. Switches you to team mode, bootstraps the rep
(cd ~/.claude/skills/gstack && ./setup --team) && ~/.claude/skills/gstack/bin/gstack-team-init required && git add .claude/ CLAUDE.md && git commit -m "require gstack for AI-assisted work"
```
-No vendored gstack install in your repo, no version drift, no manual upgrades. Required mode commits one Node 18+ CommonJS enforcement hook, so the same project hook works from POSIX and Windows hook runners without duplicate shell-specific handlers. If the hook runner omits its project directory, enforcement fails closed with an intentional denial instead of a hook error. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent).
+No vendored gstack install in your repo, no version drift, no manual upgrades. Required mode commits one Node 18+ CommonJS enforcement hook, so the same project hook works from POSIX and Windows hook runners without duplicate shell-specific handlers. If the hook runner omits its project directory or provides a stale path, enforcement fails closed with an intentional denial instead of a hook error. Every Claude Code session starts with a fast auto-update check (throttled to once/hour, network-failure-safe, completely silent).
Swap `required` for `optional` if you'd rather nudge teammates than block them.
diff --git a/bin/gstack-team-init b/bin/gstack-team-init
index 74cd5af5d..2687f7d73 100755
--- a/bin/gstack-team-init
+++ b/bin/gstack-team-init
@@ -179,7 +179,7 @@ HOOK_EOF
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
- const hookCommand = \"node -e \\\"const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){const reason='BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))}else{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}\\\"\";
+ const hookCommand = \"node -e \\\"const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}\\\"\";
let found = false;
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(entry => {
if (entry.matcher !== 'Skill' || !entry.hooks) return true;
diff --git a/implementation-notes.html b/implementation-notes.html
index 1de10a6d7..c2d307d35 100644
--- a/implementation-notes.html
+++ b/implementation-notes.html
@@ -14,6 +14,7 @@
Required mode generates .claude/hooks/check-gstack.cjs. The explicit CommonJS extension is unaffected by a consumer repository's package.json module type.
The one registered command launches Node 18+ and resolves CLAUDE_PROJECT_DIR inside JavaScript through process.env. It contains no Bash, PowerShell, or cmd environment expansion.
The launcher validates CLAUDE_PROJECT_DIR before calling path.join. Missing or empty project context emits a structured PreToolUse denial, writes a clear reason to stderr, and exits zero instead of surfacing a hook execution error.
+ Path resolution and module loading run inside the same try/catch. A nonexistent project, an existing project without the generated hook, or any other load failure returns the same concise denial without exposing a stack trace or the full project path.
The generated hook resolves the user home in HOME, USERPROFILE, then os.homedir() order so POSIX, Git Bash, PowerShell, and cmd runners use the expected global install root.
The hook contract follows docs/spikes/claude-code-hook-mutation.md: pass-through writes {}; denial writes a hookSpecificOutput object for PreToolUse with permissionDecision: "deny".
Missing or unreadable gstack is an intentional denial with exit code zero. Install instructions are present in stderr and permissionDecisionReason, so the runner does not report a hook execution error.
diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts
index a17fb6938..e3101a3ec 100644
--- a/test/team-mode.test.ts
+++ b/test/team-mode.test.ts
@@ -13,6 +13,10 @@ function bashCommand(filePath: string): string {
const SETTINGS_HOOK = bashCommand(path.join(ROOT, 'bin', 'gstack-settings-hook'));
const SESSION_UPDATE = bashCommand(path.join(ROOT, 'bin', 'gstack-session-update'));
const TEAM_INIT = bashCommand(path.join(ROOT, 'bin', 'gstack-team-init'));
+const MISSING_PROJECT_REASON =
+ 'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';
+const HOOK_LOAD_REASON =
+ 'BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.';
function mkTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-team-test-'));
@@ -77,6 +81,22 @@ function runHookInPowerShell(
};
}
+function expectStructuredDeny(
+ result: { stdout: string; stderr: string; exitCode: number },
+ reason: string,
+): void {
+ expect(result.exitCode).toBe(0);
+ expect(result.stderr.trim()).toBe(reason);
+ expect(result.stderr).not.toContain('MODULE_NOT_FOUND');
+ expect(JSON.parse(result.stdout)).toEqual({
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ permissionDecision: 'deny',
+ permissionDecisionReason: reason,
+ },
+ });
+}
+
describe('gstack-settings-hook', () => {
let tmpDir: string;
let settingsFile: string;
@@ -312,7 +332,7 @@ describe('gstack-team-init', () => {
expect(entry.hooks).toHaveLength(1);
expect(entry.hooks[0]).not.toHaveProperty('shell');
expect(entry.hooks[0].command).toBe(
- `node -e "const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){const reason='BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.';console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))}else{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}"`,
+ `node -e "const deny=reason=>{console.error(reason);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}}))};const projectDir=process.env.CLAUDE_PROJECT_DIR;if(!projectDir){deny('BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.')}else{try{require(require('node:path').join(projectDir, '.claude', 'hooks', 'check-gstack.cjs'))}catch{deny('BLOCKED: the required gstack hook could not be loaded. Verify project hook setup and retry.')}}"`,
);
expect(entry.hooks[0].command).not.toMatch(
/\$CLAUDE_PROJECT_DIR|\$env:CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/,
@@ -359,16 +379,7 @@ describe('gstack-team-init', () => {
env: { CLAUDE_PROJECT_DIR: '' },
});
- expect(result.exitCode).toBe(0);
- expect(result.stderr).toContain('BLOCKED: CLAUDE_PROJECT_DIR is unavailable');
- expect(JSON.parse(result.stdout)).toEqual({
- hookSpecificOutput: {
- hookEventName: 'PreToolUse',
- permissionDecision: 'deny',
- permissionDecisionReason:
- 'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.',
- },
- });
+ expectStructuredDeny(result, MISSING_PROJECT_REASON);
});
test('required: missing project env denies through PowerShell', () => {
@@ -385,16 +396,55 @@ describe('gstack-team-init', () => {
env: { CLAUDE_PROJECT_DIR: '' },
});
- expect(result.exitCode).toBe(0);
- expect(result.stderr).toContain('BLOCKED: CLAUDE_PROJECT_DIR is unavailable');
- expect(JSON.parse(result.stdout)).toEqual({
- hookSpecificOutput: {
- hookEventName: 'PreToolUse',
- permissionDecision: 'deny',
- permissionDecisionReason:
- 'BLOCKED: CLAUDE_PROJECT_DIR is unavailable, so the required gstack hook cannot be loaded.',
- },
- });
+ expectStructuredDeny(result, MISSING_PROJECT_REASON);
+ });
+
+ test('required: stale project paths deny through the platform default shell', () => {
+ run(`${TEAM_INIT} required`, { cwd: tmpDir });
+ const settings = JSON.parse(
+ fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
+ );
+ const command = settings.hooks.PreToolUse[0].hooks[0].command;
+ const existingWithoutHook = path.join(tmpDir, 'existing-without-hook');
+ fs.mkdirSync(existingWithoutHook);
+
+ for (const projectDir of [
+ path.join(tmpDir, 'nonexistent-project'),
+ existingWithoutHook,
+ ]) {
+ const result = runHook(command, {
+ cwd: tmpDir,
+ env: { CLAUDE_PROJECT_DIR: projectDir },
+ });
+
+ expectStructuredDeny(result, HOOK_LOAD_REASON);
+ expect(result.stderr).not.toContain(projectDir);
+ }
+ });
+
+ test('required: stale project paths deny through PowerShell', () => {
+ if (process.platform !== 'win32') return;
+
+ run(`${TEAM_INIT} required`, { cwd: tmpDir });
+ const settings = JSON.parse(
+ fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
+ );
+ const command = settings.hooks.PreToolUse[0].hooks[0].command;
+ const existingWithoutHook = path.join(tmpDir, 'powershell-without-hook');
+ fs.mkdirSync(existingWithoutHook);
+
+ for (const projectDir of [
+ path.join(tmpDir, 'powershell-nonexistent'),
+ existingWithoutHook,
+ ]) {
+ const result = runHookInPowerShell(command, {
+ cwd: tmpDir,
+ env: { CLAUDE_PROJECT_DIR: projectDir },
+ });
+
+ expectStructuredDeny(result, HOOK_LOAD_REASON);
+ expect(result.stderr).not.toContain(projectDir);
+ }
});
test('required: hook runs in PowerShell with USERPROFILE home fallback', () => {