diff --git a/.agents/skills/impeccable/reference/hooks.md b/.agents/skills/impeccable/reference/hooks.md index 5f288614d..7389fac70 100644 --- a/.agents/skills/impeccable/reference/hooks.md +++ b/.agents/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca - 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. - 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.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. +- 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 diff --git a/.claude/settings.json b/.claude/settings.json index aabed69a3..319926e08 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,7 +9,7 @@ "type": "command", "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"", "timeout": 5, - "statusMessage": "Scanning design" + "statusMessage": "Checking UI changes" } ] } diff --git a/.claude/skills/impeccable/reference/hooks.md b/.claude/skills/impeccable/reference/hooks.md index caadaa603..3bba484a6 100644 --- a/.claude/skills/impeccable/reference/hooks.md +++ b/.claude/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca - 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. - 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.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. +- 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 diff --git a/.codex/hooks.json b/.codex/hooks.json index 96d74229d..fc2d539be 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -9,7 +9,7 @@ "type": "command", "command": "node \"$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs\"", "timeout": 5, - "statusMessage": "Scanning design" + "statusMessage": "Checking UI changes" } ] } diff --git a/.cursor/skills/impeccable/reference/hooks.md b/.cursor/skills/impeccable/reference/hooks.md index 3e5d48e74..2a086a104 100644 --- a/.cursor/skills/impeccable/reference/hooks.md +++ b/.cursor/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca - 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. - 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.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. +- 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 diff --git a/.gemini/skills/impeccable/reference/hooks.md b/.gemini/skills/impeccable/reference/hooks.md index 189c35b29..f5c6823ee 100644 --- a/.gemini/skills/impeccable/reference/hooks.md +++ b/.gemini/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca - 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. - 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.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. +- 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 diff --git a/.github/skills/impeccable/reference/hooks.md b/.github/skills/impeccable/reference/hooks.md index 19ba4b289..6f9ec2493 100644 --- a/.github/skills/impeccable/reference/hooks.md +++ b/.github/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .github/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca - 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. - 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.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. +- 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 diff --git a/.kiro/skills/impeccable/reference/hooks.md b/.kiro/skills/impeccable/reference/hooks.md index 88a4063ef..facb8b8a3 100644 --- a/.kiro/skills/impeccable/reference/hooks.md +++ b/.kiro/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card - 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. - 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.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. +- 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 diff --git a/.opencode/skills/impeccable/reference/hooks.md b/.opencode/skills/impeccable/reference/hooks.md index 5caecfc45..e37243d1a 100644 --- a/.opencode/skills/impeccable/reference/hooks.md +++ b/.opencode/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/ - 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. - 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.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. +- 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 diff --git a/.pi/skills/impeccable/reference/hooks.md b/.pi/skills/impeccable/reference/hooks.md index c595d3db2..e9b1397c5 100644 --- a/.pi/skills/impeccable/reference/hooks.md +++ b/.pi/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.t - 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. - 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.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. +- 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 diff --git a/.qoder/skills/impeccable/reference/hooks.md b/.qoder/skills/impeccable/reference/hooks.md index a139f51ce..9f8da988e 100644 --- a/.qoder/skills/impeccable/reference/hooks.md +++ b/.qoder/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Car - 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. - 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.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. +- 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 diff --git a/.rovodev/skills/impeccable/reference/hooks.md b/.rovodev/skills/impeccable/reference/hooks.md index 1a6a54caa..34e2b7b02 100644 --- a/.rovodev/skills/impeccable/reference/hooks.md +++ b/.rovodev/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C - 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. - 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.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. +- 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 diff --git a/.trae-cn/skills/impeccable/reference/hooks.md b/.trae-cn/skills/impeccable/reference/hooks.md index b3da5d0f4..4131a16eb 100644 --- a/.trae-cn/skills/impeccable/reference/hooks.md +++ b/.trae-cn/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C - 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. - 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.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. +- 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 diff --git a/.trae/skills/impeccable/reference/hooks.md b/.trae/skills/impeccable/reference/hooks.md index 5aba0af26..ff63859e4 100644 --- a/.trae/skills/impeccable/reference/hooks.md +++ b/.trae/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card - 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. - 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.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. +- 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 diff --git a/README.md b/README.md index cdc42181b..a647cd920 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ On Claude Code, Codex, and Cursor, `npx impeccable skills install` and `npx impe Installed hook surfaces: -- Claude Code: `.claude/settings.json` runs `${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs`. +- Claude Code: `.claude/settings.local.json` (gitignored, machine-local) runs `${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs`. A hook moved into the shared `settings.json` is honored in place. - Cursor: `.cursor/hooks.json` runs `.cursor/skills/impeccable/scripts/hook-before-edit.mjs`. - Codex: `.codex/hooks.json` runs `.agents/skills/impeccable/scripts/hook.mjs`. diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index ce295fea9..a34fb2d93 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -68,7 +68,12 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [ ]; const PROVIDER_HOOK_ARTIFACTS = { '.claude': [ - { sourceProvider: '.claude', rel: 'settings.json', destProvider: '.claude' }, + // The hook is a machine-local install side effect, so it lands in the + // gitignored `.claude/settings.local.json` rather than the team-shared + // `settings.json`. The bundle still ships the manifest as `settings.json` + // (the `rel` source), but we write it to `destRel`. A hook the user moved + // into `settings.json` is honored in place; see copyProviderHooks. + { sourceProvider: '.claude', rel: 'settings.json', destProvider: '.claude', destRel: 'settings.local.json' }, ], '.cursor': [ { sourceProvider: '.cursor', rel: 'hooks.json', destProvider: '.cursor' }, @@ -434,19 +439,67 @@ function copyProviderSkills(bundleDir, root, targets) { } function hookArtifactsForProvider(bundleDir, root, provider) { - return (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ sourceProvider, rel, destProvider }) => ({ - src: join(bundleDir, sourceProvider, rel), - dest: join(root, destProvider, rel), - })); + return (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ sourceProvider, rel, destProvider, destRel }) => { + const writeRel = destRel || rel; + const artifact = { + src: join(bundleDir, sourceProvider, rel), + dest: join(root, destProvider, writeRel), + }; + // When the write target is a local override (e.g. settings.local.json), the + // team-shared sibling (settings.json) is where a legacy install or a + // deliberate user move would put our hook. Track it so we never duplicate. + if (writeRel !== rel) { + artifact.sharedDest = join(root, destProvider, rel); + } + return artifact; + }); } +// The file paths the CLI writes hook manifests to (the local override target, +// e.g. settings.local.json — not the shared sibling). function expectedHookDests(root, providers) { const targets = Array.isArray(providers) ? providers : [providers]; return targets.flatMap(provider => - (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ rel, destProvider }) => join(root, destProvider, rel)) + (PROVIDER_HOOK_ARTIFACTS[provider] || []).map(({ rel, destProvider, destRel }) => + join(root, destProvider, destRel || rel)) ); } +// Whether a hook manifest file actually wires up the Impeccable hook. We parse +// the JSON and scan only the `hooks` subtree (via valueHasImpeccableHookMarker), +// not the raw file text: an unrelated string elsewhere — e.g. a permissions +// allow entry that happens to mention the hook path — must not read as a hook. +function fileHasImpeccableHookMarker(file) { + if (!existsSync(file)) return false; + let parsed; + try { + parsed = JSON.parse(readFileSync(file, '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); +} + +// Whether our hook is already wired up for a provider, used to decide if the +// already-installed fast path should top up a missing hook. We look for the +// Impeccable marker — not mere file existence — because the target files +// (settings.local.json, hooks.json) commonly hold unrelated local settings; an +// existence check would falsely report "installed" and skip repairing a missing +// hook that `update` would otherwise add. For Claude we also honor our hook +// living in the shared settings.json sibling (a legacy install or user move). +function hookInstalledForProvider(root, provider) { + const artifacts = PROVIDER_HOOK_ARTIFACTS[provider] || []; + if (artifacts.length === 0) return true; + return artifacts.every(({ destProvider, rel, destRel }) => { + const writeRel = destRel || rel; + if (fileHasImpeccableHookMarker(join(root, destProvider, writeRel))) return true; + if (writeRel !== rel && fileHasImpeccableHookMarker(join(root, destProvider, rel))) return true; + return false; + }); +} + function valueHasImpeccableHookMarker(value) { if (typeof value === 'string') { return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => value.includes(marker)); @@ -483,6 +536,46 @@ function stripImpeccableHookEntries(entries) { .filter(Boolean); } +// Remove our hook from a manifest file, preserving any unrelated content. Used +// when the hook is honored in the shared settings.json so a stale machine-local +// copy doesn't make the detector run twice. Drops the file if nothing but our +// hook scaffolding remains. Returns true if it changed anything. +function pruneImpeccableHookFromManifest(manifestPath) { + if (!fileHasImpeccableHookMarker(manifestPath)) return false; + let parsed; + try { + parsed = JSON.parse(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 { + // Our hook was the only thing here; drop the hook-manifest scaffolding too. + delete next.hooks; + delete next.description; + delete next.version; + } + + if (Object.keys(next).length === 0) { + rmSync(manifestPath, { force: true }); + } else { + writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`); + } + return true; +} + function mergeHookManifests(existing, fresh) { const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}; const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {}; @@ -520,8 +613,19 @@ function copyProviderHooks(bundleDir, root, providers, { force = false } = {}) { const targets = Array.isArray(providers) ? providers : [providers]; const written = []; for (const provider of targets) { - for (const { src, dest } of hookArtifactsForProvider(bundleDir, root, provider)) { + for (const { src, dest, sharedDest } of hookArtifactsForProvider(bundleDir, root, provider)) { if (!existsSync(src)) continue; + + // Leave-it-never-duplicate: our hook already lives in the team-shared + // settings.json (a legacy install or a deliberate user move). Honor it in + // place and skip the local write — but first strip any stale copy from the + // local override, or Claude Code would load both and run the detector + // twice per edit. + if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) { + pruneImpeccableHookFromManifest(dest); + continue; + } + const fresh = readJsonFile(src, 'Bundled hook manifest'); let next = fresh; @@ -691,14 +795,14 @@ async function install(flags) { if (existing && !force) { console.log(`Impeccable skills are already installed (found in ${existing}/).`); const targets = providersValue ? resolveInstallTargets(root, providersValue) : findInstalledProviders(root); - const missingHookDests = installHooks - ? expectedHookDests(root, targets).filter(dest => !existsSync(dest)) + const missingHookTargets = installHooks + ? targets.filter(provider => !hookInstalledForProvider(root, provider)) : []; - if (missingHookDests.length > 0) { + if (missingHookTargets.length > 0) { let bundleDir; try { bundleDir = await downloadAndExtractBundle(); - const hookTargets = copyProviderHooks(bundleDir, root, targets); + const hookTargets = copyProviderHooks(bundleDir, root, missingHookTargets); if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`); } catch (e) { console.error(`Hook install failed: ${e.message}`); diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 000000000..0c3dd2272 --- /dev/null +++ b/plugin/hooks/hooks.json @@ -0,0 +1,18 @@ +{ + "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_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs\"", + "timeout": 5, + "statusMessage": "Checking UI changes" + } + ] + } + ] + } +} diff --git a/plugin/skills/impeccable/reference/hooks.md b/plugin/skills/impeccable/reference/hooks.md index caadaa603..3bba484a6 100644 --- a/plugin/skills/impeccable/reference/hooks.md +++ b/plugin/skills/impeccable/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca - 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. - 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.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. +- 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 diff --git a/scripts/build.js b/scripts/build.js index d8cce4952..e27a00c53 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -21,7 +21,7 @@ import { fileURLToPath } from 'url'; import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js'; import { generateApiData } from './lib/api-data.js'; import { createTransformer, PROVIDERS } from './lib/transformers/index.js'; -import { hooksJsonFor } from './lib/transformers/hooks.js'; +import { hooksJsonFor, buildClaudePluginHooksManifest } from './lib/transformers/hooks.js'; import { createAllZips } from './lib/zip.js'; import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs'; // Sub-page generation is now handled by Astro content collections. @@ -708,9 +708,11 @@ async function build() { const pluginManifestDir = path.join(pluginRoot, '.claude-plugin'); const pluginSkillsDir = path.join(pluginRoot, 'skills'); const pluginAgentsDir = path.join(pluginRoot, 'agents'); + const pluginHooksDir = path.join(pluginRoot, 'hooks'); if (fs.existsSync(pluginManifestDir)) fs.rmSync(pluginManifestDir, { recursive: true }); if (fs.existsSync(pluginSkillsDir)) fs.rmSync(pluginSkillsDir, { recursive: true }); if (fs.existsSync(pluginAgentsDir)) fs.rmSync(pluginAgentsDir, { recursive: true }); + if (fs.existsSync(pluginHooksDir)) fs.rmSync(pluginHooksDir, { recursive: true }); const rootManifest = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8')); const claudeAgentsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'agents'); @@ -746,6 +748,16 @@ async function build() { copyDirSync(claudeAgentsSrc, pluginAgentsDir); } + // Ship the design detector as a plugin-packaged hook. Claude Code + // auto-discovers `hooks/hooks.json` at the plugin root, so marketplace / + // `/plugin install` users get the PostToolUse hook without it being merged + // into their project `.claude/settings.json` (that path is the CLI's job). + fs.mkdirSync(pluginHooksDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginHooksDir, 'hooks.json'), + JSON.stringify(buildClaudePluginHooksManifest(), null, 2) + '\n', + ); + console.log('📦 Built Claude Code plugin subtree at ./plugin/'); } else { console.log('📋 Skipped root harness and plugin sync (--skip-root-sync)'); @@ -774,5 +786,9 @@ async function build() { console.log('\n✨ Build complete!'); } -// Run the build -build(); +// Run the build. A rejection here (e.g. the release zip failing to build) must +// exit non-zero so a broken artifact never deploys silently. +build().catch((err) => { + console.error(`\n❌ Build failed: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/lib/transformers/hooks.js b/scripts/lib/transformers/hooks.js index ed041db46..fbca947c0 100644 --- a/scripts/lib/transformers/hooks.js +++ b/scripts/lib/transformers/hooks.js @@ -1,18 +1,27 @@ /** * Build-pipeline emitters for the Impeccable design hook. * - * The hook install path in this PR is project-local: - * - Claude Code: `.claude/settings.json` - * - Codex: `.codex/hooks.json` - * - Cursor: `.cursor/hooks.json` + * Two emission targets exist: * - * No provider marketplace or Codex plugin packaging is emitted here. + * 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) + * + * 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'; 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_PROJECT_HOOK = '$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs'; const CURSOR_BEFORE_EDIT_SCRIPT = '.cursor/skills/impeccable/scripts/hook-before-edit.mjs'; @@ -28,7 +37,32 @@ export function buildClaudeSettingsManifest() { type: 'command', command: `node "${CLAUDE_PROJECT_HOOK}"`, timeout: TIMEOUT_SECONDS, - statusMessage: 'Scanning design', + statusMessage: STATUS_MESSAGE, + }, + ], + }, + ], + }, + }; +} + +// Plugin-packaged variant of the Claude hook. Same schema as the settings.json +// manifest (Claude Code reads an identical `hooks` object from a plugin's +// `hooks/hooks.json`), but the command resolves relative to ${CLAUDE_PLUGIN_ROOT} +// so it does not depend on the skill being copied into `.claude/skills/`. +export function buildClaudePluginHooksManifest() { + return { + 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_PLUGIN_HOOK}"`, + timeout: TIMEOUT_SECONDS, + statusMessage: STATUS_MESSAGE, }, ], }, @@ -49,7 +83,7 @@ export function buildCodexHooksManifest() { type: 'command', command: `node "${CODEX_PROJECT_HOOK}"`, timeout: TIMEOUT_SECONDS, - statusMessage: 'Scanning design', + statusMessage: STATUS_MESSAGE, }, ], }, diff --git a/scripts/lib/zip.js b/scripts/lib/zip.js index a4351030b..3e59a4f55 100644 --- a/scripts/lib/zip.js +++ b/scripts/lib/zip.js @@ -8,9 +8,8 @@ import path from 'path'; import { createWriteStream, existsSync, statSync } from 'fs'; -import * as archiverModule from 'archiver'; - -const createArchiver = archiverModule.default || archiverModule.create || archiverModule; +// archiver v8 is ESM and exports format-specific classes (no factory function). +import { ZipArchive } from 'archiver'; /** * Create ZIP file for a provider directory @@ -23,33 +22,42 @@ export async function createProviderZip(providerDir, distDir, providerName) { const zipPath = path.join(distDir, zipFileName); if (!existsSync(providerDir)) { - console.warn(`⚠️ Provider directory not found: ${providerDir}`); - return; + throw new Error(`Cannot create ${zipFileName}: provider directory not found: ${providerDir}`); } - try { - await new Promise((resolve, reject) => { - const output = createWriteStream(zipPath); - const archive = createArchiver('zip', { zlib: { level: 9 } }); + // Fail loud, never soft. This artifact ships to `npx impeccable skills + // install` via the bundle endpoint; a build that can't produce a real zip + // must exit non-zero rather than deploy an empty one. (archiver v8's ESM + // break previously failed here silently and shipped a 0-byte universal.zip.) + let entryCount = 0; + await new Promise((resolve, reject) => { + const output = createWriteStream(zipPath); + const archive = new ZipArchive({ zlib: { level: 9 } }); - output.on('close', resolve); - archive.on('error', reject); + output.on('close', resolve); + output.on('error', reject); + archive.on('error', reject); + archive.on('entry', () => { entryCount += 1; }); - archive.pipe(output); - archive.glob('**/*', { - cwd: providerDir, - dot: true, - ignore: ['**/.DS_Store'], - }); - archive.finalize(); + archive.pipe(output); + archive.glob('**/*', { + cwd: providerDir, + dot: true, + ignore: ['**/.DS_Store'], }); + archive.finalize(); + }); - const stats = statSync(zipPath); - const sizeMB = (stats.size / 1024 / 1024).toFixed(2); - console.log(` 📦 ${zipFileName} (${sizeMB} MB)`); - } catch (error) { - console.error(` ❌ Failed to create ${zipFileName}:`, error.message); + if (entryCount === 0) { + throw new Error(`Created ${zipFileName} but it contains no entries (source: ${providerDir}).`); } + const { size } = statSync(zipPath); + if (size === 0) { + throw new Error(`Created ${zipFileName} but it is 0 bytes.`); + } + + const sizeMB = (size / 1024 / 1024).toFixed(2); + console.log(` 📦 ${zipFileName} (${sizeMB} MB)`); } /** diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 9490bc17c..c83abc894 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -29,7 +29,7 @@ export const SUITES = { /^site\/(pages|content|components|layouts)\//, /^README(\.npm)?\.md$/, /^cli\/bin\//, - /^tests\/(build|cleanup-deprecated|context|context-signals|critique-storage|design-parser|docs-integrity|hook|hook-build|impeccable-paths|skills-cli|test-suites|windows-path-fix)\.test\.(js|mjs)$/, + /^tests\/(build|cleanup-deprecated|context|context-signals|critique-storage|design-parser|docs-integrity|hook|hook-build|impeccable-paths|skills-cli|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/, /^tests\/lib\//, ], commands: [ @@ -59,6 +59,7 @@ export const SUITES = { 'tests/hook.test.mjs', 'tests/impeccable-paths.test.mjs', 'tests/test-suites.test.mjs', + 'tests/zip.test.mjs', ], }, ], diff --git a/skill/reference/hooks.md b/skill/reference/hooks.md index f96d9ba55..78f3dd5c9 100644 --- a/skill/reference/hooks.md +++ b/skill/reference/hooks.md @@ -6,7 +6,7 @@ The hook runs the impeccable design detector on direct file edits to design-rele 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. -Supported harnesses: Claude Code (`.claude/settings.json` in the project), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project). +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). On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands. @@ -74,7 +74,7 @@ node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx" - 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. - 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.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. +- 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 diff --git a/tests/hook-build.test.mjs b/tests/hook-build.test.mjs index fc4a5df2e..822c0f3ee 100644 --- a/tests/hook-build.test.mjs +++ b/tests/hook-build.test.mjs @@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { buildClaudeSettingsManifest, + buildClaudePluginHooksManifest, buildCodexHooksManifest, buildCursorHooksManifest, hooksJsonFor, @@ -38,7 +39,7 @@ describe('hook manifest builders', () => { assert.equal(group.matcher, 'Edit|Write|MultiEdit'); assert.equal(handler.type, 'command'); assert.equal(handler.timeout, 5); - assert.equal(handler.statusMessage, 'Scanning design'); + assert.equal(handler.statusMessage, 'Checking UI changes'); expectCommand(handler.command, '.claude/skills/impeccable/scripts/hook.mjs'); assert.ok(handler.command.includes('${CLAUDE_PROJECT_DIR}')); assert.equal(handler.args, undefined); @@ -53,7 +54,7 @@ describe('hook manifest builders', () => { assert.equal(group.matcher, 'Edit|Write|apply_patch'); assert.equal(handler.type, 'command'); assert.equal(handler.timeout, 5); - assert.equal(handler.statusMessage, 'Scanning design'); + assert.equal(handler.statusMessage, 'Checking UI changes'); expectCommand(handler.command, '.agents/skills/impeccable/scripts/hook.mjs'); assert.ok(handler.command.includes('git rev-parse --show-toplevel')); assert.ok(!handler.command.includes('${PLUGIN_ROOT}')); @@ -142,19 +143,39 @@ describe('generated hook artifacts in repo', () => { } }); - it('does not generate plugin or stale Codex hook packaging artifacts', () => { + it('does not generate stale Codex hook packaging artifacts', () => { for (const rel of [ '.claude/hooks/hooks.json', '.agents/hooks', '.agents/plugins/marketplace.json', 'plugin-codex', - 'plugin/hooks/hooks.json', 'plugin/.codex-plugin/plugin.json', ]) { assert.equal(fs.existsSync(path.join(REPO_ROOT, rel)), false, `${rel} should not exist`); } }); + it('packages the Claude design hook in the plugin via plugin-root paths', () => { + const abs = path.join(REPO_ROOT, 'plugin/hooks/hooks.json'); + assert.ok(fs.existsSync(abs), 'plugin/hooks/hooks.json missing - did you forget bun run build:release?'); + + const manifest = readJson('plugin/hooks/hooks.json'); + assert.deepEqual(manifest, buildClaudePluginHooksManifest()); + + const handler = manifest.hooks.PostToolUse[0].hooks[0]; + assert.equal(manifest.hooks.PostToolUse[0].matcher, 'Edit|Write|MultiEdit'); + expectCommand(handler.command, 'skills/impeccable/scripts/hook.mjs'); + // Resolves relative to the installed plugin, not a `.claude/skills/` layout. + assert.ok(handler.command.includes('${CLAUDE_PLUGIN_ROOT}'), + `plugin hook command must use $\{CLAUDE_PLUGIN_ROOT}: ${handler.command}`); + assert.ok(!handler.command.includes('${CLAUDE_PROJECT_DIR}'), + `plugin hook command must not use $\{CLAUDE_PROJECT_DIR}: ${handler.command}`); + + // The script the plugin hook points at must ship inside the plugin payload. + assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook.mjs'))); + assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-lib.mjs'))); + }); + it('generated hook runtime can import the bundled detector', async () => { for (const scriptDir of [ '.claude/skills/impeccable/scripts', diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index cd85f114f..957aad529 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -137,7 +137,13 @@ describe('skills install: already-installed detection', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-test-')); execSync('git init', { cwd: tmp }); createFakeSkills(tmp); - writeFileSync(join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: {} })); + // Seed the canonical hook target so the already-installed path sees the hook + // wired up and doesn't try to repair it (which would need the bundle). + writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({ + hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [ + { type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }, + ] }] }, + })); const output = run('skills install -y', { cwd: tmp }); expect(output).toContain('already installed'); @@ -152,7 +158,12 @@ describe('skills install: already-installed detection', () => { const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable'); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: i-impeccable\n---\n'); - writeFileSync(join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ version: 1, hooks: {} })); + // Seed the hook so the already-installed path sees it wired up and doesn't + // try to repair it (which would need the bundle). + writeFileSync(join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"' }] }, + })); const output = run('skills install -y', { cwd: tmp }); expect(output).toContain('already installed'); @@ -173,7 +184,7 @@ describe('skills install: already-installed detection', () => { expect(output).toContain('already installed'); expect(output).toContain('Installed hooks into: .claude'); - expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(true); + expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true); rmSync(tmp, { recursive: true, force: true }); }, 15000); @@ -191,7 +202,56 @@ describe('skills install: already-installed detection', () => { expect(output).toContain('already installed'); expect(output).not.toContain('Installed hooks into'); - expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(false); + expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('repairs the hook when settings.local.json exists without the Impeccable marker', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-repair-unrelated-local-')); + execSync('git init', { cwd: tmp }); + createFakeSkills(tmp, ['impeccable'], ['.claude']); + const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']); + // A local settings file that exists for unrelated reasons (e.g. permissions) + // must not be mistaken for an installed hook. + writeFileSync(join(tmp, '.claude', 'settings.local.json'), + JSON.stringify({ permissions: { allow: ['Bash(ls:*)'] } }, null, 2)); + + const output = run('skills install -y --providers=claude', { + cwd: tmp, + env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + + expect(output).toContain('already installed'); + expect(output).toContain('Installed hooks into: .claude'); + // The hook is merged in, and the unrelated local settings are preserved. + const merged = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8')); + expect(JSON.stringify(merged)).toContain('skills/impeccable/scripts/hook.mjs'); + expect(merged.permissions.allow).toContain('Bash(ls:*)'); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('a permissions entry mentioning the hook path is not mistaken for an installed hook', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-marker-falsepos-')); + execSync('git init', { cwd: tmp }); + createFakeSkills(tmp, ['impeccable'], ['.claude']); + const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']); + // The hook path appears only inside a permissions string, not a hooks entry. + writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({ + permissions: { allow: ['Bash(node .claude/skills/impeccable/scripts/hook.mjs:*)'] }, + }, null, 2)); + + const output = run('skills install -y --providers=claude', { + cwd: tmp, + env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + + // Detected as missing -> repaired, with the real hook added under hooks. + expect(output).toContain('Installed hooks into: .claude'); + const merged = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8')); + expect(merged.hooks.PostToolUse).toBeDefined(); + expect(merged.permissions.allow[0]).toContain('skills/impeccable/scripts/hook.mjs'); rmSync(tmp, { recursive: true, force: true }); }, 15000); @@ -396,13 +456,79 @@ describe('skills install/update: local universal bundle e2e', () => { expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain(`Local deterministic bundle for ${provider}.`); expect(existsSync(join(skillDir, 'scripts', 'context.mjs'))).toBe(true); } - expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(true); + expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true); expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(true); expect(existsSync(join(tmp, '.codex', 'hooks.json'))).toBe(true); rmSync(tmp, { recursive: true, force: true }); }, 15000); + test('honors an existing hook in shared settings.json and never duplicates into settings.local.json', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-shared-hook-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']); + + // Simulate a user who moved (or whose legacy install left) the hook in the + // team-shared settings.json. + mkdirSync(join(tmp, '.claude'), { recursive: true }); + writeFileSync(join(tmp, '.claude', 'settings.json'), JSON.stringify({ + hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [ + { type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }, + ] }] }, + }, null, 2)); + + const output = run('skills install -y --providers=claude', { + cwd: tmp, + env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + expect(output).toContain('Done!'); + // The hook is honored in place: no local override is written, and the + // shared file is left exactly as the user had it (one hook, no dupes). + expect(output).not.toContain('Installed hooks into'); + expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false); + const shared = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.json'), 'utf8')); + expect(shared.hooks.PostToolUse).toHaveLength(1); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('prunes a stale local hook when the shared settings.json owns the hook', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-dedupe-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']); + mkdirSync(join(tmp, '.claude'), { recursive: true }); + + // The team added the hook to shared settings.json... + writeFileSync(join(tmp, '.claude', 'settings.json'), JSON.stringify({ + hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [ + { type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }, + ] }] }, + }, null, 2)); + // ...while a machine-local install already wrote the hook here, alongside + // unrelated local settings that must survive. + writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({ + permissions: { allow: ['Bash(ls:*)'] }, + hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [ + { type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }, + ] }] }, + }, null, 2)); + + run('skills install -y --providers=claude', { + cwd: tmp, + env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot }, + }); + + // Local duplicate is pruned (no hook left), unrelated settings preserved; + // the shared file still owns the single hook. + const local = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8')); + expect(local.hooks).toBeUndefined(); + expect(local.permissions.allow).toContain('Bash(ls:*)'); + const shared = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.json'), 'utf8')); + expect(shared.hooks.PostToolUse).toHaveLength(1); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + test('--no-hooks installs skills without hook manifests', () => { const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-no-hooks-')); execSync('git init', { cwd: tmp }); @@ -417,7 +543,7 @@ describe('skills install/update: local universal bundle e2e', () => { for (const provider of ['.claude', '.agents', '.cursor']) { expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true); } - expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(false); + expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false); expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(false); expect(existsSync(join(tmp, '.codex', 'hooks.json'))).toBe(false); @@ -443,7 +569,7 @@ describe('skills install/update: local universal bundle e2e', () => { expect(content).not.toContain('stale: true'); expect(content).toContain('version: 9.9.9-local'); expect(existsSync(join(skillDir, 'scripts', 'context.mjs'))).toBe(true); - expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(true); + expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true); rmSync(tmp, { recursive: true, force: true }); }, 15000); diff --git a/tests/zip.test.mjs b/tests/zip.test.mjs new file mode 100644 index 000000000..3dffe1774 --- /dev/null +++ b/tests/zip.test.mjs @@ -0,0 +1,74 @@ +/** + * Unit tests for the release-bundle zip writer (scripts/lib/zip.js). + * Run: node --test tests/zip.test.mjs + * + * Regression guard for the silent-broken-bundle outage: archiver v8's ESM + * change made createProviderZip fail without throwing, so the build shipped a + * 0-byte universal.zip and every `npx impeccable skills install` failed with + * "End-of-central-directory signature not found". Nothing covered the zip + * writer, so the suite stayed green. These tests exercise the real writer and + * round-trip through extract-zip (the same unpacker the CLI uses). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import extract from 'extract-zip'; + +import { createProviderZip, createAllZips } from '../scripts/lib/zip.js'; + +function makeUniversalTree(distDir) { + const skillDir = join(distDir, 'universal', 'skills', 'impeccable'); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\n---\nhello\n'); + mkdirSync(join(distDir, 'universal', '.claude'), { recursive: true }); + writeFileSync(join(distDir, 'universal', '.claude', 'settings.json'), '{}\n'); +} + +describe('release bundle zip writer', () => { + it('createAllZips produces a non-empty universal.zip that unpacks to the skill tree', async () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-zip-')); + makeUniversalTree(dist); + + await createAllZips(dist); + + const zipPath = join(dist, 'universal.zip'); + assert.ok(existsSync(zipPath), 'universal.zip was not created'); + assert.ok(statSync(zipPath).size > 0, 'universal.zip is empty (0 bytes)'); + + // Round-trip: the CLI downloads this exact artifact and extract()s it. + const out = mkdtempSync(join(tmpdir(), 'imp-unzip-')); + await extract(zipPath, { dir: out }); + const skillMd = join(out, 'skills', 'impeccable', 'SKILL.md'); + assert.ok(existsSync(skillMd), 'unpacked bundle is missing skills/impeccable/SKILL.md'); + assert.match(readFileSync(skillMd, 'utf8'), /name: impeccable/); + + rmSync(dist, { recursive: true, force: true }); + rmSync(out, { recursive: true, force: true }); + }); + + it('createProviderZip throws when the source has no files (no silent 0-byte artifact)', async () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-zip-empty-')); + mkdirSync(join(dist, 'universal'), { recursive: true }); + + await assert.rejects( + () => createProviderZip(join(dist, 'universal'), dist, 'universal'), + /no entries|0 bytes/i, + ); + + rmSync(dist, { recursive: true, force: true }); + }); + + it('createProviderZip throws when the source directory is missing', async () => { + const dist = mkdtempSync(join(tmpdir(), 'imp-zip-missing-')); + + await assert.rejects( + () => createProviderZip(join(dist, 'does-not-exist'), dist, 'universal'), + /not found/i, + ); + + rmSync(dist, { recursive: true, force: true }); + }); +});