hooks: two-tier design hook — immediate per-edit rules + full-set Stop deep pass

Eval evidence showed the per-edit PostToolUse stream fires overwhelmingly
on copy-level rules (em-dash-overuse ~97x/session) and measurably makes
models more conservative, while a full-detector pass at completion is what
actually fixes contrast/padding/glow. Split the hook accordingly:

- Per-edit (PostToolUse) now surfaces only IMMEDIATE_TIER_RULES: 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 (gradient-text, dark-glow), and design-system drift (the four
  design-system-* rules, which compound if left uncorrected). Everything
  else defers. Override with hook.perEditRules: "all" in
  .impeccable/config.json. Tiering is off for Cursor/Copilot harnesses,
  which have no Stop pass wired, so nothing gets silently dropped there.

- Stop deep pass (runStopHook): runs the FULL rule set over every UI file
  touched this session (tracked via the existing hook.cache.json session
  state; deferred-only edits now mark the file touched), dedupes against
  everything already surfaced per-edit, honors ignore-rule/file/value and
  inline disables, reuses the [impeccable@1] envelope, and no-ops fast
  when no UI files were touched. Emits hookSpecificOutput
  { hookEventName: "Stop", additionalContext } per the Claude Code SDK
  Stop contract (conversation continues so the model can act on it).
  Second Stop fire is silent - deep-pass findings are remembered.

- Wiring: Stop entries (timeout 30) in plugin/hooks/hooks.json, the
  .claude settings + .codex hooks manifests (transformers + hook-admin
  repair path). Claude Code and Codex both dispatch a native Stop event;
  Cursor's stop hook is inconsistently dispatched (pre-write gate stays)
  and Copilot's agentStop/sessionEnd don't inject model context, so
  neither gets a Stop entry - documented in reference/hooks.md.

- Tests: tiering split/override/harness gating, Stop dedupe + silent
  no-touched-files + ignore machinery + kill switches; existing per-edit
  tests moved to immediate-tier rule ids. 181 tests green; smoke-tested
  the built dist skill end to end (glow surfaced per-edit, em-dash only
  at Stop, second Stop silent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-11 16:46:00 -07:00
co-authored by Claude Fable 5
parent 8091f452d5
commit c3aba1e343
63 changed files with 4454 additions and 271 deletions
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
+13 -1
View File
@@ -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"
}
]
}
]
}
}
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
+12
View File
@@ -12,6 +12,18 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \".agents/skills/impeccable/scripts/hook.mjs\"",
"timeout": 30,
"statusMessage": "Design deep pass"
}
]
}
]
}
}
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
+23 -1
View File
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
@@ -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"')],
},
}),
},
@@ -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 });
+25 -8
View File
@@ -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 */ }
+2
View File
@@ -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.
+23 -1
View File
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
@@ -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.
+23 -1
View File
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
+1
View File
@@ -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",
+12
View File
@@ -12,6 +12,18 @@
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs\"",
"timeout": 30,
"statusMessage": "Design deep pass"
}
]
}
]
}
}
@@ -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.
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
+25 -1
View File
@@ -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}"`)],
},
};
}
+2
View File
@@ -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.
+23 -1
View File
@@ -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"')],
},
}),
},
+244 -8
View File
@@ -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 });
+25 -8
View File
@@ -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 */ }
+19
View File
@@ -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')));
+256 -31
View File
@@ -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', [
'<!doctype html>',
'<html><body>',
@@ -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 <main className="x" />; }');
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 <main className="x" />; }');
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 <main className="border-l-4 border-blue-500" />; }');
const styles = write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
const app = write('src/App.jsx', 'export default function App() { return <main className="overflow-hidden" />; }');
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 <main className="x" />; }');
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', '<div>Hi</div>');
const det = recordingDetector([finding('side-tab', 1)]);
const det = recordingDetector([finding('text-overflow', 1)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.match(r.stdout, /side-tab/);
assert.match(r.stdout, /text-overflow/);
assert.deepEqual(det.calls.text, [file]);
assert.deepEqual(det.calls.html, []);
});
@@ -2531,7 +2546,7 @@ describe('runHook() — emission enrichment', () => {
}
it('returns emission.kind fresh with findings on new hits', async () => {
write('src/styles.css', "body { font-family: 'Inter', sans-serif; }");
write('src/styles.css', 'h1 { background-clip: text; color: transparent; }');
const r = await runHook({
stdinJson: JSON.stringify({
session_id: 'emit-fresh',
@@ -2541,10 +2556,220 @@ describe('runHook() — emission enrichment', () => {
}),
env: { IMPECCABLE_HOOK_HARNESS: 'claude' },
cwd,
detector: fakeDetector([finding('overused-font', 8)]),
detector: fakeDetector([finding('gradient-text', 8)]),
});
assert.equal(r.emission?.kind, 'fresh');
assert.ok(Array.isArray(r.emission?.findings));
assert.equal(r.emission.findings.length, 1);
});
});
describe('runHook() — per-edit tiering', () => {
// The per-edit pass surfaces only IMMEDIATE_TIER_RULES; everything else is
// deferred to the Stop deep pass. See hook-lib.mjs for the tier rationale.
let cwd;
beforeEach(() => { cwd = mkTmp(); });
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
function eventFor(file, sessionId = 'tier-sid') {
return {
session_id: sessionId,
cwd,
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: file },
};
}
function write(rel, body) {
const abs = path.join(cwd, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
return abs;
}
it('splitFindingsByTier partitions on IMMEDIATE_TIER_RULES', () => {
const { immediate, deferred } = splitFindingsByTier([
finding('dark-glow', 1),
finding('em-dash-overuse', 2),
finding('low-contrast', 3),
finding('side-tab', 4),
]);
assert.deepEqual(immediate.map((f) => f.antipattern), ['dark-glow', 'low-contrast']);
assert.deepEqual(deferred.map((f) => f.antipattern), ['em-dash-overuse', 'side-tab']);
for (const f of immediate) assert.ok(IMMEDIATE_TIER_RULES.has(f.antipattern));
});
it('perEditTieringActive is on for claude, off for cursor/github and perEditRules:"all"', () => {
assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'claude'), true);
assert.equal(perEditTieringActive({ perEditRules: 'all' }, 'claude'), false);
assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'github'), false);
assert.equal(perEditTieringActive({ perEditRules: 'immediate' }, 'cursor'), false);
assert.equal(perEditTieringActive({}, 'claude'), true);
});
it('surfaces immediate-tier findings per edit and defers copy-tier ones', async () => {
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([
finding('em-dash-overuse', 3),
finding('dark-glow', 5),
]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /dark-glow/);
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
assert.equal(r.audit.deferred, 1);
const cache = readCache(cwd);
assert.deepEqual(cache.sessions['tier-sid'].files[file].findings, ['dark-glow:5']);
});
it('emits a clean ack when all findings are deferred, and still marks the file touched', async () => {
const file = write('src/Copy.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 2)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-deferred-only')), env: {}, cwd, detector: det });
assert.match(r.stdout, /No deterministic design-quality issues found/);
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
assert.equal(r.audit.deferred, 1);
// The touched-file entry is what lets the Stop deep pass find this file.
const cache = readCache(cwd);
assert.ok(cache.sessions['tier-deferred-only'].files[file], 'file should be marked touched');
assert.deepEqual(cache.sessions['tier-deferred-only'].files[file].findings || [], []);
});
it('config hook.perEditRules:"all" restores the full per-edit rule set', async () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } }));
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 2)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-all')), env: {}, cwd, detector: det });
assert.match(r.stdout, /Design hook findings requiring review/);
assert.match(r.stdout, /em-dash-overuse/);
assert.equal(r.audit.deferred, undefined);
});
it('github harness keeps the full rule set per edit (no Stop pass wired there)', async () => {
const file = write('src/Card.tsx', 'noop');
const githubEvent = {
sessionId: 'gh-tier',
cwd,
toolName: 'edit',
toolArgs: JSON.stringify({ path: file }),
};
const det = fakeDetector([finding('em-dash-overuse', 2)]);
const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det });
assert.equal(r.audit.harness, 'github');
const out = JSON.parse(r.stdout);
assert.match(out.additionalContext, /em-dash-overuse/);
});
});
describe('runStopHook()', () => {
let cwd;
beforeEach(() => { cwd = mkTmp(); });
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
function write(rel, body) {
const abs = path.join(cwd, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
return abs;
}
function editEvent(file, sessionId) {
return {
session_id: sessionId,
cwd,
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: file },
};
}
function stopEvent(sessionId) {
return {
session_id: sessionId,
cwd,
hook_event_name: 'Stop',
stop_hook_active: false,
};
}
it('runs the full rule set over touched files and dedupes per-edit-surfaced findings', async () => {
const sid = 'stop-sid';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([
finding('dark-glow', 5),
finding('em-dash-overuse', 3),
finding('side-tab', 7),
]);
// Per-edit pass: surfaces dark-glow, defers the other two.
const edit = await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
assert.match(edit.stdout, /dark-glow/);
assert.doesNotMatch(edit.stdout, /em-dash-overuse/);
// Stop deep pass: surfaces exactly the deferred remainder.
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(stop.exitCode, 0);
assert.equal(stop.audit.emitted, true);
const out = JSON.parse(stop.stdout);
assert.equal(out.hookSpecificOutput.hookEventName, 'Stop');
assert.match(out.hookSpecificOutput.additionalContext, /em-dash-overuse/);
assert.match(out.hookSpecificOutput.additionalContext, /side-tab/);
assert.doesNotMatch(out.hookSpecificOutput.additionalContext, /dark-glow/);
assert.equal(stop.emission.kind, 'stop-deep-pass');
});
it('exits silent and fast when the session touched no UI files', async () => {
const r = await runStopHook({ stdinJson: JSON.stringify(stopEvent('stop-untouched')), env: {}, cwd });
assert.equal(r.exitCode, 0);
assert.equal(r.stdout, '');
assert.equal(r.audit.skipped, 'no-touched-files');
});
it('a second Stop fire is silent: deep-pass findings are remembered', async () => {
const sid = 'stop-twice';
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
const first = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.match(first.stdout, /em-dash-overuse/);
const second = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(second.stdout, '');
assert.equal(second.audit.skipped, 'stop-clean');
});
it('respects detector.ignoreRules in the deep pass', async () => {
const sid = 'stop-ignored';
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
detector: { ignoreRules: ['em-dash-overuse'] },
}));
const file = write('src/Card.tsx', 'noop');
const det = fakeDetector([finding('em-dash-overuse', 3)]);
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
assert.equal(stop.stdout, '');
assert.equal(stop.audit.skipped, 'stop-clean');
});
it('honors kill switches and the re-entrancy guard', async () => {
const disabled = await runStopHook({
stdinJson: JSON.stringify(stopEvent('stop-killed')),
env: { IMPECCABLE_HOOK_DISABLED: '1' }, cwd,
});
assert.equal(disabled.audit.skipped, 'env-disabled');
const reentrant = await runStopHook({
stdinJson: JSON.stringify(stopEvent('stop-reentrant')),
env: { IMPECCABLE_HOOK_DEPTH: '1' }, cwd,
});
assert.equal(reentrant.audit.reentrant, true);
assert.equal(reentrant.stdout, '');
});
});