waypoint-skills 1.3.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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -0
  3. package/README.npm.md +56 -0
  4. package/cli/bin/cli.js +127 -0
  5. package/cli/bin/lib/paths.mjs +31 -0
  6. package/cli/bin/postinstall.mjs +25 -0
  7. package/manifest.json +107 -0
  8. package/package.json +44 -0
  9. package/packages/agents/inspiration-scout.md +105 -0
  10. package/packages/agents/orchestrator.md +186 -0
  11. package/packages/agents/scrutiny-validator.md +136 -0
  12. package/packages/agents/user-testing-validator.md +171 -0
  13. package/packages/agents/validator.md +102 -0
  14. package/packages/agents/worker.md +116 -0
  15. package/packages/agents/wp-router.md +69 -0
  16. package/packages/hooks/hooks.json.example +12 -0
  17. package/packages/hooks/templates/mission-worktree-bootstrap.sh +88 -0
  18. package/packages/hooks/templates/run-assertions.sh +48 -0
  19. package/packages/rules/adversarial-context-isolation.mdc +57 -0
  20. package/packages/rules/serial-git-enforcement.mdc +77 -0
  21. package/packages/skills/caveman/SKILL.md +78 -0
  22. package/packages/skills/design-taste-frontend/SKILL.md +1206 -0
  23. package/packages/skills/gpt-taste/SKILL.md +74 -0
  24. package/packages/skills/impeccable/SKILL.md +164 -0
  25. package/packages/skills/impeccable/reference/adapt.md +311 -0
  26. package/packages/skills/impeccable/reference/animate.md +201 -0
  27. package/packages/skills/impeccable/reference/audit.md +133 -0
  28. package/packages/skills/impeccable/reference/bolder.md +120 -0
  29. package/packages/skills/impeccable/reference/brand.md +108 -0
  30. package/packages/skills/impeccable/reference/clarify.md +288 -0
  31. package/packages/skills/impeccable/reference/codex.md +105 -0
  32. package/packages/skills/impeccable/reference/colorize.md +257 -0
  33. package/packages/skills/impeccable/reference/craft.md +123 -0
  34. package/packages/skills/impeccable/reference/critique.md +780 -0
  35. package/packages/skills/impeccable/reference/delight.md +302 -0
  36. package/packages/skills/impeccable/reference/distill.md +111 -0
  37. package/packages/skills/impeccable/reference/document.md +429 -0
  38. package/packages/skills/impeccable/reference/extract.md +69 -0
  39. package/packages/skills/impeccable/reference/harden.md +347 -0
  40. package/packages/skills/impeccable/reference/hooks.md +90 -0
  41. package/packages/skills/impeccable/reference/init.md +172 -0
  42. package/packages/skills/impeccable/reference/interaction-design.md +189 -0
  43. package/packages/skills/impeccable/reference/layout.md +161 -0
  44. package/packages/skills/impeccable/reference/live.md +718 -0
  45. package/packages/skills/impeccable/reference/onboard.md +234 -0
  46. package/packages/skills/impeccable/reference/optimize.md +258 -0
  47. package/packages/skills/impeccable/reference/overdrive.md +130 -0
  48. package/packages/skills/impeccable/reference/polish.md +241 -0
  49. package/packages/skills/impeccable/reference/product.md +60 -0
  50. package/packages/skills/impeccable/reference/quieter.md +99 -0
  51. package/packages/skills/impeccable/reference/shape.md +165 -0
  52. package/packages/skills/impeccable/reference/typeset.md +279 -0
  53. package/packages/skills/impeccable/scripts/command-metadata.json +94 -0
  54. package/packages/skills/impeccable/scripts/context-signals.mjs +225 -0
  55. package/packages/skills/impeccable/scripts/context.mjs +961 -0
  56. package/packages/skills/impeccable/scripts/critique-storage.mjs +242 -0
  57. package/packages/skills/impeccable/scripts/detect-csp.mjs +198 -0
  58. package/packages/skills/impeccable/scripts/detect.mjs +21 -0
  59. package/packages/skills/impeccable/scripts/detector/browser/injected/index.mjs +1937 -0
  60. package/packages/skills/impeccable/scripts/detector/cli/main.mjs +290 -0
  61. package/packages/skills/impeccable/scripts/detector/design-system.mjs +750 -0
  62. package/packages/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +5185 -0
  63. package/packages/skills/impeccable/scripts/detector/detect-antipatterns.mjs +50 -0
  64. package/packages/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +277 -0
  65. package/packages/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +568 -0
  66. package/packages/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +1015 -0
  67. package/packages/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +234 -0
  68. package/packages/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  69. package/packages/skills/impeccable/scripts/detector/findings.mjs +12 -0
  70. package/packages/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  71. package/packages/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  72. package/packages/skills/impeccable/scripts/detector/registry/antipatterns.mjs +459 -0
  73. package/packages/skills/impeccable/scripts/detector/rules/checks.mjs +2707 -0
  74. package/packages/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  75. package/packages/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  76. package/packages/skills/impeccable/scripts/detector/shared/inline-ignores.mjs +148 -0
  77. package/packages/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  78. package/packages/skills/impeccable/scripts/hook-admin.mjs +660 -0
  79. package/packages/skills/impeccable/scripts/hook-before-edit.mjs +476 -0
  80. package/packages/skills/impeccable/scripts/hook-lib.mjs +1632 -0
  81. package/packages/skills/impeccable/scripts/hook.mjs +61 -0
  82. package/packages/skills/impeccable/scripts/lib/design-parser.mjs +842 -0
  83. package/packages/skills/impeccable/scripts/lib/impeccable-config.mjs +638 -0
  84. package/packages/skills/impeccable/scripts/lib/impeccable-paths.mjs +128 -0
  85. package/packages/skills/impeccable/scripts/lib/is-generated.mjs +69 -0
  86. package/packages/skills/impeccable/scripts/lib/target-args.mjs +42 -0
  87. package/packages/skills/impeccable/scripts/live/browser-script-parts.mjs +49 -0
  88. package/packages/skills/impeccable/scripts/live/completion.mjs +19 -0
  89. package/packages/skills/impeccable/scripts/live/event-validation.mjs +137 -0
  90. package/packages/skills/impeccable/scripts/live/insert-ui.mjs +458 -0
  91. package/packages/skills/impeccable/scripts/live/manual-apply.mjs +939 -0
  92. package/packages/skills/impeccable/scripts/live/manual-edit-routes.mjs +357 -0
  93. package/packages/skills/impeccable/scripts/live/manual-edits-buffer.mjs +152 -0
  94. package/packages/skills/impeccable/scripts/live/session-store.mjs +289 -0
  95. package/packages/skills/impeccable/scripts/live/svelte-component.mjs +826 -0
  96. package/packages/skills/impeccable/scripts/live/sveltekit-adapter.mjs +274 -0
  97. package/packages/skills/impeccable/scripts/live/ui-core.mjs +180 -0
  98. package/packages/skills/impeccable/scripts/live/vocabulary.mjs +36 -0
  99. package/packages/skills/impeccable/scripts/live-accept.mjs +812 -0
  100. package/packages/skills/impeccable/scripts/live-browser-dom.js +146 -0
  101. package/packages/skills/impeccable/scripts/live-browser-session.js +123 -0
  102. package/packages/skills/impeccable/scripts/live-browser.js +11173 -0
  103. package/packages/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  104. package/packages/skills/impeccable/scripts/live-complete.mjs +75 -0
  105. package/packages/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  106. package/packages/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  107. package/packages/skills/impeccable/scripts/live-inject.mjs +583 -0
  108. package/packages/skills/impeccable/scripts/live-insert.mjs +272 -0
  109. package/packages/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  110. package/packages/skills/impeccable/scripts/live-poll.mjs +384 -0
  111. package/packages/skills/impeccable/scripts/live-resume.mjs +94 -0
  112. package/packages/skills/impeccable/scripts/live-server.mjs +1135 -0
  113. package/packages/skills/impeccable/scripts/live-status.mjs +61 -0
  114. package/packages/skills/impeccable/scripts/live-target.mjs +30 -0
  115. package/packages/skills/impeccable/scripts/live-wrap.mjs +894 -0
  116. package/packages/skills/impeccable/scripts/live.mjs +297 -0
  117. package/packages/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
  118. package/packages/skills/impeccable/scripts/palette.mjs +633 -0
  119. package/packages/skills/impeccable/scripts/pin.mjs +214 -0
  120. package/packages/skills/ponytail/SKILL.md +117 -0
  121. package/packages/skills/stitch-design-taste/DESIGN.md +121 -0
  122. package/packages/skills/stitch-design-taste/SKILL.md +184 -0
  123. package/packages/skills/waypoint/SKILL.md +67 -0
  124. package/packages/skills/wp/SKILL.md +330 -0
  125. package/packages/skills/wp/caveman-wire.md +148 -0
  126. package/packages/skills/wp/reference.md +411 -0
  127. package/scripts/detect-platform.sh +32 -0
  128. package/scripts/install.sh +123 -0
  129. package/scripts/lib/common.sh +215 -0
  130. package/scripts/sync-skills.sh +21 -0
  131. package/scripts/uninstall.sh +38 -0
  132. package/scripts/waypoint +281 -0
@@ -0,0 +1,1632 @@
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
+ designSystem: { enabled: true },
77
+ ignoreRules: [],
78
+ ignoreFiles: [],
79
+ ignoreValues: [],
80
+ limits: { maxFindings: 5, maxChars: 8000 },
81
+ });
82
+
83
+ export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
84
+ '.impeccable/hook.cache.json',
85
+ '.impeccable/hook.pending.json',
86
+ '.impeccable/config.local.json',
87
+ ]);
88
+
89
+ const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
90
+ const HOOK_IGNORE_MARKER_CLOSE = '# impeccable-hook-ignore-end';
91
+ const CACHE_MAX_SESSIONS = 8;
92
+ export const EDIT_COUNT_THRESHOLD = 6;
93
+
94
+ export function truthy(value) {
95
+ return typeof value === 'string' && TRUTHY.test(value);
96
+ }
97
+
98
+ function depthIsSet(value) {
99
+ if (value === undefined || value === null) return false;
100
+ const text = String(value).trim();
101
+ if (!text) return false;
102
+ if (TRUTHY.test(text)) return true;
103
+ return /^\d+$/.test(text) && Number(text) > 0;
104
+ }
105
+
106
+ function safeReadJson(filePath) {
107
+ try {
108
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ export function getConfigPath(cwd) {
115
+ return path.join(cwd, '.impeccable', 'config.json');
116
+ }
117
+
118
+ export function getLocalConfigPath(cwd) {
119
+ return path.join(cwd, '.impeccable', 'config.local.json');
120
+ }
121
+
122
+ export function getCachePath(cwd) {
123
+ return path.join(cwd, '.impeccable', 'hook.cache.json');
124
+ }
125
+
126
+ export function getPendingPath(cwd) {
127
+ return path.join(cwd, '.impeccable', 'hook.pending.json');
128
+ }
129
+
130
+ export function resolveProjectCwd(event, fallback = process.cwd()) {
131
+ return event?.cwd
132
+ || (Array.isArray(event?.workspace_roots) && event.workspace_roots[0])
133
+ || envProjectDir(fallback)
134
+ || fallback;
135
+ }
136
+
137
+ export function readConfig(cwd) {
138
+ const config = cloneDefaultConfig();
139
+ // Hook runtime settings live under `hook`; detector filters live under
140
+ // `detector`. Back-compat: older configs stored detector filters in `hook`,
141
+ // so read those first and let canonical `detector` settings win.
142
+ for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
143
+ const raw = safeReadJson(filePath);
144
+ applyConfigSource(config, hookSection(raw));
145
+ applyDetectorConfigSource(config, detectorSection(raw));
146
+ }
147
+ return config;
148
+ }
149
+
150
+ // The hook settings subtree of a unified config.json / config.local.json.
151
+ function hookSection(raw) {
152
+ if (!raw || typeof raw !== 'object') return null;
153
+ return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
154
+ }
155
+
156
+ function detectorSection(raw) {
157
+ if (!raw || typeof raw !== 'object') return null;
158
+ return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
159
+ }
160
+
161
+ function numberOr(value, fallback) {
162
+ return Number.isFinite(value) && value > 0 ? value : fallback;
163
+ }
164
+
165
+ function cloneDefaultConfig() {
166
+ return {
167
+ ...DEFAULT_CONFIG,
168
+ ignoreRules: [],
169
+ ignoreFiles: [],
170
+ ignoreValues: [],
171
+ designSystem: { ...DEFAULT_CONFIG.designSystem },
172
+ limits: { ...DEFAULT_CONFIG.limits },
173
+ };
174
+ }
175
+
176
+ function applyDetectorConfigSource(config, raw) {
177
+ if (!raw || typeof raw !== 'object') return config;
178
+ if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
179
+ config.designSystem = {
180
+ ...config.designSystem,
181
+ enabled: raw.designSystem.enabled === false ? false : true,
182
+ };
183
+ }
184
+ if (Array.isArray(raw.ignoreRules)) {
185
+ config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
186
+ }
187
+ if (Array.isArray(raw.ignoreFiles)) {
188
+ config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
189
+ }
190
+ if (Array.isArray(raw.ignoreValues)) {
191
+ config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
192
+ }
193
+ return config;
194
+ }
195
+
196
+ function applyConfigSource(config, raw) {
197
+ if (!raw || typeof raw !== 'object') return config;
198
+ if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
199
+ config.enabled = raw.enabled === false ? false : true;
200
+ }
201
+ if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
202
+ config.quiet = raw.quiet === true;
203
+ }
204
+ if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
205
+ config.auditLog = raw.auditLog.trim();
206
+ }
207
+ applyDetectorConfigSource(config, raw);
208
+ if (raw.limits && typeof raw.limits === 'object') {
209
+ config.limits = {
210
+ maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
211
+ maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
212
+ };
213
+ }
214
+ return config;
215
+ }
216
+
217
+ function uniqueStrings(values) {
218
+ return Array.from(new Set(values.map(String)));
219
+ }
220
+
221
+ export function normalizeIgnoreValue(value) {
222
+ return String(value || '')
223
+ .trim()
224
+ .replace(/^["']|["']$/g, '')
225
+ .replace(/\+/g, ' ')
226
+ .replace(/\s+/g, ' ')
227
+ .toLowerCase();
228
+ }
229
+
230
+ function normalizeIgnoreRule(rule) {
231
+ return String(rule || '').trim().toLowerCase();
232
+ }
233
+
234
+ function colorIgnoreKey(value) {
235
+ const color = parseIgnoreColor(value);
236
+ if (!color) return '';
237
+ return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
238
+ }
239
+
240
+ function parseIgnoreColor(value) {
241
+ const text = String(value || '').trim().toLowerCase();
242
+ if (!text) return null;
243
+
244
+ const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
245
+ if (hex) return parseHexIgnoreColor(hex[1]);
246
+
247
+ const rgb = text.match(/^rgba?\((.*)\)$/i);
248
+ if (rgb) {
249
+ const parts = splitColorArgs(rgb[1]);
250
+ if (parts.length < 3 || parts.length > 4) return null;
251
+ const r = parseRgbChannel(parts[0]);
252
+ const g = parseRgbChannel(parts[1]);
253
+ const b = parseRgbChannel(parts[2]);
254
+ const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
255
+ if ([r, g, b, a].some((v) => v === null)) return null;
256
+ return { r, g, b, a };
257
+ }
258
+
259
+ const hsl = text.match(/^hsla?\((.*)\)$/i);
260
+ if (hsl) {
261
+ const parts = splitColorArgs(hsl[1]);
262
+ if (parts.length < 3 || parts.length > 4) return null;
263
+ const h = parseHueChannel(parts[0]);
264
+ const s = parsePercentChannel(parts[1]);
265
+ const l = parsePercentChannel(parts[2]);
266
+ const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
267
+ if ([h, s, l, a].some((v) => v === null)) return null;
268
+ return hslToRgb(h, s, l, a);
269
+ }
270
+
271
+ return null;
272
+ }
273
+
274
+ function parseHexIgnoreColor(hex) {
275
+ if (hex.length === 3 || hex.length === 4) {
276
+ const r = parseInt(hex[0] + hex[0], 16);
277
+ const g = parseInt(hex[1] + hex[1], 16);
278
+ const b = parseInt(hex[2] + hex[2], 16);
279
+ const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
280
+ return { r, g, b, a };
281
+ }
282
+ const r = parseInt(hex.slice(0, 2), 16);
283
+ const g = parseInt(hex.slice(2, 4), 16);
284
+ const b = parseInt(hex.slice(4, 6), 16);
285
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
286
+ return { r, g, b, a };
287
+ }
288
+
289
+ function splitColorArgs(body) {
290
+ const text = String(body || '').trim();
291
+ if (!text) return [];
292
+ if (text.includes(',')) {
293
+ const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
294
+ const last = parts[parts.length - 1];
295
+ if (last && last.includes('/')) {
296
+ const split = last.split('/').map((part) => part.trim()).filter(Boolean);
297
+ return [...parts.slice(0, -1), ...split];
298
+ }
299
+ return parts;
300
+ }
301
+ return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
302
+ }
303
+
304
+ function parseRgbChannel(raw) {
305
+ const text = String(raw || '').trim();
306
+ const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
307
+ if (!match) return null;
308
+ const value = Number.parseFloat(match[1]);
309
+ if (!Number.isFinite(value)) return null;
310
+ const scaled = match[2] ? value * 2.55 : value;
311
+ if (scaled < 0 || scaled > 255) return null;
312
+ return Math.round(scaled);
313
+ }
314
+
315
+ function parseAlphaChannel(raw) {
316
+ const text = String(raw || '').trim();
317
+ const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
318
+ if (!match) return null;
319
+ const value = Number.parseFloat(match[1]);
320
+ if (!Number.isFinite(value)) return null;
321
+ const alpha = match[2] ? value / 100 : value;
322
+ return alpha >= 0 && alpha <= 1 ? alpha : null;
323
+ }
324
+
325
+ function parseHueChannel(raw) {
326
+ const text = String(raw || '').trim();
327
+ const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
328
+ if (!match) return null;
329
+ const value = Number.parseFloat(match[1]);
330
+ if (!Number.isFinite(value)) return null;
331
+ const unit = match[2] || 'deg';
332
+ if (unit === 'turn') return value * 360;
333
+ if (unit === 'rad') return value * (180 / Math.PI);
334
+ if (unit === 'grad') return value * 0.9;
335
+ return value;
336
+ }
337
+
338
+ function parsePercentChannel(raw) {
339
+ const text = String(raw || '').trim();
340
+ const match = text.match(/^(-?\d*\.?\d+)%$/);
341
+ if (!match) return null;
342
+ const value = Number.parseFloat(match[1]);
343
+ if (!Number.isFinite(value)) return null;
344
+ return value >= 0 && value <= 100 ? value / 100 : null;
345
+ }
346
+
347
+ function hslToRgb(hue, saturation, lightness, alpha) {
348
+ const h = (((hue % 360) + 360) % 360) / 360;
349
+ if (saturation === 0) {
350
+ const gray = clampByte(Math.round(lightness * 255));
351
+ return { r: gray, g: gray, b: gray, a: alpha };
352
+ }
353
+ const q = lightness < 0.5
354
+ ? lightness * (1 + saturation)
355
+ : lightness + saturation - lightness * saturation;
356
+ const p = 2 * lightness - q;
357
+ const toRgb = (t) => {
358
+ let channel = t;
359
+ if (channel < 0) channel += 1;
360
+ if (channel > 1) channel -= 1;
361
+ if (channel < 1 / 6) return p + (q - p) * 6 * channel;
362
+ if (channel < 1 / 2) return q;
363
+ if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
364
+ return p;
365
+ };
366
+ return {
367
+ r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
368
+ g: clampByte(Math.round(toRgb(h) * 255)),
369
+ b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
370
+ a: alpha,
371
+ };
372
+ }
373
+
374
+ function clampByte(value) {
375
+ return Math.min(255, Math.max(0, value));
376
+ }
377
+
378
+ function ignoreValueMatches(rule, entryValue, findingValue) {
379
+ if (entryValue === findingValue) return true;
380
+ if (rule !== 'design-system-color') return false;
381
+ const entryColor = colorIgnoreKey(entryValue);
382
+ return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
383
+ }
384
+
385
+ export function normalizeIgnoreValueEntries(entries) {
386
+ if (!Array.isArray(entries)) return [];
387
+ const out = [];
388
+ for (const entry of entries) {
389
+ if (!entry || typeof entry !== 'object') continue;
390
+ const rule = normalizeIgnoreRule(entry.rule);
391
+ const value = normalizeIgnoreValue(entry.value);
392
+ if (!rule || !value) continue;
393
+ const normalized = { rule, value };
394
+ const files = uniqueStrings([
395
+ ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
396
+ ...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
397
+ ]);
398
+ if (files.length > 0) normalized.files = files;
399
+ if (typeof entry.reason === 'string' && entry.reason.trim()) {
400
+ normalized.reason = entry.reason.trim();
401
+ }
402
+ if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
403
+ normalized.createdAt = entry.createdAt.trim();
404
+ }
405
+ out.push(normalized);
406
+ }
407
+ return out;
408
+ }
409
+
410
+ function mergeIgnoreValues(existing, incoming) {
411
+ const map = new Map();
412
+ for (const entry of normalizeIgnoreValueEntries(existing)) {
413
+ map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
414
+ }
415
+ for (const entry of normalizeIgnoreValueEntries(incoming)) {
416
+ map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
417
+ }
418
+ return Array.from(map.values());
419
+ }
420
+
421
+ function ignoreValueFilesKey(files) {
422
+ return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
423
+ }
424
+
425
+ export function readCache(cwd) {
426
+ const raw = safeReadJson(getCachePath(cwd));
427
+ if (!raw || typeof raw !== 'object' || raw.version !== 1) {
428
+ return { version: 1, sessions: {} };
429
+ }
430
+ return {
431
+ version: 1,
432
+ sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
433
+ };
434
+ }
435
+
436
+ export function persistCache(cwd, cache) {
437
+ const sessions = cache.sessions || {};
438
+ const ids = Object.keys(sessions);
439
+ if (ids.length > CACHE_MAX_SESSIONS) {
440
+ // Garbage-collect oldest sessions by updatedAt.
441
+ const ordered = ids
442
+ .map((id) => [id, sessions[id]?.updatedAt || 0])
443
+ .sort((a, b) => b[1] - a[1])
444
+ .slice(0, CACHE_MAX_SESSIONS);
445
+ const next = {};
446
+ for (const [id] of ordered) next[id] = sessions[id];
447
+ cache = { ...cache, sessions: next };
448
+ }
449
+ const target = getCachePath(cwd);
450
+ try {
451
+ ensureHookGitExcludes(cwd);
452
+ fs.mkdirSync(path.dirname(target), { recursive: true });
453
+ fs.writeFileSync(target, JSON.stringify(cache));
454
+ return true;
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+
460
+ export function ensureHookGitExcludes(cwd = process.cwd()) {
461
+ try {
462
+ const target = resolveHookGitExcludeTarget(cwd);
463
+ if (!target) {
464
+ return { mode: 'none', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
465
+ }
466
+
467
+ const patterns = target.patternPrefix
468
+ ? HOOK_LOCAL_IGNORE_PATTERNS.map((pattern) => `${target.patternPrefix}/${pattern}`)
469
+ : [...HOOK_LOCAL_IGNORE_PATTERNS];
470
+ const markerSuffix = target.patternPrefix || '.';
471
+ const markerOpen = `${HOOK_IGNORE_MARKER_OPEN} ${markerSuffix}`;
472
+ const markerClose = `${HOOK_IGNORE_MARKER_CLOSE} ${markerSuffix}`;
473
+ const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
474
+ const block = [markerOpen, ...patterns, markerClose].join('\n');
475
+ const markerRe = new RegExp(`${escapeRegExp(markerOpen)}[\\s\\S]*?${escapeRegExp(markerClose)}`);
476
+
477
+ let updated;
478
+ if (markerRe.test(existing)) {
479
+ updated = existing.replace(markerRe, block);
480
+ } else {
481
+ const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
482
+ updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
483
+ }
484
+
485
+ if (updated !== existing) {
486
+ fs.mkdirSync(path.dirname(target.path), { recursive: true });
487
+ fs.writeFileSync(target.path, updated, 'utf-8');
488
+ }
489
+
490
+ return {
491
+ mode: 'git-info-exclude',
492
+ file: path.relative(path.resolve(cwd), target.path).split(path.sep).join('/'),
493
+ changed: updated !== existing,
494
+ patterns,
495
+ };
496
+ } catch {
497
+ return { mode: 'error', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
498
+ }
499
+ }
500
+
501
+ function resolveHookGitExcludeTarget(cwd) {
502
+ const start = path.resolve(cwd);
503
+ let dir = start;
504
+ while (true) {
505
+ const dotGit = path.join(dir, '.git');
506
+ if (fs.existsSync(dotGit)) {
507
+ const gitDir = resolveGitDir(dotGit, dir);
508
+ if (!gitDir) return null;
509
+ const relPrefix = path.relative(dir, start).split(path.sep).join('/');
510
+ return {
511
+ path: path.join(gitDir, 'info', 'exclude'),
512
+ patternPrefix: relPrefix && relPrefix !== '.' ? relPrefix : '',
513
+ };
514
+ }
515
+ const parent = path.dirname(dir);
516
+ if (parent === dir) return null;
517
+ dir = parent;
518
+ }
519
+ }
520
+
521
+ function resolveGitDir(dotGit, worktreeDir) {
522
+ const stat = fs.statSync(dotGit);
523
+ if (stat.isDirectory()) return dotGit;
524
+ if (!stat.isFile()) return null;
525
+
526
+ const body = fs.readFileSync(dotGit, 'utf-8').trim();
527
+ const match = body.match(/^gitdir:\s*(.+)$/i);
528
+ if (!match) return null;
529
+ return path.isAbsolute(match[1]) ? match[1] : path.resolve(worktreeDir, match[1]);
530
+ }
531
+
532
+ function escapeRegExp(value) {
533
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
534
+ }
535
+
536
+ function ensureSession(cache, sessionId) {
537
+ if (!cache.sessions[sessionId]) {
538
+ cache.sessions[sessionId] = { updatedAt: Date.now(), files: {} };
539
+ }
540
+ return cache.sessions[sessionId];
541
+ }
542
+
543
+ function ensureFile(cache, sessionId, filePath) {
544
+ const session = ensureSession(cache, sessionId);
545
+ if (!session.files[filePath]) {
546
+ session.files[filePath] = { editCount: 0, findings: [] };
547
+ }
548
+ return session.files[filePath];
549
+ }
550
+
551
+ export function bumpEditCount(cache, sessionId, filePath) {
552
+ const fileEntry = ensureFile(cache, sessionId, filePath);
553
+ fileEntry.editCount = (fileEntry.editCount || 0) + 1;
554
+ ensureSession(cache, sessionId).updatedAt = Date.now();
555
+ return fileEntry.editCount;
556
+ }
557
+
558
+ export function suppressionNotice(filePath) {
559
+ return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
560
+ }
561
+
562
+ // Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
563
+ function globToRegex(glob) {
564
+ let re = '^';
565
+ let i = 0;
566
+ while (i < glob.length) {
567
+ const c = glob[i];
568
+ if (c === '*') {
569
+ if (glob[i + 1] === '*') {
570
+ re += '.*';
571
+ i += 2;
572
+ if (glob[i] === '/') i += 1;
573
+ } else {
574
+ re += '[^/]*';
575
+ i += 1;
576
+ }
577
+ } else if (c === '?') {
578
+ re += '[^/]';
579
+ i += 1;
580
+ } else if (c === '{') {
581
+ const end = glob.indexOf('}', i);
582
+ if (end === -1) { re += '\\{'; i += 1; continue; }
583
+ const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
584
+ re += `(?:${parts.join('|')})`;
585
+ i = end + 1;
586
+ } else if (/[.+^$()|[\]\\]/.test(c)) {
587
+ re += `\\${c}`;
588
+ i += 1;
589
+ } else {
590
+ re += c;
591
+ i += 1;
592
+ }
593
+ }
594
+ re += '$';
595
+ return new RegExp(re);
596
+ }
597
+
598
+ export function matchesAnyGlob(filePath, globs) {
599
+ if (!Array.isArray(globs) || globs.length === 0) return false;
600
+ const normalized = filePath.split(path.sep).join('/');
601
+ for (const glob of globs) {
602
+ try {
603
+ const re = globToRegex(String(glob));
604
+ if (re.test(normalized)) return true;
605
+ // Match against basename too for convenience: `*.generated.tsx` should
606
+ // catch `src/foo.generated.tsx` without requiring `**/`.
607
+ const base = normalized.split('/').pop();
608
+ if (re.test(base)) return true;
609
+ } catch {
610
+ /* malformed glob, skip */
611
+ }
612
+ }
613
+ return false;
614
+ }
615
+
616
+ export function filterFindings(findings, _content, _ext, config) {
617
+ if (!Array.isArray(findings) || findings.length === 0) return [];
618
+ const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
619
+ const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
620
+ return findings.filter((f) => {
621
+ if (!f || typeof f !== 'object') return false;
622
+ if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
623
+ if (isIgnoredFindingValue(f, ignoreValues)) return false;
624
+ return true;
625
+ });
626
+ }
627
+
628
+ function isIgnoredFindingValue(finding, ignoreValues) {
629
+ if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
630
+ const rule = normalizeIgnoreRule(finding.antipattern);
631
+ const value = extractFindingIgnoreValue(finding);
632
+ if (!rule || !value) return false;
633
+ return ignoreValues.some((entry) => {
634
+ const wildcardValue = entry.value === '*';
635
+ if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
636
+ if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
637
+ return findingMatchesScopedIgnoreFile(finding, entry.files);
638
+ });
639
+ }
640
+
641
+ function findingMatchesScopedIgnoreFile(finding, globs) {
642
+ const filePath = String(finding?.file || '').trim();
643
+ if (!filePath) return false;
644
+ if (matchesAnyGlob(filePath, globs)) return true;
645
+
646
+ const normalized = filePath.split(path.sep).join('/');
647
+ const parts = normalized.split('/').filter(Boolean);
648
+ for (let i = 0; i < parts.length; i++) {
649
+ const suffix = parts.slice(i).join('/');
650
+ if (matchesAnyGlob(suffix, globs)) return true;
651
+ }
652
+ return false;
653
+ }
654
+
655
+ export function extractFindingIgnoreValue(finding) {
656
+ if (!finding || typeof finding !== 'object') return '';
657
+ const rule = normalizeIgnoreRule(finding.antipattern);
658
+ const directValueRules = new Set([
659
+ 'overused-font',
660
+ 'bounce-easing',
661
+ 'design-system-font',
662
+ 'design-system-color',
663
+ 'design-system-radius',
664
+ ]);
665
+ if (!directValueRules.has(rule)) return '';
666
+ return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
667
+ }
668
+
669
+ function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
670
+ const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
671
+ if (direct) return direct;
672
+
673
+ const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
674
+ for (const text of candidates) {
675
+ if (rule === 'bounce-easing') {
676
+ const motion = extractMotionIgnoreValue(text);
677
+ if (motion) return motion;
678
+ continue;
679
+ }
680
+
681
+ const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
682
+ if (primary) return cleanIgnoreValueDisplay(primary[1]);
683
+
684
+ const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
685
+ if (family) return cleanIgnoreValueDisplay(family[1]);
686
+
687
+ const google = text.match(/[?&]family=([^&:;\n]+)/i);
688
+ if (google) {
689
+ try {
690
+ return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
691
+ } catch {
692
+ return cleanIgnoreValueDisplay(google[1]);
693
+ }
694
+ }
695
+ }
696
+
697
+ return '';
698
+ }
699
+
700
+ function extractMotionIgnoreValue(text) {
701
+ const tailwind = text.match(/\banimate-bounce\b/i);
702
+ if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
703
+
704
+ const bezier = text.match(/cubic-bezier\([^)]+\)/i);
705
+ if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
706
+
707
+ const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
708
+ if (animation) {
709
+ const token = animation[1]
710
+ .split(/[,\s]+/)
711
+ .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
712
+ if (token) return cleanIgnoreValueDisplay(token);
713
+ }
714
+
715
+ return '';
716
+ }
717
+
718
+ function cleanIgnoreValueDisplay(value) {
719
+ return String(value || '')
720
+ .trim()
721
+ .replace(/^["']|["']$/g, '')
722
+ .replace(/\+/g, ' ')
723
+ .replace(/\s+/g, ' ');
724
+ }
725
+
726
+ export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
727
+ if (!Array.isArray(findings) || findings.length === 0) return [];
728
+ const fileEntry = ensureFile(cache, sessionId, filePath);
729
+ const known = new Set(fileEntry.findings || []);
730
+ const fresh = [];
731
+ for (const f of findings) {
732
+ const key = findingCacheKey(f);
733
+ if (known.has(key)) continue;
734
+ known.add(key);
735
+ fresh.push(f);
736
+ }
737
+ return fresh;
738
+ }
739
+
740
+ export function rememberFindings(cache, sessionId, filePath, findings) {
741
+ const fileEntry = ensureFile(cache, sessionId, filePath);
742
+ const known = new Set(fileEntry.findings || []);
743
+ for (const f of findings) known.add(findingCacheKey(f));
744
+ fileEntry.findings = Array.from(known);
745
+ ensureSession(cache, sessionId).updatedAt = Date.now();
746
+ }
747
+
748
+ function findingCacheKey(finding) {
749
+ const line = finding?.line || 0;
750
+ const value = extractFindingIgnoreValue(finding);
751
+ if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`;
752
+ if (line > 0) return `${finding.antipattern}:${line}`;
753
+ if (value) return `${finding.antipattern}:0:${value}`;
754
+ const snippet = String(finding?.snippet || '').trim().slice(0, 80);
755
+ return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`;
756
+ }
757
+
758
+ export function renderTemplate(findings, filePath, config, opts = {}) {
759
+ if (!Array.isArray(findings) || findings.length === 0) return '';
760
+ const limits = config?.limits || DEFAULT_CONFIG.limits;
761
+ const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
762
+ const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
763
+
764
+ const cwd = opts.cwd || process.cwd();
765
+ const display = relativize(filePath, cwd);
766
+ const total = findings.length;
767
+ const shown = findings.slice(0, cap);
768
+ const remaining = total - shown.length;
769
+
770
+ const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
771
+ const lines = shown.map((f) => formatFindingLine(f));
772
+ const more = remaining > 0
773
+ ? `... and ${remaining} more (see /impeccable audit).`
774
+ : null;
775
+ const footer = directiveFooter(display);
776
+
777
+ const blocks = [header, ...lines];
778
+ if (more) blocks.push(more);
779
+ blocks.push('');
780
+ blocks.push(footer);
781
+ let text = blocks.join('\n');
782
+
783
+ if (text.length > maxChars) {
784
+ text = clampToBudget(header, lines, more, footer, maxChars);
785
+ }
786
+ return text;
787
+ }
788
+
789
+ function renderGroupedTemplate(groups, config, opts = {}) {
790
+ const realGroups = groups.filter((group) => Array.isArray(group.findings) && group.findings.length > 0);
791
+ if (realGroups.length === 0) return '';
792
+ if (realGroups.length === 1) {
793
+ const [group] = realGroups;
794
+ return renderTemplate(group.findings, group.filePath, config, opts);
795
+ }
796
+
797
+ const limits = config?.limits || DEFAULT_CONFIG.limits;
798
+ const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
799
+ const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
800
+ const cwd = opts.cwd || process.cwd();
801
+ const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
802
+ const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
803
+ const lines = [];
804
+ let shownCount = 0;
805
+
806
+ for (const group of realGroups) {
807
+ const display = relativize(group.filePath, cwd);
808
+ lines.push(`${display} (${group.findings.length} issue(s)):`);
809
+ const remainingCap = Math.max(0, cap - shownCount);
810
+ const shown = group.findings.slice(0, remainingCap);
811
+ for (const finding of shown) {
812
+ lines.push(formatFindingLine(finding));
813
+ }
814
+ shownCount += shown.length;
815
+ const hidden = group.findings.length - shown.length;
816
+ if (hidden > 0) {
817
+ lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
818
+ }
819
+ }
820
+
821
+ const footer = directiveFooter('the affected files', { grouped: true });
822
+ let text = [header, ...lines, '', footer].join('\n');
823
+ if (text.length > maxChars) {
824
+ text = clampGroupedToBudget(header, lines, footer, maxChars);
825
+ }
826
+ return text;
827
+ }
828
+
829
+ function clampGroupedToBudget(header, lines, footer, maxChars) {
830
+ const assemble = (linesArr, omitted) => [
831
+ header,
832
+ ...linesArr,
833
+ ...(omitted ? ['... and more (see /impeccable audit).'] : []),
834
+ '',
835
+ footer,
836
+ ].join('\n');
837
+
838
+ let working = lines.slice();
839
+ let omitted = false;
840
+ let assembled = assemble(working, omitted);
841
+ while (assembled.length > maxChars && working.length > 1) {
842
+ working.pop();
843
+ omitted = true;
844
+ assembled = assemble(working, omitted);
845
+ }
846
+ if (assembled.length > maxChars) {
847
+ assembled = `${assembled.slice(0, maxChars - 1)}…`;
848
+ }
849
+ return assembled;
850
+ }
851
+
852
+ function clampToBudget(header, lines, more, footer, maxChars) {
853
+ const assemble = (linesArr, moreText) => {
854
+ const blocks = [header, ...linesArr];
855
+ if (moreText) blocks.push(moreText);
856
+ blocks.push('');
857
+ blocks.push(footer);
858
+ return blocks.join('\n');
859
+ };
860
+
861
+ let working = lines.slice();
862
+ let moreText = more;
863
+ let assembled = assemble(working, moreText);
864
+ while (assembled.length > maxChars && working.length > 1) {
865
+ working.pop();
866
+ moreText = '... and more (see /impeccable audit).';
867
+ assembled = assemble(working, moreText);
868
+ }
869
+ if (assembled.length > maxChars) {
870
+ assembled = `${assembled.slice(0, maxChars - 1)}…`;
871
+ }
872
+ return assembled;
873
+ }
874
+
875
+ function formatFindingLine(f) {
876
+ const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
877
+ const desc = (f.description || '').trim();
878
+ const name = (f.name || '').trim();
879
+ // Description from the registry already ends in punctuation; join with a
880
+ // single space. `name` may have a trailing period already, keep it clean.
881
+ const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
882
+ const ignoreCommand = formatFindingIgnoreCommand(f);
883
+ const ignoreSegment = ignoreCommand
884
+ ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
885
+ : '';
886
+ return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
887
+ }
888
+
889
+ function formatFindingIgnoreCommand(finding) {
890
+ if (!finding || typeof finding !== 'object') return '';
891
+ const rule = normalizeIgnoreRule(finding.antipattern);
892
+ if (!rule) return '';
893
+ const normalizedValue = extractFindingIgnoreValue(finding);
894
+ if (!normalizedValue) return '';
895
+ const value = extractFindingIgnoreValueRaw(finding);
896
+ const valueArg = quoteCommandArg(value);
897
+ const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
898
+ return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
899
+ }
900
+
901
+ function quoteCommandArg(value) {
902
+ const text = String(value || '').trim();
903
+ if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
904
+ return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
905
+ }
906
+
907
+ function relativize(filePath, cwd) {
908
+ try {
909
+ const rel = path.relative(cwd, filePath);
910
+ if (!rel || rel.startsWith('..')) return filePath;
911
+ return rel.split(path.sep).join('/');
912
+ } catch {
913
+ return filePath;
914
+ }
915
+ }
916
+
917
+ // Codex `apply_patch` exposes the raw patch in `tool_input.command`, not
918
+ // `tool_input.file_path`. Claude Code may send both; parse the patch body
919
+ // so we can scan the file(s) the tool actually touched.
920
+ // https://developers.openai.com/codex/hooks#posttooluse
921
+ const APPLY_PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
922
+
923
+ export function parseApplyPatchPaths(command, projectCwd) {
924
+ if (!command || typeof command !== 'string') return [];
925
+ const out = [];
926
+ for (const m of command.matchAll(APPLY_PATCH_FILE_RE)) {
927
+ let p = (m[1] || '').trim();
928
+ if (!p) continue;
929
+ if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
930
+ out.push(p);
931
+ }
932
+ return out;
933
+ }
934
+
935
+ export function resolveTargetFiles(event, projectCwd) {
936
+ const ti = event?.tool_input;
937
+ const out = [];
938
+ const add = (filePath) => {
939
+ if (typeof filePath !== 'string' || !filePath) return;
940
+ if (!out.includes(filePath)) out.push(filePath);
941
+ };
942
+
943
+ if (event?.tool_name === 'apply_patch' && ti && typeof ti.command === 'string') {
944
+ for (const filePath of parseApplyPatchPaths(ti.command, projectCwd)) add(filePath);
945
+ }
946
+ if (ti && typeof ti.file_path === 'string' && ti.file_path) {
947
+ add(ti.file_path);
948
+ }
949
+ // Cursor Write / StrReplace use `path`, not `file_path`.
950
+ if (ti && typeof ti.path === 'string' && ti.path) {
951
+ add(ti.path);
952
+ }
953
+ if (typeof event?.file_path === 'string' && event.file_path) {
954
+ add(event.file_path);
955
+ }
956
+ return out;
957
+ }
958
+
959
+ export function resolveHarness(env = {}, event = null) {
960
+ const explicit = env?.IMPECCABLE_HOOK_HARNESS;
961
+ if (explicit === 'cursor') return 'cursor';
962
+ if (explicit === 'github') return 'github';
963
+ if (explicit === 'claude' || explicit === 'codex') return 'claude';
964
+ // GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
965
+ // has no `tool_name`/`tool_input`. That shape is the discriminator.
966
+ if (event && typeof event === 'object'
967
+ && (typeof event.toolName === 'string' || event.toolArgs !== undefined)
968
+ && event.tool_name === undefined && event.tool_input === undefined) {
969
+ return 'github';
970
+ }
971
+ if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
972
+ return 'claude';
973
+ }
974
+
975
+ // GitHub Copilot's postToolUse payload is
976
+ // { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
977
+ // mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
978
+ // `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
979
+ // JSON *string* (double-encoded) carrying the file under `path`, e.g.
980
+ // "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
981
+ // while `apply_patch` sends a raw OpenAI-format patch string (handled below in
982
+ // normalizeGitHubEvent). The detector reads the file from disk after the tool
983
+ // ran, so only the path (not the proposed content) is needed here.
984
+ export function parseGitHubToolArgs(toolArgs) {
985
+ if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
986
+ if (typeof toolArgs === 'string' && toolArgs.trim()) {
987
+ try {
988
+ const parsed = JSON.parse(toolArgs);
989
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
990
+ } catch {
991
+ return {};
992
+ }
993
+ }
994
+ return {};
995
+ }
996
+
997
+ // Copilot's `apply_patch` tool (used by interactive sessions and the cloud
998
+ // agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
999
+ // *** Begin Patch
1000
+ // *** Add File: /abs/app.css
1001
+ // +body { ... }
1002
+ // *** End Patch
1003
+ // The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
1004
+ // JSON string with the path under `path`. Both must map onto the internal shape.
1005
+ const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
1006
+
1007
+ function looksLikeApplyPatch(rawArgs) {
1008
+ if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
1009
+ // Guard against an edit/create payload whose edited *content* happens to
1010
+ // contain patch markers: that payload is a JSON object string, whereas a real
1011
+ // apply_patch payload is a raw patch string that does not parse as JSON. Only
1012
+ // treat non-JSON-object strings as apply_patch so edit events still get their
1013
+ // `path` extracted.
1014
+ try {
1015
+ const parsed = JSON.parse(rawArgs);
1016
+ if (parsed && typeof parsed === 'object') return false;
1017
+ } catch { /* not JSON → genuine raw patch */ }
1018
+ return true;
1019
+ }
1020
+
1021
+ function applyPatchText(rawArgs) {
1022
+ if (typeof rawArgs === 'string') {
1023
+ if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
1024
+ // Defensive: a future Copilot build might JSON-wrap the patch.
1025
+ const parsed = parseGitHubToolArgs(rawArgs);
1026
+ return parsed.patch || parsed.input || parsed.command || '';
1027
+ }
1028
+ if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
1029
+ return rawArgs.patch || rawArgs.input || rawArgs.command || '';
1030
+ }
1031
+ return '';
1032
+ }
1033
+
1034
+ function normalizeGitHubEvent(event, projectCwd) {
1035
+ const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
1036
+ const sessionId = event.sessionId || event.session_id || 'unknown';
1037
+ const toolName = event.toolName || event.tool_name || null;
1038
+ const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
1039
+ const rawArgs = event.toolArgs;
1040
+
1041
+ let normalizedToolName = toolName;
1042
+ if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
1043
+ // resolveTargetFiles() reads the touched paths from tool_input.command when
1044
+ // tool_name is 'apply_patch', so normalize the name even if a future build
1045
+ // sends the patch under a different tool label.
1046
+ const patch = applyPatchText(rawArgs);
1047
+ if (patch) {
1048
+ toolInput.command = patch;
1049
+ normalizedToolName = 'apply_patch';
1050
+ }
1051
+ } else {
1052
+ const args = parseGitHubToolArgs(rawArgs);
1053
+ const filePath = args.path || args.file_path || args.filePath || args.target_file;
1054
+ if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
1055
+ }
1056
+
1057
+ return {
1058
+ ...event,
1059
+ cwd,
1060
+ session_id: sessionId,
1061
+ tool_name: normalizedToolName,
1062
+ tool_input: toolInput,
1063
+ };
1064
+ }
1065
+
1066
+ export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
1067
+ if (!event || typeof event !== 'object') return event;
1068
+ if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
1069
+ if (harness !== 'cursor') return event;
1070
+
1071
+ const cwd = event.cwd
1072
+ || (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
1073
+ || envProjectDir(projectCwd)
1074
+ || projectCwd;
1075
+ const sessionId = event.session_id || event.conversation_id || 'unknown';
1076
+
1077
+ const ti = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
1078
+ const filePath = ti.file_path || ti.path || event.file_path;
1079
+ if (filePath) {
1080
+ return {
1081
+ ...event,
1082
+ cwd,
1083
+ session_id: sessionId,
1084
+ tool_input: { ...ti, file_path: filePath },
1085
+ };
1086
+ }
1087
+
1088
+ return { ...event, cwd, session_id: sessionId };
1089
+ }
1090
+
1091
+ function envProjectDir(fallback) {
1092
+ if (typeof process.env.CURSOR_PROJECT_DIR === 'string' && process.env.CURSOR_PROJECT_DIR) {
1093
+ return process.env.CURSOR_PROJECT_DIR;
1094
+ }
1095
+ return fallback;
1096
+ }
1097
+
1098
+ // UI components often keep slop in a sibling/co-located stylesheet while the
1099
+ // JSX edit is what triggered PostToolUse. Scan those styles too so an App.jsx
1100
+ // patch doesn't report "clean" while styles.css still has Inter/bounce/etc.
1101
+ const UI_CODE_EXTS = new Set(['.jsx', '.tsx', '.vue', '.svelte', '.astro']);
1102
+ const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
1103
+ const CO_SCAN_STYLE_NAMES = [
1104
+ 'styles.css', 'styles.scss', 'styles.sass', 'styles.less',
1105
+ 'index.css', 'index.scss', 'index.sass', 'index.less',
1106
+ 'global.css', 'global.scss', 'global.sass', 'global.less',
1107
+ 'globals.css', 'globals.scss', 'globals.sass', 'globals.less',
1108
+ ];
1109
+ const MAX_SCAN_TARGETS = 6;
1110
+
1111
+ const STATIC_STYLE_IMPORT_RE = /import\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]/gi;
1112
+
1113
+ function hasPathTraversal(filePath) {
1114
+ return typeof filePath === 'string' && filePath.includes('..');
1115
+ }
1116
+
1117
+ function isInsideProject(filePath, projectCwd) {
1118
+ if (!filePath || !projectCwd || hasPathTraversal(filePath)) return false;
1119
+ try {
1120
+ const rel = path.relative(projectCwd, filePath);
1121
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
1122
+ } catch {
1123
+ return false;
1124
+ }
1125
+ }
1126
+
1127
+ export function parseStaticStyleImports(content, fromFile, projectCwd) {
1128
+ if (!content || typeof content !== 'string') return [];
1129
+ const dir = path.dirname(fromFile);
1130
+ const out = [];
1131
+ for (const m of content.matchAll(STATIC_STYLE_IMPORT_RE)) {
1132
+ let p = (m[1] || '').trim();
1133
+ if (!p) continue;
1134
+ if (p.startsWith('.')) p = path.resolve(dir, p);
1135
+ else if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
1136
+ if (!isInsideProject(p, projectCwd)) continue;
1137
+ out.push(p);
1138
+ }
1139
+ return out;
1140
+ }
1141
+
1142
+ export function coLocatedStylesheets(filePath) {
1143
+ const dir = path.dirname(filePath);
1144
+ const base = path.basename(filePath, path.extname(filePath));
1145
+ const candidates = new Set([
1146
+ path.join(dir, `${base}.css`),
1147
+ path.join(dir, `${base}.module.css`),
1148
+ path.join(dir, `${base}.scss`),
1149
+ path.join(dir, `${base}.module.scss`),
1150
+ path.join(dir, `${base}.sass`),
1151
+ path.join(dir, `${base}.module.sass`),
1152
+ path.join(dir, `${base}.less`),
1153
+ path.join(dir, `${base}.module.less`),
1154
+ ]);
1155
+ for (const name of CO_SCAN_STYLE_NAMES) {
1156
+ candidates.add(path.join(dir, name));
1157
+ }
1158
+ return [...candidates].filter((p) => fs.existsSync(p));
1159
+ }
1160
+
1161
+ export function normalizeScanTargets(primaryTargets, projectCwd) {
1162
+ if (!Array.isArray(primaryTargets) || primaryTargets.length === 0) return [];
1163
+ const ordered = [];
1164
+ const seen = new Set();
1165
+ const baseCwd = projectCwd || process.cwd();
1166
+ const normalizeTarget = (p) => {
1167
+ // Preserve literal `..` segments so downstream sensitive-path checks
1168
+ // still fire. path.resolve would collapse `/foo/../etc/passwd`.
1169
+ if (hasPathTraversal(p)) return p;
1170
+ return path.isAbsolute(p) ? p : path.resolve(baseCwd, p);
1171
+ };
1172
+ const add = (p) => {
1173
+ if (ordered.length >= MAX_SCAN_TARGETS) return;
1174
+ const abs = normalizeTarget(p);
1175
+ if (seen.has(abs)) return;
1176
+ seen.add(abs);
1177
+ ordered.push(abs);
1178
+ return abs;
1179
+ };
1180
+
1181
+ for (const p of primaryTargets) add(p);
1182
+ return ordered;
1183
+ }
1184
+
1185
+ export function expandScanTargets(primaryTargets, projectCwd) {
1186
+ const ordered = normalizeScanTargets(primaryTargets, projectCwd);
1187
+ if (ordered.length === 0) return [];
1188
+ const seen = new Set(ordered);
1189
+ const baseCwd = projectCwd || process.cwd();
1190
+ const add = (p) => {
1191
+ if (ordered.length >= MAX_SCAN_TARGETS) return;
1192
+ const abs = hasPathTraversal(p) ? p : (path.isAbsolute(p) ? p : path.resolve(baseCwd, p));
1193
+ if (seen.has(abs)) return;
1194
+ seen.add(abs);
1195
+ ordered.push(abs);
1196
+ return abs;
1197
+ };
1198
+
1199
+ const normalizedPrimaries = [];
1200
+ for (const p of ordered) normalizedPrimaries.push(p);
1201
+
1202
+ for (const p of normalizedPrimaries) {
1203
+ if (ordered.length >= MAX_SCAN_TARGETS) break;
1204
+ if (!isInsideProject(p, baseCwd)) continue;
1205
+ const ext = path.extname(p).toLowerCase();
1206
+ if (STYLE_EXTS.has(ext) || !UI_CODE_EXTS.has(ext)) continue;
1207
+
1208
+ let content = '';
1209
+ try { content = fs.readFileSync(p, 'utf-8'); } catch { /* unreadable primary */ }
1210
+
1211
+ for (const imp of parseStaticStyleImports(content, p, projectCwd)) {
1212
+ add(imp);
1213
+ if (ordered.length >= MAX_SCAN_TARGETS) break;
1214
+ }
1215
+ for (const col of coLocatedStylesheets(p)) {
1216
+ add(col);
1217
+ if (ordered.length >= MAX_SCAN_TARGETS) break;
1218
+ }
1219
+ }
1220
+
1221
+ return ordered;
1222
+ }
1223
+
1224
+ export function writeAuditLog(env, entry, cwd = process.cwd()) {
1225
+ // The event's project root (entry.cwd) when present, else the passed cwd. Both
1226
+ // config reads and relative log paths resolve against this, since the hook
1227
+ // process cwd can differ from the project being edited.
1228
+ const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
1229
+ // Env wins; otherwise fall back to the unified config's hook.auditLog path.
1230
+ let target = env?.IMPECCABLE_HOOK_LOG;
1231
+ if (!target || typeof target !== 'string') {
1232
+ try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
1233
+ }
1234
+ if (!target || typeof target !== 'string') return false;
1235
+ try {
1236
+ let expanded;
1237
+ if (target.startsWith('~/')) {
1238
+ expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
1239
+ } else if (path.isAbsolute(target)) {
1240
+ expanded = target;
1241
+ } else {
1242
+ expanded = path.resolve(baseCwd, target);
1243
+ }
1244
+ fs.mkdirSync(path.dirname(expanded), { recursive: true });
1245
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
1246
+ fs.appendFileSync(expanded, line);
1247
+ return true;
1248
+ } catch {
1249
+ return false;
1250
+ }
1251
+ }
1252
+
1253
+ const DETECTOR_CANDIDATES = [
1254
+ path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
1255
+ path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
1256
+ path.join(__dirname, '..', '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
1257
+ ];
1258
+
1259
+ let detectorCache = null;
1260
+ export async function loadDetector(candidates = DETECTOR_CANDIDATES) {
1261
+ if (detectorCache) return detectorCache;
1262
+ const found = candidates.find((c) => fs.existsSync(c));
1263
+ if (!found) return null;
1264
+ const mod = await import(pathToFileURL(found));
1265
+ detectorCache = {
1266
+ detectText: mod.detectText,
1267
+ detectHtml: mod.detectHtml,
1268
+ loadDesignSystemForCwd: mod.loadDesignSystemForCwd,
1269
+ };
1270
+ return detectorCache;
1271
+ }
1272
+
1273
+ // For tests: allow injecting a detector implementation.
1274
+ export function setDetectorForTesting(impl) {
1275
+ detectorCache = impl;
1276
+ }
1277
+
1278
+ // ────────────────────────────────────────────────────────────────────────
1279
+ // Nudge/steer messages for the no-silent-fires policy.
1280
+ //
1281
+ // The hook is designed to be a conversational presence: every fire that
1282
+ // actually scans a file emits a developer-role message into the model's
1283
+ // next turn. Three states map to three templates:
1284
+ //
1285
+ // 1. **Fresh findings** → `renderTemplate` (existing, imperative).
1286
+ // 2. **Pending findings** → `renderPendingAck` (re-nudge for issues the
1287
+ // model was already told about in this
1288
+ // session but hasn't fixed yet).
1289
+ // 3. **Truly clean** → `renderCleanAck` (short positive nudge that
1290
+ // keeps the design discipline in context).
1291
+ //
1292
+ // All three are short (≤ ~40 tokens each) so the cumulative cost stays
1293
+ // bounded across a long active editing session. Users who explicitly want
1294
+ // silence-on-clean can set `IMPECCABLE_HOOK_QUIET=1` — runHook checks that
1295
+ // env before emitting #2 or #3.
1296
+ //
1297
+ // Why not stay silent on dedup-clean? Earlier versions did. The model
1298
+ // quickly forgets the prior reminder once tool output scrolls past it, so
1299
+ // re-nudging on the same file with a short "still pending" line keeps the
1300
+ // pressure on. The wording deliberately points back to "earlier this
1301
+ // session" so the model knows it's a re-mind, not a new finding.
1302
+ // ────────────────────────────────────────────────────────────────────────
1303
+
1304
+ const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
1305
+
1306
+ export function renderCleanAck(filePath, opts = {}) {
1307
+ const cwd = opts.cwd || process.cwd();
1308
+ const display = relativize(filePath, cwd);
1309
+ return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
1310
+ }
1311
+
1312
+ export function renderPendingAck(filePath, knownFindings, opts = {}) {
1313
+ const cwd = opts.cwd || process.cwd();
1314
+ const display = relativize(filePath, cwd);
1315
+ const count = knownFindings.length;
1316
+ // `knownFindings` here are the cache strings like "side-tab:3".
1317
+ const sample = knownFindings.slice(0, 3).join(', ');
1318
+ const more = count > 3 ? `, +${count - 3} more` : '';
1319
+ 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.`;
1320
+ }
1321
+
1322
+ export function shouldEmitAckForFile(filePath) {
1323
+ return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
1324
+ }
1325
+
1326
+ export function designSystemOptions(config, detector, projectCwd) {
1327
+ if (config?.designSystem?.enabled === false) return {};
1328
+ if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {};
1329
+ try {
1330
+ const designSystem = detector.loadDesignSystemForCwd(projectCwd);
1331
+ return designSystem ? { designSystem } : {};
1332
+ } catch {
1333
+ return {};
1334
+ }
1335
+ }
1336
+
1337
+ export function appendDesignSystemNote(text, scanOptions) {
1338
+ if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
1339
+ return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
1340
+ }
1341
+
1342
+ // The directive footer is the part of the hook output that steers model
1343
+ // behavior. Three intentional moves:
1344
+ // 1. **Imperative, not advisory.** "Handle these..." beats "Consider
1345
+ // revising..." which the model treats as a soft suggestion it can
1346
+ // override when the user asked for any kind of throwaway / demo UI.
1347
+ // 2. **Explicit judgment clause.** Without it, the model will try to
1348
+ // "fix" intentional motion, bad fixtures, anti-pattern examples in
1349
+ // docs, or test cases. Naming the judgment inline beats hoping the
1350
+ // model infers it from context.
1351
+ // 3. **Acknowledgement instruction.** Hook output is injected as
1352
+ // developer-role context, not a chat turn, so the user never sees the
1353
+ // raw envelope. Asking the model to surface the resolution in its
1354
+ // reply is the cheapest way to make the feedback loop visible.
1355
+ function directiveFooter(display, opts = {}) {
1356
+ const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
1357
+ const fileIgnoreGuidance = opts.grouped
1358
+ ? 'run `/impeccable hooks ignore-file <path>` for the specific file'
1359
+ : `run \`${ignoreFileCommand}\``;
1360
+ return [
1361
+ '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.',
1362
+ '',
1363
+ '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.',
1364
+ '',
1365
+ `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. 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.`,
1366
+ ].join('\n');
1367
+ }
1368
+
1369
+ /**
1370
+ * Run the hook with explicit dependencies. Returns a result object:
1371
+ * { exitCode, stdout, audit, reason? }
1372
+ *
1373
+ * Never throws. All errors are converted to `exitCode: 0` + audit entry.
1374
+ */
1375
+ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
1376
+ const audit = { ts: new Date(now()).toISOString(), event: 'PostToolUse' };
1377
+ const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
1378
+
1379
+ try {
1380
+ // Re-entrancy guard.
1381
+ if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
1382
+ return result({ reentrant: true, durationMs: 0 });
1383
+ }
1384
+
1385
+ if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
1386
+ return result({ skipped: 'env-disabled', durationMs: 0 });
1387
+ }
1388
+
1389
+ const started = Date.now();
1390
+
1391
+ let event;
1392
+ try {
1393
+ event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
1394
+ } catch {
1395
+ return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
1396
+ }
1397
+ if (!event || typeof event !== 'object') {
1398
+ return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
1399
+ }
1400
+
1401
+ const harness = resolveHarness(env, event);
1402
+ event = normalizeHookEvent(event, cwd, harness);
1403
+ audit.harness = harness;
1404
+
1405
+ const projectCwd = event.cwd || cwd;
1406
+ audit.cwd = projectCwd;
1407
+ const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
1408
+ const primaryFileSet = new Set(primaryFiles);
1409
+ const targetFiles = expandScanTargets(primaryFiles, projectCwd);
1410
+ audit.session = event.session_id || null;
1411
+ if (event.tool_name) audit.tool = event.tool_name;
1412
+
1413
+ if (targetFiles.length === 0) {
1414
+ return result({ skipped: 'no-file-path', durationMs: Date.now() - started });
1415
+ }
1416
+
1417
+ const config = readConfig(projectCwd);
1418
+ if (config.enabled === false) {
1419
+ return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
1420
+ }
1421
+
1422
+ const cache = readCache(projectCwd);
1423
+ const sessionId = event.session_id || 'unknown';
1424
+ const det = detector || await loadDetector();
1425
+ if (!det || typeof det.detectText !== 'function') {
1426
+ persistCache(projectCwd, cache);
1427
+ return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
1428
+ }
1429
+ const scanOptions = designSystemOptions(config, det, projectCwd);
1430
+
1431
+ let pendingWinner = null;
1432
+ let cleanWinner = null;
1433
+ const freshGroups = [];
1434
+ let suppressionWinner = null;
1435
+ let detectorThrewAny = false;
1436
+ let lastSkip = 'no-scannable-file';
1437
+ let suppressedHit = false;
1438
+
1439
+ for (const filePath of targetFiles) {
1440
+ audit.file = filePath;
1441
+
1442
+ if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) {
1443
+ lastSkip = 'sensitive';
1444
+ continue;
1445
+ }
1446
+ if (GENERATED_PATH.test(filePath)) {
1447
+ lastSkip = 'generated';
1448
+ continue;
1449
+ }
1450
+
1451
+ const ext = path.extname(filePath).toLowerCase();
1452
+ audit.ext = ext;
1453
+ if (!ALLOWED_EXTS.has(ext)) {
1454
+ lastSkip = 'extension';
1455
+ continue;
1456
+ }
1457
+
1458
+ const relForMatch = relativize(filePath, projectCwd);
1459
+ if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
1460
+ lastSkip = 'config-ignore-file';
1461
+ continue;
1462
+ }
1463
+ if (!fs.existsSync(filePath)) {
1464
+ lastSkip = 'file-missing';
1465
+ continue;
1466
+ }
1467
+
1468
+ if (primaryFileSet.has(filePath)) {
1469
+ const editCount = bumpEditCount(cache, sessionId, filePath);
1470
+ audit.editCount = editCount;
1471
+
1472
+ if (editCount > EDIT_COUNT_THRESHOLD) {
1473
+ const wasJustCrossed = editCount === EDIT_COUNT_THRESHOLD + 1;
1474
+ if (wasJustCrossed && !suppressionWinner) {
1475
+ suppressionWinner = { filePath };
1476
+ }
1477
+ lastSkip = 'suppressed';
1478
+ suppressedHit = true;
1479
+ continue;
1480
+ }
1481
+ }
1482
+
1483
+ const content = fs.readFileSync(filePath, 'utf-8');
1484
+ let findings;
1485
+ let detectorThrew = false;
1486
+ if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
1487
+ try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
1488
+ } else {
1489
+ try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
1490
+ }
1491
+
1492
+ const filtered = filterFindings(findings || [], content, ext, config);
1493
+ const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
1494
+ audit.findings = (findings || []).length;
1495
+ audit.freshFindings = fresh.length;
1496
+
1497
+ if (fresh.length > 0) {
1498
+ rememberFindings(cache, sessionId, filePath, fresh);
1499
+ freshGroups.push({ filePath, findings: fresh });
1500
+ continue;
1501
+ }
1502
+
1503
+ if (detectorThrew) {
1504
+ detectorThrewAny = true;
1505
+ continue;
1506
+ }
1507
+
1508
+ if (filtered.length > 0 && !pendingWinner) {
1509
+ const known = (ensureFile(cache, sessionId, filePath).findings || []).slice();
1510
+ pendingWinner = { filePath, known };
1511
+ } else if (filtered.length === 0 && !cleanWinner) {
1512
+ cleanWinner = { filePath };
1513
+ }
1514
+ }
1515
+
1516
+ persistCache(projectCwd, cache);
1517
+
1518
+ if (freshGroups.length > 0) {
1519
+ const firstGroup = freshGroups[0];
1520
+ const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
1521
+ const allFindings = freshGroups.flatMap((group) => group.findings);
1522
+ return {
1523
+ exitCode: 0,
1524
+ stdout: payload(text, 'PostToolUse', harness),
1525
+ emission: {
1526
+ kind: 'fresh',
1527
+ file: firstGroup.filePath,
1528
+ findings: firstGroup.findings,
1529
+ groups: freshGroups,
1530
+ },
1531
+ audit: {
1532
+ ...audit,
1533
+ file: firstGroup.filePath,
1534
+ emitted: true,
1535
+ freshFiles: freshGroups.length,
1536
+ freshFindings: allFindings.length,
1537
+ chars: text.length,
1538
+ durationMs: Date.now() - started,
1539
+ },
1540
+ };
1541
+ }
1542
+
1543
+ if (detectorThrewAny && !pendingWinner && !cleanWinner) {
1544
+ return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
1545
+ }
1546
+
1547
+ if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
1548
+ return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
1549
+ }
1550
+
1551
+ if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
1552
+ const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
1553
+ return {
1554
+ exitCode: 0,
1555
+ stdout: payload(text, 'PostToolUse', harness),
1556
+ emission: { kind: 'pending', file: pendingWinner.filePath, known: pendingWinner.known },
1557
+ audit: {
1558
+ ...audit,
1559
+ file: pendingWinner.filePath,
1560
+ emitted: true,
1561
+ kind: 'pending',
1562
+ pending: pendingWinner.known.length,
1563
+ chars: text.length,
1564
+ durationMs: Date.now() - started,
1565
+ },
1566
+ };
1567
+ }
1568
+
1569
+ if (suppressionWinner) {
1570
+ const text = suppressionNotice(relativize(suppressionWinner.filePath, projectCwd));
1571
+ return {
1572
+ exitCode: 0,
1573
+ stdout: payload(text, 'PostToolUse', harness),
1574
+ emission: { kind: 'suppression', file: suppressionWinner.filePath },
1575
+ audit: {
1576
+ ...audit,
1577
+ file: suppressionWinner.filePath,
1578
+ suppressed: true,
1579
+ emitted: true,
1580
+ durationMs: Date.now() - started,
1581
+ },
1582
+ };
1583
+ }
1584
+
1585
+ if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
1586
+ const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
1587
+ return {
1588
+ exitCode: 0,
1589
+ stdout: payload(text, 'PostToolUse', harness),
1590
+ emission: { kind: 'clean', file: cleanWinner.filePath },
1591
+ audit: {
1592
+ ...audit,
1593
+ file: cleanWinner.filePath,
1594
+ emitted: true,
1595
+ kind: 'clean',
1596
+ chars: text.length,
1597
+ durationMs: Date.now() - started,
1598
+ },
1599
+ };
1600
+ }
1601
+
1602
+ if (pendingWinner || cleanWinner) {
1603
+ return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
1604
+ }
1605
+
1606
+ if (suppressedHit) {
1607
+ return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });
1608
+ }
1609
+
1610
+ return result({ skipped: lastSkip, durationMs: Date.now() - started });
1611
+ } catch (err) {
1612
+ return {
1613
+ exitCode: 0,
1614
+ stdout: '',
1615
+ audit: { ...audit, error: String(err && err.message ? err.message : err) },
1616
+ };
1617
+ }
1618
+ }
1619
+
1620
+ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
1621
+ if (harness === 'cursor') {
1622
+ return JSON.stringify({ additional_context: text });
1623
+ }
1624
+ // GitHub Copilot's postToolUse hook injects context via a top-level
1625
+ // `additionalContext` string (alongside an optional `modifiedResult`).
1626
+ if (harness === 'github') {
1627
+ return JSON.stringify({ additionalContext: text });
1628
+ }
1629
+ return JSON.stringify({
1630
+ hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
1631
+ });
1632
+ }