diff --git a/.agents/skills/impeccable/scripts/context.mjs b/.agents/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.agents/skills/impeccable/scripts/context.mjs
+++ b/.agents/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.agents/skills/impeccable/scripts/detect-csp.mjs b/.agents/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.agents/skills/impeccable/scripts/detect-csp.mjs
+++ b/.agents/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.claude/skills/impeccable/scripts/context.mjs b/.claude/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.claude/skills/impeccable/scripts/context.mjs
+++ b/.claude/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.claude/skills/impeccable/scripts/detect-csp.mjs b/.claude/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.claude/skills/impeccable/scripts/detect-csp.mjs
+++ b/.claude/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.cursor/skills/impeccable/scripts/context.mjs b/.cursor/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.cursor/skills/impeccable/scripts/context.mjs
+++ b/.cursor/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.cursor/skills/impeccable/scripts/detect-csp.mjs b/.cursor/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.cursor/skills/impeccable/scripts/detect-csp.mjs
+++ b/.cursor/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.gemini/skills/impeccable/scripts/context.mjs b/.gemini/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.gemini/skills/impeccable/scripts/context.mjs
+++ b/.gemini/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.gemini/skills/impeccable/scripts/detect-csp.mjs b/.gemini/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.gemini/skills/impeccable/scripts/detect-csp.mjs
+++ b/.gemini/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.github/skills/impeccable/scripts/context.mjs b/.github/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.github/skills/impeccable/scripts/context.mjs
+++ b/.github/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.github/skills/impeccable/scripts/detect-csp.mjs b/.github/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.github/skills/impeccable/scripts/detect-csp.mjs
+++ b/.github/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.grok/skills/impeccable/scripts/context.mjs b/.grok/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.grok/skills/impeccable/scripts/context.mjs
+++ b/.grok/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.grok/skills/impeccable/scripts/detect-csp.mjs b/.grok/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.grok/skills/impeccable/scripts/detect-csp.mjs
+++ b/.grok/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.hermes/skills/impeccable/scripts/context.mjs b/.hermes/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.hermes/skills/impeccable/scripts/context.mjs
+++ b/.hermes/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.hermes/skills/impeccable/scripts/detect-csp.mjs b/.hermes/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.hermes/skills/impeccable/scripts/detect-csp.mjs
+++ b/.hermes/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.kiro/skills/impeccable/scripts/context.mjs b/.kiro/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.kiro/skills/impeccable/scripts/context.mjs
+++ b/.kiro/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.kiro/skills/impeccable/scripts/detect-csp.mjs b/.kiro/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.kiro/skills/impeccable/scripts/detect-csp.mjs
+++ b/.kiro/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.opencode/skills/impeccable/scripts/context.mjs b/.opencode/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.opencode/skills/impeccable/scripts/context.mjs
+++ b/.opencode/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.opencode/skills/impeccable/scripts/detect-csp.mjs b/.opencode/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.opencode/skills/impeccable/scripts/detect-csp.mjs
+++ b/.opencode/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.pi/skills/impeccable/scripts/context.mjs b/.pi/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.pi/skills/impeccable/scripts/context.mjs
+++ b/.pi/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.pi/skills/impeccable/scripts/detect-csp.mjs b/.pi/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.pi/skills/impeccable/scripts/detect-csp.mjs
+++ b/.pi/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.qoder/skills/impeccable/scripts/context.mjs b/.qoder/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.qoder/skills/impeccable/scripts/context.mjs
+++ b/.qoder/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.qoder/skills/impeccable/scripts/detect-csp.mjs b/.qoder/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.qoder/skills/impeccable/scripts/detect-csp.mjs
+++ b/.qoder/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.rovodev/skills/impeccable/scripts/context.mjs b/.rovodev/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.rovodev/skills/impeccable/scripts/context.mjs
+++ b/.rovodev/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.rovodev/skills/impeccable/scripts/detect-csp.mjs b/.rovodev/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.rovodev/skills/impeccable/scripts/detect-csp.mjs
+++ b/.rovodev/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.trae-cn/skills/impeccable/scripts/context.mjs b/.trae-cn/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.trae-cn/skills/impeccable/scripts/context.mjs
+++ b/.trae-cn/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.trae-cn/skills/impeccable/scripts/detect-csp.mjs b/.trae-cn/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.trae-cn/skills/impeccable/scripts/detect-csp.mjs
+++ b/.trae-cn/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.trae/skills/impeccable/scripts/context.mjs b/.trae/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.trae/skills/impeccable/scripts/context.mjs
+++ b/.trae/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.trae/skills/impeccable/scripts/detect-csp.mjs b/.trae/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.trae/skills/impeccable/scripts/detect-csp.mjs
+++ b/.trae/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/.vibe/skills/impeccable/scripts/context.mjs b/.vibe/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/.vibe/skills/impeccable/scripts/context.mjs
+++ b/.vibe/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/.vibe/skills/impeccable/scripts/detect-csp.mjs b/.vibe/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/.vibe/skills/impeccable/scripts/detect-csp.mjs
+++ b/.vibe/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
diff --git a/plugin/skills/impeccable/scripts/context.mjs b/plugin/skills/impeccable/scripts/context.mjs
index cb16553c2..3afb81b99 100644
--- a/plugin/skills/impeccable/scripts/context.mjs
+++ b/plugin/skills/impeccable/scripts/context.mjs
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
+ const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
+ const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
+ if (!repoRoot && targetGitRoot) {
+ const cwdGitRoot = findGitBoundaryRoot(absCwd);
+ if (targetGitRoot !== cwdGitRoot) {
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
+ repoRoot: targetGitRoot,
+ isMonorepo: false,
+ };
+ }
+ }
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
+ const targetIsExternal = hasTargetOption(options)
+ && targetDir !== absCwd
+ && !isPathInside(targetDir, absCwd);
+ if (targetIsExternal) {
+ const targetRepoRoot = targetGitRoot || targetDir;
+ return {
+ targetDir,
+ projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
+ repoRoot: targetRepoRoot,
+ isMonorepo: false,
+ };
+ }
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
+function findGitBoundaryRoot(startDir) {
+ let dir = path.resolve(startDir);
+ const homeDir = path.resolve(os.homedir());
+ while (true) {
+ if (dir === homeDir) return null;
+ if (hasGitBoundary(dir)) return dir;
+ const parent = path.dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
+ }
+}
+
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
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 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();
+ const add = (root) => {
+ if (!root) return;
+ const resolved = path.resolve(root);
+ if (seen.has(resolved)) return;
+ seen.add(resolved);
+ roots.push(resolved);
+ };
+
+ let current = path.resolve(ctx.projectRoot || process.cwd());
+ const home = path.resolve(os.homedir());
+ while (true) {
+ if (current === home) break;
+ add(current);
+ if (hasGitBoundary(current)) break;
+ const parent = path.dirname(current);
+ if (parent === current) break;
+ current = parent;
+ }
+
+ return roots;
+}
+
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
- const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
- for (const root of roots) {
+ for (const root of hookManifestSearchRoots(ctx)) {
+ // A manifest can live above the resolved product. Honor the hook lifecycle
+ // config beside that manifest before treating it as active coverage.
+ if (!hookEnabledAt(root)) continue;
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
diff --git a/plugin/skills/impeccable/scripts/detect-csp.mjs b/plugin/skills/impeccable/scripts/detect-csp.mjs
index a13505d23..1a5664b4d 100644
--- a/plugin/skills/impeccable/scripts/detect-csp.mjs
+++ b/plugin/skills/impeccable/scripts/detect-csp.mjs
@@ -18,8 +18,9 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
- * - "middleware": CSP set dynamically in middleware.{ts,js}.
- * Detected but not auto-patched in v1.
+ * - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
+ * Next.js 16's proxy.{ts,js,mjs} convention. Detected
+ * but not auto-patched in v1.
* - "meta-tag": in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
+const NEXT_MIDDLEWARE_FILES = new Set([
+ 'middleware.ts',
+ 'middleware.js',
+ 'middleware.mjs',
+]);
+const NEXT_PROXY_FILES = new Set([
+ 'proxy.ts',
+ 'proxy.js',
+ 'proxy.mjs',
+]);
+const NEXT_CONFIG_FILES = [
+ 'next.config.js',
+ 'next.config.mjs',
+ 'next.config.cjs',
+ 'next.config.ts',
+ 'next.config.mts',
+ 'next.config.cts',
+];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
+function hasNextProjectMarker(projectRoot) {
+ if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
+ if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
+ return ['dependencies', 'devDependencies', 'peerDependencies']
+ .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
+ } catch {
+ return false;
+ }
+}
+
+function isNextRequestHookFile(root, absPath, relPath, base) {
+ if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
+ if (!NEXT_PROXY_FILES.has(base)) return false;
+ const normalized = relPath.split(path.sep).join('/').toLowerCase();
+ // Next.js 16 recognizes proxy at the project root or in the optional src/
+ // directory, alongside app/ or pages/. The scan root is commonly a
+ // monorepo, so also accept that placement relative to a nested directory
+ // that carries a concrete Next.js project marker. A same-named helper
+ // elsewhere in the tree is not the framework request hook.
+ if (normalized === base || normalized === `src/${base}`) return true;
+ const hookDir = path.dirname(absPath);
+ const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
+ ? path.dirname(hookDir)
+ : hookDir;
+ if (path.resolve(projectRoot) === path.resolve(root)) return true;
+ return hasNextProjectMarker(projectRoot);
+}
+
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
- if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
- MIDDLEWARE_HINT.test(body)) {
+ if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}