quiver-cli 1.2.0 → 1.3.1

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 (121) hide show
  1. package/dist/cli.js +237 -10
  2. package/package.json +1 -1
  3. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  4. package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
  5. package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
  6. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
  7. package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
  8. package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
  9. package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
  10. package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
  11. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
  12. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
  13. package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
  14. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
  15. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
  16. package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
  17. package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
  18. package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
  19. package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
  21. package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
  22. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
  23. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
  24. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  25. package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
  26. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  27. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  28. package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
  29. package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
  30. package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
  31. package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
  32. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  33. package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
  34. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  35. package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
  36. package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
  37. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
  38. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
  39. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
  40. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
  41. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
  42. package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
  43. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
  44. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
  45. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
  46. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
  47. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
  48. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
  49. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
  50. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
  51. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  52. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
  53. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
  54. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
  55. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
  56. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
  57. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
  58. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
  59. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
  60. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
  61. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
  62. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
  63. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
  64. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
  65. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
  66. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
  67. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
  68. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
  69. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
  70. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
  71. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
  72. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
  73. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
  74. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
  76. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
  77. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
  78. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
  79. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
  85. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
  86. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
  88. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
  89. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
  90. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
  91. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  92. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
  93. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
  95. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
  96. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  97. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
  98. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
  99. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
  100. package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
  101. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
  102. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
  103. package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
  104. package/template/.agents/skills/hono/SKILL.md +17 -3
  105. package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
  106. package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
  107. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
  108. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
  109. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
  110. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
  111. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
  112. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
  113. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
  114. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
  115. package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
  116. package/template/.agents/skills/supabase/SKILL.md +5 -1
  117. package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
  118. package/template/.agents/skills/writing/humanizer/README.md +209 -0
  119. package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
  120. package/template/.agents/upstreams.json +21 -22
  121. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
@@ -22,6 +22,9 @@
22
22
  * dedupeAgainstCache(findings, cache, sessionId, filePath)
23
23
  * renderTemplate(findings, filePath, config, opts)
24
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)
25
28
  * shouldEmitAckForFile(filePath, config?)
26
29
  * writeAuditLog(env, entry)
27
30
  * loadDetector() -> Promise<{ detectText, detectHtml }>
@@ -40,6 +43,7 @@
40
43
  * `cli/engine/detect-antipatterns.mjs` (running from source).
41
44
  */
42
45
 
46
+ import crypto from 'node:crypto';
43
47
  import fs from 'node:fs';
44
48
  import os from 'node:os';
45
49
  import path from 'node:path';
@@ -207,12 +211,49 @@ export function getLocalConfigPath(cwd) {
207
211
  return path.join(cwd, '.impeccable', 'config.local.json');
208
212
  }
209
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
+
210
251
  export function getCachePath(cwd) {
211
- return path.join(cwd, '.impeccable', 'hook.cache.json');
252
+ return path.join(hookStateDir(cwd), 'hook.cache.json');
212
253
  }
213
254
 
214
255
  export function getPendingPath(cwd) {
215
- return path.join(cwd, '.impeccable', 'hook.pending.json');
256
+ return path.join(hookStateDir(cwd), 'hook.pending.json');
216
257
  }
217
258
 
218
259
  export function resolveProjectCwd(event, fallback = process.cwd()) {
@@ -813,9 +854,9 @@ export function splitFindingsByTier(findings) {
813
854
  }
814
855
 
815
856
  // Whether the per-edit pass for this harness should defer non-immediate
816
- // findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
817
- // hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
818
- // them would silently drop the non-immediate rules entirely.
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.
819
860
  export function perEditTieringActive(config, harness) {
820
861
  if (harness === 'cursor' || harness === 'github') return false;
821
862
  return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
@@ -970,7 +1011,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
970
1011
  if (!Array.isArray(findings) || findings.length === 0) return '';
971
1012
  const limits = config?.limits || DEFAULT_CONFIG.limits;
972
1013
  const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
973
- const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
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);
974
1021
 
975
1022
  const cwd = opts.cwd || process.cwd();
976
1023
  const display = relativize(filePath, cwd);
@@ -979,11 +1026,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
979
1026
  const remaining = total - shown.length;
980
1027
 
981
1028
  const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
982
- const lines = shown.map((f) => formatFindingLine(f));
1029
+ const seenRules = new Set();
1030
+ const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules));
983
1031
  const more = remaining > 0
984
1032
  ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
985
1033
  : null;
986
- const footer = directiveFooter(display);
1034
+ const footer = directiveFooter({ mode: opts.footer });
987
1035
 
988
1036
  const blocks = [header, ...lines];
989
1037
  if (more) blocks.push(more);
@@ -1007,12 +1055,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
1007
1055
 
1008
1056
  const limits = config?.limits || DEFAULT_CONFIG.limits;
1009
1057
  const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
1010
- const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
1058
+ const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
1011
1059
  const cwd = opts.cwd || process.cwd();
1012
1060
  const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
1013
1061
  const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
1014
1062
  const lines = [];
1015
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();
1016
1067
 
1017
1068
  for (const group of realGroups) {
1018
1069
  const display = relativize(group.filePath, cwd);
@@ -1020,7 +1071,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
1020
1071
  const remainingCap = Math.max(0, cap - shownCount);
1021
1072
  const shown = group.findings.slice(0, remainingCap);
1022
1073
  for (const finding of shown) {
1023
- lines.push(formatFindingLine(finding));
1074
+ lines.push(formatDedupedFindingLine(finding, seenRules));
1024
1075
  }
1025
1076
  shownCount += shown.length;
1026
1077
  const hidden = group.findings.length - shown.length;
@@ -1029,7 +1080,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
1029
1080
  }
1030
1081
  }
1031
1082
 
1032
- const footer = directiveFooter('the affected files', { grouped: true });
1083
+ const footer = directiveFooter({ mode: opts.footer });
1033
1084
  let text = [header, ...lines, '', footer].join('\n');
1034
1085
  if (text.length > maxChars) {
1035
1086
  text = clampGroupedToBudget(header, lines, footer, maxChars);
@@ -1037,82 +1088,149 @@ function renderGroupedTemplate(groups, config, opts = {}) {
1037
1088
  return text;
1038
1089
  }
1039
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
+
1040
1104
  function clampGroupedToBudget(header, lines, footer, maxChars) {
1041
- const assemble = (linesArr, omitted) => [
1105
+ const assemble = (linesArr, omitted, footerText) => [
1042
1106
  header,
1043
1107
  ...linesArr,
1044
1108
  ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
1045
1109
  '',
1046
- footer,
1110
+ footerText,
1047
1111
  ].join('\n');
1048
1112
 
1049
- let working = lines.slice();
1050
- let omitted = false;
1051
- let assembled = assemble(working, omitted);
1052
- while (assembled.length > maxChars && working.length > 1) {
1053
- working.pop();
1054
- omitted = true;
1055
- assembled = assemble(working, omitted);
1056
- }
1057
- if (assembled.length > maxChars) {
1058
- assembled = `${assembled.slice(0, maxChars - 1)}…`;
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;
1059
1123
  }
1060
- return assembled;
1124
+ return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
1125
+ lines.find(isFindingLine) || lines[0], maxChars);
1061
1126
  }
1062
1127
 
1063
1128
  function clampToBudget(header, lines, more, footer, maxChars) {
1064
- const assemble = (linesArr, moreText) => {
1129
+ const assemble = (linesArr, moreText, footerText) => {
1065
1130
  const blocks = [header, ...linesArr];
1066
1131
  if (moreText) blocks.push(moreText);
1067
1132
  blocks.push('');
1068
- blocks.push(footer);
1133
+ blocks.push(footerText);
1069
1134
  return blocks.join('\n');
1070
1135
  };
1071
1136
 
1072
- let working = lines.slice();
1073
- let moreText = more;
1074
- let assembled = assemble(working, moreText);
1075
- while (assembled.length > maxChars && working.length > 1) {
1076
- working.pop();
1077
- moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
1078
- assembled = assemble(working, moreText);
1079
- }
1080
- if (assembled.length > maxChars) {
1081
- assembled = `${assembled.slice(0, maxChars - 1)}…`;
1082
- }
1083
- return assembled;
1084
- }
1085
-
1086
- function formatFindingLine(f) {
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 = {}) {
1087
1182
  const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
1088
- const desc = (f.description || '').trim();
1183
+ const desc = opts.compact ? '' : (f.description || '').trim();
1089
1184
  const name = (f.name || '').trim();
1090
1185
  // Description from the registry already ends in punctuation; join with a
1091
1186
  // single space. `name` may have a trailing period already, keep it clean.
1092
1187
  const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
1093
- const ignoreCommand = formatFindingIgnoreCommand(f);
1094
- const ignoreSegment = ignoreCommand
1095
- ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
1096
- : '';
1188
+ const ignoreHint = formatFindingIgnoreHint(f);
1189
+ const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
1097
1190
  return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
1098
1191
  }
1099
1192
 
1100
- function formatFindingIgnoreCommand(finding) {
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) {
1101
1209
  if (!finding || typeof finding !== 'object') return '';
1102
1210
  const rule = normalizeIgnoreRule(finding.antipattern);
1103
1211
  if (!rule) return '';
1104
1212
  const normalizedValue = extractFindingIgnoreValue(finding);
1105
1213
  if (!normalizedValue) return '';
1106
- const value = extractFindingIgnoreValueRaw(finding);
1107
- const valueArg = quoteCommandArg(value);
1108
- const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
1109
- return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
1214
+ const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
1215
+ return `ignore-value ${rule} ${valueArg}`;
1110
1216
  }
1111
1217
 
1112
1218
  function quoteCommandArg(value) {
1113
1219
  const text = String(value || '').trim();
1114
1220
  if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
1115
- return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
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, `'\\''`)}'`;
1116
1234
  }
1117
1235
 
1118
1236
  function relativize(filePath, cwd) {
@@ -1171,18 +1289,50 @@ export function resolveHarness(env = {}, event = null) {
1171
1289
  const explicit = env?.IMPECCABLE_HOOK_HARNESS;
1172
1290
  if (explicit === 'cursor') return 'cursor';
1173
1291
  if (explicit === 'github') return 'github';
1174
- if (explicit === 'claude' || explicit === 'codex') return 'claude';
1175
- // GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
1176
- // has no `tool_name`/`tool_input`. That shape is the discriminator.
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';
1177
1301
  if (event && typeof event === 'object'
1178
1302
  && (typeof event.toolName === 'string' || event.toolArgs !== undefined)
1179
1303
  && event.tool_name === undefined && event.tool_input === undefined) {
1180
1304
  return 'github';
1181
1305
  }
1182
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';
1183
1312
  return 'claude';
1184
1313
  }
1185
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
+
1186
1336
  // GitHub Copilot's postToolUse payload is
1187
1337
  // { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
1188
1338
  // mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
@@ -1274,9 +1424,36 @@ function normalizeGitHubEvent(event, projectCwd) {
1274
1424
  };
1275
1425
  }
1276
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
+
1277
1453
  export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
1278
1454
  if (!event || typeof event !== 'object') return event;
1279
1455
  if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
1456
+ if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
1280
1457
  if (harness !== 'cursor') return event;
1281
1458
 
1282
1459
  const cwd = event.cwd
@@ -1335,6 +1512,51 @@ function isInsideProject(filePath, projectCwd) {
1335
1512
  }
1336
1513
  }
1337
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
+
1338
1560
  export function parseStaticStyleImports(content, fromFile, projectCwd) {
1339
1561
  if (!content || typeof content !== 'string') return [];
1340
1562
  const dir = path.dirname(fromFile);
@@ -1549,36 +1771,105 @@ export function designSystemOptions(config, detector, projectCwd) {
1549
1771
  }
1550
1772
  }
1551
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
+
1552
1776
  export function appendDesignSystemNote(text, scanOptions) {
1553
1777
  if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
1554
- return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
1778
+ return `${text}\n\n${DESIGN_STALE_NOTE}`;
1555
1779
  }
1556
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
+
1557
1841
  // The directive footer is the part of the hook output that steers model
1558
- // behavior. Three intentional moves:
1559
- // 1. **Imperative, not advisory.** "Handle these..." beats "Consider
1560
- // revising..." which the model treats as a soft suggestion it can
1561
- // override when the user asked for any kind of throwaway / demo UI.
1562
- // 2. **Explicit judgment clause.** Without it, the model will try to
1563
- // "fix" intentional motion, bad fixtures, anti-pattern examples in
1564
- // docs, or test cases. Naming the judgment inline beats hoping the
1565
- // model infers it from context.
1566
- // 3. **Acknowledgement instruction.** Hook output is injected as
1567
- // developer-role context, not a chat turn, so the user never sees the
1568
- // raw envelope. Asking the model to surface the resolution in its
1569
- // reply is the cheapest way to make the feedback loop visible.
1570
- function directiveFooter(display, opts = {}) {
1571
- // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
1572
- // for the path forever, which is far more than one noisy rule on a real UI
1573
- // surface justifies, and it was previously the only option named here.
1574
- const target = opts.grouped ? '<path>' : quoteCommandArg(display);
1575
- const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
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
+ }
1576
1867
  return [
1577
- 'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
1578
- '',
1579
- 'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
1580
- '',
1581
- `Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
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.`,
1582
1873
  ].join('\n');
1583
1874
  }
1584
1875
 
@@ -1693,6 +1984,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1693
1984
  lastSkip = 'file-missing';
1694
1985
  continue;
1695
1986
  }
1987
+ if (!isScanTargetInsideProject(filePath, projectCwd)) {
1988
+ lastSkip = 'outside-project';
1989
+ continue;
1990
+ }
1696
1991
 
1697
1992
  const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
1698
1993
  if (maxFileBytes > 0) {
@@ -1761,7 +2056,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1761
2056
  // findings stop being remembered and a reintroduced one reads as fresh.
1762
2057
  // Only the immediate tier is remembered: a deferred finding the per-edit
1763
2058
  // pass never reported must still read as fresh to the Stop deep pass.
1764
- rememberFindings(cache, sessionId, filePath, immediate);
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
+ }
1765
2068
  cacheDirty = true;
1766
2069
 
1767
2070
  if (fresh.length > 0) {
@@ -1796,20 +2099,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1796
2099
  }
1797
2100
  }
1798
2101
 
1799
- // Persist only when the write is earned: fresh findings justify creating
1800
- // `.impeccable/` (dedup and suppression need it), deferred findings do
1801
- // too (the Stop deep pass needs the touched-file list to surface them),
1802
- // and an already-present `.impeccable/` dir marks a project that opted
1803
- // in. A non-UI edit, or a clean UI edit in a project with no Impeccable
1804
- // footprint, must be a no-op on disk (issues #344, #305).
1805
- if (freshGroups.length > 0 || deferredTotal > 0
1806
- || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
1807
- persistCache(projectCwd, cache);
1808
- }
1809
-
2102
+ // The session notice flags mutate the cache, so they must settle before
2103
+ // the persist that makes them stick across events.
1810
2104
  if (freshGroups.length > 0) {
1811
2105
  const firstGroup = freshGroups[0];
1812
- const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
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);
1813
2119
  const allFindings = freshGroups.flatMap((group) => group.findings);
1814
2120
  return {
1815
2121
  exitCode: 0,
@@ -1832,6 +2138,38 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1832
2138
  };
1833
2139
  }
1834
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
+
1835
2173
  if (detectorThrewAny && !pendingWinner && !cleanWinner) {
1836
2174
  return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
1837
2175
  }
@@ -1840,8 +2178,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1840
2178
  return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
1841
2179
  }
1842
2180
 
1843
- if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
1844
- const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
2181
+ if (ack?.kind === 'pending') {
2182
+ const text = ack.text;
1845
2183
  return {
1846
2184
  exitCode: 0,
1847
2185
  stdout: payload(text, 'PostToolUse', harness),
@@ -1874,8 +2212,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
1874
2212
  };
1875
2213
  }
1876
2214
 
1877
- if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
1878
- const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
2215
+ if (ack?.kind === 'clean') {
2216
+ const text = ack.text;
1879
2217
  return {
1880
2218
  exitCode: 0,
1881
2219
  stdout: payload(text, 'PostToolUse', harness),
@@ -1935,8 +2273,11 @@ export const STOP_MAX_FILES = 20;
1935
2273
  * { exitCode, stdout, audit, emission? }
1936
2274
  *
1937
2275
  * Never throws; exits silent (and fast) when the session touched no UI
1938
- * files. Output uses the Stop hookSpecificOutput channel: additionalContext
1939
- * is delivered to the model and the conversation continues so it can act.
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.
1940
2281
  */
1941
2282
  export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
1942
2283
  const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
@@ -1963,22 +2304,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
1963
2304
  return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
1964
2305
  }
1965
2306
 
1966
- // Claude Code's Stop-hook contract: `stop_hook_active` is true when this
1967
- // hook is being re-invoked only because a prior invocation kept the turn
1968
- // alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
1969
- // re-blocking now would loop until Claude Code's consecutive-block cap
1970
- // force-ends the turn (issue #400). The prior fire already surfaced the
1971
- // findings; whether to act on them is the agent's call. Exit fast with no
1972
- // output before any scan. Only Claude Code sends this field; other
1973
- // harnesses omit it, so the strict `=== true` is a no-op for them. This
1974
- // guard makes the loop impossible regardless of the finding cache key's
1975
- // line-number sensitivity (out of scope here; see findingCacheKey).
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).
1976
2326
  if (event.stop_hook_active === true) {
1977
2327
  return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
1978
2328
  }
1979
2329
 
1980
- const harness = resolveHarness(env, event);
1981
- audit.harness = harness;
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
+ }
1982
2337
 
1983
2338
  // A Stop event carries no file, so the session cwd is the project.
1984
2339
  // Umbrella-dir launches keyed their per-edit cache to the edited file's
@@ -2013,6 +2368,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
2013
2368
 
2014
2369
  const freshGroups = [];
2015
2370
  let scanned = 0;
2371
+ let cacheDirty = false;
2016
2372
  for (const filePath of touched) {
2017
2373
  if (scanned >= STOP_MAX_FILES) break;
2018
2374
  if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
@@ -2023,43 +2379,68 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
2023
2379
  const relForMatch = relativize(filePath, projectCwd);
2024
2380
  if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
2025
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;
2026
2386
 
2027
2387
  scanned += 1;
2028
2388
  let content = '';
2029
2389
  try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
2030
2390
 
2031
2391
  let findings;
2392
+ let detectorThrew = false;
2032
2393
  const useHtmlEngine = configuredExt
2033
2394
  ? configuredExt.engine === 'html'
2034
2395
  : (ext === '.html' || ext === '.htm');
2035
2396
 
2036
2397
  if (useHtmlEngine && typeof det.detectHtml === 'function') {
2037
- try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
2398
+ try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
2038
2399
  } else {
2039
- try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
2400
+ try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
2040
2401
  }
2041
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
+
2042
2407
  // Full rule set: no tier split here. Config/inline ignores still apply,
2043
2408
  // and the session dedupe drops everything the per-edit pass (or an
2044
2409
  // earlier Stop pass) already surfaced.
2045
2410
  const filtered = filterFindings(findings || [], content, ext, config);
2046
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;
2047
2417
  if (fresh.length > 0) {
2048
- rememberFindings(cache, sessionId, filePath, fresh);
2049
2418
  freshGroups.push({ filePath, findings: fresh });
2050
2419
  }
2051
2420
  }
2052
2421
  audit.scannedFiles = scanned;
2053
2422
 
2054
2423
  if (freshGroups.length === 0) {
2424
+ if (cacheDirty) persistCache(projectCwd, cache);
2055
2425
  return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
2056
2426
  }
2057
2427
 
2058
- // Fresh findings earn the cache write so the next Stop fire is silent
2059
- // unless new issues appear.
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.
2060
2443
  persistCache(projectCwd, cache);
2061
-
2062
- const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
2063
2444
  return {
2064
2445
  exitCode: 0,
2065
2446
  stdout: payload(text, 'Stop', harness),
@@ -2094,6 +2475,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
2094
2475
  if (harness === 'github') {
2095
2476
  return JSON.stringify({ additionalContext: text });
2096
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
+ }
2097
2487
  return JSON.stringify({
2098
2488
  hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
2099
2489
  });