diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 1df6415d7..8146da298 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -1315,9 +1315,12 @@ const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok'] // Harness project settings are discovered by walking up from the resolved // project root. Its hook manifest can live at an enclosing git root, so -// checking only projectRoot/repoRoot produces a false -// MANUAL_DETECTOR_REQUIRED directive. Starting from projectRoot also prevents -// an explicit target from borrowing an unrelated manifest near the caller. +// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED +// directive. Starting from projectRoot also prevents an explicit target from +// borrowing an unrelated manifest near the caller. The walk itself is the +// authority: do not append repoRoot afterward, because resolveProject can +// retain an outer workspace root for a target inside an independent nested +// Git repository. function hookManifestSearchRoots(ctx) { const roots = []; const seen = new Set(); @@ -1339,8 +1342,6 @@ function hookManifestSearchRoots(ctx) { current = parent; } - add(ctx.projectRoot); - add(ctx.repoRoot); return roots; } diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 48355d218..295c5c178 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -1266,6 +1266,38 @@ describe('context.mjs CLI', () => { assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); }); + it('does not borrow an outer workspace hook for a target in a nested Git repository', () => { + const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts'); + stageContextBundle(scripts, { providerId: 'claude-code' }); + + const repo = path.join(scratch, 'repo'); + const target = path.join(repo, 'repos', 'standalone'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + fs.mkdirSync(path.join(repo, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(target, '.git'), { recursive: true }); + fs.mkdirSync(path.join(target, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['repos/*'] })); + fs.writeFileSync(path.join(repo, '.claude', 'settings.local.json'), JSON.stringify({ + hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] }, + })); + fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ name: 'standalone' })); + fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Standalone\n'); + fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "standalone"; }\n'); + + const res = spawnSync(process.execPath, [ + path.join(scripts, 'context.mjs'), + '--target', + path.join('repos', 'standalone', 'src', 'App.jsx'), + ], { + cwd: repo, + encoding: 'utf8', + env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' }, + }); + assert.equal(res.status, 0, res.stderr); + assert.match(res.stdout, /"projectRoot": ".*\/repos\/standalone"/); + assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/); + }); + it('adds no detector directive when a per-edit-only hook is active', () => { const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts'); stageContextBundle(scripts, { providerId: 'cursor' });