mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
* docs: add PRD for design detector hook integration Plans a PostToolUse hook for Claude Code and Codex that runs the existing design detector after every relevant file write and feeds findings back to the agent as advisory system-reminder context. No implementation in this commit; covers UX, technical design, build pipeline changes, distribution, coverage tradeoffs, and rollout. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: revise hook PRD with best-practices review Folds in the P0/P1/P2 findings from an online best-practices critique against the official Claude Code and Codex hook references plus 10+ 2026 community guides and similar prior-art tools (claw-hooks, claude-code-hooks-mastery). Key changes: - Exec form everywhere (Codex snippet was shell form), with Windows rationale. - Default timeout dropped from 10s to 5s. - Re-entrancy guard (CLAUDE_HOOK_DEPTH) and per-file edit counter. - Session-scoped finding dedup promoted from open question to v1. - Per-language inline-ignore syntax map (HTML/JSX/CSS/JS). - Hard-skip rules for sensitive paths and generated/lock files. - Honest framing about Claude Code lacking per-plugin hook disable. - Honest framing about Bash-written files being invisible in v1. - Codex Windows-not-supported call-out, feature flag note, trust ceremony detail. - Optional NDJSON audit log via IMPECCABLE_HOOK_LOG. - Findings cap lowered 8 → 5 with attention-budget rationale. - Versioned envelope ([impeccable@1]) on rendered template. - Expanded test plan, decision log, and stdin payload appendix. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(hooks): ship the design detector hook for Claude Code and Codex Implements docs/hooks-prd.md: a PostToolUse hook that runs the impeccable design detector after every Edit/Write/MultiEdit on a UI file and pushes findings into the agent's next-turn context as a short system reminder. Silent on clean files. Never blocks an edit. Why this matters: today, design slop (side-tab borders, gradient text, purple/cyan palettes, bounce easing, etc.) only gets caught when a human notices or someone explicitly runs /impeccable audit. The hook closes the loop at the moment slop is written. What ships in v1 - skill/scripts/hook.mjs: PostToolUse entry. Reads stdin, runs the detector in-process (no `npx impeccable` cold start), emits hookSpecificOutput.additionalContext when fresh findings exist. - skill/scripts/hook-lib.mjs: extracted helpers (config, cache, filter, render, audit log, runHook orchestrator). 100% unit-testable. - skill/scripts/hook-session-start.mjs: SessionStart greeting, gated by a project-scannable probe + 30-day throttle. - skill/scripts/hook-admin.mjs: backs /impeccable hooks on/off/status/ignore-rule/ignore-file/reset. Hardening built in - Re-entrancy guard (IMPECCABLE_HOOK_DEPTH) so the hook can never recursively spawn itself. - Hard-skip regexes for sensitive paths (.env, .pem, id_rsa, secrets, credentials, .git) and generated/lock/build output. These fire before the file is even read; cannot be turned off via config. - Path-traversal check on the inbound file_path. - Session-scoped dedup keyed by (session, file, rule, line) so the same finding never lands in context twice. Prevents the ~12.5K wasted tokens per chatty session called out in the PRD. - Per-(session, file) edit counter with a one-shot suppression notice on the 7th edit, silent after. - Fail-open contract: every error path returns exit 0 with no stdout. Optional NDJSON audit log via IMPECCABLE_HOOK_LOG. Three kill switches (precedence high to low): 1. IMPECCABLE_HOOK_DISABLED env var (1/true/yes/on, case-insensitive) 2. .impeccable/hook.json `enabled: false` 3. /impeccable hooks off slash command (writes the JSON) Inline ignores are language-aware. `// impeccable: ignore <rule>` for JS/TS, `<!-- impeccable: ignore <rule> -->` for HTML/Vue/Svelte/Astro, `{/* impeccable: ignore <rule> */}` for JSX/TSX, `/* impeccable: ignore <rule> */` for CSS. `*` matches any rule. Directive applies to the next non-blank line. Same shape as ESLint, Stylelint, Biome. Build pipeline - scripts/lib/transformers/hooks.js: per-provider hooks.json builders, plus the slim .codex-plugin/plugin.json manifest. - providers.js: emitHooks: 'claude' for claude-code, emitHooks: 'codex' for codex and agents. Codex also emits emitCodexPlugin. - factory.js: emits hooks/hooks.json next to the skills tree. - build.js: syncs hooks/ into harness roots and into the slim plugin/ subtree; writes .codex-plugin/plugin.json. Build is idempotent (verified: 98 staged files unchanged across two runs). Claude Code wiring uses exec form (command + args) and the ${CLAUDE_PLUGIN_ROOT} placeholder. Matcher: Edit|Write|MultiEdit. `if:` glob filters to UI extensions before spawning Node. PostToolUse timeout 5s, SessionStart timeout 3s. Codex wiring uses ${PLUGIN_ROOT} (Codex's native placeholder), matcher Edit|Write|apply_patch, no `if:` analog (the script does the extension filter). macOS and Linux only; hooks are disabled on Windows in current Codex builds. The trust ceremony and feature flag are documented in README.md. Routing - /impeccable hooks lives outside the 23-command router table on purpose: it is plumbing, not a design skill. The hidden routing slot is added to SKILL.md alongside pin/unpin so the LLM knows to dispatch it. The 23-command count and all stale-count validators remain happy. Tests - tests/hook.test.mjs: 38 unit tests covering env parsing, config load + defaults + malformed, cache round-trip + GC, ignoreRules/minSeverity/inline ignores (all four languages), globbing with **/*/{a,b}, render template with cap + clamp + 0-line prefix drop, audit log NDJSON, payload event-name parameterization, re-entrancy, kill switches, sensitive-path + generated-path + traversal skips, allowlist filter, config ignoreFiles, edit counter cycle including the 7th-edit notice, MultiEdit and apply_patch payload shapes, detector throw swallow, malformed stdin, missing file race. - tests/hook-build.test.mjs: 18 integration tests covering hook manifest shape (matcher, timeouts, exec form, if: glob, placeholders), Codex differences (${PLUGIN_ROOT}, no if:, no SessionStart), Codex plugin manifest (no inline hooks field to avoid the duplicate-file error), routing across the hooksJsonFor table, and presence of all three committed artifacts plus the bundled detector the runtime relative-import path depends on. Full suite: 175 bun tests + 186 node tests, all green. Docs - README.md: new "Design hook" section explaining default behavior, per-project / global / inline disable paths, the JSON schema knobs, the audit log debug flag, and the slop / a11y coverage split. - HARNESSES.md: flips the `hooks` row for Codex from No -> Yes (Claude was already Yes), adds a per-harness hook-surface table with the manifest location and matcher each provider uses. Open questions from the PRD intentionally deferred to v2: Bash-write blind spot, effort-aware suppression, Stop-hook session summary, per-rule severity, async hook mode. None block v1. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Codex hook scanning: apply_patch paths and co-located stylesheets Parse file targets from Codex apply_patch command bodies, co-scan imported and sibling CSS when UI components are edited, drop the git-sweep PostToolUse group, and align Codex SessionStart manifest and trust docs with the official hooks spec. Co-authored-by: Cursor <cursoragent@cursor.com> * Gitignore hook session cache and drop local test HTML Hook dedup/throttle state in .impeccable/hook.cache.json is per-project runtime data like other .impeccable/ sidecars. Remove an untracked bad-nested-flexbox scratch page from site/public/. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Claude Code hook: drop Edit-only if filter so Write/MultiEdit fire Claude's if permission rule binds to one tool name, so Edit(*.{…}) never spawned the hook on Write or MultiEdit despite the matcher listing them. Extension filtering now lives in hook-lib on both Claude and Codex. Co-authored-by: Cursor <cursoragent@cursor.com> * Surface Cursor design findings via stop-hook followup Replace dropped postToolUse additional_context with afterFileEdit recording and a one-shot stop followup_message so anti-pattern nudges reach the agent. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix design hook packaging and scans * Fix Cursor hook pending bucket fallback * Fix Sass hook scan coverage * Fix Cursor hook review findings * Fix session start dead hook normalization * Fix hook config and relative scan paths * Remove SessionStart design hook * Remove redundant afterFileEdit normalization * Fix Cursor suppression and module style scans * Fix sensitive path hook filter * Fix disabled Cursor stop hook emission * Refresh hook harness artifacts * Fix Cursor hook manifest install * Add hook ignore-value support * Ignore hook runtime files locally * Fix Codex plugin hook packaging * fix: address PR review bot findings Block numeric hook depth counters from re-entering. Avoid following stylesheet imports from traversal-looking hook targets. * fix: gate ignore-value suggestions by supported rules Only render exact ignore-value commands when the same finding can be suppressed by ignoreValues. * Package Codex plugin as hook-only * Remove Codex plugin packaging * Recover hook install probe plumbing * Remove Codex hook packaging follow-up doc * Remove extra hook docs and skill wording changes * Install real design hooks via skills CLI * Add provider hook smoke runner * Fix Cursor hook delivery with preToolUse gate * Simplify Cursor hook install to preToolUse * Clarify confirmed hook exceptions * Persist hook ignores in shared config * Guard font hook exceptions * Fix hook install after main rebase * Fix hook scan target handling * fix: address hook review findings * Address hook review feedback * Stabilize DeepSeek insert live fixture * Fix Cursor hook Python shell write bypass --------- Co-authored-by: Cursor <cursoragent@cursor.com>
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
import path from 'path';
|
|
import {
|
|
cleanDir,
|
|
ensureDir,
|
|
writeFile,
|
|
generateYamlFrontmatter,
|
|
generateYamlDocument,
|
|
replacePlaceholders,
|
|
compileProviderBlocks,
|
|
stripRuleMarkers,
|
|
} from '../utils.js';
|
|
import { SKILL_CATEGORIES, CATEGORY_ORDER } from '../sub-pages-data.js';
|
|
import { hooksJsonFor } from './hooks.js';
|
|
|
|
/**
|
|
* Map from frontmatter field name to extraction spec.
|
|
*
|
|
* - sourceKey: property name on the skill object
|
|
* - yamlKey: key name in YAML frontmatter
|
|
* - condition: if provided, field is only emitted when this returns true
|
|
* - value: if provided, use this instead of skill[sourceKey]
|
|
*/
|
|
const FIELD_SPECS = {
|
|
'user-invocable': {
|
|
sourceKey: 'userInvocable',
|
|
yamlKey: 'user-invocable',
|
|
condition: (skill) => skill.userInvocable,
|
|
value: () => true,
|
|
},
|
|
'argument-hint': {
|
|
sourceKey: 'argumentHint',
|
|
yamlKey: 'argument-hint',
|
|
condition: (skill) => skill.userInvocable && skill.argumentHint,
|
|
},
|
|
license: {
|
|
sourceKey: 'license',
|
|
yamlKey: 'license',
|
|
},
|
|
compatibility: {
|
|
sourceKey: 'compatibility',
|
|
yamlKey: 'compatibility',
|
|
},
|
|
metadata: {
|
|
sourceKey: 'metadata',
|
|
yamlKey: 'metadata',
|
|
},
|
|
'allowed-tools': {
|
|
sourceKey: 'allowedTools',
|
|
yamlKey: 'allowed-tools',
|
|
},
|
|
};
|
|
|
|
// Provider builds that Codex loads as a skill (it reads skills from .agents/skills,
|
|
// and the .codex build mirrors it). For these, the Codex subagent .toml travels
|
|
// INSIDE the skill's agents/ folder, which Codex auto-discovers once the skill is
|
|
// installed -- so no separate .codex/agents/ sidecar copy is needed.
|
|
const CODEX_SKILL_PROVIDERS = new Set(['agents', 'codex']);
|
|
|
|
function humanizeSkillName(name) {
|
|
return name
|
|
.split('-')
|
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
function summarizeDescription(description, maxLength = 88) {
|
|
if (!description || description.length <= maxLength) return description;
|
|
const clipped = description.slice(0, maxLength - 1);
|
|
const lastSpace = clipped.lastIndexOf(' ');
|
|
return `${(lastSpace > 48 ? clipped.slice(0, lastSpace) : clipped).trimEnd()}...`;
|
|
}
|
|
|
|
function buildOpenAIMetadata(skill) {
|
|
const displayName = humanizeSkillName(skill.name);
|
|
return {
|
|
interface: {
|
|
display_name: displayName,
|
|
short_description: summarizeDescription(skill.description),
|
|
default_prompt: `Use ${displayName} to redesign, critique, audit, or polish this frontend.`,
|
|
},
|
|
};
|
|
}
|
|
|
|
function formatTomlString(value) {
|
|
return JSON.stringify(String(value));
|
|
}
|
|
|
|
function formatTomlMultiline(value) {
|
|
const normalized = String(value).trim().replace(/\r\n/g, '\n');
|
|
if (!normalized.includes("'''")) {
|
|
return `'''\n${normalized}\n'''`;
|
|
}
|
|
return `"""\n${normalized.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""`;
|
|
}
|
|
|
|
function formatTomlArray(values) {
|
|
return `[${values.map(formatTomlString).join(', ')}]`;
|
|
}
|
|
|
|
function buildCodexAgent(agent, body) {
|
|
const lines = [
|
|
`name = ${formatTomlString(agent.codexName || agent.name.replace(/-/g, '_'))}`,
|
|
`description = ${formatTomlString(agent.description)}`,
|
|
];
|
|
|
|
if (agent.effort) {
|
|
lines.push(`model_reasoning_effort = ${formatTomlString(agent.effort)}`);
|
|
}
|
|
|
|
if (agent.nicknameCandidates?.length) {
|
|
lines.push(`nickname_candidates = ${formatTomlArray(agent.nicknameCandidates)}`);
|
|
}
|
|
|
|
lines.push(`developer_instructions = ${formatTomlMultiline(body)}`);
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
function buildClaudeAgent(agent, body) {
|
|
const frontmatter = {
|
|
name: agent.claudeName || agent.name,
|
|
description: agent.description,
|
|
};
|
|
|
|
if (agent.tools) frontmatter.tools = agent.tools;
|
|
if (agent.model) frontmatter.model = agent.model;
|
|
if (agent.effort) frontmatter.effort = agent.effort;
|
|
if (agent.maxTurns) frontmatter.maxTurns = agent.maxTurns;
|
|
|
|
return `${generateYamlFrontmatter(frontmatter)}\n${body.trim()}\n`;
|
|
}
|
|
|
|
function buildAgentFile(config, agent, body) {
|
|
if (config.agentFormat === 'codex-toml') {
|
|
return {
|
|
filename: `${agent.codexName || agent.name.replace(/-/g, '_')}.toml`,
|
|
content: buildCodexAgent(agent, body),
|
|
};
|
|
}
|
|
|
|
if (config.agentFormat === 'claude-md') {
|
|
return {
|
|
filename: `${agent.claudeName || agent.name}.md`,
|
|
content: buildClaudeAgent(agent, body),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Create a transformer function for a given provider config.
|
|
*
|
|
* @param {Object} config - Provider configuration from providers.js
|
|
* @returns {Function} transform(skills, distDir, options?)
|
|
*/
|
|
export function createTransformer(config) {
|
|
const {
|
|
provider,
|
|
configDir,
|
|
displayName,
|
|
frontmatterFields = [],
|
|
bodyTransform,
|
|
placeholderProvider,
|
|
providerTags = [provider],
|
|
writeOpenAIMetadata = false,
|
|
includeVersion = true,
|
|
} = config;
|
|
const placeholderKey = placeholderProvider || provider;
|
|
|
|
const activeFields = frontmatterFields
|
|
.map((name) => FIELD_SPECS[name])
|
|
.filter(Boolean);
|
|
|
|
return function transform(skills, distDir, options = {}) {
|
|
const { skillsVersion = '' } = options;
|
|
const providerDir = path.join(distDir, provider);
|
|
const skillsDir = path.join(providerDir, `${configDir}/skills`);
|
|
|
|
cleanDir(providerDir);
|
|
ensureDir(skillsDir);
|
|
|
|
const allSkillNames = skills.map((s) => s.name);
|
|
const commandNames = skills
|
|
.filter((s) => s.userInvocable)
|
|
.map((s) => s.name);
|
|
|
|
let refCount = 0;
|
|
let scriptCount = 0;
|
|
let agentCount = 0;
|
|
|
|
for (const skill of skills) {
|
|
const skillName = skill.name;
|
|
const skillDir = path.join(skillsDir, skillName);
|
|
|
|
// Build frontmatter
|
|
const frontmatterObj = {
|
|
name: skillName,
|
|
description: skill.description,
|
|
};
|
|
if (skillsVersion && includeVersion) frontmatterObj.version = skillsVersion;
|
|
|
|
for (const spec of activeFields) {
|
|
if (spec.condition && !spec.condition(skill)) continue;
|
|
const val = spec.value ? spec.value(skill) : skill[spec.sourceKey];
|
|
if (val) frontmatterObj[spec.yamlKey] = val;
|
|
}
|
|
|
|
// Replace {{command_hint}} in argument-hint with command names from metadata,
|
|
// grouped by category with middle dots between groups for natural line-breaking.
|
|
if (frontmatterObj['argument-hint']?.includes('{{command_hint}}')) {
|
|
const metaScript = skill.scripts?.find(s => s.name === 'command-metadata.json');
|
|
if (metaScript) {
|
|
const commands = Object.keys(JSON.parse(metaScript.content));
|
|
// Derive groups from SKILL_CATEGORIES, excluding the parent skill name
|
|
const grouped = CATEGORY_ORDER
|
|
.map(cat => commands.filter(c => SKILL_CATEGORIES[c] === cat).join('|'))
|
|
.filter(Boolean)
|
|
.join(' · ');
|
|
frontmatterObj['argument-hint'] = frontmatterObj['argument-hint'].replace(
|
|
'{{command_hint}}',
|
|
grouped
|
|
);
|
|
}
|
|
}
|
|
|
|
const frontmatter = generateYamlFrontmatter(frontmatterObj);
|
|
|
|
// Build body
|
|
let skillBody = compileProviderBlocks(skill.body, providerTags);
|
|
skillBody = replacePlaceholders(skillBody, placeholderKey, commandNames, allSkillNames);
|
|
skillBody = stripRuleMarkers(skillBody);
|
|
|
|
// Replace {{scripts_path}} with provider-aware path to skill's scripts directory
|
|
const scriptsPath = `${configDir}/skills/${skillName}/scripts`;
|
|
skillBody = skillBody.replace(/\{\{scripts_path\}\}/g, scriptsPath);
|
|
if (bodyTransform) skillBody = bodyTransform(skillBody, skill);
|
|
|
|
const content = `${frontmatter}\n\n${skillBody}`;
|
|
writeFile(path.join(skillDir, 'SKILL.md'), content);
|
|
|
|
if (writeOpenAIMetadata) {
|
|
const openaiMetadata = buildOpenAIMetadata(skill);
|
|
writeFile(path.join(skillDir, 'agents', 'openai.yaml'), generateYamlDocument(openaiMetadata));
|
|
}
|
|
|
|
// Copy reference files
|
|
if (skill.references && skill.references.length > 0) {
|
|
const refDir = path.join(skillDir, 'reference');
|
|
ensureDir(refDir);
|
|
for (const ref of skill.references) {
|
|
let refContent = compileProviderBlocks(ref.content, providerTags);
|
|
refContent = replacePlaceholders(refContent, placeholderKey, [], allSkillNames);
|
|
refContent = stripRuleMarkers(refContent);
|
|
refContent = refContent.replace(/\{\{scripts_path\}\}/g, scriptsPath);
|
|
writeFile(path.join(refDir, `${ref.name}.md`), refContent);
|
|
refCount++;
|
|
}
|
|
}
|
|
|
|
// Copy script files
|
|
if (skill.scripts && skill.scripts.length > 0) {
|
|
const scriptsOutDir = path.join(skillDir, 'scripts');
|
|
ensureDir(scriptsOutDir);
|
|
for (const script of skill.scripts) {
|
|
writeFile(path.join(scriptsOutDir, script.name), script.content);
|
|
scriptCount++;
|
|
}
|
|
}
|
|
|
|
// Bundle the Codex subagent .toml inside the skill's agents/ folder for the
|
|
// variants Codex loads as a skill. Codex auto-discovers agents nested in an
|
|
// installed skill, so this in-skill copy is the whole delivery -- the
|
|
// skills/ install carries it and no .codex/agents/ sidecar copy is required.
|
|
if (CODEX_SKILL_PROVIDERS.has(provider)) {
|
|
for (const agent of skill.agents || []) {
|
|
if (agent.providers && !agent.providers.includes('codex')) continue;
|
|
let agentBody = compileProviderBlocks(agent.body, providerTags);
|
|
agentBody = replacePlaceholders(agentBody, placeholderKey, [], allSkillNames);
|
|
const filename = `${agent.codexName || agent.name.replace(/-/g, '_')}.toml`;
|
|
ensureDir(path.join(skillDir, 'agents'));
|
|
writeFile(path.join(skillDir, 'agents', filename), buildCodexAgent(agent, agentBody));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (config.agentFormat) {
|
|
const agentsDir = path.join(providerDir, `${configDir}/agents`);
|
|
for (const skill of skills) {
|
|
for (const agent of skill.agents || []) {
|
|
// Agents can declare `providers: <list>` to limit which harnesses
|
|
// they emit to. Default (no field) ships everywhere with agentFormat.
|
|
if (agent.providers && !agent.providers.includes(provider)) continue;
|
|
let body = compileProviderBlocks(agent.body, providerTags);
|
|
body = replacePlaceholders(body, placeholderKey, [], allSkillNames);
|
|
const agentFile = buildAgentFile(config, agent, body);
|
|
if (!agentFile) continue;
|
|
ensureDir(agentsDir);
|
|
writeFile(path.join(agentsDir, agentFile.filename), agentFile.content);
|
|
agentCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit the provider hook manifest when the provider opts in.
|
|
// Claude Code uses `.claude/settings.json`, Codex uses project-local
|
|
// `.codex/hooks.json`, and Cursor uses `.cursor/hooks.json`.
|
|
let hooksEmitted = false;
|
|
if (config.emitHooks) {
|
|
const manifest = hooksJsonFor(config.emitHooks);
|
|
if (manifest) {
|
|
const hooksRel = config.hooksManifestRel || path.join('hooks', 'hooks.json');
|
|
writeFile(path.join(providerDir, configDir, hooksRel), JSON.stringify(manifest, null, 2) + '\n');
|
|
hooksEmitted = true;
|
|
}
|
|
}
|
|
|
|
const skillWord = skills.length === 1 ? 'skill' : 'skills';
|
|
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
|
|
const scriptInfo = scriptCount > 0 ? ` (${scriptCount} script files)` : '';
|
|
const agentInfo = agentCount > 0 ? ` (${agentCount} agent files)` : '';
|
|
const hooksInfo = hooksEmitted
|
|
? ` (${config.hooksManifestRel || path.join('hooks', 'hooks.json')})`
|
|
: '';
|
|
console.log(`✓ ${displayName}: ${skills.length} ${skillWord}${refInfo}${scriptInfo}${agentInfo}${hooksInfo}`);
|
|
};
|
|
}
|