diff --git a/.agents/skills/impeccable/scripts/hook-before-edit.mjs b/.agents/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.agents/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.agents/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.claude/skills/impeccable/scripts/hook-before-edit.mjs b/.claude/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.claude/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.claude/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.github/skills/impeccable/scripts/hook-before-edit.mjs b/.github/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.github/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.github/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.pi/skills/impeccable/scripts/hook-before-edit.mjs b/.pi/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.pi/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.pi/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/.trae/skills/impeccable/scripts/hook-before-edit.mjs b/.trae/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/.trae/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; diff --git a/plugin/skills/impeccable/scripts/hook-before-edit.mjs b/plugin/skills/impeccable/scripts/hook-before-edit.mjs index c9d31b5d8..e2d897b11 100644 --- a/plugin/skills/impeccable/scripts/hook-before-edit.mjs +++ b/plugin/skills/impeccable/scripts/hook-before-edit.mjs @@ -27,6 +27,7 @@ import { readCache, readConfig, renderTemplate, + resolveCacheCwd, resolveProjectCwd, truthy, writeAuditLog, @@ -379,9 +380,12 @@ async function main() { return allow({ skipped: 'stdin-empty' }); } - const cwd = resolveProjectCwd(event); + const sessionCwd = resolveProjectCwd(event); const started = Date.now(); - const filePath = proposedFilePath(event, cwd); + const filePath = proposedFilePath(event, sessionCwd); + // Re-key config/cache to the edited file's project root when the session + // was launched from a non-project umbrella directory (issue #305). + const cwd = resolveCacheCwd(filePath, sessionCwd); const audit = { harness: 'cursor', cwd, diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 32232c88b..bcbf617c7 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -10,7 +10,7 @@ * truthy(value) * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd) * normalizeIgnoreValue(value) - * readCache(cwd) / persistCache(cwd, cache) + * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) @@ -35,6 +35,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; @@ -134,6 +135,39 @@ export function resolveProjectCwd(event, fallback = process.cwd()) { || fallback; } +function looksLikeProjectRoot(dir) { + return ['.git', 'package.json', '.impeccable'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +// Where `.impeccable/` (cache + config) lives for this event. Normally the +// session cwd, untouched. But when the agent was launched from an umbrella +// directory that is not itself a project (no .git, package.json, or +// .impeccable), key to the edited file's nearest project root instead, so a +// multi-project launch dir doesn't accumulate a shared cross-project cache +// (issue #305). Climbing stops at the home dir, falling back to the session +// cwd when no marker is found. +export function resolveCacheCwd(primaryFile, sessionCwd) { + const base = path.resolve(sessionCwd || process.cwd()); + if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base; + if (looksLikeProjectRoot(base)) return base; + let dir; + try { + dir = path.dirname(path.resolve(primaryFile)); + } catch { + return base; + } + const home = path.resolve(os.homedir()); + while (true) { + if (dir === home) return base; + if (looksLikeProjectRoot(dir)) return dir; + const parent = path.dirname(dir); + if (parent === dir) return base; + dir = parent; + } +} + export function readConfig(cwd) { const config = cloneDefaultConfig(); // Hook runtime settings live under `hook`; detector filters live under @@ -1402,9 +1436,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = event = normalizeHookEvent(event, cwd, harness); audit.harness = harness; - const projectCwd = event.cwd || cwd; + const sessionCwd = event.cwd || cwd; + const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd); + const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd); audit.cwd = projectCwd; - const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd); const primaryFileSet = new Set(primaryFiles); const targetFiles = expandScanTargets(primaryFiles, projectCwd); audit.session = event.session_id || null; @@ -1423,7 +1458,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = const sessionId = event.session_id || 'unknown'; const det = detector || await loadDetector(); if (!det || typeof det.detectText !== 'function') { - persistCache(projectCwd, cache); + // Cache is not mutated yet at this point; nothing to persist. return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); @@ -1435,6 +1470,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let detectorThrewAny = false; let lastSkip = 'no-scannable-file'; let suppressedHit = false; + let cacheDirty = false; for (const filePath of targetFiles) { audit.file = filePath; @@ -1467,6 +1503,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (primaryFileSet.has(filePath)) { const editCount = bumpEditCount(cache, sessionId, filePath); + cacheDirty = true; audit.editCount = editCount; if (editCount > EDIT_COUNT_THRESHOLD) { @@ -1496,6 +1533,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); + cacheDirty = true; freshGroups.push({ filePath, findings: fresh }); continue; } @@ -1513,7 +1551,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - persistCache(projectCwd, cache); + // Persist only when the write is earned: fresh findings justify creating + // `.impeccable/` (dedup and suppression need it), and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (freshGroups.length > 0 + || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } if (freshGroups.length > 0) { const firstGroup = freshGroups[0];