diff --git a/.agents/skills/impeccable/reference/hooks.md b/.agents/skills/impeccable/reference/hooks.md index e437ca296..ce2032e7e 100644 --- a/.agents/skills/impeccable/reference/hooks.md +++ b/.agents/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.agents/skills/impeccable/scripts/hook-admin.mjs b/.agents/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.agents/skills/impeccable/scripts/hook-admin.mjs +++ b/.agents/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.agents/skills/impeccable/scripts/hook.mjs b/.agents/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.agents/skills/impeccable/scripts/hook.mjs +++ b/.agents/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.claude/settings.json b/.claude/settings.json index 319926e08..bdbf29464 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { - "description": "Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.", + "description": "Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.", "hooks": { "PostToolUse": [ { @@ -13,6 +13,18 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"", + "timeout": 30, + "statusMessage": "Design deep pass" + } + ] + } ] } } diff --git a/.claude/skills/impeccable/reference/hooks.md b/.claude/skills/impeccable/reference/hooks.md index 30f756793..ed277ad6e 100644 --- a/.claude/skills/impeccable/reference/hooks.md +++ b/.claude/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.claude/skills/impeccable/scripts/hook-admin.mjs b/.claude/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.claude/skills/impeccable/scripts/hook-admin.mjs +++ b/.claude/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.claude/skills/impeccable/scripts/hook.mjs b/.claude/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.claude/skills/impeccable/scripts/hook.mjs +++ b/.claude/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.codex/hooks.json b/.codex/hooks.json index 8f058ddaf..70f1007f9 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -12,6 +12,18 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \".agents/skills/impeccable/scripts/hook.mjs\"", + "timeout": 30, + "statusMessage": "Design deep pass" + } + ] + } ] } } diff --git a/.cursor/skills/impeccable/reference/hooks.md b/.cursor/skills/impeccable/reference/hooks.md index 829dec839..4a6b50ac6 100644 --- a/.cursor/skills/impeccable/reference/hooks.md +++ b/.cursor/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.cursor/skills/impeccable/scripts/hook-admin.mjs b/.cursor/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.cursor/skills/impeccable/scripts/hook-admin.mjs +++ b/.cursor/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.cursor/skills/impeccable/scripts/hook.mjs b/.cursor/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.cursor/skills/impeccable/scripts/hook.mjs +++ b/.cursor/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.gemini/skills/impeccable/reference/hooks.md b/.gemini/skills/impeccable/reference/hooks.md index e7bb58d14..cf1f1a44c 100644 --- a/.gemini/skills/impeccable/reference/hooks.md +++ b/.gemini/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.gemini/skills/impeccable/scripts/hook-admin.mjs b/.gemini/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.gemini/skills/impeccable/scripts/hook-admin.mjs +++ b/.gemini/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.gemini/skills/impeccable/scripts/hook.mjs b/.gemini/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.gemini/skills/impeccable/scripts/hook.mjs +++ b/.gemini/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.github/skills/impeccable/reference/hooks.md b/.github/skills/impeccable/reference/hooks.md index 7c7413f96..22a7caf38 100644 --- a/.github/skills/impeccable/reference/hooks.md +++ b/.github/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.github/skills/impeccable/scripts/hook-admin.mjs b/.github/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.github/skills/impeccable/scripts/hook-admin.mjs +++ b/.github/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.github/skills/impeccable/scripts/hook.mjs b/.github/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.github/skills/impeccable/scripts/hook.mjs +++ b/.github/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.kiro/skills/impeccable/reference/hooks.md b/.kiro/skills/impeccable/reference/hooks.md index da1084bb9..d461b71d6 100644 --- a/.kiro/skills/impeccable/reference/hooks.md +++ b/.kiro/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.kiro/skills/impeccable/scripts/hook-admin.mjs b/.kiro/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.kiro/skills/impeccable/scripts/hook-admin.mjs +++ b/.kiro/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.kiro/skills/impeccable/scripts/hook.mjs b/.kiro/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.kiro/skills/impeccable/scripts/hook.mjs +++ b/.kiro/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.opencode/skills/impeccable/reference/hooks.md b/.opencode/skills/impeccable/reference/hooks.md index 901c3a6cc..05352df20 100644 --- a/.opencode/skills/impeccable/reference/hooks.md +++ b/.opencode/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.opencode/skills/impeccable/scripts/hook-admin.mjs b/.opencode/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.opencode/skills/impeccable/scripts/hook-admin.mjs +++ b/.opencode/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.opencode/skills/impeccable/scripts/hook.mjs b/.opencode/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.opencode/skills/impeccable/scripts/hook.mjs +++ b/.opencode/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.pi/skills/impeccable/reference/hooks.md b/.pi/skills/impeccable/reference/hooks.md index 7f2f6ee4a..c6b202c93 100644 --- a/.pi/skills/impeccable/reference/hooks.md +++ b/.pi/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.pi/skills/impeccable/scripts/hook-admin.mjs b/.pi/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.pi/skills/impeccable/scripts/hook-admin.mjs +++ b/.pi/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.pi/skills/impeccable/scripts/hook.mjs b/.pi/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.pi/skills/impeccable/scripts/hook.mjs +++ b/.pi/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.qoder/skills/impeccable/reference/hooks.md b/.qoder/skills/impeccable/reference/hooks.md index 1b154f701..580c6aa81 100644 --- a/.qoder/skills/impeccable/reference/hooks.md +++ b/.qoder/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.qoder/skills/impeccable/scripts/hook-admin.mjs b/.qoder/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.qoder/skills/impeccable/scripts/hook-admin.mjs +++ b/.qoder/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.qoder/skills/impeccable/scripts/hook.mjs b/.qoder/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.qoder/skills/impeccable/scripts/hook.mjs +++ b/.qoder/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.rovodev/skills/impeccable/reference/hooks.md b/.rovodev/skills/impeccable/reference/hooks.md index 00c0b5088..23dcdf84f 100644 --- a/.rovodev/skills/impeccable/reference/hooks.md +++ b/.rovodev/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.rovodev/skills/impeccable/scripts/hook-admin.mjs b/.rovodev/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.rovodev/skills/impeccable/scripts/hook-admin.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.rovodev/skills/impeccable/scripts/hook.mjs b/.rovodev/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.rovodev/skills/impeccable/scripts/hook.mjs +++ b/.rovodev/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.trae-cn/skills/impeccable/reference/hooks.md b/.trae-cn/skills/impeccable/reference/hooks.md index e0b51cc33..70163c075 100644 --- a/.trae-cn/skills/impeccable/reference/hooks.md +++ b/.trae-cn/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.trae-cn/skills/impeccable/scripts/hook-admin.mjs b/.trae-cn/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-admin.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.trae-cn/skills/impeccable/scripts/hook.mjs b/.trae-cn/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.trae-cn/skills/impeccable/scripts/hook.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/.trae/skills/impeccable/reference/hooks.md b/.trae/skills/impeccable/reference/hooks.md index 05d4f649e..2a547f360 100644 --- a/.trae/skills/impeccable/reference/hooks.md +++ b/.trae/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/.trae/skills/impeccable/scripts/hook-admin.mjs b/.trae/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/.trae/skills/impeccable/scripts/hook-admin.mjs +++ b/.trae/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/.trae/skills/impeccable/scripts/hook.mjs b/.trae/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/.trae/skills/impeccable/scripts/hook.mjs +++ b/.trae/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/package.json b/package.json index b873d1220..62f0bbb76 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "smoke:hooks": "node scripts/smoke-provider-hooks.mjs", "bench:detector": "node scripts/benchmark-detector.mjs", "bench:detector:browser": "node scripts/benchmark-detector.mjs --browser", + "bench:live": "node scripts/benchmark-live.mjs", "audit": "bun audit --audit-level=moderate", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", "postpack": "cp README.repo.md README.md && rm README.repo.md", diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 6c490d4ee..66fd18e7e 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -12,6 +12,18 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs\"", + "timeout": 30, + "statusMessage": "Design deep pass" + } + ] + } ] } } diff --git a/plugin/skills/impeccable/reference/hooks.md b/plugin/skills/impeccable/reference/hooks.md index 30f756793..ed277ad6e 100644 --- a/plugin/skills/impeccable/reference/hooks.md +++ b/plugin/skills/impeccable/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/plugin/skills/impeccable/scripts/hook-admin.mjs b/plugin/skills/impeccable/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/plugin/skills/impeccable/scripts/hook-admin.mjs +++ b/plugin/skills/impeccable/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/plugin/skills/impeccable/scripts/hook.mjs b/plugin/skills/impeccable/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/plugin/skills/impeccable/scripts/hook.mjs +++ b/plugin/skills/impeccable/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/scripts/lib/transformers/hooks.js b/scripts/lib/transformers/hooks.js index cec41c258..202085e12 100644 --- a/scripts/lib/transformers/hooks.js +++ b/scripts/lib/transformers/hooks.js @@ -23,6 +23,26 @@ export const IMPECCABLE_HOOK_COMMAND_MARKER = 'skills/impeccable/scripts/hook.mj const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the +// full rule set, so it gets a longer budget than the single-file per-edit +// pass. Wired only for Claude Code and Codex, which both dispatch a native +// `Stop` hook event; Cursor's stop hook is not consistently dispatched and +// GitHub Copilot's stop-style events do not feed context back to the model. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const CLAUDE_PROJECT_HOOK = '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs'; const CLAUDE_PLUGIN_HOOK = '${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs'; const CODEX_PLUGIN_HOOK = '${PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs'; @@ -32,7 +52,7 @@ const GITHUB_PROJECT_HOOK = '$(git rev-parse --show-toplevel)/.github/skills/imp export function buildClaudeSettingsManifest() { return { - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -47,6 +67,7 @@ export function buildClaudeSettingsManifest() { ], }, ], + Stop: [stopEntry(`node "${CLAUDE_PROJECT_HOOK}"`)], }, }; } @@ -73,6 +94,7 @@ export function buildClaudePluginHooksManifest() { ], }, ], + Stop: [stopEntry(`node "${CLAUDE_PLUGIN_HOOK}"`)], }, }; } @@ -96,6 +118,7 @@ export function buildCodexPluginHooksManifest() { ], }, ], + Stop: [stopEntry(`node "${CODEX_PLUGIN_HOOK}"`)], }, }; } @@ -116,6 +139,7 @@ export function buildCodexHooksManifest() { ], }, ], + Stop: [stopEntry(`node "${CODEX_PROJECT_HOOK}"`)], }, }; } diff --git a/skill/reference/hooks.md b/skill/reference/hooks.md index b7f6033c3..50713e77a 100644 --- a/skill/reference/hooks.md +++ b/skill/reference/hooks.md @@ -4,6 +4,8 @@ Manage the **design detector hook** for the current project. The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. +The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so Copilot keeps the full rule set per edit instead of deferring. + This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set. Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies. diff --git a/skill/scripts/hook-admin.mjs b/skill/scripts/hook-admin.mjs index 8b37b1f3b..0fe755e6e 100644 --- a/skill/scripts/hook-admin.mjs +++ b/skill/scripts/hook-admin.mjs @@ -45,6 +45,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const TIMEOUT_SECONDS = 5; const STATUS_MESSAGE = 'Checking UI changes'; +// The Stop deep pass scans every UI file touched in the session with the full +// rule set, so it gets a longer budget than the per-edit pass. Only Claude +// Code and Codex dispatch a native Stop hook event, so only those manifests +// carry the entry. Keep these shapes in sync with +// scripts/lib/transformers/hooks.js in the repo. +const STOP_TIMEOUT_SECONDS = 30; +const STOP_STATUS_MESSAGE = 'Design deep pass'; + +function stopManifestEntry(command) { + return { + hooks: [ + { + type: 'command', + command, + timeout: STOP_TIMEOUT_SECONDS, + statusMessage: STOP_STATUS_MESSAGE, + }, + ], + }; +} const HOOK_MANIFEST_TARGETS = [ { @@ -53,7 +73,7 @@ const HOOK_MANIFEST_TARGETS = [ destRel: '.claude/settings.local.json', sharedDestRel: '.claude/settings.json', manifest: () => ({ - description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.', + description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.', hooks: { PostToolUse: [ { @@ -68,6 +88,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')], }, }), }, @@ -90,6 +111,7 @@ const HOOK_MANIFEST_TARGETS = [ ], }, ], + Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')], }, }), }, diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 0d9722953..8895754aa 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -13,8 +13,10 @@ * normalizeIgnoreValue(value) * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd) * bumpEditCount(cache, sessionId, filePath) -> number + * touchFile(cache, sessionId, filePath) * suppressionNotice(filePath) * filterFindings(findings, content, ext, config) + * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness) * matchConfiguredExtension(filePath, extensions) * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) @@ -25,6 +27,7 @@ * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) * runHook(deps) -> { exitCode, stdout, audit, reason? } + * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * * Design notes: * - All errors are swallowed at the runHook seam. The detector throwing must @@ -74,6 +77,43 @@ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[ export const TRUTHY = /^(1|true|yes|on)$/i; +// ── Two-tier rule surfacing ────────────────────────────────────────────── +// The per-edit PostToolUse pass surfaces only this "immediate" tier: rules +// that are mechanical, unambiguous, and worth interrupting an edit for — +// broken output the user would see (broken images, overflow, clipped +// popovers, text on the viewport edge), objective contrast/legibility +// failures, single-property slop that is trivial to fix in place (gradient +// text, glow shadows), and design-system drift (which compounds with every +// further edit if left uncorrected). Everything else — copy-cadence rules, +// palette/typography taste, layout rhythm — is deferred to the Stop-event +// deep pass (`runStopHook`), which runs the FULL rule set over every file +// touched this session and surfaces the remainder once. +// +// Rationale (measured in the eval harness): the per-edit stream fires +// overwhelmingly on copy-level rules, and that steady nag stream makes +// models more conservative, while a single full pass at completion fixes +// contrast/padding/glow just as reliably. Restore the old full per-edit +// behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`. +export const IMMEDIATE_TIER_RULES = new Set([ + // Broken output. + 'broken-image', + 'text-overflow', + 'clipped-overflow-container', + 'body-text-viewport-edge', + // Objective contrast / legibility failures. + 'low-contrast', + 'gray-on-color', + 'tiny-text', + // Single-property mechanical slop, trivial to fix at the edit site. + 'gradient-text', + 'dark-glow', + // Design-system drift compounds if not corrected at edit time. + 'design-system-font', + 'design-system-color', + 'design-system-radius', + 'design-system-font-size', +]); + export const DEFAULT_CONFIG = Object.freeze({ enabled: true, quiet: false, @@ -83,6 +123,7 @@ export const DEFAULT_CONFIG = Object.freeze({ ignoreFiles: [], ignoreValues: [], extensions: [], + perEditRules: 'immediate', limits: { maxFindings: 5, maxChars: 8000 }, }); @@ -307,6 +348,9 @@ function applyConfigSource(config, raw) { if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) { config.quiet = raw.quiet === true; } + if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') { + config.perEditRules = raw.perEditRules; + } if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) { config.auditLog = raw.auditLog.trim(); } @@ -661,6 +705,14 @@ export function bumpEditCount(cache, sessionId, filePath) { return fileEntry.editCount; } +// Record that a file was scanned this session without bumping its edit count. +// The Stop deep pass reads the session's file list to know what to re-scan, +// so a file whose per-edit findings were all deferred still needs an entry. +export function touchFile(cache, sessionId, filePath) { + ensureFile(cache, sessionId, filePath); + ensureSession(cache, sessionId).updatedAt = Date.now(); +} + export function suppressionNotice(filePath) { return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`; } @@ -731,6 +783,31 @@ export function filterFindings(findings, _content, _ext, config) { }); } +// Split filtered findings into the per-edit "immediate" tier and the tier +// deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering +// rationale. +export function splitFindingsByTier(findings) { + const immediate = []; + const deferred = []; + for (const f of Array.isArray(findings) ? findings : []) { + if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) { + immediate.push(f); + } else { + deferred.push(f); + } + } + return { immediate, deferred }; +} + +// Whether the per-edit pass for this harness should defer non-immediate +// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop +// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for +// them would silently drop the non-immediate rules entirely. +export function perEditTieringActive(config, harness) { + if (harness === 'cursor' || harness === 'github') return false; + return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all'; +} + function isIgnoredFindingValue(finding, ignoreValues) { if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false; const rule = normalizeIgnoreRule(finding.antipattern); @@ -1545,6 +1622,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); } const scanOptions = designSystemOptions(config, det, projectCwd); + const tiered = perEditTieringActive(config, harness); let pendingWinner = null; let cleanWinner = null; @@ -1554,6 +1632,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = let lastSkip = 'no-scannable-file'; let suppressedHit = false; let cacheDirty = false; + let deferredTotal = 0; for (const filePath of targetFiles) { audit.file = filePath; @@ -1614,9 +1693,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } const filtered = filterFindings(findings || [], content, ext, config); - const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + // Per-edit only surfaces the immediate tier; the rest waits for the + // Stop deep pass. The file is still marked touched so the deep pass + // knows to re-scan it. + const { immediate, deferred } = tiered + ? splitFindingsByTier(filtered) + : { immediate: filtered, deferred: [] }; + if (deferred.length > 0) { + touchFile(cache, sessionId, filePath); + cacheDirty = true; + deferredTotal += deferred.length; + } + const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath); audit.findings = (findings || []).length; audit.freshFindings = fresh.length; + if (deferredTotal > 0) audit.deferred = deferredTotal; if (fresh.length > 0) { rememberFindings(cache, sessionId, filePath, fresh); @@ -1630,20 +1721,21 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = continue; } - if (filtered.length > 0 && !pendingWinner) { + if (immediate.length > 0 && !pendingWinner) { const known = (ensureFile(cache, sessionId, filePath).findings || []).slice(); pendingWinner = { filePath, known }; - } else if (filtered.length === 0 && !cleanWinner) { + } else if (immediate.length === 0 && !cleanWinner) { cleanWinner = { filePath }; } } // 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 + // `.impeccable/` (dedup and suppression need it), deferred findings do + // too (the Stop deep pass needs the touched-file list to surface them), + // 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 || deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { persistCache(projectCwd, cache); } @@ -1750,6 +1842,150 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// Cap on files the Stop deep pass will scan. The touched-file list is +// session-scoped and already capped per edit, but a very long session could +// accumulate more than the 30s hook timeout comfortably covers. +export const STOP_MAX_FILES = 20; + +/** + * Run the Stop-event deep pass: the FULL detector rule set over every UI + * file touched this session, surfaced once, deduped against everything the + * per-edit hook already reported. Same result contract as runHook(): + * { exitCode, stdout, audit, emission? } + * + * Never throws; exits silent (and fast) when the session touched no UI + * files. Output uses the Stop hookSpecificOutput channel: additionalContext + * is delivered to the model and the conversation continues so it can act. + */ +export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) { + const audit = { ts: new Date(now()).toISOString(), event: 'Stop' }; + const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } }); + + try { + // Re-entrancy guard, same as the per-edit pass. + if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) { + return result({ reentrant: true, durationMs: 0 }); + } + if (truthy(env.IMPECCABLE_HOOK_DISABLED)) { + return result({ skipped: 'env-disabled', durationMs: 0 }); + } + + const started = Date.now(); + + let event; + try { + event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson; + } catch { + return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started }); + } + if (!event || typeof event !== 'object') { + return result({ skipped: 'stdin-empty', durationMs: Date.now() - started }); + } + + const harness = resolveHarness(env, event); + audit.harness = harness; + + // A Stop event carries no file, so the session cwd is the project. + // Umbrella-dir launches keyed their per-edit cache to the edited file's + // project root (resolveCacheCwd); those sessions no-op here rather than + // guessing which child project the session was about. + const projectCwd = path.resolve(event.cwd || cwd); + audit.cwd = projectCwd; + const sessionId = event.session_id || 'unknown'; + audit.session = sessionId; + + const config = readConfig(projectCwd); + if (config.enabled === false) { + return result({ skipped: 'config-disabled', durationMs: Date.now() - started }); + } + + const cache = readCache(projectCwd); + const touched = Object.keys(cache.sessions?.[sessionId]?.files || {}); + if (touched.length === 0) { + return result({ skipped: 'no-touched-files', durationMs: Date.now() - started }); + } + + const platform = resolveProjectPlatform(projectCwd); + if (isNativePlatform(platform)) { + return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started }); + } + + const det = detector || await loadDetector(); + if (!det || typeof det.detectText !== 'function') { + return result({ skipped: 'detector-missing', durationMs: Date.now() - started }); + } + const scanOptions = designSystemOptions(config, det, projectCwd); + + const freshGroups = []; + let scanned = 0; + for (const filePath of touched) { + if (scanned >= STOP_MAX_FILES) break; + if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue; + if (GENERATED_PATH.test(filePath)) continue; + const ext = path.extname(filePath).toLowerCase(); + const configuredExt = matchConfiguredExtension(filePath, config.extensions); + if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue; + const relForMatch = relativize(filePath, projectCwd); + if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; + if (!fs.existsSync(filePath)) continue; + + scanned += 1; + let content = ''; + try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; } + + let findings; + const useHtmlEngine = configuredExt + ? configuredExt.engine === 'html' + : (ext === '.html' || ext === '.htm'); + if (useHtmlEngine && typeof det.detectHtml === 'function') { + try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } + } else { + try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; } + } + + // Full rule set: no tier split here. Config/inline ignores still apply, + // and the session dedupe drops everything the per-edit pass (or an + // earlier Stop pass) already surfaced. + const filtered = filterFindings(findings || [], content, ext, config); + const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath); + if (fresh.length > 0) { + rememberFindings(cache, sessionId, filePath, fresh); + freshGroups.push({ filePath, findings: fresh }); + } + } + audit.scannedFiles = scanned; + + if (freshGroups.length === 0) { + return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); + } + + // Fresh findings earn the cache write; they also mark this batch as + // surfaced so the next Stop fire is silent unless new issues appear. + persistCache(projectCwd, cache); + + const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + return { + exitCode: 0, + stdout: payload(text, 'Stop', harness), + emission: { kind: 'stop-deep-pass', groups: freshGroups }, + audit: { + ...audit, + emitted: true, + freshFiles: freshGroups.length, + freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + chars: text.length, + durationMs: Date.now() - started, + }, + }; + } catch (err) { + return { + exitCode: 0, + stdout: '', + audit: { ...audit, error: String(err && err.message ? err.message : err) }, + }; + } +} + export function payload(text, eventName = 'PostToolUse', harness = 'claude') { if (harness === 'cursor') { return JSON.stringify({ additional_context: text }); diff --git a/skill/scripts/hook.mjs b/skill/scripts/hook.mjs index 8f5924976..5813ea4f2 100644 --- a/skill/scripts/hook.mjs +++ b/skill/scripts/hook.mjs @@ -1,19 +1,25 @@ #!/usr/bin/env node /** - * Impeccable design hook — PostToolUse entry point. + * Impeccable design hook — PostToolUse + Stop entry point. * - * Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design - * detector against the touched file, and emits a system reminder via - * `hookSpecificOutput.additionalContext` when findings exist. + * Reads the Claude Code / Codex / Cursor hook event from stdin and routes by + * `hook_event_name`: + * + * - PostToolUse: runs the immediate-tier detector rules against the touched + * file and emits a system reminder via + * `hookSpecificOutput.additionalContext` when findings exist. + * - Stop: runs the FULL detector rule set over every UI file touched this + * session (the deep pass), deduped against what the per-edit pass already + * surfaced, and emits once via the Stop additionalContext channel. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack - * unless quiet mode is enabled. + * unless quiet mode is enabled; a clean Stop pass is silent. * * Most logic lives in `hook-lib.mjs` so it is unit-testable without a * subprocess. This file is the thin stdin/stdout adapter. */ -import { runHook, writeAuditLog } from './hook-lib.mjs'; +import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs'; async function readStdin() { if (process.stdin.isTTY) return ''; @@ -22,6 +28,16 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf-8'); } +function isStopEvent(stdinJson) { + try { + const event = JSON.parse(stdinJson); + return event && typeof event === 'object' && event.hook_event_name === 'Stop'; + } catch { + // Malformed stdin falls through to runHook, which audits the skip. + return false; + } +} + async function main() { // Snapshot the inherited env FIRST so the re-entrancy guard checks the // parent's value, not the value we are about to export for any child @@ -32,7 +48,8 @@ async function main() { let stdinJson = ''; try { stdinJson = await readStdin(); } catch { /* fall through */ } - const result = await runHook({ + const run = isStopEvent(stdinJson) ? runStopHook : runHook; + const result = await run({ stdinJson, env: inheritedEnv, cwd: process.cwd(), @@ -50,7 +67,7 @@ main().catch((err) => { try { writeAuditLog(process.env, { ts: new Date().toISOString(), - event: 'PostToolUse', + event: 'hook-error', error: String(err && err.message ? err.message : err), }); } catch { /* swallow */ } diff --git a/tests/hook-build.test.mjs b/tests/hook-build.test.mjs index 1f70fc49c..d18d5abbf 100644 --- a/tests/hook-build.test.mjs +++ b/tests/hook-build.test.mjs @@ -45,6 +45,13 @@ describe('hook manifest builders', () => { assert.ok(handler.command.includes('${CLAUDE_PROJECT_DIR}')); assert.equal(handler.args, undefined); assert.equal(manifest.hooks.SessionStart, undefined); + + // Stop deep pass: same script, no matcher, longer budget. + const stop = manifest.hooks.Stop[0].hooks[0]; + assert.equal(manifest.hooks.Stop[0].matcher, undefined); + assert.equal(stop.timeout, 30); + assert.equal(stop.statusMessage, 'Design deep pass'); + expectCommand(stop.command, '.claude/skills/impeccable/scripts/hook.mjs'); }); it('builds Codex project-local hooks for the real detector hook', () => { @@ -61,6 +68,12 @@ describe('hook manifest builders', () => { assert.ok(!handler.command.includes('git rev-parse --show-toplevel')); assert.ok(!handler.command.includes('${PLUGIN_ROOT}')); assert.equal(manifest.hooks.SessionStart, undefined); + + // Codex dispatches a native Stop event (turn scope), so it gets the deep + // pass too. + const stop = manifest.hooks.Stop[0].hooks[0]; + assert.equal(stop.timeout, 30); + expectCommand(stop.command, '.agents/skills/impeccable/scripts/hook.mjs'); }); it('builds one Cursor pre-write blocking hook', () => { @@ -211,6 +224,12 @@ describe('generated hook artifacts in repo', () => { assert.ok(!handler.command.includes('${CLAUDE_PROJECT_DIR}'), `plugin hook command must not use $\{CLAUDE_PROJECT_DIR}: ${handler.command}`); + // Stop deep pass ships in the plugin manifest too, plugin-root-relative. + const stop = manifest.hooks.Stop[0].hooks[0]; + assert.equal(stop.timeout, 30); + expectCommand(stop.command, 'skills/impeccable/scripts/hook.mjs'); + assert.ok(stop.command.includes('${CLAUDE_PLUGIN_ROOT}')); + // The script the plugin hook points at must ship inside the plugin payload. assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook.mjs'))); assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-lib.mjs'))); diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 3fe0000ba..11540fba9 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -49,6 +49,10 @@ import { parseStaticStyleImports, coLocatedStylesheets, runHook, + runStopHook, + IMMEDIATE_TIER_RULES, + splitFindingsByTier, + perEditTieringActive, payload, extractFindingIgnoreValue, resolveProjectPlatform, @@ -609,7 +613,10 @@ describe('hook-admin.mjs', () => { const claude = fs.readFileSync(path.join(cwd, '.claude', 'settings.local.json'), 'utf-8'); assert.match(claude, /local-hook\.mjs/); - assert.equal(claude.split('skills/impeccable/scripts/hook.mjs').length - 1, 1); + // One PostToolUse entry plus one Stop entry; the stale pre-existing + // impeccable entry must have been stripped, not accumulated. + assert.equal(claude.split('skills/impeccable/scripts/hook.mjs').length - 1, 2); + assert.match(claude, /"Stop"/); const codex = fs.readFileSync(path.join(cwd, '.codex', 'hooks.json'), 'utf-8'); assert.match(codex, /\.agents\/skills\/impeccable\/scripts\/hook\.mjs/); @@ -915,7 +922,7 @@ rounded: // over the nudge (`renderTemplate` text), so r1 is unchanged from // before. r2 is what changed: silent → pending ack. const file = writeFixture('src/Card.tsx', 'noop'); - const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]); + const det = fakeDetector([finding('text-overflow', 1, { name: 'Content overflow' })]); const r1 = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.equal(r1.exitCode, 0); @@ -927,7 +934,7 @@ rounded: assert.equal(r2.exitCode, 0); assert.ok(r2.stdout.includes(ENVELOPE_PREFIX)); assert.match(r2.stdout, /Still has 1 finding\(s\) flagged earlier this session/); - assert.match(r2.stdout, /side-tab:1/); + assert.match(r2.stdout, /text-overflow:1/); assert.equal(r2.audit.emitted, true); assert.equal(r2.audit.kind, 'pending'); }); @@ -1005,20 +1012,20 @@ rounded: }); it('still emits findings for plain .ts files', async () => { - const file = writeFixture('src/styles.ts', 'export const css = "border-left: 4px solid #7c3aed";'); + const file = writeFixture('src/styles.ts', 'export const css = "box-shadow: 0 0 24px #7c3aed";'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, - detector: fakeDetector([finding('side-tab', 1)]), + detector: fakeDetector([finding('dark-glow', 1)]), }); assert.match(r.stdout, /Design hook findings requiring review/); - assert.match(r.stdout, /side-tab/); + assert.match(r.stdout, /dark-glow/); }); it('does not emit pending acks for plain .js files', async () => { const file = writeFixture('src/build.js', 'export const value = 1;'); - const det = fakeDetector([finding('side-tab', 1)]); + const det = fakeDetector([finding('text-overflow', 1)]); const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.match(first.stdout, /Design hook findings requiring review/); @@ -1045,7 +1052,7 @@ rounded: assert.equal(rClean.audit.quiet, true); // Findings file: still emits. - const detFindings = fakeDetector([finding('side-tab', 1)]); + const detFindings = fakeDetector([finding('text-overflow', 1)]); const rFindings = await runHook({ stdinJson: JSON.stringify(eventFor(fileB)), env: { IMPECCABLE_HOOK_QUIET: '1' }, cwd, detector: detFindings, @@ -1136,9 +1143,9 @@ rounded: it('still scans when PRODUCT.md declares web (or has no platform field)', async () => { writeFixture('PRODUCT.md', '# App\n\n## Register\n\nproduct\n\n## Platform\n\nweb\n'); const file = writeFixture('src/Card.tsx', 'noop'); - const det = fakeDetector([finding('side-tab', 1, { name: 'Side-tab' })]); + const det = fakeDetector([finding('text-overflow', 1, { name: 'Content overflow' })]); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'web-platform')), env: {}, cwd, detector: det }); - assert.match(r.stdout, /Side-tab/); + assert.match(r.stdout, /Content overflow/); }); it('only unlocks design-system detector findings when DESIGN.md exists', async () => { @@ -1331,7 +1338,7 @@ rounded: command: '*** Begin Patch\n*** Update File: src/Card.tsx\n*** End Patch', }, }; - const det = fakeDetector([finding('side-tab', 1)]); + const det = fakeDetector([finding('text-overflow', 1)]); const r = await runHook({ stdinJson: JSON.stringify(event), env: {}, cwd, detector: det }); assert.equal(r.exitCode, 0); assert.match(r.stdout, /Design hook findings requiring review/); @@ -1346,6 +1353,10 @@ rounded: }); it('awaits the real async HTML detector before deciding a page is clean', async () => { + // The fixture's finding (side-tab) sits in the deferred tier, so restore + // the full per-edit rule set for this test via the config override. + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } })); const file = writeFixture('index.html', [ '', '
', @@ -1366,6 +1377,10 @@ rounded: it('honors an inline impeccable-disable comment so the hook scans the file clean', async () => { // The hook runs the same engine as `npx impeccable detect`, so an in-file // waiver suppresses hook findings exactly like a config ignore would. + // overused-font is deferred-tier; use the perEditRules override so the + // per-edit pass surfaces it here. + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } })); const flagged = writeFixture('src/Flagged.tsx', 'const css = "font-family: Inter";'); const flaggedRun = await runHook({ stdinJson: JSON.stringify(eventFor(flagged)), env: {}, cwd, detector: { detectHtml, detectText }, @@ -1454,7 +1469,7 @@ describe('runHook() — cache write gating (issues #344, #305)', () => { it('fresh findings create the cache, and dedup works on the next run', async () => { const file = write('src/Card.tsx', 'noop'); - const det = fakeDetector([finding('side-tab', 1)]); + const det = fakeDetector([finding('text-overflow', 1)]); const first = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det }); assert.match(first.stdout, /Design hook findings requiring review/); assert.ok(fs.existsSync(path.join(cwd, '.impeccable', 'hook.cache.json')), 'cache should exist'); @@ -1480,7 +1495,7 @@ describe('runHook() — cache write gating (issues #344, #305)', () => { const child = path.join(cwd, 'app'); const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), - env: {}, cwd, detector: fakeDetector([finding('side-tab', 1)]), + env: {}, cwd, detector: fakeDetector([finding('text-overflow', 1)]), }); assert.match(r.stdout, /Design hook findings requiring review/); assert.equal(r.audit.cwd, child); @@ -1866,10 +1881,10 @@ describe('runHook() — co-located stylesheet scan', () => { it('flags slop in styles.css when only App.jsx was edited', async () => { const app = write('src/App.jsx', 'export default function App() { return ; }'); - write('src/styles.css', "body { font-family: 'Inter', sans-serif; }"); + write('src/styles.css', 'h1 { background-clip: text; color: transparent; }'); const det = { detectText: (content, filePath) => ( - filePath.endsWith('.css') ? [finding('overused-font', 8)] : [] + filePath.endsWith('.css') ? [finding('gradient-text', 8)] : [] ), detectHtml: () => [], }; @@ -1891,10 +1906,10 @@ describe('runHook() — co-located stylesheet scan', () => { it('flags slop in co-located .sass when only App.jsx was edited', async () => { const app = write('src/App.jsx', 'export default function App() { return ; }'); - write('src/styles.sass', ".card\n border-left: 4px solid #3b82f6"); + write('src/styles.sass', ".card\n box-shadow: 0 0 24px #3b82f6"); const det = { detectText: (content, filePath) => ( - filePath.endsWith('.sass') ? [finding('side-tab', 2)] : [] + filePath.endsWith('.sass') ? [finding('dark-glow', 2)] : [] ), detectHtml: () => [], }; @@ -1915,14 +1930,14 @@ describe('runHook() — co-located stylesheet scan', () => { }); it('emits fresh findings for every file scanned in the same hook run', async () => { - const app = write('src/App.jsx', 'export default function App() { return ; }'); - const styles = write('src/styles.css', "body { font-family: 'Inter', sans-serif; }"); + const app = write('src/App.jsx', 'export default function App() { return ; }'); + const styles = write('src/styles.css', 'h1 { background-clip: text; color: transparent; }'); const seen = []; const det = { detectText: (content, filePath) => { seen.push(filePath); - if (filePath.endsWith('App.jsx')) return [finding('side-tab', 1)]; - if (filePath.endsWith('styles.css')) return [finding('overused-font', 1)]; + if (filePath.endsWith('App.jsx')) return [finding('text-overflow', 1)]; + if (filePath.endsWith('styles.css')) return [finding('gradient-text', 1)]; return []; }, detectHtml: () => [], @@ -1944,23 +1959,23 @@ describe('runHook() — co-located stylesheet scan', () => { assert.match(r.stdout, /Design hook findings requiring review/); assert.match(r.stdout, /App\.jsx/); assert.match(r.stdout, /styles\.css/); - assert.match(r.stdout, /side-tab/); - assert.match(r.stdout, /overused-font/); + assert.match(r.stdout, /text-overflow/); + assert.match(r.stdout, /gradient-text/); assert.ok(seen.includes(app), 'primary file should be scanned'); assert.ok(seen.includes(styles), 'co-located stylesheet should still be scanned'); assert.equal(r.emission.groups.length, 2); const cache = readCache(cwd); const files = cache.sessions['co-scan-fresh-primary'].files; - assert.deepEqual(files[app].findings, ['side-tab:1']); - assert.deepEqual(files[styles].findings, ['overused-font:1']); + assert.deepEqual(files[app].findings, ['text-overflow:1']); + assert.deepEqual(files[styles].findings, ['gradient-text:1']); }); it('does not bump edit count for passively co-scanned stylesheets', async () => { const app = write('src/App.jsx', 'export default function App() { return ; }'); - const styles = write('src/styles.css', "body { font-family: 'Inter', sans-serif; }"); + const styles = write('src/styles.css', 'h1 { background-clip: text; color: transparent; }'); const det = { detectText: (content, filePath) => ( - filePath.endsWith('styles.css') ? [finding('overused-font', 1)] : [] + filePath.endsWith('styles.css') ? [finding('gradient-text', 1)] : [] ), detectHtml: () => [], }; @@ -2099,9 +2114,9 @@ describe('runHook() — configured template extensions (issue #316)', () => { it('routes an engine:text entry through detectText instead', async () => { writeExtensionsConfig([{ ext: '.blade.php', engine: 'text' }]); const file = writeFixture('resources/views/card.blade.php', '