diff --git a/hosts/claude/hooks/question-preference-hook.ts b/hosts/claude/hooks/question-preference-hook.ts index 12cbd5ea28..e837fd52a3 100644 --- a/hosts/claude/hooks/question-preference-hook.ts +++ b/hosts/claude/hooks/question-preference-hook.ts @@ -282,11 +282,43 @@ function extractRecommended( } function slugFromCwd(cwd: string | undefined): string { - // Mirror gstack-slug's basename fallback. The full slug resolver shells out - // to git, which is too expensive on a hot hook path; the basename is close - // enough for preference lookup (preferences are keyed by question_id, slug - // is just the directory bucket). + // Resolve the SAME slug that bin/gstack-slug produces, because that is the + // bucket /plan-tune writes project-local preferences into + // (~/.gstack/projects//question-preferences.json). + // + // gstack-slug derives the slug from the git remote (owner-repo, e.g. + // "garrytan-gstack") and only falls back to basename when the repo has NO + // remote configured. Using basename unconditionally — the previous behaviour + // — therefore looked up a directory that does not exist in any repo with a + // remote, so project-local preferences could never be found and the + // project > global precedence (D8) silently collapsed to global-only. + // + // We do NOT shell out to git: that is too expensive on this hot hook path, + // which is why the basename shortcut existed. Instead we read gstack-slug's + // own on-disk cache, keyed by the absolute path with '/' replaced by '_'. + // That is a single file read, and gstack-slug populates it on first run. + // On a cache miss we fall back to basename, matching gstack-slug's own + // no-remote fallback. if (!cwd) return 'unknown'; + + const cacheKey = cwd.replace(/\//g, '_'); + // stateRoot() honours GSTACK_STATE_ROOT/GSTACK_HOME (used by tests); + // gstack-slug itself always writes under $HOME/.gstack. Check both. + const candidates = [ + path.join(stateRoot(), 'slug-cache', cacheKey), + path.join(os.homedir(), '.gstack', 'slug-cache', cacheKey), + ]; + for (const cachePath of candidates) { + try { + // Re-sanitize on read: gstack-slug promises the [a-zA-Z0-9._-] invariant + // but a hand-edited cache file could violate it, and this value becomes + // a path segment. + const cached = fs.readFileSync(cachePath, 'utf-8').trim().replace(/[^a-zA-Z0-9._-]/g, ''); + if (cached) return cached; + } catch { + // miss → try next candidate, then basename + } + } return path.basename(cwd); } diff --git a/test/question-preference-hook.test.ts b/test/question-preference-hook.test.ts index 39de02f4e8..53ece57f86 100644 --- a/test/question-preference-hook.test.ts +++ b/test/question-preference-hook.test.ts @@ -493,3 +493,74 @@ describe('auto-decided event tagging', () => { expect(fs.existsSync(markerPath)).toBe(true); }); }); + +// ---------------------------------------------------------------------- +// Slug resolution (project-local preference bucket) +// ---------------------------------------------------------------------- + +describe('slug resolution matches gstack-slug', () => { + // Regression: the hook used path.basename(cwd) while /plan-tune writes + // project-local preferences under the gstack-slug value, which is derived + // from the git remote (owner-repo). In any repo WITH a remote the two + // disagree, so project-local preferences were never found and D8 precedence + // silently collapsed to global-only. The earlier fixtures all used a cwd + // whose basename happened to equal the slug, which is why this survived. + + test('uses the cached gstack-slug value, not the cwd basename', () => { + const remoteSlug = 'owner-reponame'; + const repoDir = path.join(stateRoot, 'reponame'); + fs.mkdirSync(repoDir, { recursive: true }); + + // gstack-slug's cache: key is the absolute path with '/' -> '_' + const cacheDir = path.join(stateRoot, 'slug-cache'); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, repoDir.replace(/\//g, '_')), remoteSlug); + + // Preference lives ONLY under the remote-derived slug bucket. + fs.mkdirSync(path.join(stateRoot, 'projects', remoteSlug), { recursive: true }); + fs.writeFileSync( + path.join(stateRoot, 'projects', remoteSlug, 'question-preferences.json'), + JSON.stringify({ 'test-q': 'never-ask' }), + ); + + const r = runHook({ + session_id: 'slug-1', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-slug-1', + tool_input: { + questions: [ + { question: ' Need approval?', options: ['A) Yes (recommended)', 'B) No'] }, + ], + }, + }, repoDir); + + // Enforcement fired => the hook looked in the remote-derived bucket. + expect(r.status).toBe(0); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); + + test('falls back to basename when no slug-cache entry exists', () => { + // Mirrors gstack-slug's own no-remote fallback. + const repoDir = path.join(stateRoot, 'no-remote-repo'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.mkdirSync(path.join(stateRoot, 'projects', 'no-remote-repo'), { recursive: true }); + fs.writeFileSync( + path.join(stateRoot, 'projects', 'no-remote-repo', 'question-preferences.json'), + JSON.stringify({ 'test-q': 'never-ask' }), + ); + + const r = runHook({ + session_id: 'slug-2', + tool_name: 'AskUserQuestion', + tool_use_id: 'tu-slug-2', + tool_input: { + questions: [ + { question: ' Need approval?', options: ['A) Yes (recommended)', 'B) No'] }, + ], + }, + }, repoDir); + + expect(r.status).toBe(0); + expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('deny'); + }); +});