Denoise the design hook and let agents self-serve confident ignores (#508)

* Denoise the design hook and let agents self-serve confident ignores (#497)

The directive footer now emits in full once per session (a one-line
reminder after), the DESIGN.md staleness note is mentioned once per
session, rule descriptions dedupe within an emission, and the per-line
ignore suggestion shrinks to the bare rule/value pair. The footer and
hooks.md replace the confirmation-gated ignore policy with a three-way
triage: fix real problems, self-serve the narrowest ignore for confident
false positives or sanctioned exceptions and disclose it (with an honest
--reason), ask when unsure. Self-serve stops at ignore-value, and the
footer now gives a runnable hook-admin.mjs command instead of a slash
command agents cannot execute.

Measured on a seeded lab session replaying 11 hook events: 33,658 to
14,063 chars of agent-visible output (-58%).

AI-assisted (Cursor agent), directed and reviewed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Preserve the policy footer and honor maxChars under constrained budgets

Greptile's runtime check found two pre-existing clamp gaps that matter
more now that the full policy emits once per session: the last-resort
tail slice cut the footer off an over-budget emission, and the DESIGN.md
staleness note was appended after clamping, pushing past maxChars.

The clamp now gives the footer the budget first, clipping the finding
line and downgrading full to short policy when needed. The staleness
note defers, without consuming its session flag, to a later emission
with room.

AI-assisted (Cursor agent), directed and reviewed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden the constrained-budget clamp: keep findings, honest flags, guaranteed note

Review follow-ups from Bugbot and Greptile on the clamp fix:

- The clamp retries with the short policy before dropping finding lines
  that fit beside it, and a grouped result that kept only a file header
  no longer counts as a fit.
- The full-footer session flag commits only when the full policy
  actually survived the clamp, so a downgraded emission does not mark
  the session as having seen a policy it never received.
- Render paths reserve room for a pending DESIGN.md staleness note, so
  it is delivered inside the budget on the first emission instead of
  deferring behind full emissions indefinitely.

AI-assisted (Cursor agent), directed and reviewed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route the Cursor deny cap through the clamp and match the whole footer

Bugbot follow-up: cursorBlockMessage tail-sliced at 4000 chars after
render, which the default 8000-char budget made reachable, and a cut
that spared the footer's opening words still committed the session
flag. The 4000 cap now feeds through the renderer's footer-preserving
clamp, and commitFooterShown matches the complete footer text instead
of a sentinel.

AI-assisted (Cursor agent), directed and reviewed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reconcile hook.test.mjs expectations with main's per-platform quoting

Three tests fell out of sync when main's quoteCommandArg change (#533,
building on #476) met this branch's footer/hint rework. Test-only
changes; production logic untouched:

- The full-footer test now accepts either close quote after the
  hook-admin.mjs path, since quoteCommandArg single-quotes absolute
  paths on POSIX and double-quotes them on Windows. The short-footer
  guard rejects `node '` and `node "` alike.
- The #476 hostile-value test asserts the new bare
  `ignore-value <rule> '<value>'` hint format. The security property is
  unchanged: the value still passes through quoteCommandArg, so
  $(touch pwned) stays single-quoted and inert.
- The #533 test previously asserted a concrete quoted `--file` path in
  the footer; directiveFooter() now carries only literal placeholders,
  so that surface is gone. The per-platform assertion moves to the
  per-finding ignore hint, the remaining user-visible surface where
  scanned file content flows through quoteCommandArg.

Prepared with AI assistance (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix two Bugbot findings: Cursor prefix budget and footer-cutting tail slice

Both flagged by Cursor Bugbot on PR #508 after the main merge; both real.

1. cursorBlockMessage computed min(maxChars, 4000 - prefix), so a
   configured maxChars at or below the Cursor ceiling never charged the
   BLOCK_PREFIX against the budget: the final deny text could exceed
   maxChars by the prefix length, and appendDesignSystemNoteOnce's size
   check lost exactly the room designNoteReserve had held back. The
   prefix now comes off whichever limit binds. Default-config behavior
   is unchanged (min(8000, 4000) - 60 equals the old 4000 - 60).

2. The note reservation is subtracted after renderTemplate's 500-char
   floor, so the clamp can run below the budget clampLastLine assumed
   safe, and its last-resort path tail-sliced the rendered text, cutting
   the policy footer (the failure mode this PR exists to eliminate) when
   a deep file path met a pending DESIGN.md note. The reservation order
   stays (the staleness-note delivery guarantee at floor budgets depends
   on it); the last resort now drops the finding line and clips the head
   instead, so the footer survives every path. New regression test pins
   it: 6 findings, 100-char path, maxChars 500, reserveChars 134.

Prepared with AI assistance (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>

* Charge the Cursor deny prefix after the renderer's floor, not before

Greptile's runtime check caught the residual from ead0346d: subtracting
BLOCK_PREFIX from the maxChars passed to renderTemplate does nothing at
floor-tier configs, because the renderer re-raises any budget below its
500-char floor. At maxChars 500 with a stale design sidecar, the
prefixed denial landed at 432 chars and appendDesignSystemNoteOnce
could not fit the staleness note inside 500, deferring it (flag
unconsumed) for every equivalent denial in the session.

The prefix now rides in reserveChars, which comes off after the floor,
so it is charged at every config tier and the final prefixed message
plus a pending note closes exactly at the binding limit (499 chars in
the regression scenario). Default-config output is byte-identical:
max(500, min(8000, 4000)) - prefix equals the old min(8000, 4000) -
prefix. New end-to-end Cursor preToolUse test pins the path with a real
stale sidecar at maxChars 500.

AI-assisted (Cursor agent), directed and reviewed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Abdul Wahab
2026-08-11 13:42:39 -04:00
committed by GitHub
co-authored by Cursor Paul Bakaus Claude
parent 251135e190
commit dc4e4a4bd6
4 changed files with 642 additions and 141 deletions
+265 -87
View File
@@ -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 ? '<path>' : quoteCommandArg(display);
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
// 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 <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
'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 <rule> "<value>" --reason "<who decided: evidence>"\` with the pair shown on the finding line, or value "*" plus \`--file <path>\` when the line shows none. Write "user confirmed" in a reason only when the user did.`,
'- 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),