diff --git a/bin/gstack-team-init b/bin/gstack-team-init
index 2687f7d73..a416d40fa 100755
--- a/bin/gstack-team-init
+++ b/bin/gstack-team-init
@@ -215,6 +215,13 @@ HOOK_EOF
" 2>/dev/null
GENERATED+=(".claude/settings.json")
echo " + .claude/settings.json — PreToolUse hook registered"
+
+ # Remove the obsolete Bash hook only after its replacement is generated and
+ # registered successfully. Other project hooks are left untouched.
+ if [ -e "$HOOKS_DIR/check-gstack.sh" ] || [ -L "$HOOKS_DIR/check-gstack.sh" ]; then
+ rm "$HOOKS_DIR/check-gstack.sh"
+ echo " - .claude/hooks/check-gstack.sh — removed legacy enforcement hook"
+ fi
else
echo " ! bun not found — manually add the PreToolUse hook to .claude/settings.json"
fi
diff --git a/implementation-notes.html b/implementation-notes.html
index c2d307d35..d4e2fad0f 100644
--- a/implementation-notes.html
+++ b/implementation-notes.html
@@ -18,7 +18,7 @@
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.
- Rerunning required mode replaces a matching legacy check-gstack.sh registration and removes duplicate matching registrations.
+ Rerunning required mode replaces a matching legacy check-gstack.sh registration, removes duplicate matching registrations, and deletes the obsolete Bash hook only after the CommonJS hook is generated and registered successfully. Unrelated project hooks remain unchanged.
Deviations
diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts
index e3101a3ec..b555f471c 100644
--- a/test/team-mode.test.ts
+++ b/test/team-mode.test.ts
@@ -397,7 +397,7 @@ describe('gstack-team-init', () => {
});
expectStructuredDeny(result, MISSING_PROJECT_REASON);
- });
+ }, 30_000);
test('required: stale project paths deny through the platform default shell', () => {
run(`${TEAM_INIT} required`, { cwd: tmpDir });
@@ -445,7 +445,7 @@ describe('gstack-team-init', () => {
expectStructuredDeny(result, HOOK_LOAD_REASON);
expect(result.stderr).not.toContain(projectDir);
}
- });
+ }, 30_000);
test('required: hook runs in PowerShell with USERPROFILE home fallback', () => {
if (process.platform !== 'win32') return;
@@ -472,7 +472,7 @@ describe('gstack-team-init', () => {
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
expect(JSON.parse(result.stdout)).toEqual({});
- });
+ }, 30_000);
test('required: missing gstack returns an intentional deny, not a hook error', () => {
run(`${TEAM_INIT} required`, { cwd: tmpDir });
@@ -507,10 +507,14 @@ describe('gstack-team-init', () => {
});
});
- test('required: rerun upgrades a legacy Bash hook without duplicates', () => {
+ test('required: rerun removes a legacy Bash hook without touching other hooks', () => {
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
+ const legacyHook = path.join(hooksDir, 'check-gstack.sh');
+ const unrelatedHook = path.join(hooksDir, 'check-project.cjs');
+ const unrelatedHookContents = "console.log('project hook');\n";
fs.mkdirSync(hooksDir, { recursive: true });
- fs.writeFileSync(path.join(hooksDir, 'check-gstack.sh'), '#!/bin/bash\n');
+ fs.writeFileSync(legacyHook, '#!/bin/bash\n');
+ fs.writeFileSync(unrelatedHook, unrelatedHookContents);
fs.writeFileSync(
path.join(tmpDir, '.claude', 'settings.json'),
JSON.stringify({
@@ -527,17 +531,31 @@ describe('gstack-team-init', () => {
},
}),
);
+ execSync('git add .claude', { cwd: tmpDir });
+ execSync('git commit -m "add legacy hook"', { cwd: tmpDir });
+
+ run(`${TEAM_INIT} required`, { cwd: tmpDir });
+ expect(fs.existsSync(legacyHook)).toBe(false);
+ expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents);
run(`${TEAM_INIT} required`, { cwd: tmpDir });
const settings = JSON.parse(
fs.readFileSync(path.join(tmpDir, '.claude', 'settings.json'), 'utf-8'),
);
+ expect(fs.existsSync(legacyHook)).toBe(false);
+ expect(fs.readFileSync(unrelatedHook, 'utf-8')).toBe(unrelatedHookContents);
+ expect(
+ execSync('git status --short -- .claude/hooks/check-gstack.sh', {
+ cwd: tmpDir,
+ encoding: 'utf-8',
+ }),
+ ).toBe(' D .claude/hooks/check-gstack.sh\n');
expect(settings.hooks.PreToolUse).toHaveLength(1);
expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1);
expect(settings.hooks.PreToolUse[0].hooks[0].command).toContain(
'check-gstack.cjs',
);
- });
+ }, 30_000);
test('idempotent: running twice does not duplicate CLAUDE.md section', () => {
run(`${TEAM_INIT} optional`, { cwd: tmpDir });