quiver-cli 1.3.1 → 2.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 (407) hide show
  1. package/README.md +197 -239
  2. package/dist/cli.js +3163 -2040
  3. package/package.json +10 -11
  4. package/template/.agents/AGENTS.md +0 -26
  5. package/template/.agents/commands/cp.md +0 -108
  6. package/template/.agents/commands/next-setup.md +0 -1064
  7. package/template/.agents/commands/review.md +0 -68
  8. package/template/.agents/commands/terraform-readme.md +0 -38
  9. package/template/.agents/config.json +0 -80
  10. package/template/.agents/plugins/opencode/rtk.ts +0 -34
  11. package/template/.agents/skills/agent-browser/SKILL.md +0 -52
  12. package/template/.agents/skills/apps/skybridge/SKILL.md +0 -52
  13. package/template/.agents/skills/code/cleanup/SKILL.md +0 -26
  14. package/template/.agents/skills/code/improve/SKILL.md +0 -122
  15. package/template/.agents/skills/code/improve/references/audit-playbook.md +0 -130
  16. package/template/.agents/skills/code/improve/references/closing-the-loop.md +0 -96
  17. package/template/.agents/skills/code/improve/references/plan-template.md +0 -197
  18. package/template/.agents/skills/code/vercel-react-best-practices/AGENTS.md +0 -3810
  19. package/template/.agents/skills/code/vercel-react-best-practices/README.md +0 -123
  20. package/template/.agents/skills/code/vercel-react-best-practices/SKILL.md +0 -149
  21. package/template/.agents/skills/code/vercel-react-best-practices/metadata.json +0 -15
  22. package/template/.agents/skills/code/vercel-react-best-practices/rules/_sections.md +0 -46
  23. package/template/.agents/skills/code/vercel-react-best-practices/rules/_template.md +0 -28
  24. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-effect-event-deps.md +0 -56
  25. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-event-handler-refs.md +0 -55
  26. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-init-once.md +0 -42
  27. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-use-latest.md +0 -39
  28. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-api-routes.md +0 -38
  29. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-cheap-condition-before-await.md +0 -37
  30. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-defer-await.md +0 -82
  31. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-dependencies.md +0 -51
  32. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-parallel.md +0 -28
  33. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-suspense-boundaries.md +0 -99
  34. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-analyzable-paths.md +0 -63
  35. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-barrel-imports.md +0 -60
  36. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-conditional.md +0 -31
  37. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-defer-third-party.md +0 -49
  38. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-dynamic-imports.md +0 -35
  39. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-preload.md +0 -50
  40. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-event-listeners.md +0 -74
  41. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-localstorage-schema.md +0 -71
  42. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-passive-event-listeners.md +0 -48
  43. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-swr-dedup.md +0 -56
  44. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-batch-dom-css.md +0 -107
  45. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-function-results.md +0 -80
  46. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-property-access.md +0 -28
  47. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-storage.md +0 -70
  48. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-combine-iterations.md +0 -32
  49. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-early-exit.md +0 -50
  50. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-flatmap-filter.md +0 -60
  51. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-hoist-regexp.md +0 -45
  52. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-index-maps.md +0 -37
  53. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-length-check-first.md +0 -49
  54. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-min-max-loop.md +0 -82
  55. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-request-idle-callback.md +0 -105
  56. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-set-map-lookups.md +0 -24
  57. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-tosorted-immutable.md +0 -57
  58. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-activity.md +0 -26
  59. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +0 -47
  60. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-conditional-render.md +0 -40
  61. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-content-visibility.md +0 -38
  62. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hoist-jsx.md +0 -46
  63. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +0 -82
  64. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +0 -30
  65. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-resource-hints.md +0 -85
  66. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-script-defer-async.md +0 -68
  67. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-svg-precision.md +0 -28
  68. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-usetransition-loading.md +0 -75
  69. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-defer-reads.md +0 -39
  70. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-dependencies.md +0 -45
  71. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +0 -40
  72. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-derived-state.md +0 -29
  73. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-functional-setstate.md +0 -74
  74. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-lazy-state-init.md +0 -58
  75. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +0 -38
  76. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-memo.md +0 -44
  77. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +0 -45
  78. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-no-inline-components.md +0 -82
  79. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +0 -35
  80. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-split-combined-hooks.md +0 -64
  81. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-transitions.md +0 -40
  82. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-use-deferred-value.md +0 -59
  83. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +0 -73
  84. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-after-nonblocking.md +0 -73
  85. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-auth-actions.md +0 -96
  86. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-cache-lru.md +0 -41
  87. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-cache-react.md +0 -76
  88. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-dedup-props.md +0 -65
  89. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-hoist-static-io.md +0 -149
  90. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-no-shared-module-state.md +0 -50
  91. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-parallel-fetching.md +0 -83
  92. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-parallel-nested-fetching.md +0 -34
  93. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-serialization.md +0 -38
  94. package/template/.agents/skills/data/prisma-cli/SKILL.md +0 -265
  95. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +0 -27
  96. package/template/.agents/skills/data/prisma-cli/references/complete.md +0 -22
  97. package/template/.agents/skills/data/prisma-cli/references/db-execute.md +0 -78
  98. package/template/.agents/skills/data/prisma-cli/references/db-pull.md +0 -185
  99. package/template/.agents/skills/data/prisma-cli/references/db-push.md +0 -150
  100. package/template/.agents/skills/data/prisma-cli/references/db-seed.md +0 -188
  101. package/template/.agents/skills/data/prisma-cli/references/debug.md +0 -46
  102. package/template/.agents/skills/data/prisma-cli/references/dev.md +0 -157
  103. package/template/.agents/skills/data/prisma-cli/references/format.md +0 -48
  104. package/template/.agents/skills/data/prisma-cli/references/generate.md +0 -173
  105. package/template/.agents/skills/data/prisma-cli/references/init.md +0 -139
  106. package/template/.agents/skills/data/prisma-cli/references/mcp.md +0 -39
  107. package/template/.agents/skills/data/prisma-cli/references/migrate-deploy.md +0 -127
  108. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +0 -145
  109. package/template/.agents/skills/data/prisma-cli/references/migrate-diff.md +0 -89
  110. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +0 -80
  111. package/template/.agents/skills/data/prisma-cli/references/migrate-resolve.md +0 -57
  112. package/template/.agents/skills/data/prisma-cli/references/migrate-status.md +0 -65
  113. package/template/.agents/skills/data/prisma-cli/references/studio.md +0 -137
  114. package/template/.agents/skills/data/prisma-cli/references/validate.md +0 -53
  115. package/template/.agents/skills/data/prisma-client-api/SKILL.md +0 -216
  116. package/template/.agents/skills/data/prisma-client-api/references/client-methods.md +0 -223
  117. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +0 -221
  118. package/template/.agents/skills/data/prisma-client-api/references/filters.md +0 -256
  119. package/template/.agents/skills/data/prisma-client-api/references/model-queries.md +0 -281
  120. package/template/.agents/skills/data/prisma-client-api/references/query-options.md +0 -276
  121. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +0 -198
  122. package/template/.agents/skills/data/prisma-client-api/references/relations.md +0 -308
  123. package/template/.agents/skills/data/prisma-client-api/references/transactions.md +0 -184
  124. package/template/.agents/skills/design/impeccable/SKILL.md +0 -84
  125. package/template/.agents/skills/design/impeccable/reference/adapt.md +0 -312
  126. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +0 -58
  127. package/template/.agents/skills/design/impeccable/reference/android.md +0 -46
  128. package/template/.agents/skills/design/impeccable/reference/animate.md +0 -89
  129. package/template/.agents/skills/design/impeccable/reference/audit.md +0 -136
  130. package/template/.agents/skills/design/impeccable/reference/audit.native.md +0 -139
  131. package/template/.agents/skills/design/impeccable/reference/bolder.md +0 -33
  132. package/template/.agents/skills/design/impeccable/reference/clarify.md +0 -94
  133. package/template/.agents/skills/design/impeccable/reference/colorize.md +0 -86
  134. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +0 -44
  135. package/template/.agents/skills/design/impeccable/reference/craft.md +0 -5
  136. package/template/.agents/skills/design/impeccable/reference/critique.md +0 -806
  137. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +0 -37
  138. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +0 -24
  139. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +0 -38
  140. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +0 -92
  141. package/template/.agents/skills/design/impeccable/reference/delight.md +0 -70
  142. package/template/.agents/skills/design/impeccable/reference/distill.md +0 -111
  143. package/template/.agents/skills/design/impeccable/reference/doctor.md +0 -54
  144. package/template/.agents/skills/design/impeccable/reference/document.md +0 -416
  145. package/template/.agents/skills/design/impeccable/reference/extract.md +0 -69
  146. package/template/.agents/skills/design/impeccable/reference/harden.md +0 -336
  147. package/template/.agents/skills/design/impeccable/reference/hooks.md +0 -111
  148. package/template/.agents/skills/design/impeccable/reference/init.md +0 -131
  149. package/template/.agents/skills/design/impeccable/reference/ios.md +0 -51
  150. package/template/.agents/skills/design/impeccable/reference/layout.md +0 -84
  151. package/template/.agents/skills/design/impeccable/reference/live-setup.md +0 -102
  152. package/template/.agents/skills/design/impeccable/reference/live.md +0 -323
  153. package/template/.agents/skills/design/impeccable/reference/new-work.md +0 -145
  154. package/template/.agents/skills/design/impeccable/reference/onboard.md +0 -234
  155. package/template/.agents/skills/design/impeccable/reference/operate.md +0 -61
  156. package/template/.agents/skills/design/impeccable/reference/optimize.md +0 -258
  157. package/template/.agents/skills/design/impeccable/reference/overdrive.md +0 -127
  158. package/template/.agents/skills/design/impeccable/reference/polish.md +0 -105
  159. package/template/.agents/skills/design/impeccable/reference/quieter.md +0 -99
  160. package/template/.agents/skills/design/impeccable/reference/routing.md +0 -18
  161. package/template/.agents/skills/design/impeccable/reference/shape.md +0 -59
  162. package/template/.agents/skills/design/impeccable/reference/typeset.md +0 -80
  163. package/template/.agents/skills/design/impeccable/reference/visualize.md +0 -46
  164. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +0 -1022
  165. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +0 -94
  166. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +0 -391
  167. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +0 -513
  168. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +0 -814
  169. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +0 -325
  170. package/template/.agents/skills/design/impeccable/scripts/context.mjs +0 -1565
  171. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +0 -473
  172. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +0 -121
  173. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +0 -1
  174. package/template/.agents/skills/design/impeccable/scripts/detect-csp.mjs +0 -198
  175. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +0 -30
  176. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +0 -2204
  177. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +0 -432
  178. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +0 -1311
  179. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +0 -9043
  180. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +0 -51
  181. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +0 -434
  182. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +0 -1319
  183. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +0 -1241
  184. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +0 -288
  185. package/template/.agents/skills/design/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +0 -189
  186. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +0 -18
  187. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +0 -213
  188. package/template/.agents/skills/design/impeccable/scripts/detector/profile/profiler.mjs +0 -166
  189. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +0 -635
  190. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +0 -5681
  191. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +0 -596
  192. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +0 -127
  193. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +0 -30
  194. package/template/.agents/skills/design/impeccable/scripts/detector/shared/inline-ignores.mjs +0 -148
  195. package/template/.agents/skills/design/impeccable/scripts/detector/shared/page.mjs +0 -7
  196. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +0 -329
  197. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +0 -166
  198. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +0 -457
  199. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +0 -447
  200. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +0 -819
  201. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +0 -538
  202. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +0 -2490
  203. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +0 -79
  204. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +0 -93
  205. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +0 -200
  206. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +0 -396
  207. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +0 -880
  208. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +0 -564
  209. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +0 -130
  210. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +0 -246
  211. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +0 -306
  212. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +0 -640
  213. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +0 -137
  214. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +0 -72
  215. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +0 -37
  216. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +0 -26
  217. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +0 -281
  218. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +0 -5
  219. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +0 -194
  220. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +0 -369
  221. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +0 -485
  222. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +0 -169
  223. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +0 -533
  224. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +0 -149
  225. package/template/.agents/skills/design/impeccable/scripts/lib/target-args.mjs +0 -42
  226. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +0 -33
  227. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +0 -146
  228. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +0 -617
  229. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +0 -60
  230. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +0 -84
  231. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +0 -28
  232. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +0 -199
  233. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +0 -47
  234. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +0 -73
  235. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +0 -143
  236. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +0 -197
  237. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +0 -49
  238. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +0 -161
  239. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +0 -17
  240. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +0 -26
  241. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +0 -71
  242. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +0 -247
  243. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +0 -70
  244. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +0 -42
  245. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +0 -149
  246. package/template/.agents/skills/design/impeccable/scripts/live/insert-ui.mjs +0 -458
  247. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +0 -142
  248. package/template/.agents/skills/design/impeccable/scripts/live/manual-apply.mjs +0 -939
  249. package/template/.agents/skills/design/impeccable/scripts/live/manual-edit-routes.mjs +0 -357
  250. package/template/.agents/skills/design/impeccable/scripts/live/manual-edits-buffer.mjs +0 -152
  251. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +0 -14
  252. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +0 -139
  253. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +0 -508
  254. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +0 -563
  255. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +0 -105
  256. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +0 -105
  257. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +0 -969
  258. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +0 -1366
  259. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +0 -304
  260. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +0 -259
  261. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +0 -75
  262. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +0 -171
  263. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +0 -938
  264. package/template/.agents/skills/design/impeccable/scripts/live-browser-dom.js +0 -146
  265. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +0 -242
  266. package/template/.agents/skills/design/impeccable/scripts/live-browser-session.js +0 -123
  267. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +0 -12551
  268. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +0 -1200
  269. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +0 -107
  270. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +0 -800
  271. package/template/.agents/skills/design/impeccable/scripts/live-discard-manual-edits.mjs +0 -51
  272. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +0 -463
  273. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +0 -292
  274. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +0 -368
  275. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +0 -430
  276. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +0 -123
  277. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +0 -1698
  278. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +0 -71
  279. package/template/.agents/skills/design/impeccable/scripts/live-target.mjs +0 -30
  280. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +0 -927
  281. package/template/.agents/skills/design/impeccable/scripts/live.mjs +0 -334
  282. package/template/.agents/skills/design/impeccable/scripts/modern-screenshot.umd.js +0 -14
  283. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +0 -628
  284. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +0 -224
  285. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +0 -1783
  286. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +0 -74
  287. package/template/.agents/skills/design/shadcn/SKILL.md +0 -277
  288. package/template/.agents/skills/design/shadcn/agents/openai.yml +0 -5
  289. package/template/.agents/skills/design/shadcn/assets/shadcn-small.png +0 -0
  290. package/template/.agents/skills/design/shadcn/assets/shadcn.png +0 -0
  291. package/template/.agents/skills/design/shadcn/cli.md +0 -290
  292. package/template/.agents/skills/design/shadcn/customization.md +0 -209
  293. package/template/.agents/skills/design/shadcn/evals/evals.json +0 -77
  294. package/template/.agents/skills/design/shadcn/mcp.md +0 -105
  295. package/template/.agents/skills/design/shadcn/registry.md +0 -277
  296. package/template/.agents/skills/design/shadcn/rules/base-vs-radix.md +0 -306
  297. package/template/.agents/skills/design/shadcn/rules/chat.md +0 -250
  298. package/template/.agents/skills/design/shadcn/rules/composition.md +0 -213
  299. package/template/.agents/skills/design/shadcn/rules/forms.md +0 -192
  300. package/template/.agents/skills/design/shadcn/rules/icons.md +0 -101
  301. package/template/.agents/skills/design/shadcn/rules/styling.md +0 -185
  302. package/template/.agents/skills/find-skills/SKILL.md +0 -141
  303. package/template/.agents/skills/hono/SKILL.md +0 -593
  304. package/template/.agents/skills/integrations/langfuse/SKILL.md +0 -150
  305. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +0 -41
  306. package/template/.agents/skills/integrations/langfuse/references/cli.md +0 -60
  307. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +0 -35
  308. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +0 -87
  309. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +0 -121
  310. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +0 -293
  311. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +0 -35
  312. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +0 -79
  313. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -34
  314. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +0 -65
  315. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +0 -35
  316. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +0 -76
  317. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +0 -66
  318. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +0 -75
  319. package/template/.agents/skills/integrations/posthog/SKILL.md +0 -102
  320. package/template/.agents/skills/integrations/posthog/references/error-tracking-alerts.md +0 -63
  321. package/template/.agents/skills/integrations/posthog/references/error-tracking-assigning-issues.md +0 -77
  322. package/template/.agents/skills/integrations/posthog/references/error-tracking-fingerprints.md +0 -57
  323. package/template/.agents/skills/integrations/posthog/references/error-tracking-monitoring.md +0 -140
  324. package/template/.agents/skills/integrations/posthog/references/error-tracking-nextjs.md +0 -490
  325. package/template/.agents/skills/integrations/posthog/references/error-tracking-source-maps.md +0 -45
  326. package/template/.agents/skills/integrations/posthog/references/feature-flags-best-practices.md +0 -139
  327. package/template/.agents/skills/integrations/posthog/references/feature-flags-react.md +0 -302
  328. package/template/.agents/skills/integrations/posthog/references/identify-users.md +0 -202
  329. package/template/.agents/skills/integrations/posthog/references/integration-example.md +0 -706
  330. package/template/.agents/skills/integrations/posthog/references/integration-nextjs.md +0 -385
  331. package/template/.agents/skills/integrations/posthog/references/integration-step-1-begin.md +0 -43
  332. package/template/.agents/skills/integrations/posthog/references/integration-step-2-edit.md +0 -37
  333. package/template/.agents/skills/integrations/posthog/references/integration-step-3-revise.md +0 -22
  334. package/template/.agents/skills/integrations/posthog/references/integration-step-4-conclude.md +0 -38
  335. package/template/.agents/skills/integrations/posthog/references/llm-analytics-anthropic.md +0 -200
  336. package/template/.agents/skills/integrations/posthog/references/llm-analytics-basics.md +0 -62
  337. package/template/.agents/skills/integrations/posthog/references/llm-analytics-costs.md +0 -197
  338. package/template/.agents/skills/integrations/posthog/references/llm-analytics-manual-capture.md +0 -397
  339. package/template/.agents/skills/integrations/posthog/references/llm-analytics-traces.md +0 -98
  340. package/template/.agents/skills/integrations/posthog/references/llm-analytics-vercel-ai.md +0 -120
  341. package/template/.agents/skills/repo/repo-ci/SKILL.md +0 -265
  342. package/template/.agents/skills/repo/repo-init-next-js/SKILL.md +0 -129
  343. package/template/.agents/skills/repo/repo-init-next-js/references/file-contents.md +0 -800
  344. package/template/.agents/skills/repo/repo-init-next-js/scripts/setup.sh +0 -47
  345. package/template/.agents/skills/repo/repo-init-node/SKILL.md +0 -196
  346. package/template/.agents/skills/skill-creator/LICENSE.txt +0 -202
  347. package/template/.agents/skills/skill-creator/SKILL.md +0 -485
  348. package/template/.agents/skills/skill-creator/agents/analyzer.md +0 -274
  349. package/template/.agents/skills/skill-creator/agents/comparator.md +0 -202
  350. package/template/.agents/skills/skill-creator/agents/grader.md +0 -223
  351. package/template/.agents/skills/skill-creator/assets/eval_review.html +0 -146
  352. package/template/.agents/skills/skill-creator/eval-viewer/generate_review.py +0 -471
  353. package/template/.agents/skills/skill-creator/eval-viewer/viewer.html +0 -1325
  354. package/template/.agents/skills/skill-creator/references/schemas.md +0 -430
  355. package/template/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  356. package/template/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -401
  357. package/template/.agents/skills/skill-creator/scripts/generate_report.py +0 -326
  358. package/template/.agents/skills/skill-creator/scripts/improve_description.py +0 -247
  359. package/template/.agents/skills/skill-creator/scripts/package_skill.py +0 -136
  360. package/template/.agents/skills/skill-creator/scripts/quick_validate.py +0 -103
  361. package/template/.agents/skills/skill-creator/scripts/run_eval.py +0 -310
  362. package/template/.agents/skills/skill-creator/scripts/run_loop.py +0 -328
  363. package/template/.agents/skills/skill-creator/scripts/utils.py +0 -47
  364. package/template/.agents/skills/supabase/CHANGELOG.md +0 -78
  365. package/template/.agents/skills/supabase/SKILL.md +0 -149
  366. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +0 -17
  367. package/template/.agents/skills/supabase/references/skill-feedback.md +0 -17
  368. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +0 -73
  369. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +0 -64
  370. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +0 -170
  371. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +0 -39
  372. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +0 -34
  373. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +0 -55
  374. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +0 -49
  375. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +0 -46
  376. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +0 -44
  377. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +0 -41
  378. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +0 -46
  379. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +0 -54
  380. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +0 -53
  381. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +0 -50
  382. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +0 -50
  383. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +0 -56
  384. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +0 -68
  385. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +0 -50
  386. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +0 -54
  387. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +0 -45
  388. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +0 -55
  389. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +0 -55
  390. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +0 -44
  391. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +0 -40
  392. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +0 -48
  393. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +0 -43
  394. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +0 -45
  395. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +0 -80
  396. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +0 -46
  397. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +0 -59
  398. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +0 -55
  399. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +0 -55
  400. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +0 -61
  401. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +0 -54
  402. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +0 -50
  403. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +0 -63
  404. package/template/.agents/skills/writing/humanizer/LICENSE +0 -21
  405. package/template/.agents/skills/writing/humanizer/README.md +0 -209
  406. package/template/.agents/skills/writing/humanizer/SKILL.md +0 -456
  407. package/template/.agents/upstreams.json +0 -115
@@ -1,2490 +0,0 @@
1
- /**
2
- * Shared library for the Impeccable design hook.
3
- *
4
- * Pure-ish helpers split out from `hook.mjs` so unit tests can exercise
5
- * config parsing, finding filtering, dedup, render, and cache logic without
6
- * spawning a subprocess. `hook.mjs` itself is the thin stdin/stdout shim.
7
- *
8
- * Public surface (everything exported is part of the contract):
9
- * ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
10
- * truthy(value)
11
- * readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
12
- * resolveProjectPlatform(cwd) / isNativePlatform(platform)
13
- * normalizeIgnoreValue(value)
14
- * readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
15
- * bumpEditCount(cache, sessionId, filePath) -> number
16
- * touchFile(cache, sessionId, filePath)
17
- * suppressionNotice(filePath)
18
- * filterFindings(findings, content, ext, config)
19
- * ADVISORY_RULES / isAdvisoryFinding(finding)
20
- * IMMEDIATE_TIER_RULES / splitFindingsByTier(findings) / perEditTieringActive(config, harness)
21
- * matchConfiguredExtension(filePath, extensions)
22
- * dedupeAgainstCache(findings, cache, sessionId, filePath)
23
- * renderTemplate(findings, filePath, config, opts)
24
- * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
25
- * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config)
26
- * designNoteReserve(scanOptions, cache, sessionId)
27
- * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text)
28
- * shouldEmitAckForFile(filePath, config?)
29
- * writeAuditLog(env, entry)
30
- * loadDetector() -> Promise<{ detectText, detectHtml }>
31
- * matchesAnyGlob(filePath, globs)
32
- * normalizeScanTargets(primaryTargets, projectCwd)
33
- * runHook(deps) -> { exitCode, stdout, audit, reason? }
34
- * runStopHook(deps) -> { exitCode, stdout, audit, emission? }
35
- *
36
- * Design notes:
37
- * - All errors are swallowed at the runHook seam. The detector throwing must
38
- * never break a turn. See PRD §5 "Failure modes".
39
- * - Cache shape is JSON-friendly; we gc the oldest sessions when there are
40
- * more than 8 to keep file size predictable across long-lived projects.
41
- * - The detector loader looks for `detector/detect-antipatterns.mjs` next to
42
- * this file first (built skill layout) and falls back to the repo root's
43
- * `cli/engine/detect-antipatterns.mjs` (running from source).
44
- */
45
-
46
- import crypto from 'node:crypto';
47
- import fs from 'node:fs';
48
- import os from 'node:os';
49
- import path from 'node:path';
50
- import { pathToFileURL, fileURLToPath } from 'node:url';
51
- import { extractPlatform, loadContext } from './context.mjs';
52
- import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
53
- // `detector.extensions` (issue #316) is shared with Live's source search, which
54
- // needs the same answer for `.heex` / `.blade.php` when it hunts for session
55
- // markers. lib/template-extensions.mjs owns the shape; re-exported here because
56
- // hook-lib has been the import site for matchConfiguredExtension since #347.
57
- import {
58
- matchConfiguredExtension,
59
- mergeExtensions,
60
- } from './lib/template-extensions.mjs';
61
-
62
- export { matchConfiguredExtension };
63
-
64
- const __filename = fileURLToPath(import.meta.url);
65
- const __dirname = path.dirname(__filename);
66
-
67
- export const ENVELOPE_PREFIX = '[impeccable@1]';
68
-
69
- export const ALLOWED_EXTS = new Set([
70
- '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
71
- '.css', '.scss', '.sass', '.less', '.ts', '.js',
72
- ]);
73
-
74
- export const ACK_EXTS = new Set([
75
- '.tsx', '.jsx', '.html', '.htm', '.vue', '.svelte', '.astro',
76
- '.css', '.scss', '.sass', '.less',
77
- ]);
78
-
79
- // Hard-skip regex for sensitive files. Cannot be turned off via config.
80
- // Match tokenized secret/credential filenames, not UI names such as
81
- // CredentialForm.tsx, SecretPage.jsx, or secretary-dashboard.vue.
82
- export const SENSITIVE_PATH = new RegExp([
83
- String.raw`(?:^|[/\\])\.env(?:\.|$)`,
84
- String.raw`(?:^|[/\\])\.git(?:[/\\]|$)`,
85
- String.raw`(?:^|[/\\])id_rsa(?:$|[._-])[^/\\]*$`,
86
- String.raw`(?:^|[/\\])[^/\\]*\.pem$`,
87
- String.raw`(?:^|[/\\])(?:[^/\\]*[._-])?(?:secret|secrets|credential|credentials)(?=[._-])[^/\\]*\.(?:json|ya?ml|toml|ini|conf|config|env|txt|key|cert|crt|pem|js|ts)$`,
88
- ].join('|'), 'i');
89
-
90
- // Hard-skip regex for generated, lock, minified, and build-output paths.
91
- // `generated` is matched as a whole path segment so authored names such as
92
- // `generated-utils.ts` or `CodeGenerator.tsx` still get scanned.
93
- export const GENERATED_PATH = /(?:\.generated\.[a-z]+$|\.d\.ts$|\.min\.[a-z]+$|[/\\]node_modules[/\\]|[/\\]generated[/\\]|[/\\](?:dist|build|out|\.next|\.cache|coverage)[/\\]|[/\\]?[^/\\]+\.lock(?:\.json)?$)/i;
94
-
95
- export const TRUTHY = /^(1|true|yes|on)$/i;
96
-
97
- // ── Two-tier rule surfacing ──────────────────────────────────────────────
98
- // The per-edit PostToolUse pass surfaces only this "immediate" tier: rules
99
- // that are mechanical, unambiguous, and worth interrupting an edit for —
100
- // broken output the user would see (broken images, overflow, clipped
101
- // popovers, text on the viewport edge), objective contrast/legibility
102
- // failures, single-property slop that is trivial to fix in place (gradient
103
- // text, glow shadows), and design-system drift (which compounds with every
104
- // further edit if left uncorrected). Everything else — copy-cadence rules,
105
- // palette/typography taste, layout rhythm — is deferred to the Stop-event
106
- // deep pass (`runStopHook`), which runs the FULL rule set over every file
107
- // touched this session and surfaces the remainder once.
108
- //
109
- // Rationale (measured in the eval harness): the per-edit stream fires
110
- // overwhelmingly on copy-level rules, and that steady nag stream makes
111
- // models more conservative, while a single full pass at completion fixes
112
- // contrast/padding/glow just as reliably. Restore the old full per-edit
113
- // behavior with `.impeccable/config.json` → `hook: { "perEditRules": "all" }`.
114
- export const IMMEDIATE_TIER_RULES = new Set([
115
- // Broken output.
116
- 'broken-image',
117
- 'text-overflow',
118
- 'clipped-overflow-container',
119
- 'body-text-viewport-edge',
120
- // Objective contrast / legibility failures.
121
- 'low-contrast',
122
- 'gray-on-color',
123
- 'tiny-text',
124
- // Single-property mechanical slop, trivial to fix at the edit site.
125
- 'gradient-text',
126
- 'dark-glow',
127
- // Design-system drift compounds if not corrected at edit time.
128
- 'design-system-font',
129
- 'design-system-color',
130
- 'design-system-radius',
131
- 'design-system-font-size',
132
- ]);
133
-
134
- // ── Advisory rules ────────────────────────────────────────────────────────
135
- // Advisory rules are opt-in noise: the CLI reports them in a separate section
136
- // and they never count as failures. The design hook skips them entirely by
137
- // default — in both the per-edit PostToolUse pass and the Stop deep pass — so
138
- // the agent is never nagged about a taste call a human might make on purpose.
139
- // A project opts back in with `.impeccable/config.json`:
140
- // { "detector": { "advisoryRules": "include" } }
141
- // This set is the hook's own copy of the registry's `advisory: true` rules,
142
- // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
143
- // self-contained and testable without loading the detector. Keep it in sync
144
- // with the registry (cli/engine/registry/antipatterns.mjs).
145
- export const ADVISORY_RULES = new Set([
146
- 'em-dash-overuse',
147
- ]);
148
-
149
- export function isAdvisoryFinding(finding) {
150
- const id = finding && normalizeIgnoreRule(finding.antipattern);
151
- return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
152
- }
153
-
154
- export const DEFAULT_CONFIG = Object.freeze({
155
- enabled: true,
156
- quiet: false,
157
- auditLog: null,
158
- designSystem: { enabled: true },
159
- ignoreRules: [],
160
- ignoreFiles: [],
161
- ignoreValues: [],
162
- extensions: [],
163
- perEditRules: 'immediate',
164
- // Advisory rules are skipped unless a project sets detector.advisoryRules to
165
- // "include". See ADVISORY_RULES above.
166
- advisoryRules: 'exclude',
167
- // maxFileBytes: not every generated artifact lives under a path we can
168
- // recognize. Committed browser bundles and vendored detector copies sit
169
- // next to source and run 200KB+, while genuinely authored stylesheets in
170
- // this codebase top out under 90KB. A single file past the ceiling is a
171
- // bundle, and findings against a bundle are never actionable.
172
- limits: { maxFindings: 5, maxChars: 8000, maxFileBytes: 131072 },
173
- });
174
-
175
- export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
176
- '.impeccable/hook.cache.json',
177
- '.impeccable/hook.pending.json',
178
- '.impeccable/config.local.json',
179
- ]);
180
-
181
- const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
182
- const HOOK_IGNORE_MARKER_CLOSE = '# impeccable-hook-ignore-end';
183
- const CACHE_MAX_SESSIONS = 8;
184
- export const EDIT_COUNT_THRESHOLD = 6;
185
-
186
- export function truthy(value) {
187
- return typeof value === 'string' && TRUTHY.test(value);
188
- }
189
-
190
- function depthIsSet(value) {
191
- if (value === undefined || value === null) return false;
192
- const text = String(value).trim();
193
- if (!text) return false;
194
- if (TRUTHY.test(text)) return true;
195
- return /^\d+$/.test(text) && Number(text) > 0;
196
- }
197
-
198
- function safeReadJson(filePath) {
199
- try {
200
- return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
201
- } catch {
202
- return null;
203
- }
204
- }
205
-
206
- export function getConfigPath(cwd) {
207
- return path.join(cwd, '.impeccable', 'config.json');
208
- }
209
-
210
- export function getLocalConfigPath(cwd) {
211
- return path.join(cwd, '.impeccable', 'config.local.json');
212
- }
213
-
214
- // Where mutable hook state (cache + pending) lives. Defaults to the
215
- // project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state
216
- // relocates to a per-project subdirectory of that root instead, keyed by a
217
- // slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's
218
- // `~/.claude/projects/` convention), so project roots stay free of tool
219
- // artifacts (issue #422). User-authored config (config.json,
220
- // config.local.json, design.json) deliberately stays project-local — only
221
- // disposable state relocates.
222
- // Read from process.env (not runHook's injected env): the cache root is a
223
- // machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation
224
- // switch. Trim guards against stray whitespace in env files; `~/` (or the
225
- // Windows `~\` spelling) expands via os.homedir(), and when no home dir can
226
- // be determined the expansion is rejected — state falls back to the
227
- // project-local default rather than anchoring under the hook process's cwd.
228
- // Resolving both sides makes the slug deterministic when callers hand in a
229
- // trailing separator or unnormalized cwd. The slug is the readable
230
- // separator-mapped path PLUS an 8-hex sha256 of the resolved path: the
231
- // readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map
232
- // to `-x-my-app` and share state), so the digest disambiguates while keeping
233
- // the dir name human-scannable.
234
- function hookStateDir(cwd) {
235
- const raw = process.env.IMPECCABLE_CACHE_ROOT;
236
- let root = typeof raw === 'string' ? raw.trim() : '';
237
- if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') {
238
- let home = '';
239
- try { home = os.homedir() || ''; } catch { home = ''; }
240
- root = home ? path.join(home, root.slice(2)) : '';
241
- }
242
- if (root) {
243
- const resolved = path.resolve(String(cwd));
244
- const slug = resolved.replace(/[:\\/.]/g, '-');
245
- const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8);
246
- return path.join(path.resolve(root), `${slug}-${digest}`);
247
- }
248
- return path.join(cwd, '.impeccable');
249
- }
250
-
251
- export function getCachePath(cwd) {
252
- return path.join(hookStateDir(cwd), 'hook.cache.json');
253
- }
254
-
255
- export function getPendingPath(cwd) {
256
- return path.join(hookStateDir(cwd), 'hook.pending.json');
257
- }
258
-
259
- export function resolveProjectCwd(event, fallback = process.cwd()) {
260
- return event?.cwd
261
- || (Array.isArray(event?.workspace_roots) && event.workspace_roots[0])
262
- || envProjectDir(fallback)
263
- || fallback;
264
- }
265
-
266
- function looksLikeProjectRoot(dir) {
267
- return ['.git', 'package.json', '.impeccable'].some((marker) => {
268
- try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
269
- });
270
- }
271
-
272
- // Where `.impeccable/` (cache + config) lives for this event. Normally the
273
- // session cwd, untouched. But when the agent was launched from an umbrella
274
- // directory that is not itself a project (no .git, package.json, or
275
- // .impeccable), key to the edited file's nearest project root instead, so a
276
- // multi-project launch dir doesn't accumulate a shared cross-project cache
277
- // (issue #305). Climbing stops at the home dir, falling back to the session
278
- // cwd when no marker is found.
279
- export function resolveCacheCwd(primaryFile, sessionCwd) {
280
- const base = path.resolve(sessionCwd || process.cwd());
281
- if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
282
- if (looksLikeProjectRoot(base)) return base;
283
- let dir;
284
- try {
285
- dir = path.dirname(path.resolve(primaryFile));
286
- } catch {
287
- return base;
288
- }
289
- const home = path.resolve(os.homedir());
290
- while (true) {
291
- if (dir === home) return base;
292
- if (looksLikeProjectRoot(dir)) return dir;
293
- const parent = path.dirname(dir);
294
- if (parent === dir) return base;
295
- dir = parent;
296
- }
297
- }
298
-
299
- // The detector's rules are web rules (HTML/CSS shapes), but a React Native or
300
- // Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
301
- // .js), so without this gate every native screen edit would draw web-shaped
302
- // findings that contradict the native platform references. PRODUCT.md's
303
- // `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
304
- // the scan entirely. Resolution goes through loadContext so the hook reads the
305
- // same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
306
- export function resolveProjectPlatform(cwd) {
307
- try {
308
- const ctx = loadContext(cwd);
309
- return extractPlatform(ctx && ctx.product);
310
- } catch {
311
- return null;
312
- }
313
- }
314
-
315
- export function isNativePlatform(platform) {
316
- return platform === 'ios' || platform === 'android' || platform === 'adaptive';
317
- }
318
-
319
- export function readConfig(cwd) {
320
- const config = cloneDefaultConfig();
321
- // Hook runtime settings live under `hook`; detector filters live under
322
- // `detector`. Back-compat: older configs stored detector filters in `hook`,
323
- // so read those first and let canonical `detector` settings win.
324
- for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
325
- const raw = safeReadJson(filePath);
326
- applyConfigSource(config, hookSection(raw));
327
- applyDetectorConfigSource(config, detectorSection(raw));
328
- }
329
- return config;
330
- }
331
-
332
- // The hook settings subtree of a unified config.json / config.local.json.
333
- function hookSection(raw) {
334
- if (!raw || typeof raw !== 'object') return null;
335
- return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
336
- }
337
-
338
- function detectorSection(raw) {
339
- if (!raw || typeof raw !== 'object') return null;
340
- return raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
341
- }
342
-
343
- function numberOr(value, fallback) {
344
- return Number.isFinite(value) && value > 0 ? value : fallback;
345
- }
346
-
347
- function cloneDefaultConfig() {
348
- return {
349
- ...DEFAULT_CONFIG,
350
- ignoreRules: [],
351
- ignoreFiles: [],
352
- ignoreValues: [],
353
- extensions: [],
354
- designSystem: { ...DEFAULT_CONFIG.designSystem },
355
- limits: { ...DEFAULT_CONFIG.limits },
356
- };
357
- }
358
-
359
- function applyDetectorConfigSource(config, raw) {
360
- if (!raw || typeof raw !== 'object') return config;
361
- // `detector.advisoryRules: "include"` opts the hook into advisory rules
362
- // (em-dash overuse, etc.). Any other value keeps the default "exclude".
363
- if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
364
- config.advisoryRules = raw.advisoryRules;
365
- }
366
- if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
367
- config.designSystem = {
368
- ...config.designSystem,
369
- enabled: raw.designSystem.enabled === false ? false : true,
370
- };
371
- }
372
- if (Array.isArray(raw.ignoreRules)) {
373
- config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
374
- }
375
- if (Array.isArray(raw.ignoreFiles)) {
376
- config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
377
- }
378
- if (Array.isArray(raw.ignoreValues)) {
379
- config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
380
- }
381
- if (Array.isArray(raw.extensions)) {
382
- config.extensions = mergeExtensions(config.extensions, raw.extensions);
383
- }
384
- return config;
385
- }
386
-
387
- function applyConfigSource(config, raw) {
388
- if (!raw || typeof raw !== 'object') return config;
389
- if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
390
- config.enabled = raw.enabled === false ? false : true;
391
- }
392
- if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
393
- config.quiet = raw.quiet === true;
394
- }
395
- if (raw.perEditRules === 'all' || raw.perEditRules === 'immediate') {
396
- config.perEditRules = raw.perEditRules;
397
- }
398
- if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
399
- config.auditLog = raw.auditLog.trim();
400
- }
401
- applyDetectorConfigSource(config, raw);
402
- if (raw.limits && typeof raw.limits === 'object') {
403
- config.limits = {
404
- maxFindings: numberOr(raw.limits.maxFindings, config.limits.maxFindings),
405
- maxChars: numberOr(raw.limits.maxChars, config.limits.maxChars),
406
- maxFileBytes: numberOr(raw.limits.maxFileBytes, config.limits.maxFileBytes),
407
- };
408
- }
409
- return config;
410
- }
411
-
412
- function uniqueStrings(values) {
413
- return Array.from(new Set(values.map(String)));
414
- }
415
-
416
- export function normalizeIgnoreValue(value) {
417
- return String(value || '')
418
- .trim()
419
- .replace(/^["']|["']$/g, '')
420
- .replace(/\+/g, ' ')
421
- .replace(/\s+/g, ' ')
422
- .toLowerCase();
423
- }
424
-
425
- function normalizeIgnoreRule(rule) {
426
- return String(rule || '').trim().toLowerCase();
427
- }
428
-
429
- function colorIgnoreKey(value) {
430
- const color = parseIgnoreColor(value);
431
- if (!color) return '';
432
- return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
433
- }
434
-
435
- function parseIgnoreColor(value) {
436
- const text = String(value || '').trim().toLowerCase();
437
- if (!text) return null;
438
-
439
- const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
440
- if (hex) return parseHexIgnoreColor(hex[1]);
441
-
442
- const rgb = text.match(/^rgba?\((.*)\)$/i);
443
- if (rgb) {
444
- const parts = splitColorArgs(rgb[1]);
445
- if (parts.length < 3 || parts.length > 4) return null;
446
- const r = parseRgbChannel(parts[0]);
447
- const g = parseRgbChannel(parts[1]);
448
- const b = parseRgbChannel(parts[2]);
449
- const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
450
- if ([r, g, b, a].some((v) => v === null)) return null;
451
- return { r, g, b, a };
452
- }
453
-
454
- const hsl = text.match(/^hsla?\((.*)\)$/i);
455
- if (hsl) {
456
- const parts = splitColorArgs(hsl[1]);
457
- if (parts.length < 3 || parts.length > 4) return null;
458
- const h = parseHueChannel(parts[0]);
459
- const s = parsePercentChannel(parts[1]);
460
- const l = parsePercentChannel(parts[2]);
461
- const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
462
- if ([h, s, l, a].some((v) => v === null)) return null;
463
- return hslToRgb(h, s, l, a);
464
- }
465
-
466
- return null;
467
- }
468
-
469
- function parseHexIgnoreColor(hex) {
470
- if (hex.length === 3 || hex.length === 4) {
471
- const r = parseInt(hex[0] + hex[0], 16);
472
- const g = parseInt(hex[1] + hex[1], 16);
473
- const b = parseInt(hex[2] + hex[2], 16);
474
- const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
475
- return { r, g, b, a };
476
- }
477
- const r = parseInt(hex.slice(0, 2), 16);
478
- const g = parseInt(hex.slice(2, 4), 16);
479
- const b = parseInt(hex.slice(4, 6), 16);
480
- const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
481
- return { r, g, b, a };
482
- }
483
-
484
- function splitColorArgs(body) {
485
- const text = String(body || '').trim();
486
- if (!text) return [];
487
- if (text.includes(',')) {
488
- const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
489
- const last = parts[parts.length - 1];
490
- if (last && last.includes('/')) {
491
- const split = last.split('/').map((part) => part.trim()).filter(Boolean);
492
- return [...parts.slice(0, -1), ...split];
493
- }
494
- return parts;
495
- }
496
- return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
497
- }
498
-
499
- function parseRgbChannel(raw) {
500
- const text = String(raw || '').trim();
501
- const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
502
- if (!match) return null;
503
- const value = Number.parseFloat(match[1]);
504
- if (!Number.isFinite(value)) return null;
505
- const scaled = match[2] ? value * 2.55 : value;
506
- if (scaled < 0 || scaled > 255) return null;
507
- return Math.round(scaled);
508
- }
509
-
510
- function parseAlphaChannel(raw) {
511
- const text = String(raw || '').trim();
512
- const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
513
- if (!match) return null;
514
- const value = Number.parseFloat(match[1]);
515
- if (!Number.isFinite(value)) return null;
516
- const alpha = match[2] ? value / 100 : value;
517
- return alpha >= 0 && alpha <= 1 ? alpha : null;
518
- }
519
-
520
- function parseHueChannel(raw) {
521
- const text = String(raw || '').trim();
522
- const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
523
- if (!match) return null;
524
- const value = Number.parseFloat(match[1]);
525
- if (!Number.isFinite(value)) return null;
526
- const unit = match[2] || 'deg';
527
- if (unit === 'turn') return value * 360;
528
- if (unit === 'rad') return value * (180 / Math.PI);
529
- if (unit === 'grad') return value * 0.9;
530
- return value;
531
- }
532
-
533
- function parsePercentChannel(raw) {
534
- const text = String(raw || '').trim();
535
- const match = text.match(/^(-?\d*\.?\d+)%$/);
536
- if (!match) return null;
537
- const value = Number.parseFloat(match[1]);
538
- if (!Number.isFinite(value)) return null;
539
- return value >= 0 && value <= 100 ? value / 100 : null;
540
- }
541
-
542
- function hslToRgb(hue, saturation, lightness, alpha) {
543
- const h = (((hue % 360) + 360) % 360) / 360;
544
- if (saturation === 0) {
545
- const gray = clampByte(Math.round(lightness * 255));
546
- return { r: gray, g: gray, b: gray, a: alpha };
547
- }
548
- const q = lightness < 0.5
549
- ? lightness * (1 + saturation)
550
- : lightness + saturation - lightness * saturation;
551
- const p = 2 * lightness - q;
552
- const toRgb = (t) => {
553
- let channel = t;
554
- if (channel < 0) channel += 1;
555
- if (channel > 1) channel -= 1;
556
- if (channel < 1 / 6) return p + (q - p) * 6 * channel;
557
- if (channel < 1 / 2) return q;
558
- if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
559
- return p;
560
- };
561
- return {
562
- r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
563
- g: clampByte(Math.round(toRgb(h) * 255)),
564
- b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
565
- a: alpha,
566
- };
567
- }
568
-
569
- function clampByte(value) {
570
- return Math.min(255, Math.max(0, value));
571
- }
572
-
573
- function ignoreValueMatches(rule, entryValue, findingValue) {
574
- if (entryValue === findingValue) return true;
575
- if (rule !== 'design-system-color') return false;
576
- const entryColor = colorIgnoreKey(entryValue);
577
- return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
578
- }
579
-
580
- export function normalizeIgnoreValueEntries(entries) {
581
- if (!Array.isArray(entries)) return [];
582
- const out = [];
583
- for (const entry of entries) {
584
- if (!entry || typeof entry !== 'object') continue;
585
- const rule = normalizeIgnoreRule(entry.rule);
586
- const value = normalizeIgnoreValue(entry.value);
587
- if (!rule || !value) continue;
588
- const normalized = { rule, value };
589
- const files = uniqueStrings([
590
- ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
591
- ...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
592
- ]);
593
- if (files.length > 0) normalized.files = files;
594
- // Key order is rule, value, files, createdAt, reason and must stay that way:
595
- // normalizing runs on every write, so emitting a different order than the one
596
- // already on disk rewrites every untouched entry and churns the diff.
597
- if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
598
- normalized.createdAt = entry.createdAt.trim();
599
- }
600
- if (typeof entry.reason === 'string' && entry.reason.trim()) {
601
- normalized.reason = entry.reason.trim();
602
- }
603
- out.push(normalized);
604
- }
605
- return out;
606
- }
607
-
608
- function mergeIgnoreValues(existing, incoming) {
609
- const map = new Map();
610
- for (const entry of normalizeIgnoreValueEntries(existing)) {
611
- map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
612
- }
613
- for (const entry of normalizeIgnoreValueEntries(incoming)) {
614
- map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
615
- }
616
- return Array.from(map.values());
617
- }
618
-
619
- function ignoreValueFilesKey(files) {
620
- // Sort before joining: a scope is a set, so an entry already on disk in another
621
- // order must compare equal rather than dedup as two distinct entries.
622
- return Array.isArray(files) && files.length > 0 ? [...files].sort().join('\x1f') : '';
623
- }
624
-
625
- export function readCache(cwd) {
626
- const raw = safeReadJson(getCachePath(cwd));
627
- if (!raw || typeof raw !== 'object' || raw.version !== 1) {
628
- return { version: 1, sessions: {} };
629
- }
630
- return {
631
- version: 1,
632
- sessions: raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {},
633
- };
634
- }
635
-
636
- export function persistCache(cwd, cache) {
637
- const sessions = cache.sessions || {};
638
- const ids = Object.keys(sessions);
639
- if (ids.length > CACHE_MAX_SESSIONS) {
640
- // Garbage-collect oldest sessions by updatedAt.
641
- const ordered = ids
642
- .map((id) => [id, sessions[id]?.updatedAt || 0])
643
- .sort((a, b) => b[1] - a[1])
644
- .slice(0, CACHE_MAX_SESSIONS);
645
- const next = {};
646
- for (const [id] of ordered) next[id] = sessions[id];
647
- cache = { ...cache, sessions: next };
648
- }
649
- const target = getCachePath(cwd);
650
- try {
651
- ensureHookGitExcludes(cwd);
652
- fs.mkdirSync(path.dirname(target), { recursive: true });
653
- fs.writeFileSync(target, JSON.stringify(cache));
654
- return true;
655
- } catch {
656
- return false;
657
- }
658
- }
659
-
660
- export function ensureHookGitExcludes(cwd = process.cwd()) {
661
- try {
662
- const target = resolveHookGitExcludeTarget(cwd);
663
- if (!target) {
664
- return { mode: 'none', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
665
- }
666
-
667
- const patterns = target.patternPrefix
668
- ? HOOK_LOCAL_IGNORE_PATTERNS.map((pattern) => `${target.patternPrefix}/${pattern}`)
669
- : [...HOOK_LOCAL_IGNORE_PATTERNS];
670
- const markerSuffix = target.patternPrefix || '.';
671
- const markerOpen = `${HOOK_IGNORE_MARKER_OPEN} ${markerSuffix}`;
672
- const markerClose = `${HOOK_IGNORE_MARKER_CLOSE} ${markerSuffix}`;
673
- const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
674
- const block = [markerOpen, ...patterns, markerClose].join('\n');
675
- const markerRe = new RegExp(`${escapeRegExp(markerOpen)}[\\s\\S]*?${escapeRegExp(markerClose)}`);
676
-
677
- let updated;
678
- if (markerRe.test(existing)) {
679
- updated = existing.replace(markerRe, block);
680
- } else {
681
- const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
682
- updated = `${prefix}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
683
- }
684
-
685
- if (updated !== existing) {
686
- fs.mkdirSync(path.dirname(target.path), { recursive: true });
687
- fs.writeFileSync(target.path, updated, 'utf-8');
688
- }
689
-
690
- return {
691
- mode: 'git-info-exclude',
692
- file: path.relative(path.resolve(cwd), target.path).split(path.sep).join('/'),
693
- changed: updated !== existing,
694
- patterns,
695
- };
696
- } catch {
697
- return { mode: 'error', changed: false, patterns: [...HOOK_LOCAL_IGNORE_PATTERNS] };
698
- }
699
- }
700
-
701
- function resolveHookGitExcludeTarget(cwd) {
702
- const start = path.resolve(cwd);
703
- let dir = start;
704
- while (true) {
705
- const dotGit = path.join(dir, '.git');
706
- if (fs.existsSync(dotGit)) {
707
- const gitDir = resolveGitDir(dotGit, dir);
708
- if (!gitDir) return null;
709
- const relPrefix = path.relative(dir, start).split(path.sep).join('/');
710
- return {
711
- path: path.join(gitDir, 'info', 'exclude'),
712
- patternPrefix: relPrefix && relPrefix !== '.' ? relPrefix : '',
713
- };
714
- }
715
- const parent = path.dirname(dir);
716
- if (parent === dir) return null;
717
- dir = parent;
718
- }
719
- }
720
-
721
- function resolveGitDir(dotGit, worktreeDir) {
722
- const stat = fs.statSync(dotGit);
723
- if (stat.isDirectory()) return dotGit;
724
- if (!stat.isFile()) return null;
725
-
726
- const body = fs.readFileSync(dotGit, 'utf-8').trim();
727
- const match = body.match(/^gitdir:\s*(.+)$/i);
728
- if (!match) return null;
729
- return path.isAbsolute(match[1]) ? match[1] : path.resolve(worktreeDir, match[1]);
730
- }
731
-
732
- function escapeRegExp(value) {
733
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
734
- }
735
-
736
- function ensureSession(cache, sessionId) {
737
- if (!cache.sessions[sessionId]) {
738
- cache.sessions[sessionId] = { updatedAt: Date.now(), files: {} };
739
- }
740
- return cache.sessions[sessionId];
741
- }
742
-
743
- function ensureFile(cache, sessionId, filePath) {
744
- const session = ensureSession(cache, sessionId);
745
- if (!session.files[filePath]) {
746
- session.files[filePath] = { editCount: 0, findings: [] };
747
- }
748
- return session.files[filePath];
749
- }
750
-
751
- export function bumpEditCount(cache, sessionId, filePath) {
752
- const fileEntry = ensureFile(cache, sessionId, filePath);
753
- fileEntry.editCount = (fileEntry.editCount || 0) + 1;
754
- ensureSession(cache, sessionId).updatedAt = Date.now();
755
- return fileEntry.editCount;
756
- }
757
-
758
- // Record that a file was scanned this session without bumping its edit count.
759
- // The Stop deep pass reads the session's file list to know what to re-scan,
760
- // so a file whose per-edit findings were all deferred still needs an entry.
761
- export function touchFile(cache, sessionId, filePath) {
762
- ensureFile(cache, sessionId, filePath);
763
- ensureSession(cache, sessionId).updatedAt = Date.now();
764
- }
765
-
766
- export function suppressionNotice(filePath) {
767
- 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.`;
768
- }
769
-
770
- // Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
771
- function globToRegex(glob) {
772
- let re = '^';
773
- let i = 0;
774
- while (i < glob.length) {
775
- const c = glob[i];
776
- if (c === '*') {
777
- if (glob[i + 1] === '*') {
778
- re += '.*';
779
- i += 2;
780
- if (glob[i] === '/') i += 1;
781
- } else {
782
- re += '[^/]*';
783
- i += 1;
784
- }
785
- } else if (c === '?') {
786
- re += '[^/]';
787
- i += 1;
788
- } else if (c === '{') {
789
- const end = glob.indexOf('}', i);
790
- if (end === -1) { re += '\\{'; i += 1; continue; }
791
- const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
792
- re += `(?:${parts.join('|')})`;
793
- i = end + 1;
794
- } else if (/[.+^$()|[\]\\]/.test(c)) {
795
- re += `\\${c}`;
796
- i += 1;
797
- } else {
798
- re += c;
799
- i += 1;
800
- }
801
- }
802
- re += '$';
803
- return new RegExp(re);
804
- }
805
-
806
- export function matchesAnyGlob(filePath, globs) {
807
- if (!Array.isArray(globs) || globs.length === 0) return false;
808
- const normalized = filePath.split(path.sep).join('/');
809
- for (const glob of globs) {
810
- try {
811
- const re = globToRegex(String(glob));
812
- if (re.test(normalized)) return true;
813
- // Match against basename too for convenience: `*.generated.tsx` should
814
- // catch `src/foo.generated.tsx` without requiring `**/`.
815
- const base = normalized.split('/').pop();
816
- if (re.test(base)) return true;
817
- } catch {
818
- /* malformed glob, skip */
819
- }
820
- }
821
- return false;
822
- }
823
-
824
- export function filterFindings(findings, _content, _ext, config) {
825
- if (!Array.isArray(findings) || findings.length === 0) return [];
826
- const ignoreRules = new Set((config.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
827
- const ignoreValues = normalizeIgnoreValueEntries(config.ignoreValues || []);
828
- // Advisory rules are skipped by default so the hook never nags about them;
829
- // a project opts in with detector.advisoryRules: "include".
830
- const includeAdvisory = (config?.advisoryRules || DEFAULT_CONFIG.advisoryRules) === 'include';
831
- return findings.filter((f) => {
832
- if (!f || typeof f !== 'object') return false;
833
- if (!includeAdvisory && isAdvisoryFinding(f)) return false;
834
- if (ignoreRules.has(normalizeIgnoreRule(f.antipattern))) return false;
835
- if (isIgnoredFindingValue(f, ignoreValues)) return false;
836
- return true;
837
- });
838
- }
839
-
840
- // Split filtered findings into the per-edit "immediate" tier and the tier
841
- // deferred to the Stop deep pass. See IMMEDIATE_TIER_RULES for the tiering
842
- // rationale.
843
- export function splitFindingsByTier(findings) {
844
- const immediate = [];
845
- const deferred = [];
846
- for (const f of Array.isArray(findings) ? findings : []) {
847
- if (f && IMMEDIATE_TIER_RULES.has(normalizeIgnoreRule(f.antipattern))) {
848
- immediate.push(f);
849
- } else {
850
- deferred.push(f);
851
- }
852
- }
853
- return { immediate, deferred };
854
- }
855
-
856
- // Whether the per-edit pass for this harness should defer non-immediate
857
- // findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
858
- // our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
859
- // deferring for them would silently drop the non-immediate rules entirely.
860
- export function perEditTieringActive(config, harness) {
861
- if (harness === 'cursor' || harness === 'github') return false;
862
- return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
863
- }
864
-
865
- function isIgnoredFindingValue(finding, ignoreValues) {
866
- if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
867
- const rule = normalizeIgnoreRule(finding.antipattern);
868
- if (!rule) return false;
869
- // File-scoped wildcards suppress rules with no extractable value, such as side-tab.
870
- const value = extractFindingIgnoreValue(finding);
871
- return ignoreValues.some((entry) => {
872
- if (entry.rule !== rule) return false;
873
- const wildcardValue = entry.value === '*';
874
- if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
875
- if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
876
- return findingMatchesScopedIgnoreFile(finding, entry.files);
877
- });
878
- }
879
-
880
- function findingMatchesScopedIgnoreFile(finding, globs) {
881
- const filePath = String(finding?.file || '').trim();
882
- if (!filePath) return false;
883
- if (matchesAnyGlob(filePath, globs)) return true;
884
-
885
- const normalized = filePath.split(path.sep).join('/');
886
- const parts = normalized.split('/').filter(Boolean);
887
- for (let i = 0; i < parts.length; i++) {
888
- const suffix = parts.slice(i).join('/');
889
- if (matchesAnyGlob(suffix, globs)) return true;
890
- }
891
- return false;
892
- }
893
-
894
- export function extractFindingIgnoreValue(finding) {
895
- if (!finding || typeof finding !== 'object') return '';
896
- const rule = normalizeIgnoreRule(finding.antipattern);
897
- const directValueRules = new Set([
898
- 'overused-font',
899
- 'bounce-easing',
900
- 'design-system-font',
901
- 'design-system-color',
902
- 'design-system-radius',
903
- 'design-system-font-size',
904
- ]);
905
- if (!directValueRules.has(rule)) return '';
906
- return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
907
- }
908
-
909
- function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
910
- const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
911
- if (direct) return direct;
912
-
913
- const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
914
- for (const text of candidates) {
915
- if (rule === 'bounce-easing') {
916
- const motion = extractMotionIgnoreValue(text);
917
- if (motion) return motion;
918
- continue;
919
- }
920
-
921
- const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
922
- if (primary) return cleanIgnoreValueDisplay(primary[1]);
923
-
924
- const googleLabel = text.match(/Google Fonts:\s*([^()\n;]+)/i);
925
- if (googleLabel) return cleanIgnoreValueDisplay(googleLabel[1]);
926
-
927
- const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
928
- if (family) return cleanIgnoreValueDisplay(family[1]);
929
-
930
- const google = text.match(/[?&]family=([^&:;\n]+)/i);
931
- if (google) {
932
- try {
933
- return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
934
- } catch {
935
- return cleanIgnoreValueDisplay(google[1]);
936
- }
937
- }
938
- }
939
-
940
- return '';
941
- }
942
-
943
- function extractMotionIgnoreValue(text) {
944
- const tailwind = text.match(/\banimate-bounce\b/i);
945
- if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
946
-
947
- const bezier = text.match(/cubic-bezier\([^)]+\)/i);
948
- if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
949
-
950
- const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
951
- if (animation) {
952
- const token = animation[1]
953
- .split(/[,\s]+/)
954
- .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
955
- if (token) return cleanIgnoreValueDisplay(token);
956
- }
957
-
958
- return '';
959
- }
960
-
961
- function cleanIgnoreValueDisplay(value) {
962
- return String(value || '')
963
- .trim()
964
- .replace(/^["']|["']$/g, '')
965
- .replace(/\+/g, ' ')
966
- .replace(/\s+/g, ' ');
967
- }
968
-
969
- export function dedupeAgainstCache(findings, cache, sessionId, filePath) {
970
- if (!Array.isArray(findings) || findings.length === 0) return [];
971
- const fileEntry = ensureFile(cache, sessionId, filePath);
972
- const known = new Set(fileEntry.findings || []);
973
- const fresh = [];
974
- for (const f of findings) {
975
- const key = findingCacheKey(f);
976
- if (known.has(key)) continue;
977
- known.add(key);
978
- fresh.push(f);
979
- }
980
- return fresh;
981
- }
982
-
983
- // Sync the remembered set to the findings present in the scan just performed.
984
- //
985
- // This replaces rather than accumulates, and that is the whole point. An
986
- // append-only set made the hook lie twice over: the pending ack counted
987
- // history instead of the live scan, so it kept naming findings the agent had
988
- // already fixed, and a finding that was fixed and later reintroduced was
989
- // deduped against a stale memory and never re-reported. Forgetting what is no
990
- // longer there is what lets the count shrink and a regression fire again.
991
- //
992
- // Callers must pass the complete current finding set, not just the fresh ones.
993
- export function rememberFindings(cache, sessionId, filePath, findings) {
994
- const fileEntry = ensureFile(cache, sessionId, filePath);
995
- const keys = new Set((findings || []).map(f => findingCacheKey(f)));
996
- fileEntry.findings = Array.from(keys);
997
- ensureSession(cache, sessionId).updatedAt = Date.now();
998
- }
999
-
1000
- function findingCacheKey(finding) {
1001
- const line = finding?.line || 0;
1002
- const value = extractFindingIgnoreValue(finding);
1003
- if (line > 0 && value) return `${finding.antipattern}:${line}:${value}`;
1004
- if (line > 0) return `${finding.antipattern}:${line}`;
1005
- if (value) return `${finding.antipattern}:0:${value}`;
1006
- const snippet = String(finding?.snippet || '').trim().slice(0, 80);
1007
- return snippet ? `${finding.antipattern}:0:${snippet}` : `${finding.antipattern}:0`;
1008
- }
1009
-
1010
- export function renderTemplate(findings, filePath, config, opts = {}) {
1011
- if (!Array.isArray(findings) || findings.length === 0) return '';
1012
- const limits = config?.limits || DEFAULT_CONFIG.limits;
1013
- const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
1014
- // reserveChars holds back room for a note the caller appends after render
1015
- // (the DESIGN.md staleness note), so the final payload stays inside the
1016
- // configured budget. It comes off after the 500-char floor, so at floor
1017
- // configs the note keeps guaranteed delivery room; the clamp budget can
1018
- // therefore sit below 500, which clampLastLine's footer-preserving
1019
- // fallback handles (Bugbot on PR #508).
1020
- const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
1021
-
1022
- const cwd = opts.cwd || process.cwd();
1023
- const display = relativize(filePath, cwd);
1024
- const total = findings.length;
1025
- const shown = findings.slice(0, cap);
1026
- const remaining = total - shown.length;
1027
-
1028
- const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
1029
- const seenRules = new Set();
1030
- const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules));
1031
- const more = remaining > 0
1032
- ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
1033
- : null;
1034
- const footer = directiveFooter({ mode: opts.footer });
1035
-
1036
- const blocks = [header, ...lines];
1037
- if (more) blocks.push(more);
1038
- blocks.push('');
1039
- blocks.push(footer);
1040
- let text = blocks.join('\n');
1041
-
1042
- if (text.length > maxChars) {
1043
- text = clampToBudget(header, lines, more, footer, maxChars);
1044
- }
1045
- return text;
1046
- }
1047
-
1048
- function renderGroupedTemplate(groups, config, opts = {}) {
1049
- const realGroups = groups.filter((group) => Array.isArray(group.findings) && group.findings.length > 0);
1050
- if (realGroups.length === 0) return '';
1051
- if (realGroups.length === 1) {
1052
- const [group] = realGroups;
1053
- return renderTemplate(group.findings, group.filePath, config, opts);
1054
- }
1055
-
1056
- const limits = config?.limits || DEFAULT_CONFIG.limits;
1057
- const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
1058
- const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
1059
- const cwd = opts.cwd || process.cwd();
1060
- const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
1061
- const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
1062
- const lines = [];
1063
- let shownCount = 0;
1064
- // One seen-set across all groups: a rule already described under one file
1065
- // is not re-described under the next.
1066
- const seenRules = new Set();
1067
-
1068
- for (const group of realGroups) {
1069
- const display = relativize(group.filePath, cwd);
1070
- lines.push(`${display} (${group.findings.length} issue(s)):`);
1071
- const remainingCap = Math.max(0, cap - shownCount);
1072
- const shown = group.findings.slice(0, remainingCap);
1073
- for (const finding of shown) {
1074
- lines.push(formatDedupedFindingLine(finding, seenRules));
1075
- }
1076
- shownCount += shown.length;
1077
- const hidden = group.findings.length - shown.length;
1078
- if (hidden > 0) {
1079
- lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
1080
- }
1081
- }
1082
-
1083
- const footer = directiveFooter({ mode: opts.footer });
1084
- let text = [header, ...lines, '', footer].join('\n');
1085
- if (text.length > maxChars) {
1086
- text = clampGroupedToBudget(header, lines, footer, maxChars);
1087
- }
1088
- return text;
1089
- }
1090
-
1091
- // The clamp contract, shared by both budget functions: the footer is policy,
1092
- // not detail, so it survives every clamp. Try the requested footer first;
1093
- // when it cannot fit even after dropping finding lines, retry with the short
1094
- // policy rather than sacrifice findings that fit beside it. A result that
1095
- // dropped every finding line (a grouped render can fit a bare file header)
1096
- // does not count as a fit: findings are why the emission exists.
1097
- const isFindingLine = (line) => line.startsWith('- ');
1098
-
1099
- function footerFallbacks(footer) {
1100
- const short = directiveFooter({ mode: 'short' });
1101
- return footer === short ? [footer] : [footer, short];
1102
- }
1103
-
1104
- function clampGroupedToBudget(header, lines, footer, maxChars) {
1105
- const assemble = (linesArr, omitted, footerText) => [
1106
- header,
1107
- ...linesArr,
1108
- ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
1109
- '',
1110
- footerText,
1111
- ].join('\n');
1112
-
1113
- for (const footerText of footerFallbacks(footer)) {
1114
- let working = lines.slice();
1115
- let omitted = false;
1116
- let assembled = assemble(working, omitted, footerText);
1117
- while (assembled.length > maxChars && working.length > 1) {
1118
- working.pop();
1119
- omitted = true;
1120
- assembled = assemble(working, omitted, footerText);
1121
- }
1122
- if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
1123
- }
1124
- return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
1125
- lines.find(isFindingLine) || lines[0], maxChars);
1126
- }
1127
-
1128
- function clampToBudget(header, lines, more, footer, maxChars) {
1129
- const assemble = (linesArr, moreText, footerText) => {
1130
- const blocks = [header, ...linesArr];
1131
- if (moreText) blocks.push(moreText);
1132
- blocks.push('');
1133
- blocks.push(footerText);
1134
- return blocks.join('\n');
1135
- };
1136
-
1137
- let lastMore = more;
1138
- for (const footerText of footerFallbacks(footer)) {
1139
- let working = lines.slice();
1140
- let moreText = more;
1141
- let assembled = assemble(working, moreText, footerText);
1142
- while (assembled.length > maxChars && working.length > 1) {
1143
- working.pop();
1144
- moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
1145
- assembled = assemble(working, moreText, footerText);
1146
- }
1147
- lastMore = moreText;
1148
- if (assembled.length <= maxChars) return assembled;
1149
- }
1150
- return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
1151
- lines.find(isFindingLine) || lines[0], maxChars);
1152
- }
1153
-
1154
- // Last resort with one finding line left: the short policy gets the budget
1155
- // first, the line is clipped to what remains. The pre-fix tail-slice cut
1156
- // whatever happened to be last, which was always the footer.
1157
- function clampLastLine(build, line, maxChars) {
1158
- const footerText = directiveFooter({ mode: 'short' });
1159
- const bare = build([], footerText);
1160
- // +1 for the newline the line itself brings when it joins the blocks.
1161
- const room = maxChars - bare.length - 1;
1162
- if (room >= 24) {
1163
- const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line;
1164
- return build([clipped], footerText);
1165
- }
1166
- // No room for even a clipped finding line: the note reservation can pull
1167
- // the budget below the 500-char floor, and a deep file path can push the
1168
- // header past what remains beside the short policy (Bugbot on PR #508).
1169
- // Drop the line, and if the bare header + policy still overflow, clip the
1170
- // head. Never tail-slice: the footer sits at the end, so a tail slice is
1171
- // exactly the footer cut this renderer exists to prevent.
1172
- if (bare.length <= maxChars) return bare;
1173
- const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4));
1174
- return `${head}…\n\n${footerText}`;
1175
- }
1176
-
1177
- // `compact` drops the registry description: within one emission the first
1178
- // occurrence of a rule carries the full description and repeats keep only the
1179
- // rule id, name, and their own ignore hint (values differ per line, so the
1180
- // hint must survive the dedupe).
1181
- function formatFindingLine(f, opts = {}) {
1182
- const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
1183
- const desc = opts.compact ? '' : (f.description || '').trim();
1184
- const name = (f.name || '').trim();
1185
- // Description from the registry already ends in punctuation; join with a
1186
- // single space. `name` may have a trailing period already, keep it clean.
1187
- const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
1188
- const ignoreHint = formatFindingIgnoreHint(f);
1189
- const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
1190
- return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
1191
- }
1192
-
1193
- // Dedupe applied in shown-line order, so the first rendered occurrence of a
1194
- // rule always carries the description. The budget clamps pop lines from the
1195
- // end, which can never orphan a compact repeat before its described first
1196
- // occurrence.
1197
- function formatDedupedFindingLine(finding, seenRules) {
1198
- const rule = normalizeIgnoreRule(finding?.antipattern);
1199
- const compact = rule ? seenRules.has(rule) : false;
1200
- if (rule) seenRules.add(rule);
1201
- return formatFindingLine(finding, { compact });
1202
- }
1203
-
1204
- // The rule/value pair the footer's `hook-admin.mjs ignore-value` command
1205
- // takes. Deliberately just the args: the executable prefix, the --reason
1206
- // contract, and the disclosure rule live in the directive footer, stated once
1207
- // instead of per line.
1208
- function formatFindingIgnoreHint(finding) {
1209
- if (!finding || typeof finding !== 'object') return '';
1210
- const rule = normalizeIgnoreRule(finding.antipattern);
1211
- if (!rule) return '';
1212
- const normalizedValue = extractFindingIgnoreValue(finding);
1213
- if (!normalizedValue) return '';
1214
- const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
1215
- return `ignore-value ${rule} ${valueArg}`;
1216
- }
1217
-
1218
- function quoteCommandArg(value) {
1219
- const text = String(value || '').trim();
1220
- if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
1221
- // The suggestion is meant to be run on this same machine, so quote for its
1222
- // shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside
1223
- // double quotes, and these values come from scanned file content (a
1224
- // font-family name) or a file path, so untrusted input must be
1225
- // single-quoted (issue #476). Windows cmd.exe performs no such command
1226
- // substitution, but it treats a single quote as a literal character rather
1227
- // than a grouping delimiter, so a value or path containing spaces has to
1228
- // stay double-quoted there (Greptile #533). Keep the pre-existing
1229
- // double-quote escaping on Windows so that path's behavior is unchanged.
1230
- if (process.platform === 'win32') {
1231
- return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
1232
- }
1233
- return `'${text.replace(/'/g, `'\\''`)}'`;
1234
- }
1235
-
1236
- function relativize(filePath, cwd) {
1237
- try {
1238
- const rel = path.relative(cwd, filePath);
1239
- if (!rel || rel.startsWith('..')) return filePath;
1240
- return rel.split(path.sep).join('/');
1241
- } catch {
1242
- return filePath;
1243
- }
1244
- }
1245
-
1246
- // Codex `apply_patch` exposes the raw patch in `tool_input.command`, not
1247
- // `tool_input.file_path`. Claude Code may send both; parse the patch body
1248
- // so we can scan the file(s) the tool actually touched.
1249
- // https://developers.openai.com/codex/hooks#posttooluse
1250
- const APPLY_PATCH_FILE_RE = /^\*\*\* (?:Update|Add) File: (.+)$/gm;
1251
-
1252
- export function parseApplyPatchPaths(command, projectCwd) {
1253
- if (!command || typeof command !== 'string') return [];
1254
- const out = [];
1255
- for (const m of command.matchAll(APPLY_PATCH_FILE_RE)) {
1256
- let p = (m[1] || '').trim();
1257
- if (!p) continue;
1258
- if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
1259
- out.push(p);
1260
- }
1261
- return out;
1262
- }
1263
-
1264
- export function resolveTargetFiles(event, projectCwd) {
1265
- const ti = event?.tool_input;
1266
- const out = [];
1267
- const add = (filePath) => {
1268
- if (typeof filePath !== 'string' || !filePath) return;
1269
- if (!out.includes(filePath)) out.push(filePath);
1270
- };
1271
-
1272
- if (event?.tool_name === 'apply_patch' && ti && typeof ti.command === 'string') {
1273
- for (const filePath of parseApplyPatchPaths(ti.command, projectCwd)) add(filePath);
1274
- }
1275
- if (ti && typeof ti.file_path === 'string' && ti.file_path) {
1276
- add(ti.file_path);
1277
- }
1278
- // Cursor Write / StrReplace use `path`, not `file_path`.
1279
- if (ti && typeof ti.path === 'string' && ti.path) {
1280
- add(ti.path);
1281
- }
1282
- if (typeof event?.file_path === 'string' && event.file_path) {
1283
- add(event.file_path);
1284
- }
1285
- return out;
1286
- }
1287
-
1288
- export function resolveHarness(env = {}, event = null) {
1289
- const explicit = env?.IMPECCABLE_HOOK_HARNESS;
1290
- if (explicit === 'cursor') return 'cursor';
1291
- if (explicit === 'github') return 'github';
1292
- if (explicit === 'grok') return 'grok';
1293
- if (explicit === 'claude') return 'claude';
1294
- if (explicit === 'codex') return 'codex';
1295
- // Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
1296
- // snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
1297
- // Check Grok first: the old GitHub heuristic (`toolName` and no
1298
- // `tool_input`) also matches Grok, which is how live PostToolUse was
1299
- // classified as Copilot and then skipped with no-file-path (#646).
1300
- if (looksLikeGrokEnvelope(event)) return 'grok';
1301
- if (event && typeof event === 'object'
1302
- && (typeof event.toolName === 'string' || event.toolArgs !== undefined)
1303
- && event.tool_name === undefined && event.tool_input === undefined) {
1304
- return 'github';
1305
- }
1306
- if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
1307
- // Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
1308
- // it here means an already-installed Codex hook emits the Codex Stop
1309
- // contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
1310
- // https://developers.openai.com/codex/hooks#stop
1311
- if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
1312
- return 'claude';
1313
- }
1314
-
1315
- function looksLikeGrokEnvelope(event) {
1316
- if (!event || typeof event !== 'object') return false;
1317
- if (event.hook_event_name !== undefined
1318
- || event.tool_name !== undefined
1319
- || event.tool_input !== undefined) {
1320
- return false;
1321
- }
1322
- if (event.toolArgs !== undefined) return false;
1323
- if (typeof event.hookEventName === 'string') return true;
1324
- return typeof event.toolName === 'string' && event.toolInput !== undefined;
1325
- }
1326
-
1327
- // Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
1328
- // `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
1329
- // normalize, so both casings must match here.
1330
- export function isStopEvent(event) {
1331
- if (!event || typeof event !== 'object') return false;
1332
- const name = event.hook_event_name || event.hookEventName;
1333
- return typeof name === 'string' && name.toLowerCase() === 'stop';
1334
- }
1335
-
1336
- // GitHub Copilot's postToolUse payload is
1337
- // { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
1338
- // mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
1339
- // `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
1340
- // JSON *string* (double-encoded) carrying the file under `path`, e.g.
1341
- // "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
1342
- // while `apply_patch` sends a raw OpenAI-format patch string (handled below in
1343
- // normalizeGitHubEvent). The detector reads the file from disk after the tool
1344
- // ran, so only the path (not the proposed content) is needed here.
1345
- export function parseGitHubToolArgs(toolArgs) {
1346
- if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
1347
- if (typeof toolArgs === 'string' && toolArgs.trim()) {
1348
- try {
1349
- const parsed = JSON.parse(toolArgs);
1350
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
1351
- } catch {
1352
- return {};
1353
- }
1354
- }
1355
- return {};
1356
- }
1357
-
1358
- // Copilot's `apply_patch` tool (used by interactive sessions and the cloud
1359
- // agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
1360
- // *** Begin Patch
1361
- // *** Add File: /abs/app.css
1362
- // +body { ... }
1363
- // *** End Patch
1364
- // The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
1365
- // JSON string with the path under `path`. Both must map onto the internal shape.
1366
- const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
1367
-
1368
- function looksLikeApplyPatch(rawArgs) {
1369
- if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
1370
- // Guard against an edit/create payload whose edited *content* happens to
1371
- // contain patch markers: that payload is a JSON object string, whereas a real
1372
- // apply_patch payload is a raw patch string that does not parse as JSON. Only
1373
- // treat non-JSON-object strings as apply_patch so edit events still get their
1374
- // `path` extracted.
1375
- try {
1376
- const parsed = JSON.parse(rawArgs);
1377
- if (parsed && typeof parsed === 'object') return false;
1378
- } catch { /* not JSON → genuine raw patch */ }
1379
- return true;
1380
- }
1381
-
1382
- function applyPatchText(rawArgs) {
1383
- if (typeof rawArgs === 'string') {
1384
- if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
1385
- // Defensive: a future Copilot build might JSON-wrap the patch.
1386
- const parsed = parseGitHubToolArgs(rawArgs);
1387
- return parsed.patch || parsed.input || parsed.command || '';
1388
- }
1389
- if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
1390
- return rawArgs.patch || rawArgs.input || rawArgs.command || '';
1391
- }
1392
- return '';
1393
- }
1394
-
1395
- function normalizeGitHubEvent(event, projectCwd) {
1396
- const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
1397
- const sessionId = event.sessionId || event.session_id || 'unknown';
1398
- const toolName = event.toolName || event.tool_name || null;
1399
- const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
1400
- const rawArgs = event.toolArgs;
1401
-
1402
- let normalizedToolName = toolName;
1403
- if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
1404
- // resolveTargetFiles() reads the touched paths from tool_input.command when
1405
- // tool_name is 'apply_patch', so normalize the name even if a future build
1406
- // sends the patch under a different tool label.
1407
- const patch = applyPatchText(rawArgs);
1408
- if (patch) {
1409
- toolInput.command = patch;
1410
- normalizedToolName = 'apply_patch';
1411
- }
1412
- } else {
1413
- const args = parseGitHubToolArgs(rawArgs);
1414
- const filePath = args.path || args.file_path || args.filePath || args.target_file;
1415
- if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
1416
- }
1417
-
1418
- return {
1419
- ...event,
1420
- cwd,
1421
- session_id: sessionId,
1422
- tool_name: normalizedToolName,
1423
- tool_input: toolInput,
1424
- };
1425
- }
1426
-
1427
- // Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
1428
- // `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
1429
- // trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
1430
- // stripping here). Only the fields the hook reads are copied; the event
1431
- // name stays camelCase because routing already happened on the raw stdin
1432
- // (isStopEvent) and nothing downstream reads `hook_event_name`.
1433
- function normalizeGrokEvent(event, projectCwd) {
1434
- const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
1435
- const sessionId = event.sessionId || event.session_id || 'unknown';
1436
- const rawInput = event.toolInput ?? event.tool_input;
1437
- const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
1438
- ? { ...rawInput }
1439
- : {};
1440
- const out = {
1441
- ...event,
1442
- cwd,
1443
- session_id: sessionId,
1444
- tool_name: event.toolName || event.tool_name || null,
1445
- tool_input: toolInput,
1446
- };
1447
- if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
1448
- out.stop_hook_active = event.stopHookActive;
1449
- }
1450
- return out;
1451
- }
1452
-
1453
- export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
1454
- if (!event || typeof event !== 'object') return event;
1455
- if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
1456
- if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
1457
- if (harness !== 'cursor') return event;
1458
-
1459
- const cwd = event.cwd
1460
- || (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
1461
- || envProjectDir(projectCwd)
1462
- || projectCwd;
1463
- const sessionId = event.session_id || event.conversation_id || 'unknown';
1464
-
1465
- const ti = event.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
1466
- const filePath = ti.file_path || ti.path || event.file_path;
1467
- if (filePath) {
1468
- return {
1469
- ...event,
1470
- cwd,
1471
- session_id: sessionId,
1472
- tool_input: { ...ti, file_path: filePath },
1473
- };
1474
- }
1475
-
1476
- return { ...event, cwd, session_id: sessionId };
1477
- }
1478
-
1479
- function envProjectDir(fallback) {
1480
- if (typeof process.env.CURSOR_PROJECT_DIR === 'string' && process.env.CURSOR_PROJECT_DIR) {
1481
- return process.env.CURSOR_PROJECT_DIR;
1482
- }
1483
- return fallback;
1484
- }
1485
-
1486
- // UI components often keep slop in a sibling/co-located stylesheet while the
1487
- // JSX edit is what triggered PostToolUse. Scan those styles too so an App.jsx
1488
- // patch doesn't report "clean" while styles.css still has Inter/bounce/etc.
1489
- const UI_CODE_EXTS = new Set(['.jsx', '.tsx', '.vue', '.svelte', '.astro']);
1490
- const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
1491
- const CO_SCAN_STYLE_NAMES = [
1492
- 'styles.css', 'styles.scss', 'styles.sass', 'styles.less',
1493
- 'index.css', 'index.scss', 'index.sass', 'index.less',
1494
- 'global.css', 'global.scss', 'global.sass', 'global.less',
1495
- 'globals.css', 'globals.scss', 'globals.sass', 'globals.less',
1496
- ];
1497
- const MAX_SCAN_TARGETS = 6;
1498
-
1499
- const STATIC_STYLE_IMPORT_RE = /import\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]/gi;
1500
-
1501
- function hasPathTraversal(filePath) {
1502
- return typeof filePath === 'string' && filePath.includes('..');
1503
- }
1504
-
1505
- function isInsideProject(filePath, projectCwd) {
1506
- if (!filePath || !projectCwd || hasPathTraversal(filePath)) return false;
1507
- try {
1508
- const rel = path.relative(projectCwd, filePath);
1509
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
1510
- } catch {
1511
- return false;
1512
- }
1513
- }
1514
-
1515
- // Resolve a path to its canonical (symlink-free) form. When the path does
1516
- // not exist yet — the before-edit hook gates proposed Writes — canonicalize
1517
- // the nearest existing ancestor and re-append the remainder, so a new file
1518
- // under a symlinked root still compares equal to its canonical project.
1519
- // Memoized: the hook runs as a fresh process per tool event, so the cache
1520
- // amounts to once-per-event work — the scan loops re-check the same project
1521
- // root for every target file. The cap only matters to long-lived importers
1522
- // like the test runner.
1523
- const canonicalPathCache = new Map();
1524
- const CANONICAL_PATH_CACHE_MAX = 1024;
1525
-
1526
- function canonicalPath(p) {
1527
- const resolved = path.resolve(p);
1528
- if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
1529
- let canonical = resolved;
1530
- let dir = resolved;
1531
- const tail = [];
1532
- while (true) {
1533
- try {
1534
- canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
1535
- break;
1536
- } catch { /* keep climbing */ }
1537
- const parent = path.dirname(dir);
1538
- if (parent === dir) break;
1539
- tail.unshift(path.basename(dir));
1540
- dir = parent;
1541
- }
1542
- if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
1543
- canonicalPathCache.set(resolved, canonical);
1544
- return canonical;
1545
- }
1546
-
1547
- // Containment gate shared by the before-edit hook and both scan passes. A
1548
- // session routinely touches files that belong to no project or to a
1549
- // different one — harness scratchpad dirs under the system temp root,
1550
- // sibling checkouts, one-off throwaway HTML — and findings against those are
1551
- // judged with THIS project's config and DESIGN.md palette, which is never
1552
- // right. Skip them (audit reason: outside-project). Paths are canonicalized
1553
- // first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
1554
- // comparison.
1555
- export function isScanTargetInsideProject(filePath, projectCwd) {
1556
- if (!filePath || !projectCwd) return false;
1557
- return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
1558
- }
1559
-
1560
- export function parseStaticStyleImports(content, fromFile, projectCwd) {
1561
- if (!content || typeof content !== 'string') return [];
1562
- const dir = path.dirname(fromFile);
1563
- const out = [];
1564
- for (const m of content.matchAll(STATIC_STYLE_IMPORT_RE)) {
1565
- let p = (m[1] || '').trim();
1566
- if (!p) continue;
1567
- if (p.startsWith('.')) p = path.resolve(dir, p);
1568
- else if (!path.isAbsolute(p)) p = path.resolve(projectCwd, p);
1569
- if (!isInsideProject(p, projectCwd)) continue;
1570
- out.push(p);
1571
- }
1572
- return out;
1573
- }
1574
-
1575
- export function coLocatedStylesheets(filePath) {
1576
- const dir = path.dirname(filePath);
1577
- const base = path.basename(filePath, path.extname(filePath));
1578
- const candidates = new Set([
1579
- path.join(dir, `${base}.css`),
1580
- path.join(dir, `${base}.module.css`),
1581
- path.join(dir, `${base}.scss`),
1582
- path.join(dir, `${base}.module.scss`),
1583
- path.join(dir, `${base}.sass`),
1584
- path.join(dir, `${base}.module.sass`),
1585
- path.join(dir, `${base}.less`),
1586
- path.join(dir, `${base}.module.less`),
1587
- ]);
1588
- for (const name of CO_SCAN_STYLE_NAMES) {
1589
- candidates.add(path.join(dir, name));
1590
- }
1591
- return [...candidates].filter((p) => fs.existsSync(p));
1592
- }
1593
-
1594
- export function normalizeScanTargets(primaryTargets, projectCwd) {
1595
- if (!Array.isArray(primaryTargets) || primaryTargets.length === 0) return [];
1596
- const ordered = [];
1597
- const seen = new Set();
1598
- const baseCwd = projectCwd || process.cwd();
1599
- const normalizeTarget = (p) => {
1600
- // Preserve literal `..` segments so downstream sensitive-path checks
1601
- // still fire. path.resolve would collapse `/foo/../etc/passwd`.
1602
- if (hasPathTraversal(p)) return p;
1603
- return path.isAbsolute(p) ? p : path.resolve(baseCwd, p);
1604
- };
1605
- const add = (p) => {
1606
- if (ordered.length >= MAX_SCAN_TARGETS) return;
1607
- const abs = normalizeTarget(p);
1608
- if (seen.has(abs)) return;
1609
- seen.add(abs);
1610
- ordered.push(abs);
1611
- return abs;
1612
- };
1613
-
1614
- for (const p of primaryTargets) add(p);
1615
- return ordered;
1616
- }
1617
-
1618
- export function expandScanTargets(primaryTargets, projectCwd) {
1619
- const ordered = normalizeScanTargets(primaryTargets, projectCwd);
1620
- if (ordered.length === 0) return [];
1621
- const seen = new Set(ordered);
1622
- const baseCwd = projectCwd || process.cwd();
1623
- const add = (p) => {
1624
- if (ordered.length >= MAX_SCAN_TARGETS) return;
1625
- const abs = hasPathTraversal(p) ? p : (path.isAbsolute(p) ? p : path.resolve(baseCwd, p));
1626
- if (seen.has(abs)) return;
1627
- seen.add(abs);
1628
- ordered.push(abs);
1629
- return abs;
1630
- };
1631
-
1632
- const normalizedPrimaries = [];
1633
- for (const p of ordered) normalizedPrimaries.push(p);
1634
-
1635
- for (const p of normalizedPrimaries) {
1636
- if (ordered.length >= MAX_SCAN_TARGETS) break;
1637
- if (!isInsideProject(p, baseCwd)) continue;
1638
- const ext = path.extname(p).toLowerCase();
1639
- if (STYLE_EXTS.has(ext) || !UI_CODE_EXTS.has(ext)) continue;
1640
-
1641
- let content = '';
1642
- try { content = fs.readFileSync(p, 'utf-8'); } catch { /* unreadable primary */ }
1643
-
1644
- for (const imp of parseStaticStyleImports(content, p, projectCwd)) {
1645
- add(imp);
1646
- if (ordered.length >= MAX_SCAN_TARGETS) break;
1647
- }
1648
- for (const col of coLocatedStylesheets(p)) {
1649
- add(col);
1650
- if (ordered.length >= MAX_SCAN_TARGETS) break;
1651
- }
1652
- }
1653
-
1654
- return ordered;
1655
- }
1656
-
1657
- export function writeAuditLog(env, entry, cwd = process.cwd()) {
1658
- // The event's project root (entry.cwd) when present, else the passed cwd. Both
1659
- // config reads and relative log paths resolve against this, since the hook
1660
- // process cwd can differ from the project being edited.
1661
- const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
1662
- // Env wins; otherwise fall back to the unified config's hook.auditLog path.
1663
- let target = env?.IMPECCABLE_HOOK_LOG;
1664
- if (!target || typeof target !== 'string') {
1665
- try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
1666
- }
1667
- if (!target || typeof target !== 'string') return false;
1668
- try {
1669
- let expanded;
1670
- if (target.startsWith('~/')) {
1671
- expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
1672
- } else if (path.isAbsolute(target)) {
1673
- expanded = target;
1674
- } else {
1675
- expanded = path.resolve(baseCwd, target);
1676
- }
1677
- fs.mkdirSync(path.dirname(expanded), { recursive: true });
1678
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
1679
- fs.appendFileSync(expanded, line);
1680
- return true;
1681
- } catch {
1682
- return false;
1683
- }
1684
- }
1685
-
1686
- const DETECTOR_CANDIDATES = [
1687
- path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
1688
- path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
1689
- path.join(__dirname, '..', '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
1690
- ];
1691
-
1692
- let detectorCache = null;
1693
- export async function loadDetector(candidates = DETECTOR_CANDIDATES) {
1694
- if (detectorCache) return detectorCache;
1695
- const found = candidates.find((c) => fs.existsSync(c));
1696
- if (!found) return null;
1697
- const mod = await import(pathToFileURL(found));
1698
- detectorCache = {
1699
- detectText: mod.detectText,
1700
- detectHtml: mod.detectHtml,
1701
- loadDesignSystemForCwd: mod.loadDesignSystemForCwd,
1702
- };
1703
- return detectorCache;
1704
- }
1705
-
1706
- // For tests: allow injecting a detector implementation.
1707
- export function setDetectorForTesting(impl) {
1708
- detectorCache = impl;
1709
- }
1710
-
1711
- // ────────────────────────────────────────────────────────────────────────
1712
- // Nudge/steer messages for the no-silent-fires policy.
1713
- //
1714
- // The hook is designed to be a conversational presence: every fire that
1715
- // actually scans a file emits a developer-role message into the model's
1716
- // next turn. Three states map to three templates:
1717
- //
1718
- // 1. **Fresh findings** → `renderTemplate` (existing, imperative).
1719
- // 2. **Pending findings** → `renderPendingAck` (re-nudge for issues the
1720
- // model was already told about in this
1721
- // session but hasn't fixed yet).
1722
- // 3. **Truly clean** → `renderCleanAck` (short positive nudge that
1723
- // keeps the design discipline in context).
1724
- //
1725
- // All three are short (≤ ~40 tokens each) so the cumulative cost stays
1726
- // bounded across a long active editing session. Users who explicitly want
1727
- // silence-on-clean can set `IMPECCABLE_HOOK_QUIET=1` — runHook checks that
1728
- // env before emitting #2 or #3.
1729
- //
1730
- // Why not stay silent on dedup-clean? Earlier versions did. The model
1731
- // quickly forgets the prior reminder once tool output scrolls past it, so
1732
- // re-nudging on the same file with a short "still pending" line keeps the
1733
- // pressure on. The wording deliberately points back to "earlier this
1734
- // session" so the model knows it's a re-mind, not a new finding.
1735
- // ────────────────────────────────────────────────────────────────────────
1736
-
1737
- const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
1738
-
1739
- export function renderCleanAck(filePath, opts = {}) {
1740
- const cwd = opts.cwd || process.cwd();
1741
- const display = relativize(filePath, cwd);
1742
- return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
1743
- }
1744
-
1745
- export function renderPendingAck(filePath, knownFindings, opts = {}) {
1746
- const cwd = opts.cwd || process.cwd();
1747
- const display = relativize(filePath, cwd);
1748
- const count = knownFindings.length;
1749
- // `knownFindings` here are the cache strings like "side-tab:3".
1750
- const sample = knownFindings.slice(0, 3).join(', ');
1751
- const more = count > 3 ? `, +${count - 3} more` : '';
1752
- 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.`;
1753
- }
1754
-
1755
- export function shouldEmitAckForFile(filePath, config = null) {
1756
- if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
1757
- // Configured html-engine extensions are declared UI markup, so they get the
1758
- // clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
1759
- const configured = matchConfiguredExtension(filePath, config?.extensions);
1760
- return Boolean(configured && configured.engine === 'html');
1761
- }
1762
-
1763
- export function designSystemOptions(config, detector, projectCwd) {
1764
- if (config?.designSystem?.enabled === false) return {};
1765
- if (!detector || typeof detector.loadDesignSystemForCwd !== 'function') return {};
1766
- try {
1767
- const designSystem = detector.loadDesignSystemForCwd(projectCwd);
1768
- return designSystem ? { designSystem } : {};
1769
- } catch {
1770
- return {};
1771
- }
1772
- }
1773
-
1774
- const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
1775
-
1776
- export function appendDesignSystemNote(text, scanOptions) {
1777
- if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
1778
- return `${text}\n\n${DESIGN_STALE_NOTE}`;
1779
- }
1780
-
1781
- // Session-scoped once-only gate for repeat-prone message parts. Returns true
1782
- // the first time a flag is consumed in a session and false after, mirroring
1783
- // the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not
1784
- // change between edits, so re-stating them on every emission spends context
1785
- // to say nothing new. Callers must persist the cache for the flag to stick.
1786
- function consumeSessionNoticeFlag(cache, sessionId, flag) {
1787
- const session = ensureSession(cache, sessionId);
1788
- if (session[flag]) return false;
1789
- session[flag] = true;
1790
- session.updatedAt = Date.now();
1791
- return true;
1792
- }
1793
-
1794
- // Once-per-session variant of appendDesignSystemNote for the emission paths
1795
- // that have cache access. The staleness note names standing project state,
1796
- // not new information, so one mention per session is enough. The note is
1797
- // appended after the renderer has clamped to the configured budget: render
1798
- // paths reserve room for it via designNoteReserve, and the size check here
1799
- // is the safety net for the ack paths, deferring (without consuming the
1800
- // flag) to a later emission rather than busting maxChars.
1801
- export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) {
1802
- if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
1803
- const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars);
1804
- if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text;
1805
- if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text;
1806
- return appendDesignSystemNote(text, scanOptions);
1807
- }
1808
-
1809
- // Render-time reservation for the note above: how many characters the
1810
- // renderer must hold back so a pending staleness note still fits inside the
1811
- // configured budget. Zero once the session has seen the note. Without the
1812
- // reservation, a session whose every emission fills the budget would defer
1813
- // the note forever.
1814
- export function designNoteReserve(scanOptions, cache, sessionId) {
1815
- if (!scanOptions?.designSystem?.mdNewerThanJson) return 0;
1816
- if (ensureSession(cache, sessionId).designNoteShown) return 0;
1817
- return DESIGN_STALE_NOTE.length + 2;
1818
- }
1819
-
1820
- // Full directive footer once per session, the short reminder after. Fresh
1821
- // emissions and Cursor denials share the session flag (`footerShown`), so a
1822
- // session pays the full policy exactly once however it first fires. The mode
1823
- // is a peek: the clamp can downgrade a requested full footer under a tight
1824
- // budget, so the flag commits only when the complete full policy actually
1825
- // reached the output. Matching the whole footer text (not a sentinel) keeps
1826
- // the flag honest against any truncation that spares the opening words.
1827
- export function footerModeForSession(cache, sessionId) {
1828
- return ensureSession(cache, sessionId).footerShown ? 'short' : 'full';
1829
- }
1830
-
1831
- export function commitFooterShown(cache, sessionId, text) {
1832
- if (!text || !text.includes(directiveFooter())) return;
1833
- const session = ensureSession(cache, sessionId);
1834
- if (session.footerShown) return;
1835
- session.footerShown = true;
1836
- session.updatedAt = Date.now();
1837
- }
1838
-
1839
- const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`;
1840
-
1841
- // The directive footer is the part of the hook output that steers model
1842
- // behavior. Intentional moves, in order:
1843
- // 1. **Imperative, not advisory.** "Triage each finding..." beats
1844
- // "Consider revising...", which the model treats as a soft suggestion.
1845
- // 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
1846
- // suppress branch names the calibration examples (demo, fixture,
1847
- // documented bad design, user-confirmed choice) because the agent now
1848
- // acts on its own confidence and needs the bar stated.
1849
- // 3. **Executable ignore path.** The old footer named only the slash
1850
- // command, which an agent reacting to hook output cannot run; the
1851
- // hook-admin.mjs invocation is runnable as-is and keeps agents out of
1852
- // hand-editing config.json.
1853
- // 4. **Honest provenance.** The --reason is the audit trail; "user
1854
- // confirmed" appears only when the user actually did.
1855
- // 5. **Acknowledgement instruction.** Hook output is injected as
1856
- // developer-role context, so the reply is where the user sees the
1857
- // resolution, including any ignore the agent persisted.
1858
- // 6. **Once per session.** The full policy emits on the session's first
1859
- // fire; later emissions carry the one-line short form (mode 'short').
1860
- function directiveFooter(opts = {}) {
1861
- if (opts.mode === 'short') {
1862
- // No command path here: the session's first emission already gave the
1863
- // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute
1864
- // path on every repeat is the duplication this mode exists to cut.
1865
- return 'Triage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `hook-admin.mjs ignore-value` and disclose them in your reply; unsure, ask in one line.';
1866
- }
1867
- return [
1868
- 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:',
1869
- '- Real design problem: fix it. Keep intentional design as designed.',
1870
- `- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run \`${HOOK_ADMIN_COMMAND} ignore-value <rule> "<value>" --reason "<who decided: evidence>"\` with the pair shown on the finding line, or value "*" plus \`--file <path>\` when the line shows none. Write "user confirmed" in a reason only when the user did.`,
1871
- '- Unsure: leave it as is and ask the user in one line.',
1872
- `Self-serve ends at ignore-value: \`ignore-file\` and \`ignore-rule\` need the user's explicit approval, and never add an ignore to push a blocked write through. Full suppression ladder: ${IMPECCABLE_COMMAND} hooks.`,
1873
- ].join('\n');
1874
- }
1875
-
1876
- /**
1877
- * Run the hook with explicit dependencies. Returns a result object:
1878
- * { exitCode, stdout, audit, reason? }
1879
- *
1880
- * Never throws. All errors are converted to `exitCode: 0` + audit entry.
1881
- */
1882
- export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
1883
- const audit = { ts: new Date(now()).toISOString(), event: 'PostToolUse' };
1884
- const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
1885
-
1886
- try {
1887
- // Re-entrancy guard.
1888
- if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
1889
- return result({ reentrant: true, durationMs: 0 });
1890
- }
1891
-
1892
- if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
1893
- return result({ skipped: 'env-disabled', durationMs: 0 });
1894
- }
1895
-
1896
- const started = Date.now();
1897
-
1898
- let event;
1899
- try {
1900
- event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
1901
- } catch {
1902
- return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
1903
- }
1904
- if (!event || typeof event !== 'object') {
1905
- return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
1906
- }
1907
-
1908
- const harness = resolveHarness(env, event);
1909
- event = normalizeHookEvent(event, cwd, harness);
1910
- audit.harness = harness;
1911
-
1912
- const sessionCwd = event.cwd || cwd;
1913
- const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
1914
- const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
1915
- audit.cwd = projectCwd;
1916
- const primaryFileSet = new Set(primaryFiles);
1917
- const targetFiles = expandScanTargets(primaryFiles, projectCwd);
1918
- audit.session = event.session_id || null;
1919
- if (event.tool_name) audit.tool = event.tool_name;
1920
-
1921
- if (targetFiles.length === 0) {
1922
- return result({ skipped: 'no-file-path', durationMs: Date.now() - started });
1923
- }
1924
-
1925
- const config = readConfig(projectCwd);
1926
- if (config.enabled === false) {
1927
- return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
1928
- }
1929
-
1930
- const platform = resolveProjectPlatform(projectCwd);
1931
- if (isNativePlatform(platform)) {
1932
- return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
1933
- }
1934
-
1935
- const cache = readCache(projectCwd);
1936
- const sessionId = event.session_id || 'unknown';
1937
- const det = detector || await loadDetector();
1938
- if (!det || typeof det.detectText !== 'function') {
1939
- // Cache is not mutated yet at this point; nothing to persist.
1940
- return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
1941
- }
1942
- const scanOptions = designSystemOptions(config, det, projectCwd);
1943
- const tiered = perEditTieringActive(config, harness);
1944
-
1945
- let pendingWinner = null;
1946
- let cleanWinner = null;
1947
- const freshGroups = [];
1948
- let suppressionWinner = null;
1949
- let cleanAckDeduped = false;
1950
- let skippedBytes = 0;
1951
- const quietMode = truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true;
1952
- let detectorThrewAny = false;
1953
- let lastSkip = 'no-scannable-file';
1954
- let suppressedHit = false;
1955
- let cacheDirty = false;
1956
- let deferredTotal = 0;
1957
-
1958
- for (const filePath of targetFiles) {
1959
- audit.file = filePath;
1960
-
1961
- if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) {
1962
- lastSkip = 'sensitive';
1963
- continue;
1964
- }
1965
- if (GENERATED_PATH.test(filePath)) {
1966
- lastSkip = 'generated';
1967
- continue;
1968
- }
1969
-
1970
- const ext = path.extname(filePath).toLowerCase();
1971
- const configuredExt = matchConfiguredExtension(filePath, config.extensions);
1972
- audit.ext = configuredExt ? configuredExt.ext : ext;
1973
- if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
1974
- lastSkip = 'extension';
1975
- continue;
1976
- }
1977
-
1978
- const relForMatch = relativize(filePath, projectCwd);
1979
- if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
1980
- lastSkip = 'config-ignore-file';
1981
- continue;
1982
- }
1983
- if (!fs.existsSync(filePath)) {
1984
- lastSkip = 'file-missing';
1985
- continue;
1986
- }
1987
- if (!isScanTargetInsideProject(filePath, projectCwd)) {
1988
- lastSkip = 'outside-project';
1989
- continue;
1990
- }
1991
-
1992
- const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
1993
- if (maxFileBytes > 0) {
1994
- let size = 0;
1995
- try { size = fs.statSync(filePath).size; } catch { size = 0; }
1996
- if (size > maxFileBytes) {
1997
- skippedBytes = size;
1998
- lastSkip = 'too-large';
1999
- continue;
2000
- }
2001
- }
2002
-
2003
- if (primaryFileSet.has(filePath)) {
2004
- const editCount = bumpEditCount(cache, sessionId, filePath);
2005
- cacheDirty = true;
2006
- audit.editCount = editCount;
2007
-
2008
- if (editCount > EDIT_COUNT_THRESHOLD) {
2009
- const wasJustCrossed = editCount === EDIT_COUNT_THRESHOLD + 1;
2010
- if (wasJustCrossed && !suppressionWinner) {
2011
- suppressionWinner = { filePath };
2012
- }
2013
- lastSkip = 'suppressed';
2014
- suppressedHit = true;
2015
- continue;
2016
- }
2017
- }
2018
-
2019
- const content = fs.readFileSync(filePath, 'utf-8');
2020
- let findings;
2021
- let detectorThrew = false;
2022
- const useHtmlEngine = configuredExt
2023
- ? configuredExt.engine === 'html'
2024
- : (ext === '.html' || ext === '.htm');
2025
- if (useHtmlEngine && typeof det.detectHtml === 'function') {
2026
- try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
2027
- } else {
2028
- try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
2029
- }
2030
-
2031
- const filtered = filterFindings(findings || [], content, ext, config);
2032
- // Per-edit only surfaces the immediate tier; the rest waits for the
2033
- // Stop deep pass. The file is still marked touched so the deep pass
2034
- // knows to re-scan it.
2035
- const { immediate, deferred } = tiered
2036
- ? splitFindingsByTier(filtered)
2037
- : { immediate: filtered, deferred: [] };
2038
- if (deferred.length > 0) {
2039
- touchFile(cache, sessionId, filePath);
2040
- cacheDirty = true;
2041
- deferredTotal += deferred.length;
2042
- }
2043
- const fresh = dedupeAgainstCache(immediate, cache, sessionId, filePath);
2044
- audit.findings = (findings || []).length;
2045
- audit.freshFindings = fresh.length;
2046
- if (deferredTotal > 0) audit.deferred = deferredTotal;
2047
-
2048
- // A detector failure tells us nothing about the file, so leave whatever
2049
- // was remembered alone rather than recording an empty scan as truth.
2050
- if (detectorThrew) {
2051
- detectorThrewAny = true;
2052
- continue;
2053
- }
2054
-
2055
- // Sync the cache to this scan before deciding what to emit, so fixed
2056
- // findings stop being remembered and a reintroduced one reads as fresh.
2057
- // Only the immediate tier is remembered: a deferred finding the per-edit
2058
- // pass never reported must still read as fresh to the Stop deep pass.
2059
- //
2060
- // Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
2061
- // Remembering here would dedupe those findings out of Stop. Touch the
2062
- // file so Stop has it, and leave the finding list empty.
2063
- if (harness === 'grok') {
2064
- touchFile(cache, sessionId, filePath);
2065
- } else {
2066
- rememberFindings(cache, sessionId, filePath, immediate);
2067
- }
2068
- cacheDirty = true;
2069
-
2070
- if (fresh.length > 0) {
2071
- freshGroups.push({ filePath, findings: fresh });
2072
- continue;
2073
- }
2074
-
2075
- if (immediate.length > 0 && !pendingWinner) {
2076
- // Count the live scan, not the session's history.
2077
- pendingWinner = { filePath, known: immediate.map(f => findingCacheKey(f)) };
2078
- } else if (immediate.length === 0 && !cleanWinner) {
2079
- // The clean ack carries no finding, only the standing steer that a
2080
- // silent hook is not a verdict on the design. Repeating it on every
2081
- // clean edit spends context to say nothing, so it fires once per file
2082
- // per session. The pending ack, which names real unresolved work, is
2083
- // deliberately left to repeat.
2084
- //
2085
- // Quiet mode emits nothing, so it must not consume the ack and leave a
2086
- // later non-quiet run in this session silent.
2087
- if (quietMode || !shouldEmitAckForFile(filePath, config)) {
2088
- cleanWinner = { filePath };
2089
- } else if (ensureFile(cache, sessionId, filePath).cleanAcked) {
2090
- // Spent for this file. Remember it for the audit trail, but keep
2091
- // scanning: another target in this same event may still be owed an
2092
- // ack, and dropping out here would lose it.
2093
- cleanAckDeduped = true;
2094
- } else {
2095
- ensureFile(cache, sessionId, filePath).cleanAcked = true;
2096
- cleanWinner = { filePath };
2097
- cleanAckDeduped = false;
2098
- }
2099
- }
2100
- }
2101
-
2102
- // The session notice flags mutate the cache, so they must settle before
2103
- // the persist that makes them stick across events.
2104
- if (freshGroups.length > 0) {
2105
- const firstGroup = freshGroups[0];
2106
- const footerMode = footerModeForSession(cache, sessionId);
2107
- const text = appendDesignSystemNoteOnce(
2108
- renderGroupedTemplate(freshGroups, config, {
2109
- cwd: projectCwd,
2110
- footer: footerMode,
2111
- reserveChars: designNoteReserve(scanOptions, cache, sessionId),
2112
- }),
2113
- scanOptions, cache, sessionId, config,
2114
- );
2115
- commitFooterShown(cache, sessionId, text);
2116
- // Fresh findings always earn the cache write, including creating
2117
- // `.impeccable/`: dedup, suppression, and the notice flags need it.
2118
- persistCache(projectCwd, cache);
2119
- const allFindings = freshGroups.flatMap((group) => group.findings);
2120
- return {
2121
- exitCode: 0,
2122
- stdout: payload(text, 'PostToolUse', harness),
2123
- emission: {
2124
- kind: 'fresh',
2125
- file: firstGroup.filePath,
2126
- findings: firstGroup.findings,
2127
- groups: freshGroups,
2128
- },
2129
- audit: {
2130
- ...audit,
2131
- file: firstGroup.filePath,
2132
- emitted: true,
2133
- freshFiles: freshGroups.length,
2134
- freshFindings: allFindings.length,
2135
- chars: text.length,
2136
- durationMs: Date.now() - started,
2137
- },
2138
- };
2139
- }
2140
-
2141
- // Resolve the ack emission before the persist below: appendDesignSystem-
2142
- // NoteOnce consumes a session flag, and the flag only sticks when the
2143
- // write happens after it. Quiet mode emits nothing, so it consumes
2144
- // nothing. The clean arm mirrors the branch order further down: pending
2145
- // outranks suppression, suppression outranks clean.
2146
- let ack = null;
2147
- if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
2148
- ack = {
2149
- kind: 'pending',
2150
- text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
2151
- };
2152
- } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
2153
- ack = {
2154
- kind: 'clean',
2155
- text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
2156
- };
2157
- }
2158
-
2159
- // Persist only when the write is earned: deferred findings need the
2160
- // touched-file list for the Stop deep pass, and an already-present
2161
- // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
2162
- // clean UI edit in a project with no Impeccable footprint, must be a
2163
- // no-op on disk (issues #344, #305). An existing cache file also counts
2164
- // as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives
2165
- // outside the project, so the project dir alone can't carry the marker —
2166
- // without this, clean-edit editCount bumps would stop persisting the
2167
- // moment state relocates. Under stock paths the cache sits inside
2168
- // `.impeccable/`, so the extra check changes nothing there.
2169
- if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) {
2170
- persistCache(projectCwd, cache);
2171
- }
2172
-
2173
- if (detectorThrewAny && !pendingWinner && !cleanWinner) {
2174
- return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
2175
- }
2176
-
2177
- if (quietMode) {
2178
- return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
2179
- }
2180
-
2181
- if (ack?.kind === 'pending') {
2182
- const text = ack.text;
2183
- return {
2184
- exitCode: 0,
2185
- stdout: payload(text, 'PostToolUse', harness),
2186
- emission: { kind: 'pending', file: pendingWinner.filePath, known: pendingWinner.known },
2187
- audit: {
2188
- ...audit,
2189
- file: pendingWinner.filePath,
2190
- emitted: true,
2191
- kind: 'pending',
2192
- pending: pendingWinner.known.length,
2193
- chars: text.length,
2194
- durationMs: Date.now() - started,
2195
- },
2196
- };
2197
- }
2198
-
2199
- if (suppressionWinner) {
2200
- const text = suppressionNotice(relativize(suppressionWinner.filePath, projectCwd));
2201
- return {
2202
- exitCode: 0,
2203
- stdout: payload(text, 'PostToolUse', harness),
2204
- emission: { kind: 'suppression', file: suppressionWinner.filePath },
2205
- audit: {
2206
- ...audit,
2207
- file: suppressionWinner.filePath,
2208
- suppressed: true,
2209
- emitted: true,
2210
- durationMs: Date.now() - started,
2211
- },
2212
- };
2213
- }
2214
-
2215
- if (ack?.kind === 'clean') {
2216
- const text = ack.text;
2217
- return {
2218
- exitCode: 0,
2219
- stdout: payload(text, 'PostToolUse', harness),
2220
- emission: { kind: 'clean', file: cleanWinner.filePath },
2221
- audit: {
2222
- ...audit,
2223
- file: cleanWinner.filePath,
2224
- emitted: true,
2225
- kind: 'clean',
2226
- chars: text.length,
2227
- durationMs: Date.now() - started,
2228
- },
2229
- };
2230
- }
2231
-
2232
- if (pendingWinner) {
2233
- return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
2234
- }
2235
-
2236
- // Distinct from non-ui-ack so the audit log shows noise being suppressed on
2237
- // purpose rather than a file the hook could not classify.
2238
- if (cleanWinner) {
2239
- return result({ emitted: false, skipped: 'non-ui-ack', durationMs: Date.now() - started });
2240
- }
2241
-
2242
- if (cleanAckDeduped) {
2243
- return result({ emitted: false, skipped: 'clean-ack-deduped', durationMs: Date.now() - started });
2244
- }
2245
-
2246
- if (suppressedHit) {
2247
- return result({ suppressed: true, emitted: false, durationMs: Date.now() - started });
2248
- }
2249
-
2250
- return result({
2251
- skipped: lastSkip,
2252
- ...(lastSkip === 'too-large' ? { bytes: skippedBytes } : {}),
2253
- durationMs: Date.now() - started,
2254
- });
2255
- } catch (err) {
2256
- return {
2257
- exitCode: 0,
2258
- stdout: '',
2259
- audit: { ...audit, error: String(err && err.message ? err.message : err) },
2260
- };
2261
- }
2262
- }
2263
-
2264
- // Cap on files the Stop deep pass will scan. The touched-file list is
2265
- // session-scoped and already capped per edit, but a very long session could
2266
- // accumulate more than the 30s hook timeout comfortably covers.
2267
- export const STOP_MAX_FILES = 20;
2268
-
2269
- /**
2270
- * Run the Stop-event deep pass: the FULL detector rule set over every UI
2271
- * file touched this session, surfaced once, deduped against everything the
2272
- * per-edit hook already reported. Same result contract as runHook():
2273
- * { exitCode, stdout, audit, emission? }
2274
- *
2275
- * Never throws; exits silent (and fast) when the session touched no UI
2276
- * files. Output goes out on the harness's Stop continuation channel: Claude
2277
- * Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
2278
- * a decision: "block" whose reason becomes the continuation prompt. Either
2279
- * way the findings reach the model and the conversation continues so it
2280
- * can act.
2281
- */
2282
- export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
2283
- const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
2284
- const result = (extra) => ({ exitCode: 0, stdout: '', audit: { ...audit, ...extra } });
2285
-
2286
- try {
2287
- // Re-entrancy guard, same as the per-edit pass.
2288
- if (depthIsSet(env.IMPECCABLE_HOOK_DEPTH) || depthIsSet(env.CLAUDE_HOOK_DEPTH)) {
2289
- return result({ reentrant: true, durationMs: 0 });
2290
- }
2291
- if (truthy(env.IMPECCABLE_HOOK_DISABLED)) {
2292
- return result({ skipped: 'env-disabled', durationMs: 0 });
2293
- }
2294
-
2295
- const started = Date.now();
2296
-
2297
- let event;
2298
- try {
2299
- event = typeof stdinJson === 'string' ? JSON.parse(stdinJson) : stdinJson;
2300
- } catch {
2301
- return result({ skipped: 'stdin-malformed', durationMs: Date.now() - started });
2302
- }
2303
- if (!event || typeof event !== 'object') {
2304
- return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
2305
- }
2306
-
2307
- const harness = resolveHarness(env, event);
2308
- audit.harness = harness;
2309
- event = normalizeHookEvent(event, cwd, harness);
2310
-
2311
- // Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
2312
- // being re-invoked only because a prior invocation kept the turn alive
2313
- // (Claude Code via hookSpecificOutput.additionalContext, Codex via a
2314
- // decision: "block" continuation). Re-scanning and re-blocking now could
2315
- // loop (issue #400). The prior fire already surfaced the findings;
2316
- // whether to act on them is the agent's call. Exit fast with no output
2317
- // before any scan. Claude Code and Codex both send this field: Codex
2318
- // mirrors the Claude contract (StopCommandInput in
2319
- // codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
2320
- // turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
2321
- // sends `stopHookActive`, copied onto the snake_case field above. Cursor
2322
- // and GitHub Copilot omit the field, so the strict `=== true` is a no-op
2323
- // for them. The guard makes the loop impossible regardless of the finding
2324
- // cache key's line-number sensitivity (out of scope here; see
2325
- // findingCacheKey).
2326
- if (event.stop_hook_active === true) {
2327
- return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
2328
- }
2329
-
2330
- // Grok fires Stop twice: `end_turn` (the gate that can inject
2331
- // additionalContext) then an observe-only `shutdown`. A second deep
2332
- // pass would re-emit the same findings. Claude omits `reason`; only
2333
- // skip when Grok named a reason that is not end_turn.
2334
- if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
2335
- return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
2336
- }
2337
-
2338
- // A Stop event carries no file, so the session cwd is the project.
2339
- // Umbrella-dir launches keyed their per-edit cache to the edited file's
2340
- // project root (resolveCacheCwd); those sessions no-op here rather than
2341
- // guessing which child project the session was about.
2342
- const projectCwd = path.resolve(event.cwd || cwd);
2343
- audit.cwd = projectCwd;
2344
- const sessionId = event.session_id || 'unknown';
2345
- audit.session = sessionId;
2346
-
2347
- const config = readConfig(projectCwd);
2348
- if (config.enabled === false) {
2349
- return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
2350
- }
2351
-
2352
- const cache = readCache(projectCwd);
2353
- const touched = Object.keys(cache.sessions?.[sessionId]?.files || {});
2354
- if (touched.length === 0) {
2355
- return result({ skipped: 'no-touched-files', durationMs: Date.now() - started });
2356
- }
2357
-
2358
- const platform = resolveProjectPlatform(projectCwd);
2359
- if (isNativePlatform(platform)) {
2360
- return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
2361
- }
2362
-
2363
- const det = detector || await loadDetector();
2364
- if (!det || typeof det.detectText !== 'function') {
2365
- return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
2366
- }
2367
- const scanOptions = designSystemOptions(config, det, projectCwd);
2368
-
2369
- const freshGroups = [];
2370
- let scanned = 0;
2371
- let cacheDirty = false;
2372
- for (const filePath of touched) {
2373
- if (scanned >= STOP_MAX_FILES) break;
2374
- if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
2375
- if (GENERATED_PATH.test(filePath)) continue;
2376
- const ext = path.extname(filePath).toLowerCase();
2377
- const configuredExt = matchConfiguredExtension(filePath, config.extensions);
2378
- if (!ALLOWED_EXTS.has(ext) && !configuredExt) continue;
2379
- const relForMatch = relativize(filePath, projectCwd);
2380
- if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
2381
- if (!fs.existsSync(filePath)) continue;
2382
- // Caches written before this gate existed can still hold out-of-project
2383
- // paths, so the Stop pass re-checks containment rather than trusting
2384
- // the per-edit pass to have filtered them.
2385
- if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
2386
-
2387
- scanned += 1;
2388
- let content = '';
2389
- try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
2390
-
2391
- let findings;
2392
- let detectorThrew = false;
2393
- const useHtmlEngine = configuredExt
2394
- ? configuredExt.engine === 'html'
2395
- : (ext === '.html' || ext === '.htm');
2396
-
2397
- if (useHtmlEngine && typeof det.detectHtml === 'function') {
2398
- try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
2399
- } else {
2400
- try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
2401
- }
2402
-
2403
- // A detector failure tells us nothing about the file. Leave whatever
2404
- // was remembered alone rather than recording an empty scan as truth.
2405
- if (detectorThrew) continue;
2406
-
2407
- // Full rule set: no tier split here. Config/inline ignores still apply,
2408
- // and the session dedupe drops everything the per-edit pass (or an
2409
- // earlier Stop pass) already surfaced.
2410
- const filtered = filterFindings(findings || [], content, ext, config);
2411
- const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
2412
- // Sync to the live scan, including empty. Remembering only `fresh`
2413
- // (or skipping the write on a clean Stop) left stale keys in place, so
2414
- // a finding that was fixed and later reintroduced never fired again.
2415
- rememberFindings(cache, sessionId, filePath, filtered);
2416
- cacheDirty = true;
2417
- if (fresh.length > 0) {
2418
- freshGroups.push({ filePath, findings: fresh });
2419
- }
2420
- }
2421
- audit.scannedFiles = scanned;
2422
-
2423
- if (freshGroups.length === 0) {
2424
- if (cacheDirty) persistCache(projectCwd, cache);
2425
- return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
2426
- }
2427
-
2428
- // A per-edit fire earlier in this session already consumed the footer
2429
- // flag, so the Stop wall of text carries the one-line short footer.
2430
- const footerMode = footerModeForSession(cache, sessionId);
2431
- const text = appendDesignSystemNoteOnce(
2432
- renderGroupedTemplate(freshGroups, config, {
2433
- cwd: projectCwd,
2434
- footer: footerMode,
2435
- reserveChars: designNoteReserve(scanOptions, cache, sessionId),
2436
- }),
2437
- scanOptions, cache, sessionId, config,
2438
- );
2439
- commitFooterShown(cache, sessionId, text);
2440
-
2441
- // Persist the live finding set so the next Stop fire is silent unless
2442
- // new issues appear; the notice flags ride along.
2443
- persistCache(projectCwd, cache);
2444
- return {
2445
- exitCode: 0,
2446
- stdout: payload(text, 'Stop', harness),
2447
- emission: {
2448
- kind: 'stop-deep-pass',
2449
- groups: freshGroups,
2450
- },
2451
- audit: {
2452
- ...audit,
2453
- emitted: true,
2454
- freshFiles: freshGroups.length,
2455
- freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0),
2456
- chars: text.length,
2457
- durationMs: Date.now() - started,
2458
- },
2459
- };
2460
- } catch (err) {
2461
- return {
2462
- exitCode: 0,
2463
- stdout: '',
2464
- audit: { ...audit, error: String(err && err.message ? err.message : err) },
2465
- };
2466
- }
2467
- }
2468
-
2469
- export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
2470
- if (harness === 'cursor') {
2471
- return JSON.stringify({ additional_context: text });
2472
- }
2473
- // GitHub Copilot's postToolUse hook injects context via a top-level
2474
- // `additionalContext` string (alongside an optional `modifiedResult`).
2475
- if (harness === 'github') {
2476
- return JSON.stringify({ additionalContext: text });
2477
- }
2478
- // Codex shares Claude Code's PostToolUse additional-context shape, but its
2479
- // Stop schema rejects unknown fields. Findings that should continue the
2480
- // turn must be a top-level blocking decision.
2481
- // https://developers.openai.com/codex/hooks#stop (schema of record:
2482
- // codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
2483
- if (harness === 'codex' && eventName === 'Stop') {
2484
- if (!String(text ?? '').trim()) return '';
2485
- return JSON.stringify({ decision: 'block', reason: text });
2486
- }
2487
- return JSON.stringify({
2488
- hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
2489
- });
2490
- }