claudecode-omc 5.9.1 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.local/settings/settings.json +8 -0
  2. package/.omc-curation/governance.json +3 -0
  3. package/.omc-curation/sources.lock.json +5 -0
  4. package/README.md +10 -1
  5. package/bundled/manifest.json +2 -1
  6. package/bundled/upstream/impeccable/.omc-source/bundle.json +20 -0
  7. package/bundled/upstream/impeccable/.omc-source/provenance.json +105 -0
  8. package/bundled/upstream/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  9. package/bundled/upstream/impeccable/skills/impeccable/SKILL.md +168 -0
  10. package/bundled/upstream/impeccable/skills/impeccable/reference/adapt.md +311 -0
  11. package/bundled/upstream/impeccable/skills/impeccable/reference/animate.md +201 -0
  12. package/bundled/upstream/impeccable/skills/impeccable/reference/audit.md +133 -0
  13. package/bundled/upstream/impeccable/skills/impeccable/reference/bolder.md +113 -0
  14. package/bundled/upstream/impeccable/skills/impeccable/reference/brand.md +108 -0
  15. package/bundled/upstream/impeccable/skills/impeccable/reference/clarify.md +288 -0
  16. package/bundled/upstream/impeccable/skills/impeccable/reference/codex.md +105 -0
  17. package/bundled/upstream/impeccable/skills/impeccable/reference/colorize.md +257 -0
  18. package/bundled/upstream/impeccable/skills/impeccable/reference/craft.md +123 -0
  19. package/bundled/upstream/impeccable/skills/impeccable/reference/critique.md +767 -0
  20. package/bundled/upstream/impeccable/skills/impeccable/reference/delight.md +302 -0
  21. package/bundled/upstream/impeccable/skills/impeccable/reference/distill.md +111 -0
  22. package/bundled/upstream/impeccable/skills/impeccable/reference/document.md +429 -0
  23. package/bundled/upstream/impeccable/skills/impeccable/reference/extract.md +69 -0
  24. package/bundled/upstream/impeccable/skills/impeccable/reference/harden.md +347 -0
  25. package/bundled/upstream/impeccable/skills/impeccable/reference/hooks.md +88 -0
  26. package/bundled/upstream/impeccable/skills/impeccable/reference/init.md +172 -0
  27. package/bundled/upstream/impeccable/skills/impeccable/reference/interaction-design.md +189 -0
  28. package/bundled/upstream/impeccable/skills/impeccable/reference/layout.md +161 -0
  29. package/bundled/upstream/impeccable/skills/impeccable/reference/live.md +718 -0
  30. package/bundled/upstream/impeccable/skills/impeccable/reference/onboard.md +234 -0
  31. package/bundled/upstream/impeccable/skills/impeccable/reference/optimize.md +258 -0
  32. package/bundled/upstream/impeccable/skills/impeccable/reference/overdrive.md +130 -0
  33. package/bundled/upstream/impeccable/skills/impeccable/reference/polish.md +241 -0
  34. package/bundled/upstream/impeccable/skills/impeccable/reference/product.md +60 -0
  35. package/bundled/upstream/impeccable/skills/impeccable/reference/quieter.md +99 -0
  36. package/bundled/upstream/impeccable/skills/impeccable/reference/shape.md +165 -0
  37. package/bundled/upstream/impeccable/skills/impeccable/reference/typeset.md +279 -0
  38. package/bundled/upstream/impeccable/skills/impeccable/scripts/command-metadata.json +94 -0
  39. package/bundled/upstream/impeccable/skills/impeccable/scripts/context-signals.mjs +225 -0
  40. package/bundled/upstream/impeccable/skills/impeccable/scripts/context.mjs +280 -0
  41. package/bundled/upstream/impeccable/skills/impeccable/scripts/critique-storage.mjs +242 -0
  42. package/bundled/upstream/impeccable/skills/impeccable/scripts/detect-csp.mjs +198 -0
  43. package/bundled/upstream/impeccable/skills/impeccable/scripts/detect.mjs +21 -0
  44. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/browser/injected/index.mjs +1735 -0
  45. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  46. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4907 -0
  47. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  48. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  49. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +552 -0
  50. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +1013 -0
  51. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  52. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  53. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/findings.mjs +12 -0
  54. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  55. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  56. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  57. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/rules/checks.mjs +2671 -0
  58. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  59. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  60. package/bundled/upstream/impeccable/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  61. package/bundled/upstream/impeccable/skills/impeccable/scripts/hook-admin.mjs +574 -0
  62. package/bundled/upstream/impeccable/skills/impeccable/scripts/hook-before-edit.mjs +473 -0
  63. package/bundled/upstream/impeccable/skills/impeccable/scripts/hook-lib.mjs +1286 -0
  64. package/bundled/upstream/impeccable/skills/impeccable/scripts/hook.mjs +61 -0
  65. package/bundled/upstream/impeccable/skills/impeccable/scripts/lib/design-parser.mjs +835 -0
  66. package/bundled/upstream/impeccable/skills/impeccable/scripts/lib/impeccable-paths.mjs +126 -0
  67. package/bundled/upstream/impeccable/skills/impeccable/scripts/lib/is-generated.mjs +69 -0
  68. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/browser-script-parts.mjs +49 -0
  69. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/completion.mjs +19 -0
  70. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/event-validation.mjs +137 -0
  71. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/insert-ui.mjs +458 -0
  72. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/manual-apply.mjs +939 -0
  73. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/manual-edit-routes.mjs +357 -0
  74. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/manual-edits-buffer.mjs +152 -0
  75. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/session-store.mjs +289 -0
  76. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/svelte-component.mjs +826 -0
  77. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/sveltekit-adapter.mjs +274 -0
  78. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/ui-core.mjs +180 -0
  79. package/bundled/upstream/impeccable/skills/impeccable/scripts/live/vocabulary.mjs +36 -0
  80. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-accept.mjs +812 -0
  81. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-browser-dom.js +146 -0
  82. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-browser-session.js +123 -0
  83. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-browser.js +11086 -0
  84. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  85. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-complete.mjs +75 -0
  86. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  87. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  88. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-inject.mjs +583 -0
  89. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-insert.mjs +272 -0
  90. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  91. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-poll.mjs +379 -0
  92. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-resume.mjs +94 -0
  93. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-server.mjs +1134 -0
  94. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-status.mjs +61 -0
  95. package/bundled/upstream/impeccable/skills/impeccable/scripts/live-wrap.mjs +894 -0
  96. package/bundled/upstream/impeccable/skills/impeccable/scripts/live.mjs +246 -0
  97. package/bundled/upstream/impeccable/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
  98. package/bundled/upstream/impeccable/skills/impeccable/scripts/palette.mjs +633 -0
  99. package/bundled/upstream/impeccable/skills/impeccable/scripts/pin.mjs +214 -0
  100. package/package.json +1 -1
  101. package/src/cli/source.js +6 -0
  102. package/src/config/sources.js +15 -0
  103. package/src/merge/content-patch.js +4 -0
@@ -0,0 +1,1286 @@
1
+ /**
2
+ * Shared library for the Impeccable design hook.
3
+ *
4
+ * Pure-ish helpers split out from `hook.mjs` so unit tests can exercise
5
+ * config parsing, finding filtering, dedup, render, and cache logic without
6
+ * spawning a subprocess. `hook.mjs` itself is the thin stdin/stdout shim.
7
+ *
8
+ * Public surface (everything exported is part of the contract):
9
+ * ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
10
+ * truthy(value)
11
+ * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
12
+ * normalizeIgnoreValue(value)
13
+ * readCache(cwd) / persistCache(cwd, cache)
14
+ * bumpEditCount(cache, sessionId, filePath) -> number
15
+ * suppressionNotice(filePath)
16
+ * filterFindings(findings, content, ext, config)
17
+ * dedupeAgainstCache(findings, cache, sessionId, filePath)
18
+ * renderTemplate(findings, filePath, config, opts)
19
+ * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
20
+ * shouldEmitAckForFile(filePath)
21
+ * writeAuditLog(env, entry)
22
+ * loadDetector() -> Promise<{ detectText, detectHtml }>
23
+ * matchesAnyGlob(filePath, globs)
24
+ * normalizeScanTargets(primaryTargets, projectCwd)
25
+ * runHook(deps) -> { exitCode, stdout, audit, reason? }
26
+ *
27
+ * Design notes:
28
+ * - All errors are swallowed at the runHook seam. The detector throwing must
29
+ * never break a turn. See PRD §5 "Failure modes".
30
+ * - Cache shape is JSON-friendly; we gc the oldest sessions when there are
31
+ * more than 8 to keep file size predictable across long-lived projects.
32
+ * - The detector loader looks for `detector/detect-antipatterns.mjs` next to
33
+ * this file first (built skill layout) and falls back to the repo root's
34
+ * `cli/engine/detect-antipatterns.mjs` (running from source).
35
+ */
36
+
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import { pathToFileURL, fileURLToPath } from 'node:url';
40
+
41
+ const __filename = fileURLToPath(import.meta.url);
42
+ const __dirname = path.dirname(__filename);
43
+
44
+ export const ENVELOPE_PREFIX = '[impeccable@1]';
45
+
46
+ export const ALLOWED_EXTS = new Set([
47
+ '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
48
+ '.css', '.scss', '.sass', '.less', '.ts', '.js',
49
+ ]);
50
+
51
+ export const ACK_EXTS = new Set([
52
+ '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
53
+ '.css', '.scss', '.sass', '.less',
54
+ ]);
55
+
56
+ // Hard-skip regex for sensitive files. Cannot be turned off via config.
57
+ // Match tokenized secret/credential filenames, not UI names such as
58
+ // CredentialForm.tsx, SecretPage.jsx, or secretary-dashboard.vue.
59
+ export const SENSITIVE_PATH = new RegExp([
60
+ String.raw`(?:^|[/\\])\.env(?:\.|$)`,
61
+ String.raw`(?:^|[/\\])\.git(?:[/\\]|$)`,
62
+ String.raw`(?:^|[/\\])id_rsa(?:$|[._-])[^/\\]*$`,
63
+ String.raw`(?:^|[/\\])[^/\\]*\.pem$`,
64
+ String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?(?:secret|secrets|credential|credentials)(?=[._-])[^/\\]*\.(?:json|ya?ml|toml|ini|conf|config|env|txt|key|cert|crt|pem|js|ts)$`,
65
+ ].join('|'), 'i');
66
+
67
+ // Hard-skip regex for generated, lock, minified, and build-output paths.
68
+ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i;
69
+
70
+ export const TRUTHY = /^(1|true|yes|on)$/i;
71
+
72
+ export const DEFAULT_CONFIG = Object.freeze({
73
+ enabled: true,
74
+ quiet: false,
75
+ auditLog: null,
76
+ ignoreRules: [],
77
+ ignoreFiles: [],
78
+ ignoreValues: [],
79
+ limits: { maxFindings: 5, maxChars: 8000 },
80
+ });
81
+
82
+ export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
83
+ '.impeccable/hook.cache.json',
84
+ '.impeccable/hook.pending.json',
85
+ '.impeccable/config.local.json',
86
+ ]);
87
+
88
+ const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
89
+ const HOOK_IGNORE_MARKER_CLOSE = '# impeccable-hook-ignore-end';
90
+ const CACHE_MAX_SESSIONS = 8;
91
+ export const EDIT_COUNT_THRESHOLD = 6;
92
+
93
+ export function truthy(value) {
94
+ return typeof value === 'string' && TRUTHY.test(value);
95
+ }
96
+
97
+ function depthIsSet(value) {
98
+ if (value === undefined || value === null) return false;
99
+ const text = String(value).trim();
100
+ if (!text) return false;
101
+ if (TRUTHY.test(text)) return true;
102
+ return /^\d+$/.test(text) && Number(text) > 0;
103
+ }
104
+
105
+ function safeReadJson(filePath) {
106
+ try {
107
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ export function getConfigPath(cwd) {
114
+ return path.join(cwd, '.impeccable', 'config.json');
115
+ }
116
+
117
+ export function getLocalConfigPath(cwd) {
118
+ return path.join(cwd, '.impeccable', 'config.local.json');
119
+ }
120
+
121
+ export function getCachePath(cwd) {
122
+ return path.join(cwd, '.impeccable', 'hook.cache.json');
123
+ }
124
+
125
+ export function getPendingPath(cwd) {
126
+ return path.join(cwd, '.impeccable', 'hook.pending.json');
127
+ }
128
+
129
+ export function resolveProjectCwd(event, fallback = process.cwd()) {
130
+ return event?.cwd
131
+ || (Array.isArray(event?.workspace_roots) && event.workspace_roots[0])
132
+ || envProjectDir(fallback)
133
+ || fallback;
134
+ }
135
+
136
+ export function readConfig(cwd) {
137
+ const config = cloneDefaultConfig();
138
+ // Hook settings live under the `hook` key of config.json (shared) and
139
+ // config.local.json (per-developer, gitignored); local wins.
140
+ applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
141
+ applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
142
+ return config;
143
+ }
144
+
145
+ // The hook settings subtree of a unified config.json / config.local.json.
146
+ function hookSection(raw) {
147
+ if (!raw || typeof raw !== 'object') return null;
148
+ return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
149
+ }
150
+
151
+ function numberOr(value, fallback) {
152
+ return Number.isFinite(value) && value > 0 ? value : fallback;
153
+ }
154
+
155
+ function cloneDefaultConfig() {
156
+ return {
157
+ ...DEFAULT_CONFIG,
158
+ ignoreRules: [],
159
+ ignoreFiles: [],
160
+ ignoreValues: [],
161
+ limits: { ...DEFAULT_CONFIG.limits },
162
+ };
163
+ }
164
+
165
+ function applyConfigSource(config, raw) {
166
+ if (!raw || typeof raw !== 'object') return config;
167
+ if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
168
+ config.enabled = raw.enabled === false ? false : true;
169
+ }
170
+ if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
171
+ config.quiet = raw.quiet === true;
172
+ }
173
+ if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
174
+ config.auditLog = raw.auditLog.trim();
175
+ }
176
+ if (Array.isArray(raw.ignoreRules)) {
177
+ config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
178
+ }
179
+ if (Array.isArray(raw.ignoreFiles)) {
180
+ config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
181
+ }
182
+ if (Array.isArray(raw.ignoreValues)) {
183
+ config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
184
+ }
185
+ if (raw.limits && typeof raw.limits === 'object') {
186
+ config.limits = {
187
+ maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
188
+ maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
189
+ };
190
+ }
191
+ return config;
192
+ }
193
+
194
+ function uniqueStrings(values) {
195
+ return Array.from(new Set(values.map(String)));
196
+ }
197
+
198
+ export function normalizeIgnoreValue(value) {
199
+ return String(value || '')
200
+ .trim()
201
+ .replace(/^["']|["']$/g, '')
202
+ .replace(/\+/g, ' ')
203
+ .replace(/\s+/g, ' ')
204
+ .toLowerCase();
205
+ }
206
+
207
+ function normalizeIgnoreRule(rule) {
208
+ return String(rule || '').trim().toLowerCase();
209
+ }
210
+
211
+ export function normalizeIgnoreValueEntries(entries) {
212
+ if (!Array.isArray(entries)) return [];
213
+ const out = [];
214
+ for (const entry of entries) {
215
+ if (!entry || typeof entry !== 'object') continue;
216
+ const rule = normalizeIgnoreRule(entry.rule);
217
+ const value = normalizeIgnoreValue(entry.value);
218
+ if (!rule || !value) continue;
219
+ const normalized = { rule, value };
220
+ if (typeof entry.reason === 'string' && entry.reason.trim()) {
221
+ normalized.reason = entry.reason.trim();
222
+ }
223
+ if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
224
+ normalized.createdAt = entry.createdAt.trim();
225
+ }
226
+ out.push(normalized);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function mergeIgnoreValues(existing, incoming) {
232
+ const map = new Map();
233
+ for (const entry of normalizeIgnoreValueEntries(existing)) {
234
+ map.set(`${entry.rule}\0${entry.value}`, entry);
235
+ }
236
+ for (const entry of normalizeIgnoreValueEntries(incoming)) {
237
+ map.set(`${entry.rule}\0${entry.value}`, entry);
238
+ }
239
+ return Array.from(map.values());
240
+ }
241
+
242
+ export function readCache(cwd) {
243
+ const raw = safeReadJson(getCachePath(cwd));
244
+ if (!raw || typeof raw !== 'object' || raw.version !== 1) {
245
+ return { version: 1, sessions: {} };
246
+ }
247
+ return {
248
+ version: 1,
249
+ sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
250
+ };
251
+ }
252
+
253
+ export function persistCache(cwd, cache) {
254
+ const sessions = cache.sessions || {};
255
+ const ids = Object.keys(sessions);
256
+ if (ids.length > CACHE_MAX_SESSIONS) {
257
+ // Garbage-collect oldest sessions by updatedAt.
258
+ const ordered = ids
259
+ .map((id) => [id, sessions[id]?.updatedAt || 0])
260
+ .sort((a, b) => b[1] - a[1])
261
+ .slice(0, CACHE_MAX_SESSIONS);
262
+ const next = {};
263
+ for (const [id] of ordered) next[id] = sessions[id];
264
+ cache = { ...cache, sessions: next };
265
+ }
266
+ const target = getCachePath(cwd);
267
+ try {
268
+ ensureHookGitExcludes(cwd);
269
+ fs.mkdirSync(path.dirname(target), { recursive: true });
270
+ fs.writeFileSync(target, JSON.stringify(cache));
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ export function ensureHookGitExcludes(cwd = process.cwd()) {
278
+ try {
279
+ const target = resolveHookGitExcludeTarget(cwd);
280
+ if (!target) {
281
+ return { mode: 'none', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
282
+ }
283
+
284
+ const patterns = target.patternPrefix
285
+ ? HOOK_LOCAL_IGNORE_PATTERNS.map((pattern) => `${target.patternPrefix}/${pattern}`)
286
+ : [...HOOK_LOCAL_IGNORE_PATTERNS];
287
+ const markerSuffix = target.patternPrefix || '.';
288
+ const markerOpen = `${HOOK_IGNORE_MARKER_OPEN} ${markerSuffix}`;
289
+ const markerClose = `${HOOK_IGNORE_MARKER_CLOSE} ${markerSuffix}`;
290
+ const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
291
+ const block = [markerOpen, ...patterns, markerClose].join('\n');
292
+ const markerRe = new RegExp(`${escapeRegExp(markerOpen)}[\\s\\S]*?${escapeRegExp(markerClose)}`);
293
+
294
+ let updated;
295
+ if (markerRe.test(existing)) {
296
+ updated = existing.replace(markerRe, block);
297
+ } else {
298
+ const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
299
+ updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
300
+ }
301
+
302
+ if (updated !== existing) {
303
+ fs.mkdirSync(path.dirname(target.path), { recursive: true });
304
+ fs.writeFileSync(target.path, updated, 'utf-8');
305
+ }
306
+
307
+ return {
308
+ mode: 'git-info-exclude',
309
+ file: path.relative(path.resolve(cwd), target.path).split(path.sep).join('/'),
310
+ changed: updated !== existing,
311
+ patterns,
312
+ };
313
+ } catch {
314
+ return { mode: 'error', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
315
+ }
316
+ }
317
+
318
+ function resolveHookGitExcludeTarget(cwd) {
319
+ const start = path.resolve(cwd);
320
+ let dir = start;
321
+ while (true) {
322
+ const dotGit = path.join(dir, '.git');
323
+ if (fs.existsSync(dotGit)) {
324
+ const gitDir = resolveGitDir(dotGit, dir);
325
+ if (!gitDir) return null;
326
+ const relPrefix = path.relative(dir, start).split(path.sep).join('/');
327
+ return {
328
+ path: path.join(gitDir, 'info', 'exclude'),
329
+ patternPrefix: relPrefix && relPrefix !== '.' ? relPrefix : '',
330
+ };
331
+ }
332
+ const parent = path.dirname(dir);
333
+ if (parent === dir) return null;
334
+ dir = parent;
335
+ }
336
+ }
337
+
338
+ function resolveGitDir(dotGit, worktreeDir) {
339
+ const stat = fs.statSync(dotGit);
340
+ if (stat.isDirectory()) return dotGit;
341
+ if (!stat.isFile()) return null;
342
+
343
+ const body = fs.readFileSync(dotGit, 'utf-8').trim();
344
+ const match = body.match(/^gitdir:\s*(.+)$/i);
345
+ if (!match) return null;
346
+ return path.isAbsolute(match[1]) ? match[1] : path.resolve(worktreeDir, match[1]);
347
+ }
348
+
349
+ function escapeRegExp(value) {
350
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
351
+ }
352
+
353
+ function ensureSession(cache, sessionId) {
354
+ if (!cache.sessions[sessionId]) {
355
+ cache.sessions[sessionId] = { updatedAt: Date.now(), files: {} };
356
+ }
357
+ return cache.sessions[sessionId];
358
+ }
359
+
360
+ function ensureFile(cache, sessionId, filePath) {
361
+ const session = ensureSession(cache, sessionId);
362
+ if (!session.files[filePath]) {
363
+ session.files[filePath] = { editCount: 0, findings: [] };
364
+ }
365
+ return session.files[filePath];
366
+ }
367
+
368
+ export function bumpEditCount(cache, sessionId, filePath) {
369
+ const fileEntry = ensureFile(cache, sessionId, filePath);
370
+ fileEntry.editCount = (fileEntry.editCount || 0) + 1;
371
+ ensureSession(cache, sessionId).updatedAt = Date.now();
372
+ return fileEntry.editCount;
373
+ }
374
+
375
+ export function suppressionNotice(filePath) {
376
+ return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
377
+ }
378
+
379
+ // Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
380
+ function globToRegex(glob) {
381
+ let re = '^';
382
+ let i = 0;
383
+ while (i < glob.length) {
384
+ const c = glob[i];
385
+ if (c === '*') {
386
+ if (glob[i + 1] === '*') {
387
+ re += '.*';
388
+ i += 2;
389
+ if (glob[i] === '/') i += 1;
390
+ } else {
391
+ re += '[^/]*';
392
+ i += 1;
393
+ }
394
+ } else if (c === '?') {
395
+ re += '[^/]';
396
+ i += 1;
397
+ } else if (c === '{') {
398
+ const end = glob.indexOf('}', i);
399
+ if (end === -1) { re += '\\{'; i += 1; continue; }
400
+ const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
401
+ re += `(?:${parts.join('|')})`;
402
+ i = end + 1;
403
+ } else if (/[.+^$()|[\]\\]/.test(c)) {
404
+ re += `\\${c}`;
405
+ i += 1;
406
+ } else {
407
+ re += c;
408
+ i += 1;
409
+ }
410
+ }
411
+ re += '$';
412
+ return new RegExp(re);
413
+ }
414
+
415
+ export function matchesAnyGlob(filePath, globs) {
416
+ if (!Array.isArray(globs) || globs.length === 0) return false;
417
+ const normalized = filePath.split(path.sep).join('/');
418
+ for (const glob of globs) {
419
+ try {
420
+ const re = globToRegex(String(glob));
421
+ if (re.test(normalized)) return true;
422
+ // Match against basename too for convenience: `*.generated.tsx` should
423
+ // catch `src/foo.generated.tsx` without requiring `**/`.
424
+ const base = normalized.split('/').pop();
425
+ if (re.test(base)) return true;
426
+ } catch {
427
+ /* malformed glob, skip */
428
+ }
429
+ }
430
+ return false;
431
+ }
432
+
433
+ export function filterFindings(findings, _content, _ext, config) {
434
+ if (!Array.isArray(findings) || findings.length === 0) return [];
435
+ const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
436
+ const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
437
+ return findings.filter((f) => {
438
+ if (!f || typeof f !== 'object') return false;
439
+ if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
440
+ if (isIgnoredFindingValue(f, ignoreValues)) return false;
441
+ return true;
442
+ });
443
+ }
444
+
445
+ function isIgnoredFindingValue(finding, ignoreValues) {
446
+ if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
447
+ const rule = normalizeIgnoreRule(finding.antipattern);
448
+ const value = extractFindingIgnoreValue(finding);
449
+ if (!rule || !value) return false;
450
+ return ignoreValues.some((entry) => entry.rule === rule && entry.value === value);
451
+ }
452
+
453
+ export function extractFindingIgnoreValue(finding) {
454
+ if (!finding || typeof finding !== 'object') return '';
455
+ const rule = normalizeIgnoreRule(finding.antipattern);
456
+ if (rule !== 'overused-font' && rule !== 'bounce-easing') return '';
457
+ return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
458
+ }
459
+
460
+ function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
461
+ const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
462
+ if (direct) return direct;
463
+
464
+ const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
465
+ for (const text of candidates) {
466
+ if (rule === 'bounce-easing') {
467
+ const motion = extractMotionIgnoreValue(text);
468
+ if (motion) return motion;
469
+ continue;
470
+ }
471
+
472
+ const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
473
+ if (primary) return cleanIgnoreValueDisplay(primary[1]);
474
+
475
+ const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
476
+ if (family) return cleanIgnoreValueDisplay(family[1]);
477
+
478
+ const google = text.match(/[?&]family=([^&:;\n]+)/i);
479
+ if (google) {
480
+ try {
481
+ return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
482
+ } catch {
483
+ return cleanIgnoreValueDisplay(google[1]);
484
+ }
485
+ }
486
+ }
487
+
488
+ return '';
489
+ }
490
+
491
+ function extractMotionIgnoreValue(text) {
492
+ const tailwind = text.match(/\banimate-bounce\b/i);
493
+ if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
494
+
495
+ const bezier = text.match(/cubic-bezier\([^)]+\)/i);
496
+ if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
497
+
498
+ const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
499
+ if (animation) {
500
+ const token = animation[1]
501
+ .split(/[,\s]+/)
502
+ .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
503
+ if (token) return cleanIgnoreValueDisplay(token);
504
+ }
505
+
506
+ return '';
507
+ }
508
+
509
+ function cleanIgnoreValueDisplay(value) {
510
+ return String(value || '')
511
+ .trim()
512
+ .replace(/^["']|["']$/g, '')
513
+ .replace(/\+/g, ' ')
514
+ .replace(/\s+/g, ' ');
515
+ }
516
+
517
+ export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
518
+ if (!Array.isArray(findings) || findings.length === 0) return [];
519
+ const fileEntry = ensureFile(cache, sessionId, filePath);
520
+ const known = new Set(fileEntry.findings || []);
521
+ const fresh = [];
522
+ for (const f of findings) {
523
+ const key = `${f.antipattern}:${f.line || 0}`;
524
+ if (known.has(key)) continue;
525
+ known.add(key);
526
+ fresh.push(f);
527
+ }
528
+ return fresh;
529
+ }
530
+
531
+ export function rememberFindings(cache, sessionId, filePath, findings) {
532
+ const fileEntry = ensureFile(cache, sessionId, filePath);
533
+ const known = new Set(fileEntry.findings || []);
534
+ for (const f of findings) known.add(`${f.antipattern}:${f.line || 0}`);
535
+ fileEntry.findings = Array.from(known);
536
+ ensureSession(cache, sessionId).updatedAt = Date.now();
537
+ }
538
+
539
+ export function renderTemplate(findings, filePath, config, opts = {}) {
540
+ if (!Array.isArray(findings) || findings.length === 0) return '';
541
+ const limits = config?.limits || DEFAULT_CONFIG.limits;
542
+ const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
543
+ const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
544
+
545
+ const cwd = opts.cwd || process.cwd();
546
+ const display = relativize(filePath, cwd);
547
+ const total = findings.length;
548
+ const shown = findings.slice(0, cap);
549
+ const remaining = total - shown.length;
550
+
551
+ const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
552
+ const lines = shown.map((f) => formatFindingLine(f));
553
+ const more = remaining > 0
554
+ ? `... and ${remaining} more (see /impeccable audit).`
555
+ : null;
556
+ const footer = directiveFooter(display);
557
+
558
+ const blocks = [header, ...lines];
559
+ if (more) blocks.push(more);
560
+ blocks.push('');
561
+ blocks.push(footer);
562
+ let text = blocks.join('\n');
563
+
564
+ if (text.length > maxChars) {
565
+ text = clampToBudget(header, lines, more, footer, maxChars);
566
+ }
567
+ return text;
568
+ }
569
+
570
+ function renderGroupedTemplate(groups, config, opts = {}) {
571
+ const realGroups = groups.filter((group) => Array.isArray(group.findings) && group.findings.length > 0);
572
+ if (realGroups.length === 0) return '';
573
+ if (realGroups.length === 1) {
574
+ const [group] = realGroups;
575
+ return renderTemplate(group.findings, group.filePath, config, opts);
576
+ }
577
+
578
+ const limits = config?.limits || DEFAULT_CONFIG.limits;
579
+ const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
580
+ const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
581
+ const cwd = opts.cwd || process.cwd();
582
+ const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
583
+ const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
584
+ const lines = [];
585
+ let shownCount = 0;
586
+
587
+ for (const group of realGroups) {
588
+ const display = relativize(group.filePath, cwd);
589
+ lines.push(`${display} (${group.findings.length} issue(s)):`);
590
+ const remainingCap = Math.max(0, cap - shownCount);
591
+ const shown = group.findings.slice(0, remainingCap);
592
+ for (const finding of shown) {
593
+ lines.push(formatFindingLine(finding));
594
+ }
595
+ shownCount += shown.length;
596
+ const hidden = group.findings.length - shown.length;
597
+ if (hidden > 0) {
598
+ lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
599
+ }
600
+ }
601
+
602
+ const footer = directiveFooter('the affected files', { grouped: true });
603
+ let text = [header, ...lines, '', footer].join('\n');
604
+ if (text.length > maxChars) {
605
+ text = clampGroupedToBudget(header, lines, footer, maxChars);
606
+ }
607
+ return text;
608
+ }
609
+
610
+ function clampGroupedToBudget(header, lines, footer, maxChars) {
611
+ const assemble = (linesArr, omitted) => [
612
+ header,
613
+ ...linesArr,
614
+ ...(omitted ? ['... and more (see /impeccable audit).'] : []),
615
+ '',
616
+ footer,
617
+ ].join('\n');
618
+
619
+ let working = lines.slice();
620
+ let omitted = false;
621
+ let assembled = assemble(working, omitted);
622
+ while (assembled.length > maxChars && working.length > 1) {
623
+ working.pop();
624
+ omitted = true;
625
+ assembled = assemble(working, omitted);
626
+ }
627
+ if (assembled.length > maxChars) {
628
+ assembled = `${assembled.slice(0, maxChars - 1)}…`;
629
+ }
630
+ return assembled;
631
+ }
632
+
633
+ function clampToBudget(header, lines, more, footer, maxChars) {
634
+ const assemble = (linesArr, moreText) => {
635
+ const blocks = [header, ...linesArr];
636
+ if (moreText) blocks.push(moreText);
637
+ blocks.push('');
638
+ blocks.push(footer);
639
+ return blocks.join('\n');
640
+ };
641
+
642
+ let working = lines.slice();
643
+ let moreText = more;
644
+ let assembled = assemble(working, moreText);
645
+ while (assembled.length > maxChars && working.length > 1) {
646
+ working.pop();
647
+ moreText = '... and more (see /impeccable audit).';
648
+ assembled = assemble(working, moreText);
649
+ }
650
+ if (assembled.length > maxChars) {
651
+ assembled = `${assembled.slice(0, maxChars - 1)}…`;
652
+ }
653
+ return assembled;
654
+ }
655
+
656
+ function formatFindingLine(f) {
657
+ const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
658
+ const desc = (f.description || '').trim();
659
+ const name = (f.name || '').trim();
660
+ // Description from the registry already ends in punctuation; join with a
661
+ // single space. `name` may have a trailing period already, keep it clean.
662
+ const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
663
+ const ignoreCommand = formatFindingIgnoreCommand(f);
664
+ const ignoreSegment = ignoreCommand
665
+ ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
666
+ : '';
667
+ return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
668
+ }
669
+
670
+ function formatFindingIgnoreCommand(finding) {
671
+ if (!finding || typeof finding !== 'object') return '';
672
+ const rule = normalizeIgnoreRule(finding.antipattern);
673
+ if (!rule) return '';
674
+ const normalizedValue = extractFindingIgnoreValue(finding);
675
+ if (!normalizedValue) return '';
676
+ const value = extractFindingIgnoreValueRaw(finding);
677
+ const valueArg = quoteCommandArg(value);
678
+ const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
679
+ return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
680
+ }
681
+
682
+ function quoteCommandArg(value) {
683
+ const text = String(value || '').trim();
684
+ if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
685
+ return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
686
+ }
687
+
688
+ function relativize(filePath, cwd) {
689
+ try {
690
+ const rel = path.relative(cwd, filePath);
691
+ if (!rel || rel.startsWith('..')) return filePath;
692
+ return rel.split(path.sep).join('/');
693
+ } catch {
694
+ return filePath;
695
+ }
696
+ }
697
+
698
+ // Codex `apply_patch` exposes the raw patch in `tool_input.command`, not
699
+ // `tool_input.file_path`. Claude Code may send both; parse the patch body
700
+ // so we can scan the file(s) the tool actually touched.
701
+ // https://developers.openai.com/codex/hooks#posttooluse
702
+ const APPLY_PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
703
+
704
+ export function parseApplyPatchPaths(command, projectCwd) {
705
+ if (!command || typeof command !== 'string') return [];
706
+ const out = [];
707
+ for (const m of command.matchAll(APPLY_PATCH_FILE_RE)) {
708
+ let p = (m[1] || '').trim();
709
+ if (!p) continue;
710
+ if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
711
+ out.push(p);
712
+ }
713
+ return out;
714
+ }
715
+
716
+ export function resolveTargetFiles(event, projectCwd) {
717
+ const ti = event?.tool_input;
718
+ const out = [];
719
+ const add = (filePath) => {
720
+ if (typeof filePath !== 'string' || !filePath) return;
721
+ if (!out.includes(filePath)) out.push(filePath);
722
+ };
723
+
724
+ if (event?.tool_name === 'apply_patch' && ti && typeof ti.command === 'string') {
725
+ for (const filePath of parseApplyPatchPaths(ti.command, projectCwd)) add(filePath);
726
+ }
727
+ if (ti && typeof ti.file_path === 'string' && ti.file_path) {
728
+ add(ti.file_path);
729
+ }
730
+ // Cursor Write / StrReplace use `path`, not `file_path`.
731
+ if (ti && typeof ti.path === 'string' && ti.path) {
732
+ add(ti.path);
733
+ }
734
+ if (typeof event?.file_path === 'string' && event.file_path) {
735
+ add(event.file_path);
736
+ }
737
+ return out;
738
+ }
739
+
740
+ export function resolveHarness(env = {}, event = null) {
741
+ const explicit = env?.IMPECCABLE_HOOK_HARNESS;
742
+ if (explicit === 'cursor') return 'cursor';
743
+ if (explicit === 'claude' || explicit === 'codex') return 'claude';
744
+ if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
745
+ return 'claude';
746
+ }
747
+
748
+ export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
749
+ if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
750
+
751
+ const cwd = event.cwd
752
+ || (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
753
+ || envProjectDir(projectCwd)
754
+ || projectCwd;
755
+ const sessionId = event.session_id || event.conversation_id || 'unknown';
756
+
757
+ const ti = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
758
+ const filePath = ti.file_path || ti.path || event.file_path;
759
+ if (filePath) {
760
+ return {
761
+ ...event,
762
+ cwd,
763
+ session_id: sessionId,
764
+ tool_input: { ...ti, file_path: filePath },
765
+ };
766
+ }
767
+
768
+ return { ...event, cwd, session_id: sessionId };
769
+ }
770
+
771
+ function envProjectDir(fallback) {
772
+ if (typeof process.env.CURSOR_PROJECT_DIR === 'string' && process.env.CURSOR_PROJECT_DIR) {
773
+ return process.env.CURSOR_PROJECT_DIR;
774
+ }
775
+ return fallback;
776
+ }
777
+
778
+ // UI components often keep slop in a sibling/co-located stylesheet while the
779
+ // JSX edit is what triggered PostToolUse. Scan those styles too so an App.jsx
780
+ // patch doesn't report "clean" while styles.css still has Inter/bounce/etc.
781
+ const UI_CODE_EXTS = new Set(['.jsx', '.tsx', '.vue', '.svelte', '.astro']);
782
+ const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
783
+ const CO_SCAN_STYLE_NAMES = [
784
+ 'styles.css', 'styles.scss', 'styles.sass', 'styles.less',
785
+ 'index.css', 'index.scss', 'index.sass', 'index.less',
786
+ 'global.css', 'global.scss', 'global.sass', 'global.less',
787
+ 'globals.css', 'globals.scss', 'globals.sass', 'globals.less',
788
+ ];
789
+ const MAX_SCAN_TARGETS = 6;
790
+
791
+ const STATIC_STYLE_IMPORT_RE = /import\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]/gi;
792
+
793
+ function hasPathTraversal(filePath) {
794
+ return typeof filePath === 'string' && filePath.includes('..');
795
+ }
796
+
797
+ function isInsideProject(filePath, projectCwd) {
798
+ if (!filePath || !projectCwd || hasPathTraversal(filePath)) return false;
799
+ try {
800
+ const rel = path.relative(projectCwd, filePath);
801
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
802
+ } catch {
803
+ return false;
804
+ }
805
+ }
806
+
807
+ export function parseStaticStyleImports(content, fromFile, projectCwd) {
808
+ if (!content || typeof content !== 'string') return [];
809
+ const dir = path.dirname(fromFile);
810
+ const out = [];
811
+ for (const m of content.matchAll(STATIC_STYLE_IMPORT_RE)) {
812
+ let p = (m[1] || '').trim();
813
+ if (!p) continue;
814
+ if (p.startsWith('.')) p = path.resolve(dir, p);
815
+ else if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
816
+ if (!isInsideProject(p, projectCwd)) continue;
817
+ out.push(p);
818
+ }
819
+ return out;
820
+ }
821
+
822
+ export function coLocatedStylesheets(filePath) {
823
+ const dir = path.dirname(filePath);
824
+ const base = path.basename(filePath, path.extname(filePath));
825
+ const candidates = new Set([
826
+ path.join(dir, `${base}.css`),
827
+ path.join(dir, `${base}.module.css`),
828
+ path.join(dir, `${base}.scss`),
829
+ path.join(dir, `${base}.module.scss`),
830
+ path.join(dir, `${base}.sass`),
831
+ path.join(dir, `${base}.module.sass`),
832
+ path.join(dir, `${base}.less`),
833
+ path.join(dir, `${base}.module.less`),
834
+ ]);
835
+ for (const name of CO_SCAN_STYLE_NAMES) {
836
+ candidates.add(path.join(dir, name));
837
+ }
838
+ return [...candidates].filter((p) => fs.existsSync(p));
839
+ }
840
+
841
+ export function normalizeScanTargets(primaryTargets, projectCwd) {
842
+ if (!Array.isArray(primaryTargets) || primaryTargets.length === 0) return [];
843
+ const ordered = [];
844
+ const seen = new Set();
845
+ const baseCwd = projectCwd || process.cwd();
846
+ const normalizeTarget = (p) => {
847
+ // Preserve literal `..` segments so downstream sensitive-path checks
848
+ // still fire. path.resolve would collapse `/foo/../etc/passwd`.
849
+ if (hasPathTraversal(p)) return p;
850
+ return path.isAbsolute(p) ? p : path.resolve(baseCwd, p);
851
+ };
852
+ const add = (p) => {
853
+ if (ordered.length >= MAX_SCAN_TARGETS) return;
854
+ const abs = normalizeTarget(p);
855
+ if (seen.has(abs)) return;
856
+ seen.add(abs);
857
+ ordered.push(abs);
858
+ return abs;
859
+ };
860
+
861
+ for (const p of primaryTargets) add(p);
862
+ return ordered;
863
+ }
864
+
865
+ export function expandScanTargets(primaryTargets, projectCwd) {
866
+ const ordered = normalizeScanTargets(primaryTargets, projectCwd);
867
+ if (ordered.length === 0) return [];
868
+ const seen = new Set(ordered);
869
+ const baseCwd = projectCwd || process.cwd();
870
+ const add = (p) => {
871
+ if (ordered.length >= MAX_SCAN_TARGETS) return;
872
+ const abs = hasPathTraversal(p) ? p : (path.isAbsolute(p) ? p : path.resolve(baseCwd, p));
873
+ if (seen.has(abs)) return;
874
+ seen.add(abs);
875
+ ordered.push(abs);
876
+ return abs;
877
+ };
878
+
879
+ const normalizedPrimaries = [];
880
+ for (const p of ordered) normalizedPrimaries.push(p);
881
+
882
+ for (const p of normalizedPrimaries) {
883
+ if (ordered.length >= MAX_SCAN_TARGETS) break;
884
+ if (!isInsideProject(p, baseCwd)) continue;
885
+ const ext = path.extname(p).toLowerCase();
886
+ if (STYLE_EXTS.has(ext) || !UI_CODE_EXTS.has(ext)) continue;
887
+
888
+ let content = '';
889
+ try { content = fs.readFileSync(p, 'utf-8'); } catch { /* unreadable primary */ }
890
+
891
+ for (const imp of parseStaticStyleImports(content, p, projectCwd)) {
892
+ add(imp);
893
+ if (ordered.length >= MAX_SCAN_TARGETS) break;
894
+ }
895
+ for (const col of coLocatedStylesheets(p)) {
896
+ add(col);
897
+ if (ordered.length >= MAX_SCAN_TARGETS) break;
898
+ }
899
+ }
900
+
901
+ return ordered;
902
+ }
903
+
904
+ export function writeAuditLog(env, entry, cwd = process.cwd()) {
905
+ // The event's project root (entry.cwd) when present, else the passed cwd. Both
906
+ // config reads and relative log paths resolve against this, since the hook
907
+ // process cwd can differ from the project being edited.
908
+ const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
909
+ // Env wins; otherwise fall back to the unified config's hook.auditLog path.
910
+ let target = env?.IMPECCABLE_HOOK_LOG;
911
+ if (!target || typeof target !== 'string') {
912
+ try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
913
+ }
914
+ if (!target || typeof target !== 'string') return false;
915
+ try {
916
+ let expanded;
917
+ if (target.startsWith('~/')) {
918
+ expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
919
+ } else if (path.isAbsolute(target)) {
920
+ expanded = target;
921
+ } else {
922
+ expanded = path.resolve(baseCwd, target);
923
+ }
924
+ fs.mkdirSync(path.dirname(expanded), { recursive: true });
925
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
926
+ fs.appendFileSync(expanded, line);
927
+ return true;
928
+ } catch {
929
+ return false;
930
+ }
931
+ }
932
+
933
+ const DETECTOR_CANDIDATES = [
934
+ path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
935
+ path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
936
+ path.join(__dirname, '..', '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
937
+ ];
938
+
939
+ let detectorCache = null;
940
+ export async function loadDetector(candidates = DETECTOR_CANDIDATES) {
941
+ if (detectorCache) return detectorCache;
942
+ const found = candidates.find((c) => fs.existsSync(c));
943
+ if (!found) return null;
944
+ const mod = await import(pathToFileURL(found));
945
+ detectorCache = { detectText: mod.detectText, detectHtml: mod.detectHtml };
946
+ return detectorCache;
947
+ }
948
+
949
+ // For tests: allow injecting a detector implementation.
950
+ export function setDetectorForTesting(impl) {
951
+ detectorCache = impl;
952
+ }
953
+
954
+ // ────────────────────────────────────────────────────────────────────────
955
+ // Nudge/steer messages for the no-silent-fires policy.
956
+ //
957
+ // The hook is designed to be a conversational presence: every fire that
958
+ // actually scans a file emits a developer-role message into the model's
959
+ // next turn. Three states map to three templates:
960
+ //
961
+ // 1. **Fresh findings** → `renderTemplate` (existing, imperative).
962
+ // 2. **Pending findings** → `renderPendingAck` (re-nudge for issues the
963
+ // model was already told about in this
964
+ // session but hasn't fixed yet).
965
+ // 3. **Truly clean** → `renderCleanAck` (short positive nudge that
966
+ // keeps the design discipline in context).
967
+ //
968
+ // All three are short (≤ ~40 tokens each) so the cumulative cost stays
969
+ // bounded across a long active editing session. Users who explicitly want
970
+ // silence-on-clean can set `IMPECCABLE_HOOK_QUIET=1` — runHook checks that
971
+ // env before emitting #2 or #3.
972
+ //
973
+ // Why not stay silent on dedup-clean? Earlier versions did. The model
974
+ // quickly forgets the prior reminder once tool output scrolls past it, so
975
+ // re-nudging on the same file with a short "still pending" line keeps the
976
+ // pressure on. The wording deliberately points back to "earlier this
977
+ // session" so the model knows it's a re-mind, not a new finding.
978
+ // ────────────────────────────────────────────────────────────────────────
979
+
980
+ const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
981
+
982
+ export function renderCleanAck(filePath, opts = {}) {
983
+ const cwd = opts.cwd || process.cwd();
984
+ const display = relativize(filePath, cwd);
985
+ return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
986
+ }
987
+
988
+ export function renderPendingAck(filePath, knownFindings, opts = {}) {
989
+ const cwd = opts.cwd || process.cwd();
990
+ const display = relativize(filePath, cwd);
991
+ const count = knownFindings.length;
992
+ // `knownFindings` here are the cache strings like "side-tab:3".
993
+ const sample = knownFindings.slice(0, 3).join(', ');
994
+ const more = count > 3 ? `, +${count - 3} more` : '';
995
+ return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
996
+ }
997
+
998
+ export function shouldEmitAckForFile(filePath) {
999
+ return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
1000
+ }
1001
+
1002
+ // The directive footer is the part of the hook output that steers model
1003
+ // behavior. Three intentional moves:
1004
+ // 1. **Imperative, not advisory.** "Handle these..." beats "Consider
1005
+ // revising..." which the model treats as a soft suggestion it can
1006
+ // override when the user asked for any kind of throwaway / demo UI.
1007
+ // 2. **Explicit judgment clause.** Without it, the model will try to
1008
+ // "fix" intentional motion, bad fixtures, anti-pattern examples in
1009
+ // docs, or test cases. Naming the judgment inline beats hoping the
1010
+ // model infers it from context.
1011
+ // 3. **Acknowledgement instruction.** Hook output is injected as
1012
+ // developer-role context, not a chat turn, so the user never sees the
1013
+ // raw envelope. Asking the model to surface the resolution in its
1014
+ // reply is the cheapest way to make the feedback loop visible.
1015
+ function directiveFooter(display, opts = {}) {
1016
+ const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
1017
+ const fileIgnoreGuidance = opts.grouped
1018
+ ? 'run `/impeccable hooks ignore-file <path>` for the specific file'
1019
+ : `run \`${ignoreFileCommand}\``;
1020
+ return [
1021
+ 'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
1022
+ '',
1023
+ 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
1024
+ '',
1025
+ `Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
1026
+ ].join('\n');
1027
+ }
1028
+
1029
+ /**
1030
+ * Run the hook with explicit dependencies. Returns a result object:
1031
+ * { exitCode, stdout, audit, reason? }
1032
+ *
1033
+ * Never throws. All errors are converted to `exitCode: 0` + audit entry.
1034
+ */
1035
+ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
1036
+ const audit = { ts: new Date(now()).toISOString(), event: 'PostToolUse' };
1037
+ const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
1038
+
1039
+ try {
1040
+ // Re-entrancy guard.
1041
+ if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
1042
+ return result({ reentrant: true, durationMs: 0 });
1043
+ }
1044
+
1045
+ if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
1046
+ return result({ skipped: 'env-disabled', durationMs: 0 });
1047
+ }
1048
+
1049
+ const started = Date.now();
1050
+
1051
+ let event;
1052
+ try {
1053
+ event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
1054
+ } catch {
1055
+ return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
1056
+ }
1057
+ if (!event || typeof event !== 'object') {
1058
+ return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
1059
+ }
1060
+
1061
+ const harness = resolveHarness(env, event);
1062
+ event = normalizeHookEvent(event, cwd, harness);
1063
+ audit.harness = harness;
1064
+
1065
+ const projectCwd = event.cwd || cwd;
1066
+ audit.cwd = projectCwd;
1067
+ const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
1068
+ const primaryFileSet = new Set(primaryFiles);
1069
+ const targetFiles = expandScanTargets(primaryFiles, projectCwd);
1070
+ audit.session = event.session_id || null;
1071
+ if (event.tool_name) audit.tool = event.tool_name;
1072
+
1073
+ if (targetFiles.length === 0) {
1074
+ return result({ skipped: 'no-file-path', durationMs: Date.now() - started });
1075
+ }
1076
+
1077
+ const config = readConfig(projectCwd);
1078
+ if (config.enabled === false) {
1079
+ return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
1080
+ }
1081
+
1082
+ const cache = readCache(projectCwd);
1083
+ const sessionId = event.session_id || 'unknown';
1084
+ const det = detector || await loadDetector();
1085
+ if (!det || typeof det.detectText !== 'function') {
1086
+ persistCache(projectCwd, cache);
1087
+ return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
1088
+ }
1089
+
1090
+ let pendingWinner = null;
1091
+ let cleanWinner = null;
1092
+ const freshGroups = [];
1093
+ let suppressionWinner = null;
1094
+ let detectorThrewAny = false;
1095
+ let lastSkip = 'no-scannable-file';
1096
+ let suppressedHit = false;
1097
+
1098
+ for (const filePath of targetFiles) {
1099
+ audit.file = filePath;
1100
+
1101
+ if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) {
1102
+ lastSkip = 'sensitive';
1103
+ continue;
1104
+ }
1105
+ if (GENERATED_PATH.test(filePath)) {
1106
+ lastSkip = 'generated';
1107
+ continue;
1108
+ }
1109
+
1110
+ const ext = path.extname(filePath).toLowerCase();
1111
+ audit.ext = ext;
1112
+ if (!ALLOWED_EXTS.has(ext)) {
1113
+ lastSkip = 'extension';
1114
+ continue;
1115
+ }
1116
+
1117
+ const relForMatch = relativize(filePath, projectCwd);
1118
+ if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
1119
+ lastSkip = 'config-ignore-file';
1120
+ continue;
1121
+ }
1122
+ if (!fs.existsSync(filePath)) {
1123
+ lastSkip = 'file-missing';
1124
+ continue;
1125
+ }
1126
+
1127
+ if (primaryFileSet.has(filePath)) {
1128
+ const editCount = bumpEditCount(cache, sessionId, filePath);
1129
+ audit.editCount = editCount;
1130
+
1131
+ if (editCount > EDIT_COUNT_THRESHOLD) {
1132
+ const wasJustCrossed = editCount === EDIT_COUNT_THRESHOLD + 1;
1133
+ if (wasJustCrossed && !suppressionWinner) {
1134
+ suppressionWinner = { filePath };
1135
+ }
1136
+ lastSkip = 'suppressed';
1137
+ suppressedHit = true;
1138
+ continue;
1139
+ }
1140
+ }
1141
+
1142
+ const content = fs.readFileSync(filePath, 'utf-8');
1143
+ let findings;
1144
+ let detectorThrew = false;
1145
+ if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
1146
+ try { findings = await det.detectHtml(filePath); } catch { findings = []; detectorThrew = true; }
1147
+ } else {
1148
+ try { findings = await det.detectText(content, filePath); } catch { findings = []; detectorThrew = true; }
1149
+ }
1150
+
1151
+ const filtered = filterFindings(findings || [], content, ext, config);
1152
+ const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
1153
+ audit.findings = (findings || []).length;
1154
+ audit.freshFindings = fresh.length;
1155
+
1156
+ if (fresh.length > 0) {
1157
+ rememberFindings(cache, sessionId, filePath, fresh);
1158
+ freshGroups.push({ filePath, findings: fresh });
1159
+ continue;
1160
+ }
1161
+
1162
+ if (detectorThrew) {
1163
+ detectorThrewAny = true;
1164
+ continue;
1165
+ }
1166
+
1167
+ if (filtered.length > 0 && !pendingWinner) {
1168
+ const known = (ensureFile(cache, sessionId, filePath).findings || []).slice();
1169
+ pendingWinner = { filePath, known };
1170
+ } else if (filtered.length === 0 && !cleanWinner) {
1171
+ cleanWinner = { filePath };
1172
+ }
1173
+ }
1174
+
1175
+ persistCache(projectCwd, cache);
1176
+
1177
+ if (freshGroups.length > 0) {
1178
+ const firstGroup = freshGroups[0];
1179
+ const text = renderGroupedTemplate(freshGroups, config, { cwd: projectCwd });
1180
+ const allFindings = freshGroups.flatMap((group) => group.findings);
1181
+ return {
1182
+ exitCode: 0,
1183
+ stdout: payload(text, 'PostToolUse', harness),
1184
+ emission: {
1185
+ kind: 'fresh',
1186
+ file: firstGroup.filePath,
1187
+ findings: firstGroup.findings,
1188
+ groups: freshGroups,
1189
+ },
1190
+ audit: {
1191
+ ...audit,
1192
+ file: firstGroup.filePath,
1193
+ emitted: true,
1194
+ freshFiles: freshGroups.length,
1195
+ freshFindings: allFindings.length,
1196
+ chars: text.length,
1197
+ durationMs: Date.now() - started,
1198
+ },
1199
+ };
1200
+ }
1201
+
1202
+ if (detectorThrewAny && !pendingWinner && !cleanWinner) {
1203
+ return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
1204
+ }
1205
+
1206
+ if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
1207
+ return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
1208
+ }
1209
+
1210
+ if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
1211
+ const text = renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd });
1212
+ return {
1213
+ exitCode: 0,
1214
+ stdout: payload(text, 'PostToolUse', harness),
1215
+ emission: { kind: 'pending', file: pendingWinner.filePath, known: pendingWinner.known },
1216
+ audit: {
1217
+ ...audit,
1218
+ file: pendingWinner.filePath,
1219
+ emitted: true,
1220
+ kind: 'pending',
1221
+ pending: pendingWinner.known.length,
1222
+ chars: text.length,
1223
+ durationMs: Date.now() - started,
1224
+ },
1225
+ };
1226
+ }
1227
+
1228
+ if (suppressionWinner) {
1229
+ const text = suppressionNotice(relativize(suppressionWinner.filePath, projectCwd));
1230
+ return {
1231
+ exitCode: 0,
1232
+ stdout: payload(text, 'PostToolUse', harness),
1233
+ emission: { kind: 'suppression', file: suppressionWinner.filePath },
1234
+ audit: {
1235
+ ...audit,
1236
+ file: suppressionWinner.filePath,
1237
+ suppressed: true,
1238
+ emitted: true,
1239
+ durationMs: Date.now() - started,
1240
+ },
1241
+ };
1242
+ }
1243
+
1244
+ if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
1245
+ const text = renderCleanAck(cleanWinner.filePath, { cwd: projectCwd });
1246
+ return {
1247
+ exitCode: 0,
1248
+ stdout: payload(text, 'PostToolUse', harness),
1249
+ emission: { kind: 'clean', file: cleanWinner.filePath },
1250
+ audit: {
1251
+ ...audit,
1252
+ file: cleanWinner.filePath,
1253
+ emitted: true,
1254
+ kind: 'clean',
1255
+ chars: text.length,
1256
+ durationMs: Date.now() - started,
1257
+ },
1258
+ };
1259
+ }
1260
+
1261
+ if (pendingWinner || cleanWinner) {
1262
+ return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
1263
+ }
1264
+
1265
+ if (suppressedHit) {
1266
+ return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });
1267
+ }
1268
+
1269
+ return result({ skipped: lastSkip, durationMs: Date.now() - started });
1270
+ } catch (err) {
1271
+ return {
1272
+ exitCode: 0,
1273
+ stdout: '',
1274
+ audit: { ...audit, error: String(err && err.message ? err.message : err) },
1275
+ };
1276
+ }
1277
+ }
1278
+
1279
+ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
1280
+ if (harness === 'cursor') {
1281
+ return JSON.stringify({ additional_context: text });
1282
+ }
1283
+ return JSON.stringify({
1284
+ hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
1285
+ });
1286
+ }