Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-06-14 09:42:49 +00:00
parent 8cf2be110d
commit 50f68ffffc
91 changed files with 4589 additions and 442 deletions
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `$impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `$impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `$impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .github/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.t
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
+14
View File
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Car
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+11 -11
View File
@@ -2,9 +2,9 @@
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 and Codex use `PostToolUse` 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 `IMPECCABLE_HOOK_QUIET=1` is set. 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 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 and Codex use `PostToolUse` 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.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `/impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
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.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',