mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165f94e939 | ||
|
|
c9c6928be6 | ||
|
|
fcd7622cd2 | ||
|
|
356b761391 | ||
|
|
1159100c96 | ||
|
|
0e9b6f9884 | ||
|
|
47e411952b | ||
|
|
6bea544a0a | ||
|
|
5d7c1cce34 | ||
|
|
2ef8e43d1e | ||
|
|
043e8a5bfd | ||
|
|
78b50aa416 | ||
|
|
c3a30086bc | ||
|
|
21510c3632 | ||
|
|
5d00e30405 | ||
|
|
f01a808890 | ||
|
|
2064b0696f | ||
|
|
c3ea1c79f5 | ||
|
|
f849d610f3 | ||
|
|
c0b1ec6fef | ||
|
|
c9e7cd8a64 | ||
|
|
49571365a8 | ||
|
|
e975bec412 | ||
|
|
91f2c7b47e | ||
|
|
dca8f1ca6f |
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -113,6 +113,8 @@ npx impeccable update
|
||||
|
||||
Codex users should open `/hooks` after install or update and approve the project hook when prompted. Codex tracks trust by hook definition, so updates that change `.codex/hooks.json` can require approval again. Grok Build users need project folder trust (`/hooks-trust` or launch with `--trust`) before `.grok/hooks/` scripts run.
|
||||
|
||||
See [Allow the hook in your harness](https://impeccable.style/docs/hooks#allow-the-hook-in-your-harness) for harness-specific trust and verification steps.
|
||||
|
||||
### Option 2: Git Submodule
|
||||
|
||||
For teams that want to keep Impeccable vendored and updated through Git, add this repo as a submodule and link the compiled provider build into your harness folders:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@ai-sdk/google": "^4.0.8",
|
||||
"@ai-sdk/openai": "^4.0.7",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
"@anthropic-ai/sdk": "^0.120.0",
|
||||
"@babel/parser": "^8.0.4",
|
||||
"ai": "^7.0.14",
|
||||
"archiver": "^8.0.0",
|
||||
@@ -31,37 +31,37 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JAMGtYeEuaBzqbsPO4fkho6vQyNoVhsHASM4o59wmJRU6Vh7prjOp490Kmc7YQTY+ioU1/xYzXvWOtxZBup0Xw=="],
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.28" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cFlCZspCUC1LGDipARsKx3+4A8c9qI+vFuG0/04Phs0deKwifNsl8wDmcU2HO31aiNR9AJgEBcvg5S00zUS70g=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.52", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.55", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7WP/nlDz2BkXFlZzwF3w5JgCvktyHC++LP4PZ4mQpbvxrq+M7OdNslevlkuddHhJ+62BZu4oiL/afXlsWOSJZQ=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@4.0.44", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bmRTDg06jQD+eX8nf214pET9+Oe8O1+lUIRGbWsGXj9IN2UJkpl1O1x7cvtiboyTtKSLvSRdVtItUfSl8sQ2GA=="],
|
||||
"@ai-sdk/google": ["@ai-sdk/google@4.0.49", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.28" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZJMehjQNVyvLytV5cUQcTMNEG79NS+p8d2AHu+bH/Mg2M2IQ8Pueh/xQga2v85TX8BGT55m9Q01taY1fZjS4ag=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZxDca6jJalYuXrIGVrw6dnkpz1Io9AWy+/b/wVWIbjigHCbd+zWLpPi8NnK0OFU+U3YCpP+KWfUvEnG5pFhltA=="],
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.28" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-F/zHXTfCaHO0Q5egf8HRdc645Mp/ofLE9NSjl5RwaXfqrqLfiLROE9xScgKf4iziElsjuQYbesPEcW37mmZy3w=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.28", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TnHUyd/rCYQqHg5RuiOaz/hUql6U+kbUaBW0Rp+0N5UhnAInA9CzzV0HXvuAAPwppDsK6fAx9Rd+tRawpJ/3pg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.233", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.233", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.233", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.233", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.233", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.233" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-Dy+YqhggwtbezDy3Ap2pb1sK3bOqnI+sLNnsVjB3AUWvR0QlGnjjrjORXY03Y50I+B1eFRNEcYPAZKRYlCkSLQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.238", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.238", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.238", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.238", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.238", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.238" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-ppRfbAflZuV7HPqr8BkHPEk8c7gih3PK+GHnZq6zP0Uo7bJHXAJwc4Za8LJRm4hXMixBNMjK1cU/VWfQ9fE2sg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4WDiBZgcrmvTDJjS8RNZwoxGgMz/0EpOM+sYa6EtyjwHTd6It1H/+k5zBckCmBajbgS5/ASCJqdwZzi7dwBl0Q=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.238", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7KctNItTzHRiDg+jFxTM46o+Y/YS52V6HSopaWPggO6BbR+3MV/rKMmq+zP93If7eVwadW9Yg9vHLjXDKIw9OQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233", "", { "os": "darwin", "cpu": "x64" }, "sha512-RaaEfNrbqSh77H5NdVF9cJQ0xhAUO92aOv71LSKSdAYModMeUvJN0k22Q7gvmx0TlmqJ+aVyCG8J8gVfgSL9mg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.238", "", { "os": "darwin", "cpu": "x64" }, "sha512-aDt8LXjWISwLzqeCBHxJC5AR1iVYlY5UYIm2VLztjI1U0PCb1BiH/IfyyjjizqRhhEwyQp9YAJeZZq8n/2vQEA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233", "", { "os": "linux", "cpu": "arm64" }, "sha512-Az9HjQthYQqRjJCacBtDIAHX3TRGK9WlACNb/UOGAK3JndNzZMprM2mK/t6YmP2cRLJsGyorxL7HZmR9R9HYaw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.238", "", { "os": "linux", "cpu": "arm64" }, "sha512-P7V9TFokcNRdIJPUzDQ0GLBfMuoewWEH4rh6U3e7RaEAxzdeUcdS9P0N4arXqK2YAOtK1o93QSEtiGUF1fTfWw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z3uZdzt6xgJ3f4NIgO6lzBYSELULKSq6AL4OsNLBzuaEpVW0iYs1kUCaD9rcMlMrf3cV+Dk/GA/lTCGMgbucjQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.238", "", { "os": "linux", "cpu": "arm64" }, "sha512-ankSEMAMTVulKYg0NT8fZ7a5+q+aIzfiqhcrLe9zYPeuhE+lpNeq0JPMcHyOtUAENxVI0/2J2q5OXGl9O0qWcg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233", "", { "os": "linux", "cpu": "x64" }, "sha512-jpbhV+n9PnxLiyheQ/HjtHIg/E5/jVsk2Vdu132BSoL/3bsObSmMqKgsqoMutzwRZvtpqRs2RPVcjsC8G4A9Zw=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.238", "", { "os": "linux", "cpu": "x64" }, "sha512-/arAOSqtIAWDu7Z4Uf2Un3n+/5Zg3NpxZKzMQEDnYNkbJRhr44sM59TFaEOiWq1FqlUJKi6wHjgm+TW5B8DWWg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233", "", { "os": "linux", "cpu": "x64" }, "sha512-kYBIAQCu2f1YITcGbpUN2jfrkAzs59TVAragAhE2z+GrkIcxcpZwmaRY6heMBtaSY8SuyrwgqbCW9hJALYFnEg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.238", "", { "os": "linux", "cpu": "x64" }, "sha512-buo3IBSd7EmYcQsH+OKARgNNbSF1+l/+z1hDXSXGKzwD1LFioJZ/k9FWWWC166SoQ93jPmN+G/ksTzEUB3zRGQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233", "", { "os": "win32", "cpu": "arm64" }, "sha512-aO2MaNdmQofyPLKszE4s+Ope/sLJPeI/ZlGdCcjYp7qhji2hgZ4bRWWsOrx5eKjz0gFK5CFFltILkFcNcxCsVg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.238", "", { "os": "win32", "cpu": "arm64" }, "sha512-yW8lhV7QgYiNu3NatjgmkhUspgJsG2N2N6lmm/7B99sWFobKTbVLMLsXxj6A46R6qE2C4Zzm6PjxgGaMYOMn0g=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233", "", { "os": "win32", "cpu": "x64" }, "sha512-TcAYyWPXS5mREZGUksuCZsLIRQjbo/Vriur2PqIhAmgZ1oiqBZO27a90sX60EUczD7yV8wpwOVhVLhUxO0kAEg=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.238", "", { "os": "win32", "cpu": "x64" }, "sha512-6Fb2JRrBci282fBfhCGB0Tpq7N8V5HMMqsO3YGFkc9hhbn/y9G7glNKSNgRtxD5Ujjxj8rhiaYstXW2DJzanzw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.115.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.120.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ZlvmNFT/iIF6JD13rxbbMWD8nvGR0RaUp6yMQnoc+4Af0YjVVe/bIdW1XSQQsoxXAtg1NaT6Vak0LKFlJ4d37Q=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="],
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"ai": ["ai@7.0.66", "", { "dependencies": { "@ai-sdk/gateway": "4.0.52", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg=="],
|
||||
"ai": ["ai@7.0.69", "", { "dependencies": { "@ai-sdk/gateway": "4.0.55", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gudEqQYt/FuRpQkckLHPYKOh5M5v5ayPmzQAk5xyZOaWkUM0ArNGdXQiBGEuZA7GCwYOjzmWxB44ZW0CyRhHfQ=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
@@ -327,7 +327,7 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="],
|
||||
"marked": ["marked@18.0.10", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-FJeH4bRpYoXiggcgriCGItKCSv3xkngJc4QCZ/rkQCogU3VYaLxYJoZl8Nw/b4+x7iij/pd+09mZ6A1dXzpL0A=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
@@ -435,7 +435,7 @@
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"svelte": ["svelte@5.56.9", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA=="],
|
||||
"svelte": ["svelte@5.56.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="],
|
||||
|
||||
@@ -483,10 +483,14 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="],
|
||||
|
||||
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
@@ -1402,7 +1402,7 @@ function hookScriptPathForProvider(skillRoot, provider) {
|
||||
if (provider === '.cursor') {
|
||||
return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook-before-edit.mjs');
|
||||
}
|
||||
if (provider === '.claude' || provider === '.agents') {
|
||||
if (provider === '.claude' || provider === '.agents' || provider === '.grok') {
|
||||
return join(skillRoot, provider, 'skills', 'impeccable', 'scripts', 'hook.mjs');
|
||||
}
|
||||
return null;
|
||||
@@ -1478,8 +1478,8 @@ function guardHookCommand(quotedPath, provider) {
|
||||
// entries additionally get a `commandWindows` sibling for cmd.exe.
|
||||
function rewriteHookCommandsForSkillRoot(value, provider, { skillRoot, absolute }) {
|
||||
const hookScript = hookScriptPathForProvider(skillRoot, provider);
|
||||
// Providers we don't own a `node "PATH"` command hook for (.github, .grok)
|
||||
// carry their own portable command forms; leave them untouched.
|
||||
// Providers we don't own a `node "PATH"` command hook for (.github) carry
|
||||
// their own portable command forms; leave them untouched.
|
||||
if (!hookScript) return value;
|
||||
|
||||
// Project-scope installs derive the provider's own project-relative path
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
|
||||
// Puppeteer detection (for URLs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function detectUrl(url, options = {}) {
|
||||
function decodeUrlComponent(value) {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function splitScanUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { href: url, credentials: null };
|
||||
}
|
||||
if (!parsed.username && !parsed.password) {
|
||||
return { href: url, credentials: null };
|
||||
}
|
||||
const credentials =
|
||||
parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
? {
|
||||
username: decodeUrlComponent(parsed.username),
|
||||
password: decodeUrlComponent(parsed.password),
|
||||
}
|
||||
: null;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
return { href: parsed.href, credentials };
|
||||
}
|
||||
|
||||
function basicAuthHeader(credentials) {
|
||||
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
// page.authenticate is page-wide: a cross-origin redirect that then 401s
|
||||
// would receive these credentials. Attach Authorization only to the scan origin.
|
||||
async function applyOriginScopedAuth(page, href, credentials) {
|
||||
if (!credentials) return;
|
||||
let origin = '';
|
||||
try {
|
||||
origin = new URL(href).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!origin) return;
|
||||
const header = basicAuthHeader(credentials);
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (request) => {
|
||||
let headers;
|
||||
try {
|
||||
if (new URL(request.url()).origin === origin) {
|
||||
headers = { ...request.headers(), authorization: header };
|
||||
}
|
||||
} catch {
|
||||
// invalid request URL: continue without auth
|
||||
}
|
||||
void request.continue(headers ? { headers } : undefined).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function detectUrl(rawUrl, options = {}) {
|
||||
const { href: url, credentials } = splitScanUrl(rawUrl);
|
||||
const profile = options?.profile;
|
||||
const waitUntil = options?.waitUntil || 'networkidle0';
|
||||
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
|
||||
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
|
||||
ruleId: 'set-viewport',
|
||||
target: url,
|
||||
}, () => page.setViewport(viewport));
|
||||
await applyOriginScopedAuth(page, url, credentials);
|
||||
await profileStepAsync(profile, {
|
||||
engine: 'browser',
|
||||
phase: 'load',
|
||||
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
|
||||
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@
|
||||
"@ai-sdk/google": "^4.0.8",
|
||||
"@ai-sdk/openai": "^4.0.7",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.165",
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
"@anthropic-ai/sdk": "^0.120.0",
|
||||
"@babel/parser": "^8.0.4",
|
||||
"ai": "^7.0.14",
|
||||
"archiver": "^8.0.0",
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, 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.
|
||||
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. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
|
||||
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
|
||||
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
|
||||
|
||||
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
|
||||
|
||||
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
|
||||
|
||||
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), 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.
|
||||
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), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), 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.
|
||||
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
// CLI can't import (separate tree). `.git` and `package.json` are the common
|
||||
// boundaries; `.impeccable` is our own project marker.
|
||||
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
|
||||
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
|
||||
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
|
||||
// or a marker file beside apps/ or packages/ children.
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const COLOR_CHANNEL_TOLERANCE = 6;
|
||||
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
|
||||
// difference between a documented shadow and drift), so shadow matching cannot
|
||||
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
|
||||
// projectRoots govern any path they match (positive or negated); package-manager
|
||||
// globs only apply to paths the Impeccable group does not match.
|
||||
function readWorkspacePatternGroups(dir) {
|
||||
const impeccable = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
|
||||
if (Array.isArray(roots)) {
|
||||
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
|
||||
}
|
||||
}
|
||||
const pkg = [];
|
||||
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
|
||||
if (Array.isArray(workspaces)) pkg.push(...workspaces);
|
||||
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
|
||||
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
|
||||
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
|
||||
try {
|
||||
let inPackages = false;
|
||||
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
|
||||
const trimmed = stripInlineYamlComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flow) {
|
||||
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
|
||||
break;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
|
||||
if (!inPackages) continue;
|
||||
const item = trimmed.match(/^-\s*(.+)$/);
|
||||
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
|
||||
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
}
|
||||
} catch { /* no pnpm-workspace.yaml */ }
|
||||
return [impeccable, pkg];
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(dir) {
|
||||
return readWorkspacePatternGroups(dir).flat();
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
|
||||
try {
|
||||
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function monorepoOwnsPath(root, boundaryDir) {
|
||||
const rel = path.relative(root, boundaryDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function matchGlobSegments(patternSegments, relSegments) {
|
||||
function rec(pi, ri) {
|
||||
if (pi === patternSegments.length) return ri === relSegments.length;
|
||||
if (patternSegments[pi] === '**') {
|
||||
if (pi === patternSegments.length - 1) return true;
|
||||
for (let k = ri; k <= relSegments.length; k++) {
|
||||
if (rec(pi + 1, k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (ri >= relSegments.length) return false;
|
||||
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
|
||||
return rec(pi + 1, ri + 1);
|
||||
}
|
||||
return rec(0, 0);
|
||||
}
|
||||
|
||||
// Negations like !packages/excluded must also cover nested dirs under that path.
|
||||
function matchesNegation(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Positive globs identify workspace packages at exact depth (`*` is a direct
|
||||
// child). A nested package.json under that package is still owned: the
|
||||
// ancestor directory of glob length must itself be a package.
|
||||
function positiveOwns(pattern) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
if (relSegments.length === patternSegments.length) return true;
|
||||
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
|
||||
return fs.existsSync(path.join(ancestorDir, 'package.json'));
|
||||
}
|
||||
|
||||
function groupOwns(rawPatterns) {
|
||||
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
|
||||
if (!patterns.length) return null;
|
||||
const excluded = patterns.some((pattern) => (
|
||||
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
|
||||
));
|
||||
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
|
||||
if (!excluded && !included) return null;
|
||||
if (excluded) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [impeccable, pkg] = readWorkspacePatternGroups(root);
|
||||
const fromImpeccable = groupOwns(impeccable);
|
||||
if (fromImpeccable !== null) return fromImpeccable;
|
||||
const fromPkg = groupOwns(pkg);
|
||||
if (fromPkg !== null) return fromPkg;
|
||||
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
|
||||
return false;
|
||||
}
|
||||
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
|
||||
}
|
||||
|
||||
// Both forms of the home directory. The walk compares path strings, and a
|
||||
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
|
||||
// paths a cwd-resolved target produces, which would let the post-boundary walk
|
||||
// sail through $HOME and inherit from it.
|
||||
function homeDirForms() {
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const forms = new Set([homeDir]);
|
||||
try {
|
||||
forms.add(fs.realpathSync(homeDir));
|
||||
} catch { /* keep the logical form only */ }
|
||||
return forms;
|
||||
}
|
||||
|
||||
// Walk up from `startDir` to the directory that governs the target's design
|
||||
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
|
||||
//
|
||||
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
|
||||
// design root — that's where the rules live.
|
||||
// - A directory carrying a project marker (.git / package.json / .impeccable)
|
||||
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
|
||||
// system, so a sibling project never inherits a parent's or cwd's rules.
|
||||
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
|
||||
// the ancestor DESIGN.md only when that ancestor's workspace declarations
|
||||
// include the path (negations win; a nested package under a matched
|
||||
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
|
||||
// with no globs) still own apps/<name> and packages/<name>. A stray nested
|
||||
// package that matches no glob does not inherit. This is detect's
|
||||
// contamination contract, not skill-context's repoRoot fallback for
|
||||
// excluded paths. A nested separate repository (.git with no workspace
|
||||
// declaration) still inherits nothing (issue #570).
|
||||
// - Reaching the home directory / filesystem root with neither means no
|
||||
// design system at all — never process.cwd()'s.
|
||||
//
|
||||
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
|
||||
// runs out. This is the fix for cross-project contamination.
|
||||
export function findDesignRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
const homeDirs = homeDirForms();
|
||||
let boundary = null;
|
||||
while (true) {
|
||||
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
return { dir, hasDesign: false };
|
||||
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
|
||||
if (boundary) {
|
||||
// Past the boundary the walk only looks for the monorepo root that owns
|
||||
// the workspace path (workspace globs including negations, or marker-only
|
||||
// apps/packages fallback). Monorepo-root before .git, same order as
|
||||
// context.mjs: a workspace root carrying its own .git is still recognized,
|
||||
// while a .git that declares no workspaces is a separate repository and
|
||||
// stops the walk with nothing inherited. The home directory is never an
|
||||
// owning root, same as context.mjs's findMonorepoRoot, which stops at
|
||||
// homeDir before its monorepo check.
|
||||
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
|
||||
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
|
||||
return boundary;
|
||||
}
|
||||
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
|
||||
boundary = { dir, hasDesign: false };
|
||||
// A boundary that is itself a monorepo root, or a separate repository
|
||||
// with its own .git, inherits nothing from above.
|
||||
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
|
||||
}
|
||||
if (dir === homeDir) return null;
|
||||
if (homeDirs.has(dir)) return boundary;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
if (parent === dir) return boundary;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
|
||||
}
|
||||
|
||||
// Whether the per-edit pass for this harness should defer non-immediate
|
||||
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
|
||||
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
|
||||
// them would silently drop the non-immediate rules entirely.
|
||||
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
|
||||
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
|
||||
// deferring for them would silently drop the non-immediate rules entirely.
|
||||
export function perEditTieringActive(config, harness) {
|
||||
if (harness === 'cursor' || harness === 'github') return false;
|
||||
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
|
||||
@@ -1251,18 +1251,50 @@ 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 (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
// `tool_input`) also matches Grok, which is how live PostToolUse was
|
||||
// classified as Copilot and then skipped with no-file-path (#646).
|
||||
if (looksLikeGrokEnvelope(event)) return 'grok';
|
||||
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';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
function looksLikeGrokEnvelope(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
if (event.hook_event_name !== undefined
|
||||
|| event.tool_name !== undefined
|
||||
|| event.tool_input !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (event.toolArgs !== undefined) return false;
|
||||
if (typeof event.hookEventName === 'string') return true;
|
||||
return typeof event.toolName === 'string' && event.toolInput !== undefined;
|
||||
}
|
||||
|
||||
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
|
||||
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
|
||||
// normalize, so both casings must match here.
|
||||
export function isStopEvent(event) {
|
||||
if (!event || typeof event !== 'object') return false;
|
||||
const name = event.hook_event_name || event.hookEventName;
|
||||
return typeof name === 'string' && name.toLowerCase() === 'stop';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
|
||||
};
|
||||
}
|
||||
|
||||
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
|
||||
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
|
||||
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
|
||||
// stripping here). Only the fields the hook reads are copied; the event
|
||||
// name stays camelCase because routing already happened on the raw stdin
|
||||
// (isStopEvent) and nothing downstream reads `hook_event_name`.
|
||||
function normalizeGrokEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const rawInput = event.toolInput ?? event.tool_input;
|
||||
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? { ...rawInput }
|
||||
: {};
|
||||
const out = {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName || event.tool_name || null,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
|
||||
out.stop_hook_active = event.stopHookActive;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
// findings stop being remembered and a reintroduced one reads as fresh.
|
||||
// Only the immediate tier is remembered: a deferred finding the per-edit
|
||||
// pass never reported must still read as fresh to the Stop deep pass.
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
//
|
||||
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
|
||||
// Remembering here would dedupe those findings out of Stop. Touch the
|
||||
// file so Stop has it, and leave the finding list empty.
|
||||
if (harness === 'grok') {
|
||||
touchFile(cache, sessionId, filePath);
|
||||
} else {
|
||||
rememberFindings(cache, sessionId, filePath, immediate);
|
||||
}
|
||||
cacheDirty = true;
|
||||
|
||||
if (fresh.length > 0) {
|
||||
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Only Claude Code sends this field; other
|
||||
// harnesses omit it, so the strict `=== true` is a no-op for them. This
|
||||
// guard makes the loop impossible regardless of the finding cache key's
|
||||
// line-number sensitivity (out of scope here; see findingCacheKey).
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const harness = resolveHarness(env, event);
|
||||
audit.harness = harness;
|
||||
// Grok fires Stop twice: `end_turn` (the gate that can inject
|
||||
// additionalContext) then an observe-only `shutdown`. A second deep
|
||||
// pass would re-emit the same findings. Claude omits `reason`; only
|
||||
// skip when Grok named a reason that is not end_turn.
|
||||
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
|
||||
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// A Stop event carries no file, so the session cwd is the project.
|
||||
// Umbrella-dir launches keyed their per-edit cache to the edited file's
|
||||
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
|
||||
const freshGroups = [];
|
||||
let scanned = 0;
|
||||
let cacheDirty = false;
|
||||
for (const filePath of touched) {
|
||||
if (scanned >= STOP_MAX_FILES) break;
|
||||
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
|
||||
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
}
|
||||
|
||||
// A detector failure tells us nothing about the file. Leave whatever
|
||||
// was remembered alone rather than recording an empty scan as truth.
|
||||
if (detectorThrew) continue;
|
||||
|
||||
// Full rule set: no tier split here. Config/inline ignores still apply,
|
||||
// and the session dedupe drops everything the per-edit pass (or an
|
||||
// earlier Stop pass) already surfaced.
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
|
||||
// Sync to the live scan, including empty. Remembering only `fresh`
|
||||
// (or skipping the write on a clean Stop) left stale keys in place, so
|
||||
// a finding that was fixed and later reintroduced never fired again.
|
||||
rememberFindings(cache, sessionId, filePath, filtered);
|
||||
cacheDirty = true;
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
}
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0) {
|
||||
if (cacheDirty) persistCache(projectCwd, cache);
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
);
|
||||
commitFooterShown(cache, sessionId, text);
|
||||
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear; the notice flags ride along.
|
||||
// Persist the live finding set so the next Stop fire is silent unless
|
||||
// new issues appear; the notice flags ride along.
|
||||
persistCache(projectCwd, cache);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse + Stop entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
|
||||
* `hook_event_name`:
|
||||
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
|
||||
* and routes by Stop vs everything else. Claude uses `hook_event_name:
|
||||
* "Stop"`; Grok uses `hookEventName: "stop"`.
|
||||
*
|
||||
* - PostToolUse: runs the immediate-tier detector rules against the touched
|
||||
* file and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
* `hookSpecificOutput.additionalContext` when findings exist. Grok
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
@@ -19,7 +21,7 @@
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
|
||||
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
@@ -28,10 +30,9 @@ async function readStdin() {
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function isStopEvent(stdinJson) {
|
||||
function stdinIsStop(stdinJson) {
|
||||
try {
|
||||
const event = JSON.parse(stdinJson);
|
||||
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
|
||||
return isStopEvent(JSON.parse(stdinJson));
|
||||
} catch {
|
||||
// Malformed stdin falls through to runHook, which audits the skip.
|
||||
return false;
|
||||
@@ -48,7 +49,7 @@ async function main() {
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
|
||||
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
|
||||
const result = await run({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
|
||||
@@ -107,6 +107,7 @@ export const SUITES = {
|
||||
'tests/detect-antipatterns-fixtures.test.mjs',
|
||||
'tests/detect-antipatterns-browser.test.mjs',
|
||||
'tests/detect-cli-design-contamination.test.mjs',
|
||||
'tests/detect-cli-design-monorepo.test.mjs',
|
||||
'tests/detect-cli-stdin-dispatch.test.mjs',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy fetch's global undici dispatcher before process.exit(): a live
|
||||
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
|
||||
// successful boot (nodejs/node#56645, issue #573).
|
||||
async function destroyFetchDispatcher() {
|
||||
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
|
||||
if (dispatcher && typeof dispatcher.destroy === 'function') {
|
||||
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the boot payload before process.exit(): a live pipe that has not
|
||||
// flushed yet is truncated when Node tears down (issue #573 review). Then
|
||||
// close fetch so Windows teardown does not abort on the keep-alive socket.
|
||||
async function finishCli(output) {
|
||||
await new Promise((resolve) => {
|
||||
process.stdout.write(output, () => resolve());
|
||||
});
|
||||
await destroyFetchDispatcher();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Two instructions used to sit in one directive: ask, and "if they agree, run
|
||||
// it". Nothing gated the second on an answer, and the same sentence said to
|
||||
// continue without waiting, so a run that could never establish agreement was
|
||||
@@ -1159,8 +1180,7 @@ async function cli() {
|
||||
appendImageToolsDirective(parts);
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
|
||||
if (ctx.hasDesign) {
|
||||
@@ -1206,7 +1226,7 @@ async function cli() {
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
await finishCli(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
|
||||
+36
-14
@@ -1252,7 +1252,8 @@ export function resolveHarness(env = {}, event = null) {
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'grok') return 'grok';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
if (explicit === 'claude') return 'claude';
|
||||
if (explicit === 'codex') return 'codex';
|
||||
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
|
||||
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
|
||||
// Check Grok first: the old GitHub heuristic (`toolName` and no
|
||||
@@ -1265,6 +1266,11 @@ export function resolveHarness(env = {}, event = null) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
|
||||
// it here means an already-installed Codex hook emits the Codex Stop
|
||||
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
|
||||
// https://developers.openai.com/codex/hooks#stop
|
||||
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
@@ -2224,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
|
||||
* is delivered to the model and the conversation continues so it can act.
|
||||
* files. Output goes out on the harness's Stop continuation channel: Claude
|
||||
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
|
||||
* a decision: "block" whose reason becomes the continuation prompt. Either
|
||||
* way the findings reach the model and the conversation continues so it
|
||||
* can act.
|
||||
*/
|
||||
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
|
||||
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
|
||||
@@ -2256,17 +2265,21 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
audit.harness = harness;
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
|
||||
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
|
||||
// hook is being re-invoked only because a prior invocation kept the turn
|
||||
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
|
||||
// re-blocking now would loop until Claude Code's consecutive-block cap
|
||||
// force-ends the turn (issue #400). The prior fire already surfaced the
|
||||
// findings; whether to act on them is the agent's call. Exit fast with no
|
||||
// output before any scan. Claude sends `stop_hook_active`; Grok sends
|
||||
// `stopHookActive`, copied onto the snake_case field above. The strict
|
||||
// `=== true` is a no-op when the field is absent. This guard makes the
|
||||
// loop impossible regardless of the finding cache key's line-number
|
||||
// sensitivity (out of scope here; see findingCacheKey).
|
||||
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
|
||||
// being re-invoked only because a prior invocation kept the turn alive
|
||||
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
|
||||
// decision: "block" continuation). Re-scanning and re-blocking now could
|
||||
// loop (issue #400). The prior fire already surfaced the findings;
|
||||
// whether to act on them is the agent's call. Exit fast with no output
|
||||
// before any scan. Claude Code and Codex both send this field: Codex
|
||||
// mirrors the Claude contract (StopCommandInput in
|
||||
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
|
||||
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
|
||||
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
|
||||
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
|
||||
// for them. The guard makes the loop impossible regardless of the finding
|
||||
// cache key's line-number sensitivity (out of scope here; see
|
||||
// findingCacheKey).
|
||||
if (event.stop_hook_active === true) {
|
||||
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
|
||||
}
|
||||
@@ -2419,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
// Codex shares Claude Code's PostToolUse additional-context shape, but its
|
||||
// Stop schema rejects unknown fields. Findings that should continue the
|
||||
// turn must be a top-level blocking decision.
|
||||
// https://developers.openai.com/codex/hooks#stop (schema of record:
|
||||
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
|
||||
if (harness === 'codex' && eventName === 'Stop') {
|
||||
if (!String(text ?? '').trim()) return '';
|
||||
return JSON.stringify({ decision: 'block', reason: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* discards that stdout; the scan still warms the session cache for Stop.
|
||||
* - Stop: runs the FULL detector rule set over every UI file touched this
|
||||
* session (the deep pass), deduped against what the per-edit pass already
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
* surfaced, and emits once via the harness-specific continuation channel.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled; a clean Stop pass is silent.
|
||||
|
||||
@@ -1060,6 +1060,27 @@ describe('context.mjs CLI', () => {
|
||||
assert.match(res.stdout, /detect\.mjs --json <changed targets>/);
|
||||
});
|
||||
|
||||
it('drains stdout before exit when the parent pipe is paused', async () => {
|
||||
const MARKER = 'END_MARKER_573';
|
||||
write('PRODUCT.md', `# Acme\n\n${'x'.repeat(256 * 1024)}\n\n${MARKER}\n`);
|
||||
const child = spawn(process.execPath, [SCRIPT_PATH], {
|
||||
cwd: scratch,
|
||||
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stdout.pause();
|
||||
const resume = setTimeout(() => child.stdout.resume(), 100);
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', resolve);
|
||||
});
|
||||
clearTimeout(resume);
|
||||
assert.equal(status, 0);
|
||||
assert.match(stdout, /END_MARKER_573/);
|
||||
assert.match(stdout, /RESOLVED_CONTEXT:/);
|
||||
});
|
||||
|
||||
// The build-path preference rides the unified config beside hook and
|
||||
// detector settings. The local file wins because whether a machine can
|
||||
// generate images is a property of that machine, not of the committed
|
||||
@@ -1588,4 +1609,39 @@ describe('context.mjs update check', () => {
|
||||
assert.equal(typeof cache.lastCheck, 'number'); // stamped so we don't re-poll every boot
|
||||
assert.equal(cache.latestVersion, undefined); // nothing learned
|
||||
});
|
||||
|
||||
// Targeted live-fetch boot: the Windows abort in issue #573 fired after
|
||||
// stdout was already complete, so the contract is exit 0 with the full
|
||||
// context still on stdout.
|
||||
it('exits 0 after a targeted live-fetch boot writes full context', async () => {
|
||||
const { srv, host } = await startStub({ skills: '2.0.0' });
|
||||
try {
|
||||
const { skillScript, project, env } = setup({}, { host });
|
||||
fs.writeFileSync(
|
||||
path.join(project, 'package.json'),
|
||||
JSON.stringify({ private: true, workspaces: ['packages/*'] }),
|
||||
);
|
||||
const jervPi = path.join(project, 'packages', 'jerv-pi');
|
||||
fs.mkdirSync(jervPi, { recursive: true });
|
||||
fs.writeFileSync(path.join(jervPi, 'PRODUCT.md'), '# Jerv Pi product\n');
|
||||
|
||||
const result = await new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(process.execPath, [skillScript, '--target', 'packages/jerv-pi'], {
|
||||
cwd: project,
|
||||
env,
|
||||
});
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.on('error', rejectRun);
|
||||
child.on('close', (status) => resolveRun({ status, stdout }));
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /RESOLVED_CONTEXT:/);
|
||||
assert.match(result.stdout, /# Jerv Pi product/);
|
||||
assert.match(result.stdout, /UPDATE_AVAILABLE/);
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* Regression for issue #570: design-system rules must reach a monorepo workspace
|
||||
* by inheriting the repo root's DESIGN.md.
|
||||
*
|
||||
* Run with: node --test tests/detect-cli-design-monorepo.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { findDesignRoot } from '../cli/engine/design-system.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = path.resolve(__dirname, '../cli/bin/cli.js');
|
||||
|
||||
const PAGE_HTML =
|
||||
'<!doctype html><html><head><style>.card { font-family: Verdana, sans-serif; }</style></head>' +
|
||||
'<body><div class="card">Hi</div></body></html>';
|
||||
|
||||
const DESIGN_MD = `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "Palatino, Georgia, serif"
|
||||
---
|
||||
# Project A Design System
|
||||
`;
|
||||
|
||||
const tempRoots = [];
|
||||
|
||||
function runDetect(cwd, targets, env = {}) {
|
||||
const result = spawnSync(process.execPath, [CLI, 'detect', '--json', ...targets], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
let findings = [];
|
||||
try {
|
||||
findings = JSON.parse(result.stdout || '[]');
|
||||
} catch {
|
||||
throw new Error(`Non-JSON CLI output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function fontFindingsFor(findings, file) {
|
||||
return findings.filter(
|
||||
(f) => f.antipattern === 'design-system-font' && (!file || f.file === file),
|
||||
);
|
||||
}
|
||||
|
||||
function mkPnpmMonorepo({ workspaceDesign = null } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-pnpm-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
if (workspaceDesign) {
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/DESIGN.md'), workspaceDesign);
|
||||
}
|
||||
return { dir, page, webDir: path.join(dir, 'apps/web') };
|
||||
}
|
||||
|
||||
function mkTempRoot(prefix) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempRoots.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempRoots) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
describe('detect CLI monorepo DESIGN.md inheritance', () => {
|
||||
it('pnpm workspace root: workspace page inherits root DESIGN.md', () => {
|
||||
const { dir, page } = mkPnpmMonorepo();
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('npm/yarn workspaces root: workspace page inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-npm-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono","workspaces":["packages/*"]}');
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
const page = path.join(dir, 'packages/ui/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('turbo marker root: workspace page inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-turbo-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono"}');
|
||||
fs.writeFileSync(path.join(dir, 'turbo.json'), '{}');
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('lerna packages globs: workspace outside apps/packages inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-lerna-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'lerna.json'), '{"packages":["modules/*"]}');
|
||||
fs.mkdirSync(path.join(dir, 'modules/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'modules/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'modules/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'lerna packages globs must be read as workspace declarations',
|
||||
);
|
||||
});
|
||||
|
||||
it('impeccable projectRoots: workspace inherits root DESIGN.md', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-iroots-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.impeccable/config.json'), '{"projectRoots":["sites/*"]}');
|
||||
fs.mkdirSync(path.join(dir, 'sites/docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'sites/docs/package.json'), '{"name":"docs"}');
|
||||
const page = path.join(dir, 'sites/docs/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'impeccable projectRoots must be read as workspace declarations',
|
||||
);
|
||||
});
|
||||
|
||||
it('pnpm flow list with inline comment and non-standard dirs still detected', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-flow-'));
|
||||
tempRoots.push(dir);
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'packages: ["services/*"] # deploy targets\n');
|
||||
fs.mkdirSync(path.join(dir, 'services/api'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'services/api/package.json'), '{"name":"api"}');
|
||||
const page = path.join(dir, 'services/api/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'inline YAML comment must not defeat workspace-glob recognition',
|
||||
);
|
||||
});
|
||||
|
||||
it('directory target: scan apps/web dir inherits root DESIGN.md', () => {
|
||||
const { dir, page, webDir } = mkPnpmMonorepo();
|
||||
const findings = runDetect(dir, [webDir]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'Verdana must be flagged when scanning the workspace directory',
|
||||
);
|
||||
});
|
||||
|
||||
it('workspace-owned DESIGN.md wins over monorepo root', () => {
|
||||
const workspaceDesign = `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "Verdana, sans-serif"
|
||||
---
|
||||
# Workspace Design System
|
||||
`;
|
||||
const { dir, page } = mkPnpmMonorepo({ workspaceDesign });
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'workspace DESIGN.md allowing Verdana must suppress inherited root rules',
|
||||
);
|
||||
});
|
||||
|
||||
it('nested separate repo inherits nothing from monorepo root', () => {
|
||||
const { dir } = mkPnpmMonorepo();
|
||||
fs.mkdirSync(path.join(dir, 'vendor/other/.git'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'vendor/other/package.json'), '{"name":"other"}');
|
||||
const page = path.join(dir, 'vendor/other/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'nested repo with no workspaces must not inherit monorepo root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('home directory is never an owning monorepo root', () => {
|
||||
// context.mjs's findMonorepoRoot stops at homeDir before its monorepo
|
||||
// check; the engine walk must match, or a workspace-declaring $HOME
|
||||
// leaks its DESIGN.md into every git-less project beneath it.
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-home-'));
|
||||
tempRoots.push(home);
|
||||
fs.writeFileSync(path.join(home, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(home, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(home, 'project'), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, 'project/package.json'), '{"name":"p"}');
|
||||
const page = path.join(home, 'project/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(home, [page], { HOME: home, USERPROFILE: home });
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'a project under a workspace-declaring $HOME must not inherit its DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('symlinked $HOME still stops the walk', () => {
|
||||
// Some distros symlink home paths (/home -> /var/home), so $HOME never
|
||||
// string-matches the physical paths a cwd-resolved target produces. The
|
||||
// walk must compare against the realpath form too.
|
||||
const real = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-mono-realhome-'));
|
||||
tempRoots.push(real);
|
||||
const link = path.join(os.tmpdir(), `impeccable-detect-mono-linkhome-${path.basename(real).slice(-6)}`);
|
||||
fs.symlinkSync(real, link);
|
||||
tempRoots.push(link);
|
||||
fs.writeFileSync(path.join(real, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(real, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(real, 'project'), { recursive: true });
|
||||
fs.writeFileSync(path.join(real, 'project/package.json'), '{"name":"p"}');
|
||||
const page = path.join(real, 'project/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
// HOME is the symlink; the target is passed via its physical path, so a
|
||||
// logical-only comparison would walk straight past home and inherit.
|
||||
const findings = runDetect(real, [fs.realpathSync(page)], { HOME: link, USERPROFILE: link });
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length + fontFindingsFor(findings, fs.realpathSync(page)).length,
|
||||
0,
|
||||
'a symlinked $HOME must still stop the walk before inheriting',
|
||||
);
|
||||
});
|
||||
|
||||
it('CSS module at apps/web/app/page.module.css inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-cssmod-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(dir, 'apps/web/app'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const css = path.join(dir, 'apps/web/app/page.module.css');
|
||||
fs.writeFileSync(css, '.c{font-family:Verdana,sans-serif}');
|
||||
|
||||
const findings = runDetect(dir, [css]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, css).some((f) => f.ignoreValue?.toLowerCase() === 'verdana'),
|
||||
'Verdana in a CSS module must be flagged via inherited root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('yarn workspaces object form: workspace inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-yarnobj-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: { packages: ['packages/*'] },
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
const page = path.join(dir, 'packages/ui/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'yarn workspaces object form must inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('nx.json marker root: workspace inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nx-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"mono"}');
|
||||
fs.writeFileSync(path.join(dir, 'nx.json'), '{}');
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const page = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'nx.json marker root must inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('file at monorepo root still flags against root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-rootfile-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
const page = path.join(dir, 'page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'root-level file must be judged against root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('scan from different cwd still inherits via target path', () => {
|
||||
const { page } = mkPnpmMonorepo();
|
||||
const otherCwd = mkTempRoot('impeccable-detect-mono-othercwd-');
|
||||
|
||||
const findings = runDetect(otherCwd, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'resolution must follow the target path, not process.cwd()',
|
||||
);
|
||||
});
|
||||
|
||||
it('findDesignRoot(apps/web) returns monorepo root with hasDesign true', () => {
|
||||
const { dir, webDir } = mkPnpmMonorepo();
|
||||
const found = findDesignRoot(webDir);
|
||||
assert.equal(found.dir, dir);
|
||||
assert.equal(found.hasDesign, true);
|
||||
});
|
||||
|
||||
it('negated workspace package does not inherit root DESIGN.md (Greptile P1)', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-negated-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['packages/*', '!packages/excluded'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'packages/included'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/included/package.json'), '{"name":"included"}');
|
||||
const includedPage = path.join(dir, 'packages/included/page.html');
|
||||
fs.writeFileSync(includedPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'packages/excluded'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/excluded/package.json'), '{"name":"excluded"}');
|
||||
const excludedPage = path.join(dir, 'packages/excluded/page.html');
|
||||
fs.writeFileSync(excludedPage, PAGE_HTML);
|
||||
const excludedDir = path.join(dir, 'packages/excluded');
|
||||
|
||||
const findings = runDetect(dir, [includedPage, excludedPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, includedPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'included workspace package must inherit root DESIGN.md',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, excludedPage).length,
|
||||
0,
|
||||
'negated workspace package must not inherit root DESIGN.md',
|
||||
);
|
||||
const excludedRoot = findDesignRoot(excludedDir);
|
||||
assert.equal(excludedRoot.dir, excludedDir);
|
||||
assert.equal(excludedRoot.hasDesign, false);
|
||||
});
|
||||
|
||||
it('stray nested package outside globs does not inherit root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-stray-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n");
|
||||
fs.mkdirSync(path.join(dir, 'apps/web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'apps/web/package.json'), '{"name":"web"}');
|
||||
const webPage = path.join(dir, 'apps/web/page.html');
|
||||
fs.writeFileSync(webPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'vendor/tool'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'vendor/tool/package.json'), '{"name":"tool"}');
|
||||
const vendorPage = path.join(dir, 'vendor/tool/page.html');
|
||||
fs.writeFileSync(vendorPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [webPage, vendorPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, webPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'apps/web must inherit root DESIGN.md',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, vendorPage).length,
|
||||
0,
|
||||
'vendor/tool outside globs must not inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('non-monorepo nested package.json does not inherit root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nestedpkg-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"root"}');
|
||||
fs.mkdirSync(path.join(dir, '.git'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'packages/nested'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/nested/package.json'), '{"name":"nested"}');
|
||||
const nestedPage = path.join(dir, 'packages/nested/page.html');
|
||||
fs.writeFileSync(nestedPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [nestedPage]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, nestedPage).length,
|
||||
0,
|
||||
'nested package.json in a non-monorepo must not inherit root DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('non-monorepo without DESIGN.md: no design-system-font findings', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nodesign-');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"root"}');
|
||||
const page = path.join(dir, 'page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, page).length,
|
||||
0,
|
||||
'no DESIGN.md means no design-system-font findings',
|
||||
);
|
||||
});
|
||||
|
||||
it('single-package repo: src/page.html inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-single-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app"}');
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
const page = path.join(dir, 'src/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'src/ without its own package.json must inherit project DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('DESIGN.md in docs/ fallback still flags in single-package repo', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-docsfb-');
|
||||
fs.mkdirSync(path.join(dir, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'docs/DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"app"}');
|
||||
const page = path.join(dir, 'src/page.html');
|
||||
fs.mkdirSync(path.dirname(page), { recursive: true });
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'docs/DESIGN.md fallback must apply to nested files',
|
||||
);
|
||||
});
|
||||
|
||||
it('pnpm !**/test/** does not smash sibling workspaces', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-globstar-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-workspace.yaml'), [
|
||||
'packages:',
|
||||
" - 'packages/*'",
|
||||
" - 'components/**'",
|
||||
" - '!**/test/**'",
|
||||
'',
|
||||
].join('\n'));
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
const uiPage = path.join(dir, 'packages/ui/page.html');
|
||||
fs.writeFileSync(uiPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'components/button'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'components/button/package.json'), '{"name":"button"}');
|
||||
const buttonPage = path.join(dir, 'components/button/page.html');
|
||||
fs.writeFileSync(buttonPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui/test/fixture'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/test/fixture/package.json'), '{"name":"fixture"}');
|
||||
const testPage = path.join(dir, 'packages/ui/test/fixture/page.html');
|
||||
fs.writeFileSync(testPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [uiPage, buttonPage, testPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, uiPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'packages/ui must still inherit when a globstar test exclusion is present',
|
||||
);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, buttonPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'components/** must still inherit when a globstar test exclusion is present',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, testPage).length,
|
||||
0,
|
||||
'packages/ui/test/fixture must not inherit under !**/test/**',
|
||||
);
|
||||
});
|
||||
|
||||
it('workspaces ["*"] owns only direct children, not vendor/tool', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-star-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['*'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'web'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'web/package.json'), '{"name":"web"}');
|
||||
const webPage = path.join(dir, 'web/page.html');
|
||||
fs.writeFileSync(webPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'web/examples'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'web/examples/package.json'), '{"name":"examples"}');
|
||||
const nestedPage = path.join(dir, 'web/examples/page.html');
|
||||
fs.writeFileSync(nestedPage, PAGE_HTML);
|
||||
fs.mkdirSync(path.join(dir, 'vendor/tool'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'vendor/tool/package.json'), '{"name":"tool"}');
|
||||
const vendorPage = path.join(dir, 'vendor/tool/page.html');
|
||||
fs.writeFileSync(vendorPage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [webPage, nestedPage, vendorPage]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, webPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'direct-child workspace under * must inherit root DESIGN.md',
|
||||
);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, nestedPage).some((f) => f.ignoreValue === 'verdana'),
|
||||
'nested package under a * workspace child must still inherit',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, vendorPage).length,
|
||||
0,
|
||||
'vendor/tool is not a direct child of * and must not inherit',
|
||||
);
|
||||
});
|
||||
|
||||
it('nested package under an included workspace inherits root DESIGN.md', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-nestedws-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['packages/*'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/package.json'), '{"name":"ui"}');
|
||||
fs.mkdirSync(path.join(dir, 'packages/ui/examples'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'packages/ui/examples/package.json'), '{"name":"examples"}');
|
||||
const page = path.join(dir, 'packages/ui/examples/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'packages/ui/examples must inherit as nested content of packages/*',
|
||||
);
|
||||
const found = findDesignRoot(path.join(dir, 'packages/ui/examples'));
|
||||
assert.equal(found.dir, dir);
|
||||
assert.equal(found.hasDesign, true);
|
||||
});
|
||||
|
||||
it('impeccable projectRoots beat a package-manager negation of the same path', () => {
|
||||
const dir = mkTempRoot('impeccable-detect-mono-iroots-win-');
|
||||
fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
fs.mkdirSync(path.join(dir, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.impeccable/config.json'), '{"projectRoots":["sites/*"]}');
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'mono',
|
||||
workspaces: ['sites/*', '!sites/docs'],
|
||||
}));
|
||||
fs.mkdirSync(path.join(dir, 'sites/docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'sites/docs/package.json'), '{"name":"docs"}');
|
||||
const page = path.join(dir, 'sites/docs/page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(dir, [page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'projectRoots must govern a path they match even when workspaces exclude it',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { launchBrowser } from '../cli/engine/engines/browser/detect-url.mjs';
|
||||
import http from 'node:http';
|
||||
import { launchBrowser, detectUrl, splitScanUrl } from '../cli/engine/engines/browser/detect-url.mjs';
|
||||
|
||||
// launchBrowser prefers the system-installed Chrome on Windows to dodge the
|
||||
// bundled-Chrome GPU crash-loop (issue #372), and keeps the pinned bundled
|
||||
@@ -79,3 +80,215 @@ describe('launchBrowser', () => {
|
||||
expect(p.calls.every(c => c.channel === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitScanUrl', () => {
|
||||
test('strips http(s) userinfo and returns credentials', () => {
|
||||
expect(splitScanUrl('https://user:pass@example.com')).toEqual({
|
||||
href: 'https://example.com/',
|
||||
credentials: { username: 'user', password: 'pass' },
|
||||
});
|
||||
expect(splitScanUrl('https://user:p%40ss@example.com/path?q=1')).toEqual({
|
||||
href: 'https://example.com/path?q=1',
|
||||
credentials: { username: 'user', password: 'p@ss' },
|
||||
});
|
||||
expect(splitScanUrl('https://user@example.com')).toEqual({
|
||||
href: 'https://example.com/',
|
||||
credentials: { username: 'user', password: '' },
|
||||
});
|
||||
expect(splitScanUrl('http://:secret@host.com/')).toEqual({
|
||||
href: 'http://host.com/',
|
||||
credentials: { username: '', password: 'secret' },
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves original string when no userinfo', () => {
|
||||
expect(splitScanUrl('https://example.com')).toEqual({
|
||||
href: 'https://example.com',
|
||||
credentials: null,
|
||||
});
|
||||
expect(splitScanUrl('https://example.com/path?email=a@b.com')).toEqual({
|
||||
href: 'https://example.com/path?email=a@b.com',
|
||||
credentials: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles IPv6 and non-http(s) URLs', () => {
|
||||
expect(splitScanUrl('https://user:pass@[::1]:8080/x')).toEqual({
|
||||
href: 'https://[::1]:8080/x',
|
||||
credentials: { username: 'user', password: 'pass' },
|
||||
});
|
||||
expect(splitScanUrl('file:///tmp/a.html')).toEqual({
|
||||
href: 'file:///tmp/a.html',
|
||||
credentials: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns original string for invalid URLs', () => {
|
||||
expect(splitScanUrl('not a url')).toEqual({
|
||||
href: 'not a url',
|
||||
credentials: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function makeFakeBrowser() {
|
||||
const calls = { intercept: false, requestHandler: null, authenticate: [], goto: [] };
|
||||
const page = {
|
||||
on(event, handler) {
|
||||
if (event === 'request') calls.requestHandler = handler;
|
||||
},
|
||||
async setViewport() {},
|
||||
async setRequestInterception() { calls.intercept = true; },
|
||||
async authenticate(creds) { calls.authenticate.push(creds); },
|
||||
async goto(url, opts) { calls.goto.push({ url, opts }); },
|
||||
async evaluate(fn) {
|
||||
if (typeof fn === 'function' && fn.toString().includes('impeccableDetect')) {
|
||||
return [{ findings: [{ type: 'low-contrast', detail: 'x', ignoreValue: '', severity: '' }] }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
browser: {
|
||||
async newPage() { return page; },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeRequest(url, calls) {
|
||||
return {
|
||||
url: () => url,
|
||||
headers: () => ({ accept: 'text/html' }),
|
||||
continue(overrides) {
|
||||
calls.continues.push({ url, overrides });
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
resolve(`http://127.0.0.1:${server.address().port}/`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('detectUrl credential redaction', () => {
|
||||
test('scopes Authorization to the scan origin and redacts findings', async () => {
|
||||
const { calls, browser } = makeFakeBrowser();
|
||||
calls.continues = [];
|
||||
const findings = await detectUrl('https://user:p%40ss@example.com/path', {
|
||||
browser,
|
||||
visualContrast: false,
|
||||
contentHidden: false,
|
||||
});
|
||||
|
||||
expect(calls.authenticate).toEqual([]);
|
||||
expect(calls.intercept).toBe(true);
|
||||
expect(typeof calls.requestHandler).toBe('function');
|
||||
expect(calls.goto).toHaveLength(1);
|
||||
expect(calls.goto[0].url).toBe('https://example.com/path');
|
||||
|
||||
const expected = `Basic ${Buffer.from('user:p@ss').toString('base64')}`;
|
||||
await calls.requestHandler(fakeRequest('https://example.com/path', calls));
|
||||
await calls.requestHandler(fakeRequest('https://evil.example/steal', calls));
|
||||
expect(calls.continues[0].overrides.headers.authorization).toBe(expected);
|
||||
expect(calls.continues[1].overrides).toBeUndefined();
|
||||
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
for (const f of findings) {
|
||||
expect(f.file).toBe('https://example.com/path');
|
||||
}
|
||||
});
|
||||
|
||||
test('does not intercept when URL has no userinfo', async () => {
|
||||
const { calls, browser } = makeFakeBrowser();
|
||||
const url = 'https://example.com/path';
|
||||
const findings = await detectUrl(url, {
|
||||
browser,
|
||||
visualContrast: false,
|
||||
contentHidden: false,
|
||||
});
|
||||
|
||||
expect(calls.authenticate).toEqual([]);
|
||||
expect(calls.intercept).toBe(false);
|
||||
expect(calls.requestHandler).toBe(null);
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
for (const f of findings) {
|
||||
expect(f.file).toBe(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectUrl origin-scoped basic auth', () => {
|
||||
test('does not send URL credentials to a cross-origin redirect that challenges', async () => {
|
||||
const user = 'qa-scanner';
|
||||
const pass = 'Hunter2-657-SHOULD-NOT-LEAK';
|
||||
const expected = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
||||
const seenOnB = [];
|
||||
|
||||
const serverB = http.createServer((req, res) => {
|
||||
seenOnB.push(req.headers.authorization || '');
|
||||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="b"' });
|
||||
res.end('b');
|
||||
});
|
||||
const urlB = await listen(serverB);
|
||||
const serverA = http.createServer((req, res) => {
|
||||
res.writeHead(302, { Location: urlB });
|
||||
res.end();
|
||||
});
|
||||
const urlA = await listen(serverA);
|
||||
|
||||
try {
|
||||
try {
|
||||
await detectUrl(urlA.replace('http://', `http://${user}:${pass}@`), {
|
||||
visualContrast: false,
|
||||
contentHidden: false,
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
} catch {
|
||||
// B's 401 may fail navigation once credentials are withheld.
|
||||
}
|
||||
expect(seenOnB.includes(expected)).toBe(false);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
new Promise((resolve) => serverA.close(resolve)),
|
||||
new Promise((resolve) => serverB.close(resolve)),
|
||||
]);
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
|
||||
test('still authenticates the original scan origin', async () => {
|
||||
const user = 'qa-scanner';
|
||||
const pass = 'Hunter2-657-SHOULD-NOT-LEAK';
|
||||
const expected = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
||||
const seen = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
seen.push(req.headers.authorization || '');
|
||||
if (req.headers.authorization !== expected) {
|
||||
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="a"' });
|
||||
res.end('no');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end('<!doctype html><html><body><h1>ok</h1></body></html>');
|
||||
});
|
||||
const origin = await listen(server);
|
||||
|
||||
try {
|
||||
await detectUrl(origin.replace('http://', `http://${user}:${pass}@`), {
|
||||
visualContrast: false,
|
||||
contentHidden: false,
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
expect(seen.includes(expected)).toBe(true);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
});
|
||||
|
||||
+77
-1
@@ -1366,12 +1366,33 @@ describe('writeAuditLog()', () => {
|
||||
});
|
||||
|
||||
describe('payload()', () => {
|
||||
it('produces hookSpecificOutput for Claude/Codex', () => {
|
||||
it('produces hookSpecificOutput for Claude', () => {
|
||||
const obj = JSON.parse(payload('hello'));
|
||||
assert.equal(obj.hookSpecificOutput.hookEventName, 'PostToolUse');
|
||||
assert.equal(obj.hookSpecificOutput.additionalContext, 'hello');
|
||||
});
|
||||
|
||||
it('keeps Codex PostToolUse on the Claude-compatible context channel', () => {
|
||||
const obj = JSON.parse(payload('hello', 'PostToolUse', 'codex'));
|
||||
assert.equal(obj.hookSpecificOutput.hookEventName, 'PostToolUse');
|
||||
assert.equal(obj.hookSpecificOutput.additionalContext, 'hello');
|
||||
});
|
||||
|
||||
it('produces a blocking decision for Codex Stop', () => {
|
||||
const obj = JSON.parse(payload('hello', 'Stop', 'codex'));
|
||||
assert.deepEqual(obj, { decision: 'block', reason: 'hello' });
|
||||
});
|
||||
|
||||
it('emits nothing for a Codex Stop with no findings text', () => {
|
||||
assert.equal(payload('', 'Stop', 'codex'), '');
|
||||
});
|
||||
|
||||
it('keeps Claude Stop on the additional-context channel', () => {
|
||||
const obj = JSON.parse(payload('hello', 'Stop', 'claude'));
|
||||
assert.equal(obj.hookSpecificOutput.hookEventName, 'Stop');
|
||||
assert.equal(obj.hookSpecificOutput.additionalContext, 'hello');
|
||||
});
|
||||
|
||||
it('produces additional_context for Cursor', () => {
|
||||
const obj = JSON.parse(payload('hello', 'PostToolUse', 'cursor'));
|
||||
assert.equal(obj.additional_context, 'hello');
|
||||
@@ -2739,10 +2760,20 @@ describe('resolveTargetFiles()', () => {
|
||||
describe('resolveHarness() / normalizeHookEvent()', () => {
|
||||
it('routes explicit env and Cursor conversation_id to cursor harness', () => {
|
||||
assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'cursor' }), 'cursor');
|
||||
assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'codex' }), 'codex');
|
||||
assert.equal(resolveHarness({}, { conversation_id: 'c1' }), 'cursor');
|
||||
assert.equal(resolveHarness({}, { turn_id: 'turn-1' }), 'codex');
|
||||
assert.equal(resolveHarness({}), 'claude');
|
||||
});
|
||||
|
||||
it('prefers explicit harness and Cursor detection over the Codex turn_id', () => {
|
||||
assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'claude' }, { turn_id: 'turn-1' }), 'claude');
|
||||
assert.equal(resolveHarness({ IMPECCABLE_HOOK_HARNESS: 'grok' }, { turn_id: 'turn-1' }), 'grok');
|
||||
assert.equal(resolveHarness({}, { conversation_id: 'c1', turn_id: 'turn-1' }), 'cursor');
|
||||
assert.equal(resolveHarness({}, { turn_id: '' }), 'claude');
|
||||
assert.equal(resolveHarness({}, { turn_id: 42 }), 'claude');
|
||||
});
|
||||
|
||||
it('maps Cursor postToolUse Write path into file_path + cwd', () => {
|
||||
const normalized = normalizeHookEvent({
|
||||
conversation_id: 'c1',
|
||||
@@ -3886,6 +3917,51 @@ describe('runStopHook()', () => {
|
||||
assert.equal(stop.emission.kind, 'stop-deep-pass');
|
||||
});
|
||||
|
||||
it('emits Codex Stop findings as a blocking decision', async () => {
|
||||
const sid = 'stop-codex';
|
||||
write('package.json', '{}');
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('marketing-buzzword', 3)]);
|
||||
const editEventCodex = { ...editEvent(file, sid), turn_id: 'turn-1' };
|
||||
const stopEventCodex = { ...stopEvent(sid), turn_id: 'turn-1' };
|
||||
|
||||
const edit = await runHook({ stdinJson: JSON.stringify(editEventCodex), env: {}, cwd, detector: det });
|
||||
assert.equal(edit.audit.harness, 'codex');
|
||||
assert.equal(edit.audit.deferred, 1);
|
||||
const editOut = JSON.parse(edit.stdout);
|
||||
assert.ok(editOut.hookSpecificOutput, 'Codex per-edit output stays on the PostToolUse context channel');
|
||||
assert.equal(editOut.decision, undefined);
|
||||
|
||||
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEventCodex), env: {}, cwd, detector: det });
|
||||
assert.equal(stop.exitCode, 0);
|
||||
assert.equal(stop.audit.harness, 'codex');
|
||||
assert.equal(stop.audit.emitted, true, JSON.stringify(stop.audit));
|
||||
const out = JSON.parse(stop.stdout);
|
||||
assert.equal(out.decision, 'block');
|
||||
assert.match(out.reason, /marketing-buzzword/);
|
||||
assert.ok(out.reason.trim().length > 0, 'Codex ignores a block whose reason trims empty');
|
||||
assert.equal(out.hookSpecificOutput, undefined);
|
||||
});
|
||||
|
||||
it('skips the Codex Stop re-fire after a block instead of blocking again', async () => {
|
||||
const sid = 'stop-codex-refire';
|
||||
write('package.json', '{}');
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('marketing-buzzword', 3)]);
|
||||
|
||||
await runHook({
|
||||
stdinJson: JSON.stringify({ ...editEvent(file, sid), turn_id: 'turn-1' }),
|
||||
env: {},
|
||||
cwd,
|
||||
detector: det,
|
||||
});
|
||||
const refire = { ...stopEvent(sid), turn_id: 'turn-1', stop_hook_active: true };
|
||||
const stop = await runStopHook({ stdinJson: JSON.stringify(refire), env: {}, cwd, detector: det });
|
||||
assert.equal(stop.exitCode, 0);
|
||||
assert.equal(stop.stdout, '');
|
||||
assert.equal(stop.audit.skipped, 'stop-hook-active');
|
||||
});
|
||||
|
||||
it('keeps a policy footer when the grouped Stop render is clamped to the minimum budget', async () => {
|
||||
const sid = 'stop-clamp';
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
|
||||
@@ -108,6 +108,12 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
|
||||
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] },
|
||||
}, null, 2));
|
||||
}
|
||||
if (providers.includes('.grok')) {
|
||||
mkdirSync(join(bundleRoot, '.grok', 'hooks'), { recursive: true });
|
||||
writeFileSync(join(bundleRoot, '.grok', 'hooks', 'impeccable.json'), JSON.stringify({
|
||||
hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [{ type: 'command', command: 'node ".grok/skills/impeccable/scripts/hook.mjs"' }] }] },
|
||||
}, null, 2));
|
||||
}
|
||||
// Native subagent definitions, mirroring the build's provider agents output.
|
||||
if (providers.includes('.github')) {
|
||||
mkdirSync(join(bundleRoot, '.github', 'agents'), { recursive: true });
|
||||
@@ -1125,21 +1131,24 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-hooks-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-hooks-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor']);
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor', '.grok']);
|
||||
|
||||
const output = run('skills install -y --providers=claude,codex,cursor --scope=global', {
|
||||
const output = run('skills install -y --providers=claude,codex,cursor,grok --scope=global', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
|
||||
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor (global)');
|
||||
for (const provider of ['.claude', '.agents', '.cursor']) {
|
||||
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor, .grok (global)');
|
||||
for (const provider of ['.claude', '.agents', '.cursor', '.grok']) {
|
||||
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
|
||||
}
|
||||
expect(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8')).toContain(join(home, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
|
||||
expect(readFileSync(join(tmp, '.codex', 'hooks.json'), 'utf8')).toContain(join(home, '.agents', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
|
||||
expect(readFileSync(join(tmp, '.cursor', 'hooks.json'), 'utf8')).toContain(join(home, '.cursor', 'skills', 'impeccable', 'scripts', 'hook-before-edit.mjs'));
|
||||
const grokHooks = readFileSync(join(tmp, '.grok', 'hooks', 'impeccable.json'), 'utf8');
|
||||
expect(grokHooks).toContain(join(home, '.grok', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
|
||||
expect(grokHooks).not.toContain('".grok/skills/impeccable/scripts/hook.mjs"');
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user