mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Eval evidence showed the per-edit PostToolUse stream fires overwhelmingly
on copy-level rules (em-dash-overuse ~97x/session) and measurably makes
models more conservative, while a full-detector pass at completion is what
actually fixes contrast/padding/glow. Split the hook accordingly:
- Per-edit (PostToolUse) now surfaces only IMMEDIATE_TIER_RULES: broken
output (broken-image, text-overflow, clipped-overflow-container,
body-text-viewport-edge), objective contrast/legibility failures
(low-contrast, gray-on-color, tiny-text), single-property mechanical
slop (gradient-text, dark-glow), and design-system drift (the four
design-system-* rules, which compound if left uncorrected). Everything
else defers. Override with hook.perEditRules: "all" in
.impeccable/config.json. Tiering is off for Cursor/Copilot harnesses,
which have no Stop pass wired, so nothing gets silently dropped there.
- Stop deep pass (runStopHook): runs the FULL rule set over every UI file
touched this session (tracked via the existing hook.cache.json session
state; deferred-only edits now mark the file touched), dedupes against
everything already surfaced per-edit, honors ignore-rule/file/value and
inline disables, reuses the [impeccable@1] envelope, and no-ops fast
when no UI files were touched. Emits hookSpecificOutput
{ hookEventName: "Stop", additionalContext } per the Claude Code SDK
Stop contract (conversation continues so the model can act on it).
Second Stop fire is silent - deep-pass findings are remembered.
- Wiring: Stop entries (timeout 30) in plugin/hooks/hooks.json, the
.claude settings + .codex hooks manifests (transformers + hook-admin
repair path). Claude Code and Codex both dispatch a native Stop event;
Cursor's stop hook is inconsistently dispatched (pre-write gate stays)
and Copilot's agentStop/sessionEnd don't inject model context, so
neither gets a Stop entry - documented in reference/hooks.md.
- Tests: tiering split/override/harness gating, Stop dedupe + silent
no-touched-files + ignore machinery + kill switches; existing per-edit
tests moved to immediate-tier rule ids. 181 tests green; smoke-tested
the built dist skill end to end (glow surfaced per-edit, em-dash only
at Stop, second Stop silent).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
6.5 KiB
JavaScript
202 lines
6.5 KiB
JavaScript
/**
|
|
* Build-pipeline emitters for the Impeccable design hook.
|
|
*
|
|
* Two emission targets exist:
|
|
*
|
|
* 1. Project-local install (the `npx impeccable skills install` CLI path):
|
|
* - Claude Code: `.claude/settings.json` (${CLAUDE_PROJECT_DIR}-relative)
|
|
* - Codex: `.codex/hooks.json`
|
|
* - Cursor: `.cursor/hooks.json`
|
|
*
|
|
* 2. Claude Code plugin package (the marketplace / `/plugin install` path):
|
|
* - `plugin/hooks/hooks.json` (${CLAUDE_PLUGIN_ROOT}-relative)
|
|
*
|
|
* 3. OpenAI plugin package:
|
|
* - `hooks/hooks.json` (${PLUGIN_ROOT}-relative)
|
|
*
|
|
* The plugin variant resolves the hook script relative to the installed plugin
|
|
* root rather than assuming a `.claude/skills/impeccable/` layout, so it stays
|
|
* correct wherever Claude Code unpacks the plugin.
|
|
*/
|
|
|
|
export const IMPECCABLE_HOOK_COMMAND_MARKER = 'skills/impeccable/scripts/hook.mjs';
|
|
|
|
const TIMEOUT_SECONDS = 5;
|
|
const STATUS_MESSAGE = 'Checking UI changes';
|
|
// The Stop deep pass scans every UI file touched in the session with the
|
|
// full rule set, so it gets a longer budget than the single-file per-edit
|
|
// pass. Wired only for Claude Code and Codex, which both dispatch a native
|
|
// `Stop` hook event; Cursor's stop hook is not consistently dispatched and
|
|
// GitHub Copilot's stop-style events do not feed context back to the model.
|
|
const STOP_TIMEOUT_SECONDS = 30;
|
|
const STOP_STATUS_MESSAGE = 'Design deep pass';
|
|
|
|
function stopEntry(command) {
|
|
return {
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command,
|
|
timeout: STOP_TIMEOUT_SECONDS,
|
|
statusMessage: STOP_STATUS_MESSAGE,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
const CLAUDE_PROJECT_HOOK = '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs';
|
|
const CLAUDE_PLUGIN_HOOK = '${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs';
|
|
const CODEX_PLUGIN_HOOK = '${PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs';
|
|
const CODEX_PROJECT_HOOK = '.agents/skills/impeccable/scripts/hook.mjs';
|
|
const CURSOR_BEFORE_EDIT_SCRIPT = '.cursor/skills/impeccable/scripts/hook-before-edit.mjs';
|
|
const GITHUB_PROJECT_HOOK = '$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs';
|
|
|
|
export function buildClaudeSettingsManifest() {
|
|
return {
|
|
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
|
|
hooks: {
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Edit|Write|MultiEdit',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `node "${CLAUDE_PROJECT_HOOK}"`,
|
|
timeout: TIMEOUT_SECONDS,
|
|
statusMessage: STATUS_MESSAGE,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
Stop: [stopEntry(`node "${CLAUDE_PROJECT_HOOK}"`)],
|
|
},
|
|
};
|
|
}
|
|
|
|
// Plugin-packaged variant of the Claude hook. Claude Code reads the `hooks`
|
|
// object from a plugin's `hooks/hooks.json`, and the command resolves relative
|
|
// to ${CLAUDE_PLUGIN_ROOT} so it does not depend on the skill being copied into
|
|
// `.claude/skills/`. No top-level `description`: Codex also loads bundled plugin
|
|
// hooks from `hooks/hooks.json` and its strict parser rejects any field other
|
|
// than `hooks`, failing the whole manifest (issue #330).
|
|
export function buildClaudePluginHooksManifest() {
|
|
return {
|
|
hooks: {
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Edit|Write|MultiEdit',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `node "${CLAUDE_PLUGIN_HOOK}"`,
|
|
timeout: TIMEOUT_SECONDS,
|
|
statusMessage: STATUS_MESSAGE,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
Stop: [stopEntry(`node "${CLAUDE_PLUGIN_HOOK}"`)],
|
|
},
|
|
};
|
|
}
|
|
|
|
// OpenAI plugin-packaged variant. Codex exposes ${PLUGIN_ROOT} for resources
|
|
// inside the installed plugin, so the public bundle can use the native path
|
|
// instead of relying on its Claude compatibility alias.
|
|
export function buildCodexPluginHooksManifest() {
|
|
return {
|
|
hooks: {
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Edit|Write|apply_patch',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `node "${CODEX_PLUGIN_HOOK}"`,
|
|
timeout: TIMEOUT_SECONDS,
|
|
statusMessage: STATUS_MESSAGE,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
Stop: [stopEntry(`node "${CODEX_PLUGIN_HOOK}"`)],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildCodexHooksManifest() {
|
|
return {
|
|
hooks: {
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Edit|Write|apply_patch',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `node "${CODEX_PROJECT_HOOK}"`,
|
|
timeout: TIMEOUT_SECONDS,
|
|
statusMessage: STATUS_MESSAGE,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
Stop: [stopEntry(`node "${CODEX_PROJECT_HOOK}"`)],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildCursorHooksManifest() {
|
|
return {
|
|
version: 1,
|
|
hooks: {
|
|
preToolUse: [
|
|
{
|
|
command: `node "${CURSOR_BEFORE_EDIT_SCRIPT}"`,
|
|
timeout: TIMEOUT_SECONDS,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
// GitHub Copilot reads project hooks from `.github/hooks/*.json`. Its schema
|
|
// differs from Claude/Codex/Cursor: the event key is lowercase `postToolUse`,
|
|
// each entry is flat (no nested `hooks` array), the command lives under `bash`
|
|
// (with an optional `powershell` sibling), the timeout key is `timeoutSec`, and
|
|
// `matcher` is a full-match regex (`^(?:PATTERN)$`) tested against the tool name.
|
|
// Copilot's file-editing tool names vary by surface (verified against CLI
|
|
// 1.0.63): `copilot -p` runs use `edit` ({path, old_str, new_str}) and `create`
|
|
// ({path, file_text}); interactive sessions and the cloud agent use
|
|
// `apply_patch` (a raw OpenAI-format patch string). The matcher covers all
|
|
// three. The same manifest is honored by both the CLI and the cloud/app agent.
|
|
// https://docs.github.com/en/copilot/reference/hooks-reference
|
|
export function buildGitHubHooksManifest() {
|
|
return {
|
|
version: 1,
|
|
hooks: {
|
|
postToolUse: [
|
|
{
|
|
type: 'command',
|
|
matcher: 'edit|create|apply_patch',
|
|
bash: `node "${GITHUB_PROJECT_HOOK}"`,
|
|
timeoutSec: TIMEOUT_SECONDS,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
export function hooksJsonFor(provider) {
|
|
switch (provider) {
|
|
case 'claude':
|
|
return buildClaudeSettingsManifest();
|
|
case 'codex':
|
|
return buildCodexHooksManifest();
|
|
case 'cursor':
|
|
return buildCursorHooksManifest();
|
|
case 'github':
|
|
return buildGitHubHooksManifest();
|
|
default:
|
|
return null;
|
|
}
|
|
}
|