diff --git a/.agents/skills/impeccable/reference/hooks.md b/.agents/skills/impeccable/reference/hooks.md index f0321a2cc..24a98f86e 100644 --- a/.agents/skills/impeccable/reference/hooks.md +++ b/.agents/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.agents/skills/impeccable/scripts/hook-before-edit.mjs b/.agents/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.agents/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.agents/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.claude/skills/impeccable/reference/hooks.md b/.claude/skills/impeccable/reference/hooks.md index 77ee7b871..28fa8b424 100644 --- a/.claude/skills/impeccable/reference/hooks.md +++ b/.claude/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.claude/skills/impeccable/scripts/hook-before-edit.mjs b/.claude/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.claude/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.claude/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.cursor/skills/impeccable/reference/hooks.md b/.cursor/skills/impeccable/reference/hooks.md index 057b9af04..3efd53e2c 100644 --- a/.cursor/skills/impeccable/reference/hooks.md +++ b/.cursor/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.gemini/skills/impeccable/reference/hooks.md b/.gemini/skills/impeccable/reference/hooks.md index 67bb37e8d..3c8f6e3a3 100644 --- a/.gemini/skills/impeccable/reference/hooks.md +++ b/.gemini/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .gemini/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.github/skills/impeccable/reference/hooks.md b/.github/skills/impeccable/reference/hooks.md index 3751452a8..315d52a40 100644 --- a/.github/skills/impeccable/reference/hooks.md +++ b/.github/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .github/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .github/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .github/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.github/skills/impeccable/scripts/hook-before-edit.mjs b/.github/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.github/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.github/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.grok/skills/impeccable/reference/hooks.md b/.grok/skills/impeccable/reference/hooks.md index a30eb8e4b..a40d5d3a1 100644 --- a/.grok/skills/impeccable/reference/hooks.md +++ b/.grok/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .grok/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .grok/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .grok/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.grok/skills/impeccable/scripts/hook-before-edit.mjs b/.grok/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.grok/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.grok/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.grok/skills/impeccable/scripts/hook-lib.mjs b/.grok/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.grok/skills/impeccable/scripts/hook-lib.mjs +++ b/.grok/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.kiro/skills/impeccable/reference/hooks.md b/.kiro/skills/impeccable/reference/hooks.md index b97b06eb9..a016a9c4e 100644 --- a/.kiro/skills/impeccable/reference/hooks.md +++ b/.kiro/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .kiro/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.opencode/skills/impeccable/reference/hooks.md b/.opencode/skills/impeccable/reference/hooks.md index cf953a9c2..21e9fe860 100644 --- a/.opencode/skills/impeccable/reference/hooks.md +++ b/.opencode/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .opencode/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.pi/skills/impeccable/reference/hooks.md b/.pi/skills/impeccable/reference/hooks.md index 7d5f1ab48..4637d4309 100644 --- a/.pi/skills/impeccable/reference/hooks.md +++ b/.pi/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .pi/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.pi/skills/impeccable/scripts/hook-before-edit.mjs b/.pi/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.pi/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.pi/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.qoder/skills/impeccable/reference/hooks.md b/.qoder/skills/impeccable/reference/hooks.md index ba5d0625e..7e71bcd54 100644 --- a/.qoder/skills/impeccable/reference/hooks.md +++ b/.qoder/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .qoder/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.rovodev/skills/impeccable/reference/hooks.md b/.rovodev/skills/impeccable/reference/hooks.md index 36e43f21a..5a27a8564 100644 --- a/.rovodev/skills/impeccable/reference/hooks.md +++ b/.rovodev/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .rovodev/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.trae-cn/skills/impeccable/reference/hooks.md b/.trae-cn/skills/impeccable/reference/hooks.md index cacec1645..8857b6262 100644 --- a/.trae-cn/skills/impeccable/reference/hooks.md +++ b/.trae-cn/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .trae-cn/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.trae/skills/impeccable/reference/hooks.md b/.trae/skills/impeccable/reference/hooks.md index 25fe7cf8a..52e892f8c 100644 --- a/.trae/skills/impeccable/reference/hooks.md +++ b/.trae/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .trae/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.trae/skills/impeccable/scripts/hook-before-edit.mjs b/.trae/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.trae/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/.vibe/skills/impeccable/reference/hooks.md b/.vibe/skills/impeccable/reference/hooks.md index c26d6dba5..51874d8c6 100644 --- a/.vibe/skills/impeccable/reference/hooks.md +++ b/.vibe/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .vibe/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .vibe/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .vibe/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/.vibe/skills/impeccable/scripts/hook-before-edit.mjs b/.vibe/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/.vibe/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.vibe/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/.vibe/skills/impeccable/scripts/hook-lib.mjs b/.vibe/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/.vibe/skills/impeccable/scripts/hook-lib.mjs +++ b/.vibe/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness), diff --git a/plugin/skills/impeccable/reference/hooks.md b/plugin/skills/impeccable/reference/hooks.md index 77ee7b871..28fa8b424 100644 --- a/plugin/skills/impeccable/reference/hooks.md +++ b/plugin/skills/impeccable/reference/hooks.md @@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`. 5. If `` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception. 6. If `` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question. -## Intentional findings +## Triage findings -The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`. +The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes: + +- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through. +- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `""`; write "user confirmed" only when the user actually did. +- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit. + +Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first. Prefer the narrowest exception: -- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default. -- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font. +- If the finding line shows an `ignore-value ` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default. +- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font. - If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value "*" --file `. Run `npx impeccable detect ` first to see what actually fires there. - Reach for `ignore-file ` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above. - Use `ignore-rule ` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally. @@ -67,10 +73,10 @@ Example value-specific exception: node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional" ``` -Example intentional motion exception: +Example self-served exception, with the evidence named: ```bash -node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional" +node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject" ``` Example whole-rule font exception: diff --git a/plugin/skills/impeccable/scripts/hook-before-edit.mjs b/plugin/skills/impeccable/scripts/hook-before-edit.mjs index 1dcde6ef3..04050a8eb 100644 --- a/plugin/skills/impeccable/scripts/hook-before-edit.mjs +++ b/plugin/skills/impeccable/scripts/hook-before-edit.mjs @@ -16,11 +16,15 @@ import path from 'node:path'; import { ALLOWED_EXTS, + DEFAULT_CONFIG, EDIT_COUNT_THRESHOLD, GENERATED_PATH, SENSITIVE_PATH, - appendDesignSystemNote, + appendDesignSystemNoteOnce, + commitFooterShown, + designNoteReserve, designSystemOptions, + footerModeForSession, filterFindings, isNativePlatform, isScanTargetInsideProject, @@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) { } } -function cursorBlockMessage(findings, filePath, config, cwd) { - const rendered = renderTemplate(findings, filePath, config, { cwd }); - const blocked = rendered.replace( +// Cursor caps deny messages around 4000 chars. The cap feeds through the +// renderer's clamp, which preserves the policy footer, rather than tail- +// slicing the rendered text, which cut the footer off any message the +// default 8000-char budget let past 4000. +const CURSOR_DENY_LIMIT = 4000; +const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. '; + +function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) { + const limits = config?.limits || DEFAULT_CONFIG.limits; + // Charge the prefix via reserveChars, not by subtracting from maxChars: + // renderTemplate's 500-char floor re-raises any maxChars pushed below it, + // un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508). + // reserveChars comes off after the floor, so the prefix is charged at every + // config tier and the final prefixed message plus a pending staleness note + // fits the binding limit. Default-config output is byte-identical. + const budget = Math.min( + limits.maxChars || DEFAULT_CONFIG.limits.maxChars, + CURSOR_DENY_LIMIT, + ); + const rendered = renderTemplate(findings, filePath, + { ...config, limits: { ...limits, maxChars: budget } }, + { cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length }); + return rendered.replace( '[impeccable@1] Design hook findings requiring review', - '[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review', + `[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`, ); - return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked; } function findingSignature(findings) { @@ -468,9 +491,16 @@ async function main() { }); } - const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions); const sessionId = event.session_id || event.conversation_id || 'unknown'; const cache = readCache(cwd); + // Repeated denials for the same session repeat the findings, not the + // policy: the full footer emits once per session, the short form after. + const footerMode = footerModeForSession(cache, sessionId); + const message = appendDesignSystemNoteOnce( + cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, message); const denial = bumpCursorDenial(cache, sessionId, filePath, filtered); persistCache(cwd, cache); if (denial.count > EDIT_COUNT_THRESHOLD) { diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 9170aa696..794ac59a1 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -22,6 +22,9 @@ * dedupeAgainstCache(findings, cache, sessionId, filePath) * renderTemplate(findings, filePath, config, opts) * renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts) + * appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) + * designNoteReserve(scanOptions, cache, sessionId) + * footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text) * shouldEmitAckForFile(filePath, config?) * writeAuditLog(env, entry) * loadDetector() -> Promise<{ detectText, detectHtml }> @@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) { if (!Array.isArray(findings) || findings.length === 0) return ''; const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + // reserveChars holds back room for a note the caller appends after render + // (the DESIGN.md staleness note), so the final payload stays inside the + // configured budget. It comes off after the 500-char floor, so at floor + // configs the note keeps guaranteed delivery room; the clamp budget can + // therefore sit below 500, which clampLastLine's footer-preserving + // fallback handles (Bugbot on PR #508). + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const display = relativize(filePath, cwd); @@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) { const remaining = total - shown.length; const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`; - const lines = shown.map((f) => formatFindingLine(f)); + const seenRules = new Set(); + const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules)); const more = remaining > 0 ? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).` : null; - const footer = directiveFooter(display); + const footer = directiveFooter({ mode: opts.footer }); const blocks = [header, ...lines]; if (more) blocks.push(more); @@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) { const limits = config?.limits || DEFAULT_CONFIG.limits; const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings); - const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars); + const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0); const cwd = opts.cwd || process.cwd(); const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0); const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`; const lines = []; let shownCount = 0; + // One seen-set across all groups: a rule already described under one file + // is not re-described under the next. + const seenRules = new Set(); for (const group of realGroups) { const display = relativize(group.filePath, cwd); @@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { const remainingCap = Math.max(0, cap - shownCount); const shown = group.findings.slice(0, remainingCap); for (const finding of shown) { - lines.push(formatFindingLine(finding)); + lines.push(formatDedupedFindingLine(finding, seenRules)); } shownCount += shown.length; const hidden = group.findings.length - shown.length; @@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) { } } - const footer = directiveFooter('the affected files', { grouped: true }); + const footer = directiveFooter({ mode: opts.footer }); let text = [header, ...lines, '', footer].join('\n'); if (text.length > maxChars) { text = clampGroupedToBudget(header, lines, footer, maxChars); @@ -1037,76 +1050,131 @@ function renderGroupedTemplate(groups, config, opts = {}) { return text; } +// The clamp contract, shared by both budget functions: the footer is policy, +// not detail, so it survives every clamp. Try the requested footer first; +// when it cannot fit even after dropping finding lines, retry with the short +// policy rather than sacrifice findings that fit beside it. A result that +// dropped every finding line (a grouped render can fit a bare file header) +// does not count as a fit: findings are why the emission exists. +const isFindingLine = (line) => line.startsWith('- '); + +function footerFallbacks(footer) { + const short = directiveFooter({ mode: 'short' }); + return footer === short ? [footer] : [footer, short]; +} + function clampGroupedToBudget(header, lines, footer, maxChars) { - const assemble = (linesArr, omitted) => [ + const assemble = (linesArr, omitted, footerText) => [ header, ...linesArr, ...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []), '', - footer, + footerText, ].join('\n'); - let working = lines.slice(); - let omitted = false; - let assembled = assemble(working, omitted); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - omitted = true; - assembled = assemble(working, omitted); + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let omitted = false; + let assembled = assemble(working, omitted, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + omitted = true; + assembled = assemble(working, omitted, footerText); + } + if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } function clampToBudget(header, lines, more, footer, maxChars) { - const assemble = (linesArr, moreText) => { + const assemble = (linesArr, moreText, footerText) => { const blocks = [header, ...linesArr]; if (moreText) blocks.push(moreText); blocks.push(''); - blocks.push(footer); + blocks.push(footerText); return blocks.join('\n'); }; - let working = lines.slice(); - let moreText = more; - let assembled = assemble(working, moreText); - while (assembled.length > maxChars && working.length > 1) { - working.pop(); - moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; - assembled = assemble(working, moreText); + let lastMore = more; + for (const footerText of footerFallbacks(footer)) { + let working = lines.slice(); + let moreText = more; + let assembled = assemble(working, moreText, footerText); + while (assembled.length > maxChars && working.length > 1) { + working.pop(); + moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`; + assembled = assemble(working, moreText, footerText); + } + lastMore = moreText; + if (assembled.length <= maxChars) return assembled; } - if (assembled.length > maxChars) { - assembled = `${assembled.slice(0, maxChars - 1)}…`; - } - return assembled; + return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText), + lines.find(isFindingLine) || lines[0], maxChars); } -function formatFindingLine(f) { +// Last resort with one finding line left: the short policy gets the budget +// first, the line is clipped to what remains. The pre-fix tail-slice cut +// whatever happened to be last, which was always the footer. +function clampLastLine(build, line, maxChars) { + const footerText = directiveFooter({ mode: 'short' }); + const bare = build([], footerText); + // +1 for the newline the line itself brings when it joins the blocks. + const room = maxChars - bare.length - 1; + if (room >= 24) { + const clipped = line.length > room ? `${line.slice(0, room - 1)}…` : line; + return build([clipped], footerText); + } + // No room for even a clipped finding line: the note reservation can pull + // the budget below the 500-char floor, and a deep file path can push the + // header past what remains beside the short policy (Bugbot on PR #508). + // Drop the line, and if the bare header + policy still overflow, clip the + // head. Never tail-slice: the footer sits at the end, so a tail slice is + // exactly the footer cut this renderer exists to prevent. + if (bare.length <= maxChars) return bare; + const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4)); + return `${head}…\n\n${footerText}`; +} + +// `compact` drops the registry description: within one emission the first +// occurrence of a rule carries the full description and repeats keep only the +// rule id, name, and their own ignore hint (values differ per line, so the +// hint must survive the dedupe). +function formatFindingLine(f, opts = {}) { const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-'; - const desc = (f.description || '').trim(); + const desc = opts.compact ? '' : (f.description || '').trim(); const name = (f.name || '').trim(); // Description from the registry already ends in punctuation; join with a // single space. `name` may have a trailing period already, keep it clean. const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : ''; - const ignoreCommand = formatFindingIgnoreCommand(f); - const ignoreSegment = ignoreCommand - ? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.` - : ''; + const ignoreHint = formatFindingIgnoreHint(f); + const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : ''; return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim(); } -function formatFindingIgnoreCommand(finding) { +// Dedupe applied in shown-line order, so the first rendered occurrence of a +// rule always carries the description. The budget clamps pop lines from the +// end, which can never orphan a compact repeat before its described first +// occurrence. +function formatDedupedFindingLine(finding, seenRules) { + const rule = normalizeIgnoreRule(finding?.antipattern); + const compact = rule ? seenRules.has(rule) : false; + if (rule) seenRules.add(rule); + return formatFindingLine(finding, { compact }); +} + +// The rule/value pair the footer's `hook-admin.mjs ignore-value` command +// takes. Deliberately just the args: the executable prefix, the --reason +// contract, and the disclosure rule live in the directive footer, stated once +// instead of per line. +function formatFindingIgnoreHint(finding) { if (!finding || typeof finding !== 'object') return ''; const rule = normalizeIgnoreRule(finding.antipattern); if (!rule) return ''; const normalizedValue = extractFindingIgnoreValue(finding); if (!normalizedValue) return ''; - const value = extractFindingIgnoreValueRaw(finding); - const valueArg = quoteCommandArg(value); - const reason = quoteCommandArg(`User confirmed ${value} is intentional`); - return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`; + const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding)); + return `ignore-value ${rule} ${valueArg}`; } function quoteCommandArg(value) { @@ -1606,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) { } } +const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + export function appendDesignSystemNote(text, scanOptions) { if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; - return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`; + return `${text}\n\n${DESIGN_STALE_NOTE}`; } +// Session-scoped once-only gate for repeat-prone message parts. Returns true +// the first time a flag is consumed in a session and false after, mirroring +// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not +// change between edits, so re-stating them on every emission spends context +// to say nothing new. Callers must persist the cache for the flag to stick. +function consumeSessionNoticeFlag(cache, sessionId, flag) { + const session = ensureSession(cache, sessionId); + if (session[flag]) return false; + session[flag] = true; + session.updatedAt = Date.now(); + return true; +} + +// Once-per-session variant of appendDesignSystemNote for the emission paths +// that have cache access. The staleness note names standing project state, +// not new information, so one mention per session is enough. The note is +// appended after the renderer has clamped to the configured budget: render +// paths reserve room for it via designNoteReserve, and the size check here +// is the safety net for the ack paths, deferring (without consuming the +// flag) to a later emission rather than busting maxChars. +export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) { + if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text; + const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars); + if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text; + if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text; + return appendDesignSystemNote(text, scanOptions); +} + +// Render-time reservation for the note above: how many characters the +// renderer must hold back so a pending staleness note still fits inside the +// configured budget. Zero once the session has seen the note. Without the +// reservation, a session whose every emission fills the budget would defer +// the note forever. +export function designNoteReserve(scanOptions, cache, sessionId) { + if (!scanOptions?.designSystem?.mdNewerThanJson) return 0; + if (ensureSession(cache, sessionId).designNoteShown) return 0; + return DESIGN_STALE_NOTE.length + 2; +} + +// Full directive footer once per session, the short reminder after. Fresh +// emissions and Cursor denials share the session flag (`footerShown`), so a +// session pays the full policy exactly once however it first fires. The mode +// is a peek: the clamp can downgrade a requested full footer under a tight +// budget, so the flag commits only when the complete full policy actually +// reached the output. Matching the whole footer text (not a sentinel) keeps +// the flag honest against any truncation that spares the opening words. +export function footerModeForSession(cache, sessionId) { + return ensureSession(cache, sessionId).footerShown ? 'short' : 'full'; +} + +export function commitFooterShown(cache, sessionId, text) { + if (!text || !text.includes(directiveFooter())) return; + const session = ensureSession(cache, sessionId); + if (session.footerShown) return; + session.footerShown = true; + session.updatedAt = Date.now(); +} + +const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`; + // The directive footer is the part of the hook output that steers model -// behavior. Three intentional moves: -// 1. **Imperative, not advisory.** "Handle these..." beats "Consider -// revising..." which the model treats as a soft suggestion it can -// override when the user asked for any kind of throwaway / demo UI. -// 2. **Explicit judgment clause.** Without it, the model will try to -// "fix" intentional motion, bad fixtures, anti-pattern examples in -// docs, or test cases. Naming the judgment inline beats hoping the -// model infers it from context. -// 3. **Acknowledgement instruction.** Hook output is injected as -// developer-role context, not a chat turn, so the user never sees the -// raw envelope. Asking the model to surface the resolution in its -// reply is the cheapest way to make the feedback loop visible. -function directiveFooter(display, opts = {}) { - // Offer the rule-scoped-to-file form first. `ignore-file` silences every rule - // for the path forever, which is far more than one noisy rule on a real UI - // surface justifies, and it was previously the only option named here. - const target = opts.grouped ? '' : quoteCommandArg(display); - const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value "*" --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)`; +// behavior. Intentional moves, in order: +// 1. **Imperative, not advisory.** "Triage each finding..." beats +// "Consider revising...", which the model treats as a soft suggestion. +// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The +// suppress branch names the calibration examples (demo, fixture, +// documented bad design, user-confirmed choice) because the agent now +// acts on its own confidence and needs the bar stated. +// 3. **Executable ignore path.** The old footer named only the slash +// command, which an agent reacting to hook output cannot run; the +// hook-admin.mjs invocation is runnable as-is and keeps agents out of +// hand-editing config.json. +// 4. **Honest provenance.** The --reason is the audit trail; "user +// confirmed" appears only when the user actually did. +// 5. **Acknowledgement instruction.** Hook output is injected as +// developer-role context, so the reply is where the user sees the +// resolution, including any ignore the agent persisted. +// 6. **Once per session.** The full policy emits on the session's first +// fire; later emissions carry the one-line short form (mode 'short'). +function directiveFooter(opts = {}) { + if (opts.mode === 'short') { + // No command path here: the session's first emission already gave the + // runnable hook-admin.mjs invocation, and restating ~70 chars of absolute + // path on every repeat is the duplication this mode exists to cut. + 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.'; + } return [ - '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.', - '', - '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.', - '', - `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 \` 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 \` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`, + 'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:', + '- Real design problem: fix it. Keep intentional design as designed.', + `- 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 "" --reason ""\` with the pair shown on the finding line, or value "*" plus \`--file \` when the line shows none. Write "user confirmed" in a reason only when the user did.`, + '- Unsure: leave it as is and ask the user in one line.', + `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.`, ].join('\n'); } @@ -1857,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } - // Persist only when the write is earned: fresh findings justify creating - // `.impeccable/` (dedup and suppression need it), deferred findings do - // too (the Stop deep pass needs the touched-file list to surface them), - // and an already-present `.impeccable/` dir marks a project that opted - // in. A non-UI edit, or a clean UI edit in a project with no Impeccable - // footprint, must be a no-op on disk (issues #344, #305). - if (freshGroups.length > 0 || deferredTotal > 0 - || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { - persistCache(projectCwd, cache); - } - + // The session notice flags mutate the cache, so they must settle before + // the persist that makes them stick across events. if (freshGroups.length > 0) { const firstGroup = freshGroups[0]; - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); + // Fresh findings always earn the cache write, including creating + // `.impeccable/`: dedup, suppression, and the notice flags need it. + persistCache(projectCwd, cache); const allFindings = freshGroups.flatMap((group) => group.findings); return { exitCode: 0, @@ -1893,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } + // Resolve the ack emission before the persist below: appendDesignSystem- + // NoteOnce consumes a session flag, and the flag only sticks when the + // write happens after it. Quiet mode emits nothing, so it consumes + // nothing. The clean arm mirrors the branch order further down: pending + // outranks suppression, suppression outranks clean. + let ack = null; + if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { + ack = { + kind: 'pending', + text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { + ack = { + kind: 'clean', + text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config), + }; + } + + // Persist only when the write is earned: deferred findings need the + // touched-file list for the Stop deep pass, and an already-present + // `.impeccable/` dir marks a project that opted in. A non-UI edit, or a + // clean UI edit in a project with no Impeccable footprint, must be a + // no-op on disk (issues #344, #305). + if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) { + persistCache(projectCwd, cache); + } + if (detectorThrewAny && !pendingWinner && !cleanWinner) { return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started }); } @@ -1901,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = return result({ emitted: false, quiet: true, durationMs: Date.now() - started }); } - if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) { - const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'pending') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -1935,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = }; } - if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) { - const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions); + if (ack?.kind === 'clean') { + const text = ack.text; return { exitCode: 0, stdout: payload(text, 'PostToolUse', harness), @@ -2120,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write so the next Stop fire is silent - // unless new issues appear. - persistCache(projectCwd, cache); + // A per-edit fire earlier in this session already consumed the footer + // flag, so the Stop wall of text carries the one-line short footer. + const footerMode = footerModeForSession(cache, sessionId); + const text = appendDesignSystemNoteOnce( + renderGroupedTemplate(freshGroups, config, { + cwd: projectCwd, + footer: footerMode, + reserveChars: designNoteReserve(scanOptions, cache, sessionId), + }), + scanOptions, cache, sessionId, config, + ); + commitFooterShown(cache, sessionId, text); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Fresh findings earn the cache write so the next Stop fire is silent + // unless new issues appear; the notice flags ride along. + persistCache(projectCwd, cache); return { exitCode: 0, stdout: payload(text, 'Stop', harness),