quiver-cli 0.7.0 → 1.0.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 (200) hide show
  1. package/README.md +110 -45
  2. package/bin/quiver-cli.mjs +3 -1
  3. package/dist/cli.js +733 -499
  4. package/package.json +2 -2
  5. package/template/.agents/AGENTS.md +4 -3
  6. package/template/.agents/config.json +7 -0
  7. package/template/.agents/plugins/opencode/rtk.ts +34 -0
  8. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  9. package/template/.agents/skills/apps/skybridge/SKILL.md +4 -0
  10. package/template/.agents/skills/design/impeccable/SKILL.md +36 -118
  11. package/template/.agents/skills/design/impeccable/reference/adapt.md +1 -0
  12. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +58 -0
  13. package/template/.agents/skills/design/impeccable/reference/android.md +40 -0
  14. package/template/.agents/skills/design/impeccable/reference/animate.md +73 -188
  15. package/template/.agents/skills/design/impeccable/reference/audit.md +12 -9
  16. package/template/.agents/skills/design/impeccable/reference/audit.native.md +139 -0
  17. package/template/.agents/skills/design/impeccable/reference/bolder.md +19 -101
  18. package/template/.agents/skills/design/impeccable/reference/clarify.md +59 -253
  19. package/template/.agents/skills/design/impeccable/reference/colorize.md +51 -222
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +42 -0
  21. package/template/.agents/skills/design/impeccable/reference/craft.md +3 -121
  22. package/template/.agents/skills/design/impeccable/reference/critique.md +44 -23
  23. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +90 -0
  24. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +24 -0
  25. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +37 -0
  26. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  27. package/template/.agents/skills/design/impeccable/reference/delight.md +47 -279
  28. package/template/.agents/skills/design/impeccable/reference/distill.md +2 -2
  29. package/template/.agents/skills/design/impeccable/reference/doctor.md +53 -0
  30. package/template/.agents/skills/design/impeccable/reference/document.md +60 -73
  31. package/template/.agents/skills/design/impeccable/reference/harden.md +1 -12
  32. package/template/.agents/skills/design/impeccable/reference/hooks.md +20 -5
  33. package/template/.agents/skills/design/impeccable/reference/init.md +72 -119
  34. package/template/.agents/skills/design/impeccable/reference/ios.md +45 -0
  35. package/template/.agents/skills/design/impeccable/reference/layout.md +54 -131
  36. package/template/.agents/skills/design/impeccable/reference/live-setup.md +102 -0
  37. package/template/.agents/skills/design/impeccable/reference/live.md +116 -511
  38. package/template/.agents/skills/design/impeccable/reference/new-work.md +105 -0
  39. package/template/.agents/skills/design/impeccable/reference/{product.md → operate.md} +6 -5
  40. package/template/.agents/skills/design/impeccable/reference/optimize.md +4 -4
  41. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -4
  42. package/template/.agents/skills/design/impeccable/reference/polish.md +68 -212
  43. package/template/.agents/skills/design/impeccable/reference/quieter.md +3 -3
  44. package/template/.agents/skills/design/impeccable/reference/routing.md +18 -0
  45. package/template/.agents/skills/design/impeccable/reference/shape.md +38 -144
  46. package/template/.agents/skills/design/impeccable/reference/typeset.md +51 -250
  47. package/template/.agents/skills/design/impeccable/reference/visualize.md +47 -0
  48. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +1 -1
  49. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +558 -0
  50. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +119 -10
  51. package/template/.agents/skills/design/impeccable/scripts/context.mjs +534 -45
  52. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +18 -47
  53. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
  54. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +174 -26
  55. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +233 -0
  56. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +3348 -203
  57. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
  58. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +297 -97
  59. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +187 -16
  60. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +41 -11
  61. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +7 -1
  62. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +16 -2
  63. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +207 -38
  64. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +3082 -173
  65. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +11 -0
  66. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +30 -0
  67. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +336 -0
  68. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +133 -0
  69. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +240 -0
  70. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +98 -18
  71. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +46 -6
  72. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +518 -50
  73. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +25 -8
  74. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +93 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +200 -0
  76. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +357 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +27 -7
  78. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +17 -8
  79. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +5 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +362 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +457 -0
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +169 -0
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +457 -0
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +151 -0
  85. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +33 -0
  86. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +146 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +617 -0
  88. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +60 -0
  89. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +7 -1
  90. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +10 -1
  91. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +67 -5
  92. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +47 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +73 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +143 -0
  95. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +197 -0
  96. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
  97. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +161 -0
  98. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +17 -0
  99. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
  100. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
  101. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
  102. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
  103. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
  104. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +149 -0
  105. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +142 -0
  106. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +14 -0
  107. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +508 -0
  108. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +324 -50
  109. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +105 -0
  110. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +105 -0
  111. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +961 -0
  112. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +588 -72
  113. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +59 -17
  114. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
  115. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +135 -0
  116. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +210 -68
  117. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1562 -223
  118. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
  119. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +33 -1
  120. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +175 -255
  121. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +26 -6
  122. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
  123. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +61 -16
  124. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +39 -10
  125. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +573 -47
  126. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +17 -7
  127. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +124 -91
  128. package/template/.agents/skills/design/impeccable/scripts/live.mjs +88 -26
  129. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +76 -81
  130. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +18 -11
  131. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +932 -0
  132. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +74 -0
  133. package/template/.agents/skills/design/shadcn/SKILL.md +46 -11
  134. package/template/.agents/skills/design/shadcn/cli.md +49 -16
  135. package/template/.agents/skills/design/shadcn/customization.md +14 -7
  136. package/template/.agents/skills/design/shadcn/evals/evals.json +30 -0
  137. package/template/.agents/skills/design/shadcn/mcp.md +27 -16
  138. package/template/.agents/skills/design/shadcn/registry.md +277 -0
  139. package/template/.agents/skills/design/shadcn/rules/chat.md +224 -0
  140. package/template/.agents/skills/design/shadcn/rules/composition.md +20 -2
  141. package/template/.agents/skills/design/shadcn/rules/styling.md +23 -0
  142. package/template/.agents/skills/find-skills/SKILL.md +2 -3
  143. package/template/.agents/skills/hono/SKILL.md +579 -0
  144. package/template/.agents/skills/integrations/langfuse/SKILL.md +10 -6
  145. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +41 -0
  146. package/template/.agents/skills/integrations/langfuse/references/cli.md +8 -0
  147. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +15 -27
  148. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +39 -52
  149. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +5 -2
  150. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +35 -0
  151. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +41 -196
  152. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +3 -0
  153. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +76 -0
  154. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -0
  155. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +73 -0
  156. package/template/.agents/skills/supabase/CHANGELOG.md +71 -0
  157. package/template/.agents/skills/supabase/SKILL.md +145 -0
  158. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +17 -0
  159. package/template/.agents/skills/supabase/references/skill-feedback.md +17 -0
  160. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +73 -0
  161. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +64 -0
  162. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
  163. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
  164. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +34 -0
  165. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
  166. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
  167. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
  168. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
  169. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
  170. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
  171. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
  172. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
  173. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
  174. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
  175. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
  176. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
  177. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
  178. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
  179. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
  180. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
  181. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
  182. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
  183. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
  184. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
  185. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
  186. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
  187. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
  188. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
  189. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
  190. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
  191. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
  192. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
  193. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
  194. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
  195. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
  196. package/template/.agents/upstreams.json +34 -12
  197. package/template/.agents/skills/design/impeccable/reference/brand.md +0 -108
  198. package/template/.agents/skills/design/impeccable/reference/codex.md +0 -105
  199. package/template/.agents/skills/design/impeccable/reference/interaction-design.md +0 -189
  200. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -175
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * API image generation fallback: renders a mock or world board with the
4
+ * user's own OpenAI key when the harness has no native image generation.
5
+ *
6
+ * context.mjs reports availability (it checks OPENAI_API_KEY); harness-native
7
+ * generation always wins when present. This uses gpt-image-2 and spends the
8
+ * user's API credit (roughly $0.05-0.25 per image at default quality), so the
9
+ * skill states that before the first call in a session.
10
+ *
11
+ * node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
12
+ * node generate-image.mjs --prompt-file prompt.txt --out mock.png
13
+ */
14
+ import fs from 'node:fs';
15
+ import zlib from 'node:zlib';
16
+
17
+ function arg(name, fallback = null) {
18
+ const i = process.argv.indexOf(`--${name}`);
19
+ if (i === -1) return fallback;
20
+ const v = process.argv[i + 1];
21
+ return v && !v.startsWith('--') ? v : fallback;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Fake mode (IMPECCABLE_IMAGE_GEN_FAKE=1)
26
+ //
27
+ // Deterministic offline stand-in for the OpenAI call: same prompt -> identical
28
+ // bytes, no network, no key, cost line reads $0.00. Used by the new-work smoke
29
+ // suite so the concept/serve-question/image chain can run without spend. The
30
+ // output renders the prompt over a 2-3 color palette hashed from the prompt,
31
+ // plus a "SYNTHETIC COMP" corner label. SVG carries the readable text; the
32
+ // raster (.png/.webp/.jpg) fallback carries palette stripes and stows the
33
+ // prompt + marker in a PNG tEXt chunk so downstream stays a valid image.
34
+ // ---------------------------------------------------------------------------
35
+
36
+ // FNV-1a 32-bit: tiny, dependency-free, stable across runs and platforms.
37
+ function hash32(str) {
38
+ let h = 0x811c9dc5;
39
+ for (let i = 0; i < str.length; i++) {
40
+ h ^= str.charCodeAt(i);
41
+ h = Math.imul(h, 0x01000193);
42
+ }
43
+ return h >>> 0;
44
+ }
45
+
46
+ function hslToRgb(hDeg, s, l) {
47
+ const h = ((hDeg % 360) + 360) % 360 / 360;
48
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
49
+ const p = 2 * l - q;
50
+ const hue = (t) => {
51
+ let tt = t;
52
+ if (tt < 0) tt += 1;
53
+ if (tt > 1) tt -= 1;
54
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
55
+ if (tt < 1 / 2) return q;
56
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
57
+ return p;
58
+ };
59
+ return [hue(h + 1 / 3), hue(h), hue(h - 1 / 3)].map((c) => Math.round(c * 255));
60
+ }
61
+
62
+ const toHex = ([r, g, b]) =>
63
+ '#' + [r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('');
64
+
65
+ // Two or three deterministic swatches derived from the prompt hash. The band
66
+ // count itself is prompt-derived, so different prompts differ in palette.
67
+ function palette(prompt) {
68
+ const h = hash32(prompt);
69
+ const base = h % 360;
70
+ const bands = 2 + (h >>> 9) % 2; // 2 or 3
71
+ const spread = 40 + (h >>> 3) % 120;
72
+ const out = [];
73
+ for (let i = 0; i < bands; i++) {
74
+ const hue = base + i * spread;
75
+ const light = 0.32 + ((h >>> (i * 5)) % 40) / 100; // 0.32 - 0.71
76
+ out.push(hslToRgb(hue, 0.55, light));
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function svgFake(prompt, [w, h]) {
82
+ const colors = palette(prompt).map(toHex);
83
+ const stops = colors
84
+ .map((c, i) => `<stop offset="${Math.round((i / (colors.length - 1)) * 100)}%" stop-color="${c}"/>`)
85
+ .join('');
86
+ // Greedy word wrap tuned to the canvas width so the prompt stays legible.
87
+ const perLine = Math.max(12, Math.floor(w / 26));
88
+ const words = String(prompt).replace(/\s+/g, ' ').trim().split(' ');
89
+ const lines = [];
90
+ let cur = '';
91
+ for (const word of words) {
92
+ if ((cur + ' ' + word).trim().length > perLine) {
93
+ if (cur) lines.push(cur);
94
+ cur = word;
95
+ } else {
96
+ cur = (cur + ' ' + word).trim();
97
+ }
98
+ if (lines.length >= 10) break;
99
+ }
100
+ if (cur && lines.length < 11) lines.push(cur);
101
+ const escape = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
102
+ const fontSize = Math.round(w / 24);
103
+ const startY = h / 2 - ((lines.length - 1) * fontSize * 1.3) / 2;
104
+ const text = lines
105
+ .map((line, i) => `<text x="${w / 2}" y="${Math.round(startY + i * fontSize * 1.3)}" font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">${escape(line)}</text>`)
106
+ .join('');
107
+ return `<?xml version="1.0" encoding="UTF-8"?>
108
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
109
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">${stops}</linearGradient></defs>
110
+ <rect width="${w}" height="${h}" fill="url(#g)"/>
111
+ <rect x="0" y="0" width="${w}" height="${h}" fill="#000000" fill-opacity="0.22"/>
112
+ ${text}
113
+ <rect x="${w - Math.round(w / 4.2)}" y="${h - Math.round(h / 16)}" width="${Math.round(w / 4.2)}" height="${Math.round(h / 16)}" fill="#000000" fill-opacity="0.55"/>
114
+ <text x="${w - Math.round(w / 8.4)}" y="${h - Math.round(h / 32)}" font-family="Helvetica, Arial, sans-serif" font-size="${Math.round(w / 60)}" letter-spacing="2" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">SYNTHETIC COMP</text>
115
+ </svg>
116
+ `;
117
+ }
118
+
119
+ // Minimal valid PNG: palette stripes plus a tEXt chunk carrying the marker and
120
+ // prompt, so a .png/.webp fake stays a decodable image and still contains the
121
+ // "SYNTHETIC" bytes downstream tools look for.
122
+ function crc32(buf) {
123
+ let c = 0xffffffff;
124
+ for (let i = 0; i < buf.length; i++) {
125
+ c ^= buf[i];
126
+ for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
127
+ }
128
+ return (c ^ 0xffffffff) >>> 0;
129
+ }
130
+
131
+ function pngChunk(type, data) {
132
+ const typeBuf = Buffer.from(type, 'latin1');
133
+ const body = Buffer.concat([typeBuf, data]);
134
+ const len = Buffer.alloc(4);
135
+ len.writeUInt32BE(data.length, 0);
136
+ const crc = Buffer.alloc(4);
137
+ crc.writeUInt32BE(crc32(body), 0);
138
+ return Buffer.concat([len, body, crc]);
139
+ }
140
+
141
+ function pngFake(prompt, [w, h]) {
142
+ const colors = palette(prompt); // [[r,g,b], ...]
143
+ const bandH = Math.ceil(h / colors.length);
144
+ // Raw image: each scanline prefixed with a 0 filter byte, RGB pixels.
145
+ const stride = w * 3;
146
+ const raw = Buffer.alloc(h * (stride + 1));
147
+ for (let y = 0; y < h; y++) {
148
+ const rowStart = y * (stride + 1);
149
+ raw[rowStart] = 0;
150
+ const [r, g, b] = colors[Math.min(colors.length - 1, Math.floor(y / bandH))];
151
+ for (let x = 0; x < w; x++) {
152
+ const p = rowStart + 1 + x * 3;
153
+ raw[p] = r;
154
+ raw[p + 1] = g;
155
+ raw[p + 2] = b;
156
+ }
157
+ }
158
+ const ihdr = Buffer.alloc(13);
159
+ ihdr.writeUInt32BE(w, 0);
160
+ ihdr.writeUInt32BE(h, 4);
161
+ ihdr[8] = 8; // bit depth
162
+ ihdr[9] = 2; // color type: truecolor RGB
163
+ const idat = zlib.deflateSync(raw, { level: 9 });
164
+ const textData = Buffer.concat([
165
+ Buffer.from('Comment', 'latin1'),
166
+ Buffer.from([0]),
167
+ Buffer.from(`SYNTHETIC COMP: ${String(prompt).replace(/\s+/g, ' ').trim()}`, 'latin1'),
168
+ ]);
169
+ return Buffer.concat([
170
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
171
+ pngChunk('IHDR', ihdr),
172
+ pngChunk('tEXt', textData),
173
+ pngChunk('IDAT', idat),
174
+ pngChunk('IEND', Buffer.alloc(0)),
175
+ ]);
176
+ }
177
+
178
+ function parseSize(sizeStr) {
179
+ const m = String(sizeStr).match(/^(\d+)x(\d+)$/);
180
+ if (!m) return [1536, 1024];
181
+ return [Number(m[1]), Number(m[2])];
182
+ }
183
+
184
+ if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
185
+ const fakePromptFile = arg('prompt-file');
186
+ const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
187
+ const fakeOut = arg('out');
188
+ if (!fakePrompt || !fakeOut) {
189
+ console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
190
+ process.exit(1);
191
+ }
192
+ const dims = parseSize(arg('size', '1536x1024'));
193
+ const bytes = fakeOut.endsWith('.svg')
194
+ ? Buffer.from(svgFake(fakePrompt, dims), 'utf8')
195
+ : pngFake(fakePrompt, dims);
196
+ fs.writeFileSync(fakeOut, bytes);
197
+ console.log(`IMAGE: ${fakeOut} (${dims[0]}x${dims[1]}, fake synthetic comp, $0.00, no API call)`);
198
+ process.exit(0);
199
+ }
200
+
201
+ const key = process.env.OPENAI_API_KEY;
202
+ if (!key) {
203
+ console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
204
+ process.exit(1);
205
+ }
206
+ const promptFile = arg('prompt-file');
207
+ const prompt = promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt');
208
+ const out = arg('out');
209
+ if (!prompt || !out) {
210
+ console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
211
+ process.exit(1);
212
+ }
213
+ const size = arg('size', '1536x1024');
214
+ const quality = arg('quality', 'medium');
215
+
216
+ const response = await fetch('https://api.openai.com/v1/images/generations', {
217
+ method: 'POST',
218
+ headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
219
+ body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
220
+ });
221
+ if (!response.ok) {
222
+ console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
223
+ process.exit(1);
224
+ }
225
+ const json = await response.json();
226
+ const b64 = json?.data?.[0]?.b64_json;
227
+ if (!b64) {
228
+ console.error('generate-image: no image in response');
229
+ process.exit(1);
230
+ }
231
+ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
232
+ // The prompt travels with the asset: embedded in the file itself (EXIF-class
233
+ // metadata via embed-prompt.mjs) so intent survives copies across harnesses,
234
+ // plus a sidecar for anything that indexes rather than opens the image.
235
+ try {
236
+ const { spawnSync } = await import('node:child_process');
237
+ spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
238
+ fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2' }, null, 2));
239
+ } catch { /* embedding is best-effort */ }
240
+ console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `/impeccable hooks <on|off|status|reset>` manage the design hook runtime
3
+ * The Impeccable hooks command manages the design hook runtime
4
4
  * via the `hook` key and shared detector ignores via the `detector` key in
5
5
  * .impeccable/config.json / .impeccable/config.local.json.
6
6
  *
@@ -13,6 +13,8 @@
13
13
  * node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
14
14
  * node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
15
15
  * node hook-admin.mjs ignore-value <rule> <value> --local
16
+ * node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
17
+ * node hook-admin.mjs ignore-value <rule> "*" # refused: scope it or use ignore-rule
16
18
  * node hook-admin.mjs reset # remove all config + cache
17
19
  *
18
20
  * Designed to be invoked by the LLM from the reference/hooks.md flow.
@@ -21,6 +23,7 @@
21
23
 
22
24
  import fs from 'node:fs';
23
25
  import path from 'node:path';
26
+ import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
24
27
 
25
28
  import {
26
29
  getConfigPath,
@@ -44,6 +47,26 @@ const IMPECCABLE_HOOK_COMMAND_MARKERS = [
44
47
  ];
45
48
  const TIMEOUT_SECONDS = 5;
46
49
  const STATUS_MESSAGE = 'Checking UI changes';
50
+ // The Stop deep pass scans every UI file touched in the session with the full
51
+ // rule set, so it gets a longer budget than the per-edit pass. Only Claude
52
+ // Code and Codex dispatch a native Stop hook event, so only those manifests
53
+ // carry the entry. Keep these shapes in sync with
54
+ // scripts/lib/transformers/hooks.js in the repo.
55
+ const STOP_TIMEOUT_SECONDS = 30;
56
+ const STOP_STATUS_MESSAGE = 'Design deep pass';
57
+
58
+ function stopManifestEntry(command) {
59
+ return {
60
+ hooks: [
61
+ {
62
+ type: 'command',
63
+ command,
64
+ timeout: STOP_TIMEOUT_SECONDS,
65
+ statusMessage: STOP_STATUS_MESSAGE,
66
+ },
67
+ ],
68
+ };
69
+ }
47
70
 
48
71
  const HOOK_MANIFEST_TARGETS = [
49
72
  {
@@ -52,7 +75,7 @@ const HOOK_MANIFEST_TARGETS = [
52
75
  destRel: '.claude/settings.local.json',
53
76
  sharedDestRel: '.claude/settings.json',
54
77
  manifest: () => ({
55
- description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
78
+ description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
56
79
  hooks: {
57
80
  PostToolUse: [
58
81
  {
@@ -67,6 +90,7 @@ const HOOK_MANIFEST_TARGETS = [
67
90
  ],
68
91
  },
69
92
  ],
93
+ Stop: [stopManifestEntry('node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"')],
70
94
  },
71
95
  }),
72
96
  },
@@ -75,7 +99,6 @@ const HOOK_MANIFEST_TARGETS = [
75
99
  skillRel: '.agents/skills/impeccable',
76
100
  destRel: '.codex/hooks.json',
77
101
  manifest: () => ({
78
- description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
79
102
  hooks: {
80
103
  PostToolUse: [
81
104
  {
@@ -83,13 +106,14 @@ const HOOK_MANIFEST_TARGETS = [
83
106
  hooks: [
84
107
  {
85
108
  type: 'command',
86
- command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
109
+ command: 'node ".agents/skills/impeccable/scripts/hook.mjs"',
87
110
  timeout: TIMEOUT_SECONDS,
88
111
  statusMessage: STATUS_MESSAGE,
89
112
  },
90
113
  ],
91
114
  },
92
115
  ],
116
+ Stop: [stopManifestEntry('node ".agents/skills/impeccable/scripts/hook.mjs"')],
93
117
  },
94
118
  }),
95
119
  },
@@ -185,7 +209,7 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
185
209
  const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
186
210
  const existingHook = stripDetectorKeys(hookSection(existing));
187
211
  // Merge over the existing hook object so fields the merge helpers don't manage
188
- // (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
212
+ // (consent, quiet, auditLog) survive an Impeccable hooks edit.
189
213
  const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
190
214
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
191
215
  fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
@@ -265,7 +289,10 @@ function mergeIgnoreValueEntries(existing, incoming) {
265
289
  }
266
290
 
267
291
  function ignoreValueEntryKey(entry) {
268
- const files = Array.isArray(entry.files) && entry.files.length > 0 ? entry.files.join('\x1f') : '';
292
+ // Sorted: a file scope is a set. Comparing stored order made an on-disk scope
293
+ // miss the sorted argv form, so a re-add duplicated the entry and a remove
294
+ // silently failed. Every key that hashes `files` must sort — there are four.
295
+ const files = Array.isArray(entry.files) && entry.files.length > 0 ? [...entry.files].sort().join('\x1f') : '';
269
296
  return `${entry.rule}\0${entry.value}\0${files}`;
270
297
  }
271
298
 
@@ -283,7 +310,14 @@ function statusReport(cwd) {
283
310
  if (info.exists) return relPath;
284
311
  return `${relPath} (${absent})`;
285
312
  };
286
- const ignoreValues = cfg.ignoreValues.map((entry) => `${entry.rule}=${entry.value}`);
313
+ // Show the file scope. Dropping it rendered a file-scoped entry as
314
+ // `design-system-font-size=*`, which reads as the project-wide wildcard this
315
+ // command refuses — the opposite of what is on disk. Matches the
316
+ // `rule=value [files]` shape `impeccable ignores list` already prints.
317
+ const ignoreValues = cfg.ignoreValues.map((entry) => {
318
+ const scope = Array.isArray(entry.files) && entry.files.length ? ` [${entry.files.join(', ')}]` : '';
319
+ return `${entry.rule}=${entry.value}${scope}`;
320
+ });
287
321
 
288
322
  const lines = [
289
323
  `Impeccable design hook`,
@@ -514,9 +548,9 @@ function parseIgnoreRuleArgs(args) {
514
548
  function addIgnoreRule(cwd, args) {
515
549
  const parsed = parseIgnoreRuleArgs(args);
516
550
  const rule = parsed.rule;
517
- if (!rule) throw new Error('Pass a rule id, e.g. /impeccable hooks ignore-rule side-tab');
551
+ if (!rule) throw new Error(`Pass a rule id, e.g. ${IMPECCABLE_COMMAND} hooks ignore-rule side-tab`);
518
552
  if (rule === 'overused-font' && !parsed.allValues) {
519
- throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font <font> for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.');
553
+ throw new Error(`overused-font is value-specific by default. Use ${IMPECCABLE_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
520
554
  }
521
555
  const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
522
556
  if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
@@ -525,21 +559,36 @@ function addIgnoreRule(cwd, args) {
525
559
  }
526
560
 
527
561
  function addIgnoreFile(cwd, glob) {
528
- if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
562
+ if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
529
563
  const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
530
564
  if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
531
565
  writeDetectorConfig(cwd, config);
532
566
  return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
533
567
  }
534
568
 
569
+ // An empty glob used to be dropped by filter(Boolean), so `--file=` reported
570
+ // success and wrote an entry with no files: the user asked to scope a rule to one
571
+ // file and silently got the project-wide suppression instead. Refuse it.
572
+ function requireGlob(raw, flag) {
573
+ const glob = String(raw ?? '').trim();
574
+ if (!glob) throw new Error(`${flag} requires a non-empty glob`);
575
+ // A following flag is not a glob. `--file --reason "why"` consumed `--reason`
576
+ // as the scope and left the reason text to fold into the value, storing
577
+ // value="* why" files=["--reason"] and reporting success. Same silent-no-op
578
+ // class as an unknown flag folding into the value; refuse it the same way.
579
+ if (glob.startsWith('--')) throw new Error(`${flag} requires a glob, got the flag ${glob}`);
580
+ return glob;
581
+ }
582
+
535
583
  function parseIgnoreValueArgs(args) {
536
584
  const positionals = [];
585
+ const files = [];
537
586
  let shared = false;
538
587
  let local = false;
539
588
  let reason = '';
540
589
 
541
590
  for (let i = 0; i < args.length; i++) {
542
- const arg = args[i];
591
+ const arg = String(args[i] || '');
543
592
  if (arg === '--shared') {
544
593
  shared = true;
545
594
  } else if (arg === '--local') {
@@ -550,8 +599,20 @@ function parseIgnoreValueArgs(args) {
550
599
  chunks.push(args[++i]);
551
600
  }
552
601
  reason = chunks.join(' ').trim();
553
- } else if (String(arg).startsWith('--reason=')) {
554
- reason = String(arg).slice('--reason='.length).trim();
602
+ } else if (arg.startsWith('--reason=')) {
603
+ reason = arg.slice('--reason='.length).trim();
604
+ } else if (arg === '--file' || arg === '--files') {
605
+ if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`);
606
+ files.push(requireGlob(args[++i], arg));
607
+ } else if (arg.startsWith('--file=')) {
608
+ files.push(requireGlob(arg.slice('--file='.length), '--file'));
609
+ } else if (arg.startsWith('--files=')) {
610
+ files.push(requireGlob(arg.slice('--files='.length), '--files'));
611
+ } else if (arg.startsWith('--')) {
612
+ // Otherwise a typo folds into the value: `ignore-value overused-font Inter
613
+ // --shard` stored the value "inter --shard", which matches no finding, and
614
+ // reported success. Matches `impeccable ignores add-value`.
615
+ throw new Error(`Unknown ignore-value flag: ${arg}`);
555
616
  } else {
556
617
  positionals.push(arg);
557
618
  }
@@ -561,6 +622,9 @@ function parseIgnoreValueArgs(args) {
561
622
  return {
562
623
  rule: String(rule || '').trim().toLowerCase(),
563
624
  value: normalizeIgnoreValue(valueParts.join(' ')),
625
+ // Sorted: the dedup key compares the files array, so an unsorted scope made
626
+ // `--file b.css --file a.css` a different entry from `--file a.css --file b.css`.
627
+ files: Array.from(new Set(files.filter(Boolean))).sort(),
564
628
  shared,
565
629
  local,
566
630
  reason,
@@ -570,17 +634,31 @@ function parseIgnoreValueArgs(args) {
570
634
  function addIgnoreValue(cwd, args) {
571
635
  const parsed = parseIgnoreValueArgs(args);
572
636
  if (!parsed.rule || !parsed.value) {
573
- throw new Error('Pass a rule id and value, e.g. /impeccable hooks ignore-value overused-font Inter');
637
+ throw new Error(`Pass a rule id and value, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value overused-font Inter`);
574
638
  }
575
639
 
576
640
  if (parsed.shared && parsed.local) {
577
641
  throw new Error('Pass only one scope flag: --shared or --local');
578
642
  }
579
643
 
644
+ // A bare `*` would suppress the rule everywhere, which is ignore-rule's job and
645
+ // not what a finding in one file justifies. detector.ignoreValues honours a
646
+ // `files` scope, so require one — matching `impeccable ignores add-value`.
647
+ if (parsed.value === '*' && parsed.files.length === 0) {
648
+ // `ignore-rule overused-font` refuses on its own without --all-values, so
649
+ // naming the bare form here would hand the user a second error.
650
+ const projectWide = parsed.rule === 'overused-font'
651
+ ? `${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule} --all-values`
652
+ : `${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule}`;
653
+ throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
654
+ }
655
+
580
656
  const local = parsed.local;
581
657
  const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
582
- const key = `${parsed.rule}\0${parsed.value}`;
583
- const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
658
+ // Key on the file scope too: the same rule/value legitimately appears more than
659
+ // once with different scopes, and a rule+value-only key overwrote them.
660
+ const key = ignoreValueEntryKey({ rule: parsed.rule, value: parsed.value, files: parsed.files });
661
+ const existing = config.ignoreValues.find((entry) => ignoreValueEntryKey(entry) === key);
584
662
 
585
663
  if (existing) {
586
664
  if (parsed.reason) existing.reason = parsed.reason;
@@ -588,15 +666,17 @@ function addIgnoreValue(cwd, args) {
588
666
  const entry = {
589
667
  rule: parsed.rule,
590
668
  value: parsed.value,
591
- createdAt: new Date().toISOString(),
592
669
  };
670
+ if (parsed.files.length) entry.files = parsed.files;
671
+ entry.createdAt = new Date().toISOString();
593
672
  if (parsed.reason) entry.reason = parsed.reason;
594
673
  config.ignoreValues.push(entry);
595
674
  }
596
675
 
597
676
  const target = writeDetectorConfig(cwd, config, { local });
598
677
  const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
599
- return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
678
+ const scopeSuffix = parsed.files.length ? ` scoped to ${parsed.files.join(', ')}` : '';
679
+ return `Added ${parsed.rule}=${parsed.value}${scopeSuffix} to ${scope} (${path.relative(cwd, target) || target}).`;
600
680
  }
601
681
 
602
682
  function reset(cwd) {
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import fs from 'node:fs';
14
+ import os from 'node:os';
14
15
  import path from 'node:path';
15
16
 
16
17
  import {
@@ -21,13 +22,17 @@ import {
21
22
  appendDesignSystemNote,
22
23
  designSystemOptions,
23
24
  filterFindings,
25
+ isNativePlatform,
24
26
  loadDetector,
27
+ matchConfiguredExtension,
25
28
  matchesAnyGlob,
26
29
  persistCache,
27
30
  readCache,
28
31
  readConfig,
29
32
  renderTemplate,
33
+ resolveCacheCwd,
30
34
  resolveProjectCwd,
35
+ resolveProjectPlatform,
31
36
  truthy,
32
37
  writeAuditLog,
33
38
  } from './hook-lib.mjs';
@@ -332,6 +337,22 @@ function isInsideProject(filePath, cwd) {
332
337
  }
333
338
  }
334
339
 
340
+ // The static HTML engine reads its input from disk, but preToolUse only has
341
+ // the proposed content. Stage it in a temp file so html-engine targets get the
342
+ // same DOM-structural rules pre-write that runHook applies post-edit.
343
+ async function detectProposedHtml(detector, content, filePath, scanOptions) {
344
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
345
+ const tmpFile = path.join(dir, path.basename(filePath));
346
+ try {
347
+ fs.writeFileSync(tmpFile, content);
348
+ const findings = await detector.detectHtml(tmpFile, scanOptions);
349
+ // Findings carry the temp path; remap so file-scoped ignores still match.
350
+ return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
351
+ } finally {
352
+ fs.rmSync(dir, { recursive: true, force: true });
353
+ }
354
+ }
355
+
335
356
  function cursorBlockMessage(findings, filePath, config, cwd) {
336
357
  const rendered = renderTemplate(findings, filePath, config, { cwd });
337
358
  const blocked = rendered.replace(
@@ -379,9 +400,12 @@ async function main() {
379
400
  return allow({ skipped: 'stdin-empty' });
380
401
  }
381
402
 
382
- const cwd = resolveProjectCwd(event);
403
+ const sessionCwd = resolveProjectCwd(event);
383
404
  const started = Date.now();
384
- const filePath = proposedFilePath(event, cwd);
405
+ const filePath = proposedFilePath(event, sessionCwd);
406
+ // Re-key config/cache to the edited file's project root when the session
407
+ // was launched from a non-project umbrella directory (issue #305).
408
+ const cwd = resolveCacheCwd(filePath, sessionCwd);
385
409
  const audit = {
386
410
  harness: 'cursor',
387
411
  cwd,
@@ -394,9 +418,13 @@ async function main() {
394
418
  if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
395
419
  if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
396
420
 
421
+ // Config is read before the extension gate so `detector.extensions` entries
422
+ // (e.g. `.blade.php` template files, issue #316) can widen it.
423
+ const config = readConfig(cwd);
397
424
  const ext = path.extname(filePath).toLowerCase();
398
- audit.ext = ext;
399
- if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
425
+ const configuredExt = matchConfiguredExtension(filePath, config.extensions);
426
+ audit.ext = configuredExt ? configuredExt.ext : ext;
427
+ if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
400
428
 
401
429
  const contentResult = proposedContent(event, cwd, filePath);
402
430
  if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
@@ -405,9 +433,14 @@ async function main() {
405
433
  const content = typeof contentResult === 'string' ? contentResult : '';
406
434
  if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
407
435
 
408
- const config = readConfig(cwd);
409
436
  if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
410
437
 
438
+ // Web rule engine, native project: stand aside (see resolveProjectPlatform).
439
+ const platform = resolveProjectPlatform(cwd);
440
+ if (isNativePlatform(platform)) {
441
+ return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
442
+ }
443
+
411
444
  const rel = relativePath(filePath, cwd);
412
445
  if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
413
446
  return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
@@ -419,9 +452,16 @@ async function main() {
419
452
  }
420
453
  const scanOptions = designSystemOptions(config, detector, cwd);
421
454
 
455
+ // Mirror runHook's engine routing so template issues the HTML engine catches
456
+ // post-edit cannot slip past the pre-write gate.
457
+ const useHtmlEngine = configuredExt
458
+ ? configuredExt.engine === 'html'
459
+ : (ext === '.html' || ext === '.htm');
422
460
  let findings = [];
423
461
  try {
424
- findings = await detector.detectText(content, filePath, scanOptions);
462
+ findings = useHtmlEngine && typeof detector.detectHtml === 'function'
463
+ ? await detectProposedHtml(detector, content, filePath, scanOptions)
464
+ : await detector.detectText(content, filePath, scanOptions);
425
465
  } catch {
426
466
  return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
427
467
  }