diff --git a/.agents/skills/impeccable/reference/hooks.md b/.agents/skills/impeccable/reference/hooks.md index 7389fac70..24d6843e5 100644 --- a/.agents/skills/impeccable/reference/hooks.md +++ b/.agents/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.agents/skills/impeccable/scripts/context.mjs b/.agents/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.agents/skills/impeccable/scripts/context.mjs +++ b/.agents/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.agents/skills/impeccable/scripts/hook-admin.mjs b/.agents/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.agents/skills/impeccable/scripts/hook-admin.mjs +++ b/.agents/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.agents/skills/impeccable/scripts/hook-before-edit.mjs b/.agents/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.agents/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.agents/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.agents/skills/impeccable/scripts/hook.mjs b/.agents/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.agents/skills/impeccable/scripts/hook.mjs +++ b/.agents/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.claude/skills/impeccable/reference/hooks.md b/.claude/skills/impeccable/reference/hooks.md index 3bba484a6..ded6199eb 100644 --- a/.claude/skills/impeccable/reference/hooks.md +++ b/.claude/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.claude/skills/impeccable/scripts/context.mjs b/.claude/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.claude/skills/impeccable/scripts/context.mjs +++ b/.claude/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.claude/skills/impeccable/scripts/hook-admin.mjs b/.claude/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.claude/skills/impeccable/scripts/hook-admin.mjs +++ b/.claude/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.claude/skills/impeccable/scripts/hook-before-edit.mjs b/.claude/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.claude/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.claude/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.claude/skills/impeccable/scripts/hook.mjs b/.claude/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.claude/skills/impeccable/scripts/hook.mjs +++ b/.claude/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.cursor/skills/impeccable/reference/hooks.md b/.cursor/skills/impeccable/reference/hooks.md index 2a086a104..a68b32d4c 100644 --- a/.cursor/skills/impeccable/reference/hooks.md +++ b/.cursor/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.cursor/skills/impeccable/scripts/context.mjs b/.cursor/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.cursor/skills/impeccable/scripts/context.mjs +++ b/.cursor/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.cursor/skills/impeccable/scripts/hook-admin.mjs b/.cursor/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.cursor/skills/impeccable/scripts/hook-admin.mjs +++ b/.cursor/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.cursor/skills/impeccable/scripts/hook.mjs b/.cursor/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.cursor/skills/impeccable/scripts/hook.mjs +++ b/.cursor/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.gemini/skills/impeccable/reference/hooks.md b/.gemini/skills/impeccable/reference/hooks.md index f5c6823ee..5d8f14db7 100644 --- a/.gemini/skills/impeccable/reference/hooks.md +++ b/.gemini/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.gemini/skills/impeccable/scripts/context.mjs b/.gemini/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.gemini/skills/impeccable/scripts/context.mjs +++ b/.gemini/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.gemini/skills/impeccable/scripts/hook-admin.mjs b/.gemini/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.gemini/skills/impeccable/scripts/hook-admin.mjs +++ b/.gemini/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.gemini/skills/impeccable/scripts/hook.mjs b/.gemini/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.gemini/skills/impeccable/scripts/hook.mjs +++ b/.gemini/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.github/skills/impeccable/reference/hooks.md b/.github/skills/impeccable/reference/hooks.md index 6f9ec2493..6b496d2ff 100644 --- a/.github/skills/impeccable/reference/hooks.md +++ b/.github/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.github/skills/impeccable/scripts/context.mjs b/.github/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.github/skills/impeccable/scripts/context.mjs +++ b/.github/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.github/skills/impeccable/scripts/hook-admin.mjs b/.github/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.github/skills/impeccable/scripts/hook-admin.mjs +++ b/.github/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.github/skills/impeccable/scripts/hook-before-edit.mjs b/.github/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.github/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.github/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.github/skills/impeccable/scripts/hook.mjs b/.github/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.github/skills/impeccable/scripts/hook.mjs +++ b/.github/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.kiro/skills/impeccable/reference/hooks.md b/.kiro/skills/impeccable/reference/hooks.md index facb8b8a3..f61d4678f 100644 --- a/.kiro/skills/impeccable/reference/hooks.md +++ b/.kiro/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.kiro/skills/impeccable/scripts/context.mjs b/.kiro/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.kiro/skills/impeccable/scripts/context.mjs +++ b/.kiro/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.kiro/skills/impeccable/scripts/hook-admin.mjs b/.kiro/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.kiro/skills/impeccable/scripts/hook-admin.mjs +++ b/.kiro/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.kiro/skills/impeccable/scripts/hook.mjs b/.kiro/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.kiro/skills/impeccable/scripts/hook.mjs +++ b/.kiro/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.opencode/skills/impeccable/reference/hooks.md b/.opencode/skills/impeccable/reference/hooks.md index e37243d1a..3bd7c06d8 100644 --- a/.opencode/skills/impeccable/reference/hooks.md +++ b/.opencode/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.opencode/skills/impeccable/scripts/context.mjs b/.opencode/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.opencode/skills/impeccable/scripts/context.mjs +++ b/.opencode/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.opencode/skills/impeccable/scripts/hook-admin.mjs b/.opencode/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.opencode/skills/impeccable/scripts/hook-admin.mjs +++ b/.opencode/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.opencode/skills/impeccable/scripts/hook.mjs b/.opencode/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.opencode/skills/impeccable/scripts/hook.mjs +++ b/.opencode/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.pi/skills/impeccable/reference/hooks.md b/.pi/skills/impeccable/reference/hooks.md index e9b1397c5..acb15fb25 100644 --- a/.pi/skills/impeccable/reference/hooks.md +++ b/.pi/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.pi/skills/impeccable/scripts/context.mjs b/.pi/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.pi/skills/impeccable/scripts/context.mjs +++ b/.pi/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.pi/skills/impeccable/scripts/hook-admin.mjs b/.pi/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.pi/skills/impeccable/scripts/hook-admin.mjs +++ b/.pi/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.pi/skills/impeccable/scripts/hook-before-edit.mjs b/.pi/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.pi/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.pi/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.pi/skills/impeccable/scripts/hook.mjs b/.pi/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.pi/skills/impeccable/scripts/hook.mjs +++ b/.pi/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.qoder/skills/impeccable/reference/hooks.md b/.qoder/skills/impeccable/reference/hooks.md index 9f8da988e..4b7d34cac 100644 --- a/.qoder/skills/impeccable/reference/hooks.md +++ b/.qoder/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.qoder/skills/impeccable/scripts/context.mjs b/.qoder/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.qoder/skills/impeccable/scripts/context.mjs +++ b/.qoder/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.qoder/skills/impeccable/scripts/hook-admin.mjs b/.qoder/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.qoder/skills/impeccable/scripts/hook-admin.mjs +++ b/.qoder/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.qoder/skills/impeccable/scripts/hook.mjs b/.qoder/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.qoder/skills/impeccable/scripts/hook.mjs +++ b/.qoder/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.qoder/skills/impeccable/scripts/live-inject.mjs +++ b/.qoder/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.rovodev/skills/impeccable/reference/hooks.md b/.rovodev/skills/impeccable/reference/hooks.md index 34e2b7b02..ae8bb5feb 100644 --- a/.rovodev/skills/impeccable/reference/hooks.md +++ b/.rovodev/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.rovodev/skills/impeccable/scripts/context.mjs b/.rovodev/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.rovodev/skills/impeccable/scripts/context.mjs +++ b/.rovodev/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.rovodev/skills/impeccable/scripts/hook-admin.mjs b/.rovodev/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.rovodev/skills/impeccable/scripts/hook-admin.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.rovodev/skills/impeccable/scripts/hook.mjs b/.rovodev/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.rovodev/skills/impeccable/scripts/hook.mjs +++ b/.rovodev/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.trae-cn/skills/impeccable/reference/hooks.md b/.trae-cn/skills/impeccable/reference/hooks.md index 4131a16eb..d4762a6b0 100644 --- a/.trae-cn/skills/impeccable/reference/hooks.md +++ b/.trae-cn/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.trae-cn/skills/impeccable/scripts/context.mjs b/.trae-cn/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.trae-cn/skills/impeccable/scripts/context.mjs +++ b/.trae-cn/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.trae-cn/skills/impeccable/scripts/hook-admin.mjs b/.trae-cn/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-admin.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.trae-cn/skills/impeccable/scripts/hook.mjs b/.trae-cn/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.trae-cn/skills/impeccable/scripts/hook.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/.trae/skills/impeccable/reference/hooks.md b/.trae/skills/impeccable/reference/hooks.md index ff63859e4..1e8500147 100644 --- a/.trae/skills/impeccable/reference/hooks.md +++ b/.trae/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/.trae/skills/impeccable/scripts/context.mjs b/.trae/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/.trae/skills/impeccable/scripts/context.mjs +++ b/.trae/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/.trae/skills/impeccable/scripts/hook-admin.mjs b/.trae/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/.trae/skills/impeccable/scripts/hook-admin.mjs +++ b/.trae/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/.trae/skills/impeccable/scripts/hook-before-edit.mjs b/.trae/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/.trae/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/.trae/skills/impeccable/scripts/hook.mjs b/.trae/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/.trae/skills/impeccable/scripts/hook.mjs +++ b/.trae/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -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/', diff --git a/plugin/skills/impeccable/reference/hooks.md b/plugin/skills/impeccable/reference/hooks.md index 3bba484a6..ded6199eb 100644 --- a/plugin/skills/impeccable/reference/hooks.md +++ b/plugin/skills/impeccable/reference/hooks.md @@ -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 ` | Append `` to `ignoreRules`; for `overused-font`, requires `--all-values`. | | `ignore-file ` | Append `` to `ignoreFiles`. | -| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. | -| `ignore-value --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. | +| `ignore-value [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. | +| `ignore-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 `` 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 `` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file." -5. If `` 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 `` 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 `` 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 ` for the current file. - Use `ignore-rule ` 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. diff --git a/plugin/skills/impeccable/scripts/context.mjs b/plugin/skills/impeccable/scripts/context.mjs index 87a1784ca..3912a9060 100644 --- a/plugin/skills/impeccable/scripts/context.mjs +++ b/plugin/skills/impeccable/scripts/context.mjs @@ -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; diff --git a/plugin/skills/impeccable/scripts/hook-admin.mjs b/plugin/skills/impeccable/scripts/hook-admin.mjs index 86fcfa909..6332a122a 100644 --- a/plugin/skills/impeccable/scripts/hook-admin.mjs +++ b/plugin/skills/impeccable/scripts/hook-admin.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** * `/impeccable hooks ` — 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); diff --git a/plugin/skills/impeccable/scripts/hook-before-edit.mjs b/plugin/skills/impeccable/scripts/hook-before-edit.mjs index 61cd50039..467896e4f 100644 --- a/plugin/skills/impeccable/scripts/hook-before-edit.mjs +++ b/plugin/skills/impeccable/scripts/hook-before-edit.mjs @@ -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, }; diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 02199f530..4d357d2e3 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -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 }); } diff --git a/plugin/skills/impeccable/scripts/hook.mjs b/plugin/skills/impeccable/scripts/hook.mjs index b862f8033..8f5924976 100644 --- a/plugin/skills/impeccable/scripts/hook.mjs +++ b/plugin/skills/impeccable/scripts/hook.mjs @@ -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); diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs index 69ce530c1..7c8d91182 100644 --- a/plugin/skills/impeccable/scripts/live-inject.mjs +++ b/plugin/skills/impeccable/scripts/live-inject.mjs @@ -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/',