quiver-cli 0.8.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 +546 -458
  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
@@ -9,20 +9,26 @@
9
9
  * ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
10
10
  * truthy(value)
11
11
  * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
12
+ * resolveProjectPlatform(cwd) / isNativePlatform(platform)
12
13
  * normalizeIgnoreValue(value)
13
- * readCache(cwd) / persistCache(cwd, cache)
14
+ * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
14
15
  * bumpEditCount(cache, sessionId, filePath) -> number
16
+ * touchFile(cache, sessionId, filePath)
15
17
  * suppressionNotice(filePath)
16
18
  * filterFindings(findings, content, ext, config)
19
+ * ADVISORY_RULES / isAdvisoryFinding(finding)
20
+ * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
21
+ * matchConfiguredExtension(filePath, extensions)
17
22
  * dedupeAgainstCache(findings, cache, sessionId, filePath)
18
23
  * renderTemplate(findings, filePath, config, opts)
19
24
  * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
20
- * shouldEmitAckForFile(filePath)
25
+ * shouldEmitAckForFile(filePath, config?)
21
26
  * writeAuditLog(env, entry)
22
27
  * loadDetector() -> Promise<{ detectText, detectHtml }>
23
28
  * matchesAnyGlob(filePath, globs)
24
29
  * normalizeScanTargets(primaryTargets, projectCwd)
25
30
  * runHook(deps) -> { exitCode, stdout, audit, reason? }
31
+ * runStopHook(deps) -> { exitCode, stdout, audit, emission? }
26
32
  *
27
33
  * Design notes:
28
34
  * - All errors are swallowed at the runHook seam. The detector throwing must
@@ -35,8 +41,21 @@
35
41
  */
36
42
 
37
43
  import fs from 'node:fs';
44
+ import os from 'node:os';
38
45
  import path from 'node:path';
39
46
  import { pathToFileURL, fileURLToPath } from 'node:url';
47
+ import { extractPlatform, loadContext } from './context.mjs';
48
+ import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
49
+ // `detector.extensions` (issue #316) is shared with Live's source search, which
50
+ // needs the same answer for `.heex` / `.blade.php` when it hunts for session
51
+ // markers. lib/template-extensions.mjs owns the shape; re-exported here because
52
+ // hook-lib has been the import site for matchConfiguredExtension since #347.
53
+ import {
54
+ matchConfiguredExtension,
55
+ mergeExtensions,
56
+ } from './lib/template-extensions.mjs';
57
+
58
+ export { matchConfiguredExtension };
40
59
 
41
60
  const __filename = fileURLToPath(import.meta.url);
42
61
  const __dirname = path.dirname(__filename);
@@ -65,10 +84,69 @@ export const SENSITIVE_PATH = new RegExp([
65
84
  ].join('|'), 'i');
66
85
 
67
86
  // 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;
87
+ // `generated` is matched as a whole path segment so authored names such as
88
+ // `generated-utils.ts` or `CodeGenerator.tsx` still get scanned.
89
+ export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i;
69
90
 
70
91
  export const TRUTHY = /^(1|true|yes|on)$/i;
71
92
 
93
+ // ── Two-tier rule surfacing ──────────────────────────────────────────────
94
+ // The per-edit PostToolUse pass surfaces only this "immediate" tier: rules
95
+ // that are mechanical, unambiguous, and worth interrupting an edit for —
96
+ // broken output the user would see (broken images, overflow, clipped
97
+ // popovers, text on the viewport edge), objective contrast/legibility
98
+ // failures, single-property slop that is trivial to fix in place (gradient
99
+ // text, glow shadows), and design-system drift (which compounds with every
100
+ // further edit if left uncorrected). Everything else — copy-cadence rules,
101
+ // palette/typography taste, layout rhythm — is deferred to the Stop-event
102
+ // deep pass (`runStopHook`), which runs the FULL rule set over every file
103
+ // touched this session and surfaces the remainder once.
104
+ //
105
+ // Rationale (measured in the eval harness): the per-edit stream fires
106
+ // overwhelmingly on copy-level rules, and that steady nag stream makes
107
+ // models more conservative, while a single full pass at completion fixes
108
+ // contrast/padding/glow just as reliably. Restore the old full per-edit
109
+ // behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`.
110
+ export const IMMEDIATE_TIER_RULES = new Set([
111
+ // Broken output.
112
+ 'broken-image',
113
+ 'text-overflow',
114
+ 'clipped-overflow-container',
115
+ 'body-text-viewport-edge',
116
+ // Objective contrast / legibility failures.
117
+ 'low-contrast',
118
+ 'gray-on-color',
119
+ 'tiny-text',
120
+ // Single-property mechanical slop, trivial to fix at the edit site.
121
+ 'gradient-text',
122
+ 'dark-glow',
123
+ // Design-system drift compounds if not corrected at edit time.
124
+ 'design-system-font',
125
+ 'design-system-color',
126
+ 'design-system-radius',
127
+ 'design-system-font-size',
128
+ ]);
129
+
130
+ // ── Advisory rules ────────────────────────────────────────────────────────
131
+ // Advisory rules are opt-in noise: the CLI reports them in a separate section
132
+ // and they never count as failures. The design hook skips them entirely by
133
+ // default — in both the per-edit PostToolUse pass and the Stop deep pass — so
134
+ // the agent is never nagged about a taste call a human might make on purpose.
135
+ // A project opts back in with `.impeccable/config.json`:
136
+ // { "detector": { "advisoryRules": "include" } }
137
+ // This set is the hook's own copy of the registry's `advisory: true` rules,
138
+ // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
139
+ // self-contained and testable without loading the detector. Keep it in sync
140
+ // with the registry (cli/engine/registry/antipatterns.mjs).
141
+ export const ADVISORY_RULES = new Set([
142
+ 'em-dash-overuse',
143
+ ]);
144
+
145
+ export function isAdvisoryFinding(finding) {
146
+ const id = finding && normalizeIgnoreRule(finding.antipattern);
147
+ return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
148
+ }
149
+
72
150
  export const DEFAULT_CONFIG = Object.freeze({
73
151
  enabled: true,
74
152
  quiet: false,
@@ -77,7 +155,17 @@ export const DEFAULT_CONFIG = Object.freeze({
77
155
  ignoreRules: [],
78
156
  ignoreFiles: [],
79
157
  ignoreValues: [],
80
- limits: { maxFindings: 5, maxChars: 8000 },
158
+ extensions: [],
159
+ perEditRules: 'immediate',
160
+ // Advisory rules are skipped unless a project sets detector.advisoryRules to
161
+ // "include". See ADVISORY_RULES above.
162
+ advisoryRules: 'exclude',
163
+ // maxFileBytes: not every generated artifact lives under a path we can
164
+ // recognize. Committed browser bundles and vendored detector copies sit
165
+ // next to source and run 200KB+, while genuinely authored stylesheets in
166
+ // this codebase top out under 90KB. A single file past the ceiling is a
167
+ // bundle, and findings against a bundle are never actionable.
168
+ limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 },
81
169
  });
82
170
 
83
171
  export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
@@ -134,6 +222,59 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
134
222
  || fallback;
135
223
  }
136
224
 
225
+ function looksLikeProjectRoot(dir) {
226
+ return ['.git', 'package.json', '.impeccable'].some((marker) => {
227
+ try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
228
+ });
229
+ }
230
+
231
+ // Where `.impeccable/` (cache + config) lives for this event. Normally the
232
+ // session cwd, untouched. But when the agent was launched from an umbrella
233
+ // directory that is not itself a project (no .git, package.json, or
234
+ // .impeccable), key to the edited file's nearest project root instead, so a
235
+ // multi-project launch dir doesn't accumulate a shared cross-project cache
236
+ // (issue #305). Climbing stops at the home dir, falling back to the session
237
+ // cwd when no marker is found.
238
+ export function resolveCacheCwd(primaryFile, sessionCwd) {
239
+ const base = path.resolve(sessionCwd || process.cwd());
240
+ if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
241
+ if (looksLikeProjectRoot(base)) return base;
242
+ let dir;
243
+ try {
244
+ dir = path.dirname(path.resolve(primaryFile));
245
+ } catch {
246
+ return base;
247
+ }
248
+ const home = path.resolve(os.homedir());
249
+ while (true) {
250
+ if (dir === home) return base;
251
+ if (looksLikeProjectRoot(dir)) return dir;
252
+ const parent = path.dirname(dir);
253
+ if (parent === dir) return base;
254
+ dir = parent;
255
+ }
256
+ }
257
+
258
+ // The detector's rules are web rules (HTML/CSS shapes), but a React Native or
259
+ // Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
260
+ // .js), so without this gate every native screen edit would draw web-shaped
261
+ // findings that contradict the native platform references. PRODUCT.md's
262
+ // `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
263
+ // the scan entirely. Resolution goes through loadContext so the hook reads the
264
+ // same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
265
+ export function resolveProjectPlatform(cwd) {
266
+ try {
267
+ const ctx = loadContext(cwd);
268
+ return extractPlatform(ctx && ctx.product);
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ export function isNativePlatform(platform) {
275
+ return platform === 'ios' || platform === 'android' || platform === 'adaptive';
276
+ }
277
+
137
278
  export function readConfig(cwd) {
138
279
  const config = cloneDefaultConfig();
139
280
  // Hook runtime settings live under `hook`; detector filters live under
@@ -168,6 +309,7 @@ function cloneDefaultConfig() {
168
309
  ignoreRules: [],
169
310
  ignoreFiles: [],
170
311
  ignoreValues: [],
312
+ extensions: [],
171
313
  designSystem: { ...DEFAULT_CONFIG.designSystem },
172
314
  limits: { ...DEFAULT_CONFIG.limits },
173
315
  };
@@ -175,6 +317,11 @@ function cloneDefaultConfig() {
175
317
 
176
318
  function applyDetectorConfigSource(config, raw) {
177
319
  if (!raw || typeof raw !== 'object') return config;
320
+ // `detector.advisoryRules: "include"` opts the hook into advisory rules
321
+ // (em-dash overuse, etc.). Any other value keeps the default "exclude".
322
+ if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
323
+ config.advisoryRules = raw.advisoryRules;
324
+ }
178
325
  if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
179
326
  config.designSystem = {
180
327
  ...config.designSystem,
@@ -190,6 +337,9 @@ function applyDetectorConfigSource(config, raw) {
190
337
  if (Array.isArray(raw.ignoreValues)) {
191
338
  config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
192
339
  }
340
+ if (Array.isArray(raw.extensions)) {
341
+ config.extensions = mergeExtensions(config.extensions, raw.extensions);
342
+ }
193
343
  return config;
194
344
  }
195
345
 
@@ -201,6 +351,9 @@ function applyConfigSource(config, raw) {
201
351
  if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
202
352
  config.quiet = raw.quiet === true;
203
353
  }
354
+ if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') {
355
+ config.perEditRules = raw.perEditRules;
356
+ }
204
357
  if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
205
358
  config.auditLog = raw.auditLog.trim();
206
359
  }
@@ -209,6 +362,7 @@ function applyConfigSource(config, raw) {
209
362
  config.limits = {
210
363
  maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
211
364
  maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
365
+ maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes),
212
366
  };
213
367
  }
214
368
  return config;
@@ -396,12 +550,15 @@ export function normalizeIgnoreValueEntries(entries) {
396
550
  ...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
397
551
  ]);
398
552
  if (files.length > 0) normalized.files = files;
399
- if (typeof entry.reason === 'string' && entry.reason.trim()) {
400
- normalized.reason = entry.reason.trim();
401
- }
553
+ // Key order is rule, value, files, createdAt, reason and must stay that way:
554
+ // normalizing runs on every write, so emitting a different order than the one
555
+ // already on disk rewrites every untouched entry and churns the diff.
402
556
  if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
403
557
  normalized.createdAt = entry.createdAt.trim();
404
558
  }
559
+ if (typeof entry.reason === 'string' && entry.reason.trim()) {
560
+ normalized.reason = entry.reason.trim();
561
+ }
405
562
  out.push(normalized);
406
563
  }
407
564
  return out;
@@ -419,7 +576,9 @@ function mergeIgnoreValues(existing, incoming) {
419
576
  }
420
577
 
421
578
  function ignoreValueFilesKey(files) {
422
- return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
579
+ // Sort before joining: a scope is a set, so an entry already on disk in another
580
+ // order must compare equal rather than dedup as two distinct entries.
581
+ return Array.isArray(files) && files.length > 0 ? [...files].sort().join('\x1f') : '';
423
582
  }
424
583
 
425
584
  export function readCache(cwd) {
@@ -555,8 +714,16 @@ export function bumpEditCount(cache, sessionId, filePath) {
555
714
  return fileEntry.editCount;
556
715
  }
557
716
 
717
+ // Record that a file was scanned this session without bumping its edit count.
718
+ // The Stop deep pass reads the session's file list to know what to re-scan,
719
+ // so a file whose per-edit findings were all deferred still needs an entry.
720
+ export function touchFile(cache, sessionId, filePath) {
721
+ ensureFile(cache, sessionId, filePath);
722
+ ensureSession(cache, sessionId).updatedAt = Date.now();
723
+ }
724
+
558
725
  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.`;
726
+ return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`;
560
727
  }
561
728
 
562
729
  // Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
@@ -617,22 +784,53 @@ export function filterFindings(findings, _content, _ext, config) {
617
784
  if (!Array.isArray(findings) || findings.length === 0) return [];
618
785
  const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
619
786
  const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
787
+ // Advisory rules are skipped by default so the hook never nags about them;
788
+ // a project opts in with detector.advisoryRules: "include".
789
+ const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
620
790
  return findings.filter((f) => {
621
791
  if (!f || typeof f !== 'object') return false;
792
+ if (!includeAdvisory && isAdvisoryFinding(f)) return false;
622
793
  if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
623
794
  if (isIgnoredFindingValue(f, ignoreValues)) return false;
624
795
  return true;
625
796
  });
626
797
  }
627
798
 
799
+ // Split filtered findings into the per-edit "immediate" tier and the tier
800
+ // deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering
801
+ // rationale.
802
+ export function splitFindingsByTier(findings) {
803
+ const immediate = [];
804
+ const deferred = [];
805
+ for (const f of Array.isArray(findings) ? findings : []) {
806
+ if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) {
807
+ immediate.push(f);
808
+ } else {
809
+ deferred.push(f);
810
+ }
811
+ }
812
+ return { immediate, deferred };
813
+ }
814
+
815
+ // Whether the per-edit pass for this harness should defer non-immediate
816
+ // findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
817
+ // hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
818
+ // them would silently drop the non-immediate rules entirely.
819
+ export function perEditTieringActive(config, harness) {
820
+ if (harness === 'cursor' || harness === 'github') return false;
821
+ return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
822
+ }
823
+
628
824
  function isIgnoredFindingValue(finding, ignoreValues) {
629
825
  if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
630
826
  const rule = normalizeIgnoreRule(finding.antipattern);
827
+ if (!rule) return false;
828
+ // File-scoped wildcards suppress rules with no extractable value, such as side-tab.
631
829
  const value = extractFindingIgnoreValue(finding);
632
- if (!rule || !value) return false;
633
830
  return ignoreValues.some((entry) => {
831
+ if (entry.rule !== rule) return false;
634
832
  const wildcardValue = entry.value === '*';
635
- if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
833
+ if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
636
834
  if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
637
835
  return findingMatchesScopedIgnoreFile(finding, entry.files);
638
836
  });
@@ -661,6 +859,7 @@ export function extractFindingIgnoreValue(finding) {
661
859
  'design-system-font',
662
860
  'design-system-color',
663
861
  'design-system-radius',
862
+ 'design-system-font-size',
664
863
  ]);
665
864
  if (!directValueRules.has(rule)) return '';
666
865
  return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
@@ -681,6 +880,9 @@ function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(findin
681
880
  const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
682
881
  if (primary) return cleanIgnoreValueDisplay(primary[1]);
683
882
 
883
+ const googleLabel = text.match(/Google Fonts:\s*([^()\n;]+)/i);
884
+ if (googleLabel) return cleanIgnoreValueDisplay(googleLabel[1]);
885
+
684
886
  const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
685
887
  if (family) return cleanIgnoreValueDisplay(family[1]);
686
888
 
@@ -737,11 +939,20 @@ export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
737
939
  return fresh;
738
940
  }
739
941
 
942
+ // Sync the remembered set to the findings present in the scan just performed.
943
+ //
944
+ // This replaces rather than accumulates, and that is the whole point. An
945
+ // append-only set made the hook lie twice over: the pending ack counted
946
+ // history instead of the live scan, so it kept naming findings the agent had
947
+ // already fixed, and a finding that was fixed and later reintroduced was
948
+ // deduped against a stale memory and never re-reported. Forgetting what is no
949
+ // longer there is what lets the count shrink and a regression fire again.
950
+ //
951
+ // Callers must pass the complete current finding set, not just the fresh ones.
740
952
  export function rememberFindings(cache, sessionId, filePath, findings) {
741
953
  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);
954
+ const keys = new Set((findings || []).map(f => findingCacheKey(f)));
955
+ fileEntry.findings = Array.from(keys);
745
956
  ensureSession(cache, sessionId).updatedAt = Date.now();
746
957
  }
747
958
 
@@ -770,7 +981,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
770
981
  const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
771
982
  const lines = shown.map((f) => formatFindingLine(f));
772
983
  const more = remaining > 0
773
- ? `... and ${remaining} more (see /impeccable audit).`
984
+ ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
774
985
  : null;
775
986
  const footer = directiveFooter(display);
776
987
 
@@ -814,7 +1025,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
814
1025
  shownCount += shown.length;
815
1026
  const hidden = group.findings.length - shown.length;
816
1027
  if (hidden > 0) {
817
- lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
1028
+ lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
818
1029
  }
819
1030
  }
820
1031
 
@@ -830,7 +1041,7 @@ function clampGroupedToBudget(header, lines, footer, maxChars) {
830
1041
  const assemble = (linesArr, omitted) => [
831
1042
  header,
832
1043
  ...linesArr,
833
- ...(omitted ? ['... and more (see /impeccable audit).'] : []),
1044
+ ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
834
1045
  '',
835
1046
  footer,
836
1047
  ].join('\n');
@@ -863,7 +1074,7 @@ function clampToBudget(header, lines, more, footer, maxChars) {
863
1074
  let assembled = assemble(working, moreText);
864
1075
  while (assembled.length > maxChars && working.length > 1) {
865
1076
  working.pop();
866
- moreText = '... and more (see /impeccable audit).';
1077
+ moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
867
1078
  assembled = assemble(working, moreText);
868
1079
  }
869
1080
  if (assembled.length > maxChars) {
@@ -895,7 +1106,7 @@ function formatFindingIgnoreCommand(finding) {
895
1106
  const value = extractFindingIgnoreValueRaw(finding);
896
1107
  const valueArg = quoteCommandArg(value);
897
1108
  const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
898
- return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
1109
+ return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
899
1110
  }
900
1111
 
901
1112
  function quoteCommandArg(value) {
@@ -1319,8 +1530,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
1319
1530
  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
1531
  }
1321
1532
 
1322
- export function shouldEmitAckForFile(filePath) {
1323
- return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
1533
+ export function shouldEmitAckForFile(filePath, config = null) {
1534
+ if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
1535
+ // Configured html-engine extensions are declared UI markup, so they get the
1536
+ // clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
1537
+ const configured = matchConfiguredExtension(filePath, config?.extensions);
1538
+ return Boolean(configured && configured.engine === 'html');
1324
1539
  }
1325
1540
 
1326
1541
  export function designSystemOptions(config, detector, projectCwd) {
@@ -1336,7 +1551,7 @@ export function designSystemOptions(config, detector, projectCwd) {
1336
1551
 
1337
1552
  export function appendDesignSystemNote(text, scanOptions) {
1338
1553
  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.`;
1554
+ return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
1340
1555
  }
1341
1556
 
1342
1557
  // The directive footer is the part of the hook output that steers model
@@ -1353,16 +1568,17 @@ export function appendDesignSystemNote(text, scanOptions) {
1353
1568
  // raw envelope. Asking the model to surface the resolution in its
1354
1569
  // reply is the cheapest way to make the feedback loop visible.
1355
1570
  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}\``;
1571
+ // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
1572
+ // for the path forever, which is far more than one noisy rule on a real UI
1573
+ // surface justifies, and it was previously the only option named here.
1574
+ const target = opts.grouped ? '<path>' : quoteCommandArg(display);
1575
+ const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
1360
1576
  return [
1361
1577
  '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
1578
  '',
1363
1579
  '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
1580
  '',
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.`,
1581
+ `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_COMMAND} 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_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
1366
1582
  ].join('\n');
1367
1583
  }
1368
1584
 
@@ -1402,9 +1618,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1402
1618
  event = normalizeHookEvent(event, cwd, harness);
1403
1619
  audit.harness = harness;
1404
1620
 
1405
- const projectCwd = event.cwd || cwd;
1621
+ const sessionCwd = event.cwd || cwd;
1622
+ const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
1623
+ const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
1406
1624
  audit.cwd = projectCwd;
1407
- const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
1408
1625
  const primaryFileSet = new Set(primaryFiles);
1409
1626
  const targetFiles = expandScanTargets(primaryFiles, projectCwd);
1410
1627
  audit.session = event.session_id || null;
@@ -1419,22 +1636,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1419
1636
  return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
1420
1637
  }
1421
1638
 
1639
+ const platform = resolveProjectPlatform(projectCwd);
1640
+ if (isNativePlatform(platform)) {
1641
+ return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
1642
+ }
1643
+
1422
1644
  const cache = readCache(projectCwd);
1423
1645
  const sessionId = event.session_id || 'unknown';
1424
1646
  const det = detector || await loadDetector();
1425
1647
  if (!det || typeof det.detectText !== 'function') {
1426
- persistCache(projectCwd, cache);
1648
+ // Cache is not mutated yet at this point; nothing to persist.
1427
1649
  return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
1428
1650
  }
1429
1651
  const scanOptions = designSystemOptions(config, det, projectCwd);
1652
+ const tiered = perEditTieringActive(config, harness);
1430
1653
 
1431
1654
  let pendingWinner = null;
1432
1655
  let cleanWinner = null;
1433
1656
  const freshGroups = [];
1434
1657
  let suppressionWinner = null;
1658
+ let cleanAckDeduped = false;
1659
+ let skippedBytes = 0;
1660
+ const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true;
1435
1661
  let detectorThrewAny = false;
1436
1662
  let lastSkip = 'no-scannable-file';
1437
1663
  let suppressedHit = false;
1664
+ let cacheDirty = false;
1665
+ let deferredTotal = 0;
1438
1666
 
1439
1667
  for (const filePath of targetFiles) {
1440
1668
  audit.file = filePath;
@@ -1449,8 +1677,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1449
1677
  }
1450
1678
 
1451
1679
  const ext = path.extname(filePath).toLowerCase();
1452
- audit.ext = ext;
1453
- if (!ALLOWED_EXTS.has(ext)) {
1680
+ const configuredExt = matchConfiguredExtension(filePath, config.extensions);
1681
+ audit.ext = configuredExt ? configuredExt.ext : ext;
1682
+ if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
1454
1683
  lastSkip = 'extension';
1455
1684
  continue;
1456
1685
  }
@@ -1465,8 +1694,20 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1465
1694
  continue;
1466
1695
  }
1467
1696
 
1697
+ const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
1698
+ if (maxFileBytes > 0) {
1699
+ let size = 0;
1700
+ try { size = fs.statSync(filePath).size; } catch { size = 0; }
1701
+ if (size > maxFileBytes) {
1702
+ skippedBytes = size;
1703
+ lastSkip = 'too-large';
1704
+ continue;
1705
+ }
1706
+ }
1707
+
1468
1708
  if (primaryFileSet.has(filePath)) {
1469
1709
  const editCount = bumpEditCount(cache, sessionId, filePath);
1710
+ cacheDirty = true;
1470
1711
  audit.editCount = editCount;
1471
1712
 
1472
1713
  if (editCount > EDIT_COUNT_THRESHOLD) {
@@ -1483,37 +1724,88 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1483
1724
  const content = fs.readFileSync(filePath, 'utf-8');
1484
1725
  let findings;
1485
1726
  let detectorThrew = false;
1486
- if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
1727
+ const useHtmlEngine = configuredExt
1728
+ ? configuredExt.engine === 'html'
1729
+ : (ext === '.html' || ext === '.htm');
1730
+ if (useHtmlEngine && typeof det.detectHtml === 'function') {
1487
1731
  try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
1488
1732
  } else {
1489
1733
  try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
1490
1734
  }
1491
1735
 
1492
1736
  const filtered = filterFindings(findings || [], content, ext, config);
1493
- const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
1737
+ // Per-edit only surfaces the immediate tier; the rest waits for the
1738
+ // Stop deep pass. The file is still marked touched so the deep pass
1739
+ // knows to re-scan it.
1740
+ const { immediate, deferred } = tiered
1741
+ ? splitFindingsByTier(filtered)
1742
+ : { immediate: filtered, deferred: [] };
1743
+ if (deferred.length > 0) {
1744
+ touchFile(cache, sessionId, filePath);
1745
+ cacheDirty = true;
1746
+ deferredTotal += deferred.length;
1747
+ }
1748
+ const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath);
1494
1749
  audit.findings = (findings || []).length;
1495
1750
  audit.freshFindings = fresh.length;
1751
+ if (deferredTotal > 0) audit.deferred = deferredTotal;
1496
1752
 
1497
- if (fresh.length > 0) {
1498
- rememberFindings(cache, sessionId, filePath, fresh);
1499
- freshGroups.push({ filePath, findings: fresh });
1753
+ // A detector failure tells us nothing about the file, so leave whatever
1754
+ // was remembered alone rather than recording an empty scan as truth.
1755
+ if (detectorThrew) {
1756
+ detectorThrewAny = true;
1500
1757
  continue;
1501
1758
  }
1502
1759
 
1503
- if (detectorThrew) {
1504
- detectorThrewAny = true;
1760
+ // Sync the cache to this scan before deciding what to emit, so fixed
1761
+ // findings stop being remembered and a reintroduced one reads as fresh.
1762
+ // Only the immediate tier is remembered: a deferred finding the per-edit
1763
+ // pass never reported must still read as fresh to the Stop deep pass.
1764
+ rememberFindings(cache, sessionId, filePath, immediate);
1765
+ cacheDirty = true;
1766
+
1767
+ if (fresh.length > 0) {
1768
+ freshGroups.push({ filePath, findings: fresh });
1505
1769
  continue;
1506
1770
  }
1507
1771
 
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 };
1772
+ if (immediate.length > 0 && !pendingWinner) {
1773
+ // Count the live scan, not the session's history.
1774
+ pendingWinner = { filePath, known: immediate.map(f => findingCacheKey(f)) };
1775
+ } else if (immediate.length === 0 && !cleanWinner) {
1776
+ // The clean ack carries no finding, only the standing steer that a
1777
+ // silent hook is not a verdict on the design. Repeating it on every
1778
+ // clean edit spends context to say nothing, so it fires once per file
1779
+ // per session. The pending ack, which names real unresolved work, is
1780
+ // deliberately left to repeat.
1781
+ //
1782
+ // Quiet mode emits nothing, so it must not consume the ack and leave a
1783
+ // later non-quiet run in this session silent.
1784
+ if (quietMode || !shouldEmitAckForFile(filePath, config)) {
1785
+ cleanWinner = { filePath };
1786
+ } else if (ensureFile(cache, sessionId, filePath).cleanAcked) {
1787
+ // Spent for this file. Remember it for the audit trail, but keep
1788
+ // scanning: another target in this same event may still be owed an
1789
+ // ack, and dropping out here would lose it.
1790
+ cleanAckDeduped = true;
1791
+ } else {
1792
+ ensureFile(cache, sessionId, filePath).cleanAcked = true;
1793
+ cleanWinner = { filePath };
1794
+ cleanAckDeduped = false;
1795
+ }
1513
1796
  }
1514
1797
  }
1515
1798
 
1516
- persistCache(projectCwd, cache);
1799
+ // Persist only when the write is earned: fresh findings justify creating
1800
+ // `.impeccable/` (dedup and suppression need it), deferred findings do
1801
+ // too (the Stop deep pass needs the touched-file list to surface them),
1802
+ // and an already-present `.impeccable/` dir marks a project that opted
1803
+ // in. A non-UI edit, or a clean UI edit in a project with no Impeccable
1804
+ // footprint, must be a no-op on disk (issues #344, #305).
1805
+ if (freshGroups.length > 0 || deferredTotal > 0
1806
+ || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
1807
+ persistCache(projectCwd, cache);
1808
+ }
1517
1809
 
1518
1810
  if (freshGroups.length > 0) {
1519
1811
  const firstGroup = freshGroups[0];
@@ -1544,11 +1836,11 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1544
1836
  return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
1545
1837
  }
1546
1838
 
1547
- if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
1839
+ if (quietMode) {
1548
1840
  return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
1549
1841
  }
1550
1842
 
1551
- if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
1843
+ if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
1552
1844
  const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
1553
1845
  return {
1554
1846
  exitCode: 0,
@@ -1582,7 +1874,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1582
1874
  };
1583
1875
  }
1584
1876
 
1585
- if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
1877
+ if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
1586
1878
  const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
1587
1879
  return {
1588
1880
  exitCode: 0,
@@ -1599,15 +1891,191 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1599
1891
  };
1600
1892
  }
1601
1893
 
1602
- if (pendingWinner || cleanWinner) {
1894
+ if (pendingWinner) {
1895
+ return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
1896
+ }
1897
+
1898
+ // Distinct from non-ui-ack so the audit log shows noise being suppressed on
1899
+ // purpose rather than a file the hook could not classify.
1900
+ if (cleanWinner) {
1603
1901
  return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
1604
1902
  }
1605
1903
 
1904
+ if (cleanAckDeduped) {
1905
+ return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started });
1906
+ }
1907
+
1606
1908
  if (suppressedHit) {
1607
1909
  return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });
1608
1910
  }
1609
1911
 
1610
- return result({ skipped: lastSkip, durationMs: Date.now() - started });
1912
+ return result({
1913
+ skipped: lastSkip,
1914
+ ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}),
1915
+ durationMs: Date.now() - started,
1916
+ });
1917
+ } catch (err) {
1918
+ return {
1919
+ exitCode: 0,
1920
+ stdout: '',
1921
+ audit: { ...audit, error: String(err && err.message ? err.message : err) },
1922
+ };
1923
+ }
1924
+ }
1925
+
1926
+ // Cap on files the Stop deep pass will scan. The touched-file list is
1927
+ // session-scoped and already capped per edit, but a very long session could
1928
+ // accumulate more than the 30s hook timeout comfortably covers.
1929
+ export const STOP_MAX_FILES = 20;
1930
+
1931
+ /**
1932
+ * Run the Stop-event deep pass: the FULL detector rule set over every UI
1933
+ * file touched this session, surfaced once, deduped against everything the
1934
+ * per-edit hook already reported. Same result contract as runHook():
1935
+ * { exitCode, stdout, audit, emission? }
1936
+ *
1937
+ * Never throws; exits silent (and fast) when the session touched no UI
1938
+ * files. Output uses the Stop hookSpecificOutput channel: additionalContext
1939
+ * is delivered to the model and the conversation continues so it can act.
1940
+ */
1941
+ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
1942
+ const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
1943
+ const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
1944
+
1945
+ try {
1946
+ // Re-entrancy guard, same as the per-edit pass.
1947
+ if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
1948
+ return result({ reentrant: true, durationMs: 0 });
1949
+ }
1950
+ if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
1951
+ return result({ skipped: 'env-disabled', durationMs: 0 });
1952
+ }
1953
+
1954
+ const started = Date.now();
1955
+
1956
+ let event;
1957
+ try {
1958
+ event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
1959
+ } catch {
1960
+ return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
1961
+ }
1962
+ if (!event || typeof event !== 'object') {
1963
+ return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
1964
+ }
1965
+
1966
+ // Claude Code's Stop-hook contract: `stop_hook_active` is true when this
1967
+ // hook is being re-invoked only because a prior invocation kept the turn
1968
+ // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
1969
+ // re-blocking now would loop until Claude Code's consecutive-block cap
1970
+ // force-ends the turn (issue #400). The prior fire already surfaced the
1971
+ // findings; whether to act on them is the agent's call. Exit fast with no
1972
+ // output before any scan. Only Claude Code sends this field; other
1973
+ // harnesses omit it, so the strict `=== true` is a no-op for them. This
1974
+ // guard makes the loop impossible regardless of the finding cache key's
1975
+ // line-number sensitivity (out of scope here; see findingCacheKey).
1976
+ if (event.stop_hook_active === true) {
1977
+ return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
1978
+ }
1979
+
1980
+ const harness = resolveHarness(env, event);
1981
+ audit.harness = harness;
1982
+
1983
+ // A Stop event carries no file, so the session cwd is the project.
1984
+ // Umbrella-dir launches keyed their per-edit cache to the edited file's
1985
+ // project root (resolveCacheCwd); those sessions no-op here rather than
1986
+ // guessing which child project the session was about.
1987
+ const projectCwd = path.resolve(event.cwd || cwd);
1988
+ audit.cwd = projectCwd;
1989
+ const sessionId = event.session_id || 'unknown';
1990
+ audit.session = sessionId;
1991
+
1992
+ const config = readConfig(projectCwd);
1993
+ if (config.enabled === false) {
1994
+ return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
1995
+ }
1996
+
1997
+ const cache = readCache(projectCwd);
1998
+ const touched = Object.keys(cache.sessions?.[sessionId]?.files || {});
1999
+ if (touched.length === 0) {
2000
+ return result({ skipped: 'no-touched-files', durationMs: Date.now() - started });
2001
+ }
2002
+
2003
+ const platform = resolveProjectPlatform(projectCwd);
2004
+ if (isNativePlatform(platform)) {
2005
+ return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
2006
+ }
2007
+
2008
+ const det = detector || await loadDetector();
2009
+ if (!det || typeof det.detectText !== 'function') {
2010
+ return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
2011
+ }
2012
+ const scanOptions = designSystemOptions(config, det, projectCwd);
2013
+
2014
+ const freshGroups = [];
2015
+ let scanned = 0;
2016
+ for (const filePath of touched) {
2017
+ if (scanned >= STOP_MAX_FILES) break;
2018
+ if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
2019
+ if (GENERATED_PATH.test(filePath)) continue;
2020
+ const ext = path.extname(filePath).toLowerCase();
2021
+ const configuredExt = matchConfiguredExtension(filePath, config.extensions);
2022
+ if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue;
2023
+ const relForMatch = relativize(filePath, projectCwd);
2024
+ if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
2025
+ if (!fs.existsSync(filePath)) continue;
2026
+
2027
+ scanned += 1;
2028
+ let content = '';
2029
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
2030
+
2031
+ let findings;
2032
+ const useHtmlEngine = configuredExt
2033
+ ? configuredExt.engine === 'html'
2034
+ : (ext === '.html' || ext === '.htm');
2035
+
2036
+ if (useHtmlEngine && typeof det.detectHtml === 'function') {
2037
+ try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
2038
+ } else {
2039
+ try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
2040
+ }
2041
+
2042
+ // Full rule set: no tier split here. Config/inline ignores still apply,
2043
+ // and the session dedupe drops everything the per-edit pass (or an
2044
+ // earlier Stop pass) already surfaced.
2045
+ const filtered = filterFindings(findings || [], content, ext, config);
2046
+ const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
2047
+ if (fresh.length > 0) {
2048
+ rememberFindings(cache, sessionId, filePath, fresh);
2049
+ freshGroups.push({ filePath, findings: fresh });
2050
+ }
2051
+ }
2052
+ audit.scannedFiles = scanned;
2053
+
2054
+ if (freshGroups.length === 0) {
2055
+ return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
2056
+ }
2057
+
2058
+ // Fresh findings earn the cache write so the next Stop fire is silent
2059
+ // unless new issues appear.
2060
+ persistCache(projectCwd, cache);
2061
+
2062
+ const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
2063
+ return {
2064
+ exitCode: 0,
2065
+ stdout: payload(text, 'Stop', harness),
2066
+ emission: {
2067
+ kind: 'stop-deep-pass',
2068
+ groups: freshGroups,
2069
+ },
2070
+ audit: {
2071
+ ...audit,
2072
+ emitted: true,
2073
+ freshFiles: freshGroups.length,
2074
+ freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0),
2075
+ chars: text.length,
2076
+ durationMs: Date.now() - started,
2077
+ },
2078
+ };
1611
2079
  } catch (err) {
1612
2080
  return {
1613
2081
  exitCode: 0,