mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daae1d4117 | ||
|
|
2b88aa5231 | ||
|
|
fcd7622cd2 | ||
|
|
356b761391 | ||
|
|
1159100c96 | ||
|
|
0e9b6f9884 | ||
|
|
47e411952b | ||
|
|
6bea544a0a | ||
|
|
5d7c1cce34 | ||
|
|
2ef8e43d1e | ||
|
|
043e8a5bfd | ||
|
|
78b50aa416 | ||
|
|
c3a30086bc | ||
|
|
21510c3632 | ||
|
|
5d00e30405 | ||
|
|
f01a808890 | ||
|
|
2064b0696f | ||
|
|
c3ea1c79f5 | ||
|
|
f849d610f3 | ||
|
|
bfe634e254 | ||
|
|
3c442af7ad | ||
|
|
c0b1ec6fef | ||
|
|
35ae07339b | ||
|
|
c9e7cd8a64 | ||
|
|
c39b6425fa | ||
|
|
2c39c39f16 | ||
|
|
c87e460f5e | ||
|
|
b40982a967 | ||
|
|
82cb738f46 | ||
|
|
869c887372 | ||
|
|
bda7411acd | ||
|
|
d008dd98c3 | ||
|
|
93dce3d62e | ||
|
|
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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
|
||||
});
|
||||
} catch (err) {
|
||||
event._completionAck = { ok: false, error: err.message };
|
||||
return event;
|
||||
}
|
||||
if (!event._completionAck) {
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
}
|
||||
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
|
||||
// Situational plumbing rides with the event itself: `_instructions` is the
|
||||
// authoritative next step, with real ids and paths substituted, so the
|
||||
// reference doc can stay lean and can never drift from script behavior.
|
||||
if (event && typeof event === 'object' && !event._instructions) {
|
||||
// A wire-supplied value must never win over the locally generated one.
|
||||
if (event && typeof event === 'object') {
|
||||
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
|
||||
if (instructions) event._instructions = instructions;
|
||||
else delete event._instructions;
|
||||
}
|
||||
console.log(JSON.stringify(event));
|
||||
}
|
||||
|
||||
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
|
||||
// cap at 10 MB to guard against runaway writes from a misbehaving client.
|
||||
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
|
||||
|
||||
function stripPollerOwnedEventFields(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
|
||||
}
|
||||
|
||||
function enqueueEvent(event) {
|
||||
if (!event) return;
|
||||
stripPollerOwnedEventFields(event);
|
||||
// Dedupe by (session, type), except mount failures, which are per-variant:
|
||||
// variant 2 failing must not be swallowed because variant 1's failure is
|
||||
// still queued.
|
||||
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
res.end(JSON.stringify({ error }));
|
||||
return;
|
||||
}
|
||||
stripPollerOwnedEventFields(msg);
|
||||
if (msg.type === 'agent_phase') {
|
||||
recordAgentPhase(msg.id, msg.phase, {
|
||||
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
|
||||
|
||||
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
|
||||
'svelte.config.js',
|
||||
'svelte.config.mjs',
|
||||
'svelte.config.cjs',
|
||||
'svelte.config.ts',
|
||||
]));
|
||||
const hasKitPackage = hasAnyDependency(cwd, [
|
||||
'@sveltejs/kit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'svelte',
|
||||
]);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
return firstExistingFile(cwd, [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
]) || 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
|
||||
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
|
||||
|
||||
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
|
||||
@@ -42,8 +44,8 @@ const START_PACKAGES = [
|
||||
];
|
||||
|
||||
export function detectTanStackStartProject(cwd = process.cwd()) {
|
||||
if (!packageHasTanStackStart(cwd)) return null;
|
||||
const rootRoute = findRootRouteFile(cwd);
|
||||
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
|
||||
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
|
||||
if (!rootRoute) return null;
|
||||
|
||||
const ext = path.extname(rootRoute);
|
||||
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
|
||||
return String(content || '').includes('impeccable-live-tanstack');
|
||||
}
|
||||
|
||||
function findRootRouteFile(cwd) {
|
||||
for (const rel of ROOT_ROUTE_CANDIDATES) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageHasTanStackStart(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return START_PACKAGES.some((name) => Boolean(deps[name]));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function relativeImportSpecifier(fromFile, toFile) {
|
||||
const rel = path.posix.relative(
|
||||
path.posix.dirname(fromFile.split(path.sep).join('/')),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user