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.
- package/dist/cli.js +237 -10
- package/package.json +1 -1
- package/template/.agents/skills/agent-browser/SKILL.md +1 -0
- package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
- package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
- package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
- package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
- package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
- package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
- package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
- package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
- package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
- package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
- package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
- package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
- package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
- package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
- package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
- package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
- package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
- package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
- package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
- package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
- package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
- package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
- package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
- package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
- package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
- package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
- package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
- package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
- package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
- package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
- package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
- package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
- package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
- package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
- package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
- package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
- package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
- package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
- package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
- package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
- package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
- package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
- package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
- package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
- package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
- package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
- package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
- package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
- package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
- package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
- package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
- package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
- package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
- package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
- package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
- package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
- package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
- package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
- package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
- package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
- package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
- package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
- package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
- package/template/.agents/skills/hono/SKILL.md +17 -3
- package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
- package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
- package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
- package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
- package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
- package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
- package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
- package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
- package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
- package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
- package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
- package/template/.agents/skills/supabase/SKILL.md +5 -1
- package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
- package/template/.agents/skills/writing/humanizer/README.md +209 -0
- package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
- package/template/.agents/upstreams.json +21 -22
- 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, '
|
|
252
|
+
return path.join(hookStateDir(cwd), 'hook.cache.json');
|
|
212
253
|
}
|
|
213
254
|
|
|
214
255
|
export function getPendingPath(cwd) {
|
|
215
|
-
return path.join(cwd, '
|
|
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.
|
|
817
|
-
// hook; Cursor and GitHub Copilot have no deep pass wired, so
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1110
|
+
footerText,
|
|
1047
1111
|
].join('\n');
|
|
1048
1112
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
working.
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
|
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(
|
|
1133
|
+
blocks.push(footerText);
|
|
1069
1134
|
return blocks.join('\n');
|
|
1070
1135
|
};
|
|
1071
1136
|
|
|
1072
|
-
let
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
working
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
|
1094
|
-
const ignoreSegment =
|
|
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
|
-
|
|
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
|
|
1107
|
-
|
|
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
|
-
|
|
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 === '
|
|
1175
|
-
|
|
1176
|
-
|
|
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${
|
|
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.
|
|
1559
|
-
// 1. **Imperative, not advisory.** "
|
|
1560
|
-
// revising..." which the model treats as a soft suggestion
|
|
1561
|
-
//
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
-
'
|
|
1578
|
-
'',
|
|
1579
|
-
|
|
1580
|
-
'',
|
|
1581
|
-
`
|
|
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
|
-
|
|
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
|
-
//
|
|
1800
|
-
//
|
|
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
|
|
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 (
|
|
1844
|
-
const text =
|
|
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 (
|
|
1878
|
-
const text =
|
|
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
|
|
1939
|
-
*
|
|
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
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
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
|
-
|
|
1981
|
-
|
|
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
|
-
//
|
|
2059
|
-
//
|
|
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
|
});
|