mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
Sync generated provider output
This commit is contained in:
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks$impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks$impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks$impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks$impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .github/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.t
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Car
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/C
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
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).
|
||||
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), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
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.
|
||||
|
||||
@@ -81,8 +81,8 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
- 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.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot 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`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user