From 313b641361eb747c5e42573229270f93fe7960b2 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 14 Jul 2026 08:58:19 -0700 Subject: [PATCH] hooks: direction-contract audit in the Stop deep pass The skill's decide-then-build step opens the built HTML artifact with a DIRECTION CONTRACT comment (UNIQUE / NOT-TEMPLATE / OWN-WORLD / STORY / FIRST VIEWPORT / FORM). Until now nothing ever judged the finished build against that contract; the eval harness proved sample contracts promised radical compositions while the build shipped the standard template anyway. The Stop deep pass now extracts the leading contract comment from each session-touched HTML file (marker match in the first 200 chars, body capped at 1800 chars) and appends a contract-audit section after the detector findings: audit the render promise by promise, naming the two observed failure shapes (a promise not in the pixels; a contract whose own plan is the standard template wearing the concept's nouns). Zero extra API calls; the audit rides the existing single Stop emission and fires at most once per file per session via a contractAudited flag on the same session cache entry the finding dedupe uses. Ported from the eval harness reference implementation (extractDirectionContract / composeContractAuditMessage in impeccable-evals runner/workers/anthropic-native.ts). No hooks.json changes needed: Claude Code and Codex both already dispatch Stop to hook.mjs. Tests: 163 -> 179 in tests/hook.test.mjs (extraction unit coverage plus Stop-pass integration: present/absent/once-per-session/non-HTML/ malformed/oversized). hook-build 18/18, build:skills prose gate clean. Co-Authored-By: Claude Fable 5 --- skill/scripts/hook-lib.mjs | 120 ++++++++++++++++++- skill/scripts/hook.mjs | 4 +- tests/hook.test.mjs | 229 +++++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 7 deletions(-) diff --git a/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index 8895754aa..e61ea692b 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -26,6 +26,7 @@ * loadDetector() -> Promise<{ detectText, detectHtml }> * matchesAnyGlob(filePath, globs) * normalizeScanTargets(primaryTargets, projectCwd) + * extractDirectionContract(content) / renderContractAudit(entries, opts) * runHook(deps) -> { exitCode, stdout, audit, reason? } * runStopHook(deps) -> { exitCode, stdout, audit, emission? } * @@ -1842,6 +1843,76 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = } } +// ── Direction-contract audit ───────────────────────────────────────────── +// The skill's decide-then-build step opens the built HTML artifact with a +// DIRECTION CONTRACT comment (UNIQUE / NOT-TEMPLATE / OWN-WORLD / STORY / +// FIRST VIEWPORT / FORM blocks). At Stop time the deep pass extracts that +// comment and feeds it back so the model audits the render against its own +// promises. Proven in the eval harness: sample contracts promised radical +// compositions and the build shipped the standard template anyway, because +// nothing ever judged the build against the contract. Zero extra API calls: +// this is a message, not a judge. It fires at most once per file per session +// (a `contractAudited` flag on the session cache entry, the same state the +// deep pass uses for finding dedupe) and rides the Stop pass's existing +// emission rather than adding another block round. + +// How far into the file to look for the leading comment. A contract lives at +// the very top of the artifact; anything deeper is not the contract. +export const CONTRACT_HEAD_CHARS = 6000; +// The contract/concept marker must appear early in the comment body, so a +// license header or unrelated note does not get mistaken for a contract. +export const CONTRACT_MARKER_CHARS = 200; +// Cap the extracted contract so a rambling comment cannot blow up the +// Stop message. +export const CONTRACT_MAX_CHARS = 1800; +// Cap contract sections per Stop emission so many touched artifacts cannot +// stack an unbounded message. +export const CONTRACT_AUDIT_MAX_FILES = 3; + +/** + * Extract the artifact's own direction-contract comment: the first HTML + * comment in the head of the file, when its opening chars identify it as a + * contract/concept block. Returns the trimmed, length-capped body, or null + * when the file carries none (no comment, unclosed comment, marker missing, + * or the comment starts past the head window). + */ +export function extractDirectionContract(content) { + if (typeof content !== 'string' || !content) return null; + const head = content.slice(0, CONTRACT_HEAD_CHARS); + const m = //.exec(head); + if (!m) return null; + const body = m[1].trim(); + if (!body || !/contract|concept/i.test(body.slice(0, CONTRACT_MARKER_CHARS))) return null; + return body.slice(0, CONTRACT_MAX_CHARS); +} + +/** + * Render the contract-audit section of the Stop message. `entries` is + * [{ filePath, contract }]; at most CONTRACT_AUDIT_MAX_FILES are shown. + * The two failure shapes named here are the ones observed in practice: + * a promise the pixels do not deliver, and a contract whose own plan is + * the standard template wearing the concept's nouns. + */ +export function renderContractAudit(entries, opts = {}) { + if (!Array.isArray(entries) || entries.length === 0) return ''; + const cwd = opts.cwd || process.cwd(); + const shown = entries.slice(0, CONTRACT_AUDIT_MAX_FILES); + const blocks = shown.map(({ filePath, contract }) => { + const display = relativize(filePath, cwd); + return `${display} opens with this direction contract, written when the direction was decided:\n\n${contract}`; + }); + return [ + `${ENVELOPE_PREFIX} Direction-contract audit. Before finishing, audit the rendered page against the contract it opens with, promise by promise.`, + '', + blocks.join('\n\n'), + '', + 'Two failure shapes to check honestly:', + '1. A promise that is not in the pixels: the contract describes a composition or structure the built page does not deliver, because the build fell back to a standard arrangement, possibly one the contract explicitly rejects. Rebuild that part until the render matches the promise.', + "2. A contract whose own section plan is the standard template wearing the concept's nouns: if a neighboring product could ship the same sequence of sections under different labels, revise the plan and the page together.", + 'State each promise and whether the render delivers it, and fix every gap before finishing.', + ].join('\n'); +} + // Cap on files the Stop deep pass will scan. The touched-file list is // session-scoped and already capped per edit, but a very long session could // accumulate more than the 30s hook timeout comfortably covers. @@ -1850,7 +1921,10 @@ export const STOP_MAX_FILES = 20; /** * Run the Stop-event deep pass: the FULL detector rule set over every UI * file touched this session, surfaced once, deduped against everything the - * per-edit hook already reported. Same result contract as runHook(): + * per-edit hook already reported. Touched HTML artifacts that open with a + * direction-contract comment additionally get a one-time contract-audit + * section appended after the detector findings (see extractDirectionContract + * above). Same result contract as runHook(): * { exitCode, stdout, audit, emission? } * * Never throws; exits silent (and fast) when the session touched no UI @@ -1917,6 +1991,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const scanOptions = designSystemOptions(config, det, projectCwd); const freshGroups = []; + const contractEntries = []; let scanned = 0; for (const filePath of touched) { if (scanned >= STOP_MAX_FILES) break; @@ -1937,6 +2012,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const useHtmlEngine = configuredExt ? configuredExt.engine === 'html' : (ext === '.html' || ext === '.htm'); + + // Direction-contract audit: HTML artifacts only, at most once per file + // per session. The flag lives on the same session cache entry the + // finding dedupe uses, so a second Stop fire stays quiet about it. + if (useHtmlEngine) { + const fileEntry = ensureFile(cache, sessionId, filePath); + if (!fileEntry.contractAudited) { + const contract = extractDirectionContract(content); + if (contract) { + fileEntry.contractAudited = true; + ensureSession(cache, sessionId).updatedAt = Date.now(); + contractEntries.push({ filePath, contract }); + } + } + } + if (useHtmlEngine && typeof det.detectHtml === 'function') { try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; } } else { @@ -1955,24 +2046,41 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no } audit.scannedFiles = scanned; - if (freshGroups.length === 0) { + if (freshGroups.length === 0 && contractEntries.length === 0) { return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started }); } - // Fresh findings earn the cache write; they also mark this batch as - // surfaced so the next Stop fire is silent unless new issues appear. + // Fresh findings and first-time contract audits earn the cache write; + // both mark this batch as surfaced so the next Stop fire is silent + // unless new issues appear. persistCache(projectCwd, cache); - const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions); + // Detector findings first, then the contract audit. Both ride the same + // single Stop emission: the audit never adds an extra block round. + const parts = []; + if (freshGroups.length > 0) { + parts.push(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd })); + } + if (contractEntries.length > 0) { + parts.push(renderContractAudit(contractEntries, { cwd: projectCwd })); + } + const text = appendDesignSystemNote(parts.join('\n\n'), scanOptions); return { exitCode: 0, stdout: payload(text, 'Stop', harness), - emission: { kind: 'stop-deep-pass', groups: freshGroups }, + emission: { + kind: 'stop-deep-pass', + groups: freshGroups, + ...(contractEntries.length > 0 + ? { contractFiles: contractEntries.map((entry) => entry.filePath) } + : {}), + }, audit: { ...audit, emitted: true, freshFiles: freshGroups.length, freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0), + ...(contractEntries.length > 0 ? { contractAudits: contractEntries.length } : {}), chars: text.length, durationMs: Date.now() - started, }, diff --git a/skill/scripts/hook.mjs b/skill/scripts/hook.mjs index 5813ea4f2..b251d727c 100644 --- a/skill/scripts/hook.mjs +++ b/skill/scripts/hook.mjs @@ -10,7 +10,9 @@ * `hookSpecificOutput.additionalContext` when findings exist. * - Stop: runs the FULL detector rule set over every UI file touched this * session (the deep pass), deduped against what the per-edit pass already - * surfaced, and emits once via the Stop additionalContext channel. + * surfaced, and emits once via the Stop additionalContext channel. Touched + * HTML artifacts opening with a direction-contract comment get a one-time + * contract-audit section appended to the same emission. * * Contract: never break a turn. Always exit 0. Clean files emit a small ack * unless quiet mode is enabled; a clean Stop pass is silent. diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 11540fba9..3bca3e536 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -53,6 +53,11 @@ import { IMMEDIATE_TIER_RULES, splitFindingsByTier, perEditTieringActive, + extractDirectionContract, + renderContractAudit, + CONTRACT_MAX_CHARS, + CONTRACT_HEAD_CHARS, + CONTRACT_AUDIT_MAX_FILES, payload, extractFindingIgnoreValue, resolveProjectPlatform, @@ -2773,3 +2778,227 @@ describe('runStopHook()', () => { assert.equal(reentrant.stdout, ''); }); }); + +describe('extractDirectionContract()', () => { + const contractComment = [ + '', + ].join('\n'); + + it('extracts the leading contract comment body', () => { + const html = `${contractComment}\nhi`; + const body = extractDirectionContract(html); + assert.ok(body); + assert.match(body, /^DIRECTION CONTRACT/); + assert.match(body, /boarding pass/); + assert.doesNotMatch(body, //); + }); + + it('finds the contract even after a doctype line', () => { + const html = `\n${contractComment}\n`; + assert.match(extractDirectionContract(html) || '', /boarding pass/); + }); + + it('returns null when there is no comment at all', () => { + assert.equal(extractDirectionContract(''), null); + assert.equal(extractDirectionContract(''), null); + assert.equal(extractDirectionContract(null), null); + }); + + it('returns null when the first comment is not a contract', () => { + const html = '\n'; + assert.equal(extractDirectionContract(html), null); + }); + + it('requires the marker word inside the first 200 chars of the comment', () => { + const padding = 'x'.repeat(250); + const html = `\n`; + assert.equal(extractDirectionContract(html), null); + }); + + it('caps the extracted body at CONTRACT_MAX_CHARS', () => { + const longBody = `DIRECTION CONTRACT\n${'promise '.repeat(600)}END-MARKER`; + const html = `\n`; + const body = extractDirectionContract(html); + assert.ok(body); + assert.equal(body.length, CONTRACT_MAX_CHARS); + assert.doesNotMatch(body, /END-MARKER/); + }); + + it('returns null for an unclosed (malformed) comment', () => { + const html = '', + '

Gate 12

', + ].join('\n'); + + function write(rel, body) { + const abs = path.join(cwd, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + return abs; + } + + function editEvent(file, sessionId) { + return { + session_id: sessionId, + cwd, + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: file }, + }; + } + + function stopEvent(sessionId) { + return { + session_id: sessionId, + cwd, + hook_event_name: 'Stop', + stop_hook_active: false, + }; + } + + // Seed the session's touched-file list. `.impeccable/` must exist so a + // clean per-edit pass still persists the cache (see runHook write gating). + async function touch(file, sid, det) { + fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true }); + await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det }); + } + + it('appends the contract audit after the detector findings', async () => { + const sid = 'contract-with-findings'; + const file = write('index.html', CONTRACT_HTML); + const det = fakeDetector([finding('em-dash-overuse', 3)]); + await touch(file, sid, det); + + const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); + assert.equal(stop.audit.emitted, true); + assert.equal(stop.audit.contractAudits, 1); + const text = JSON.parse(stop.stdout).hookSpecificOutput.additionalContext; + assert.match(text, /em-dash-overuse/); + assert.match(text, /Direction-contract audit/); + assert.match(text, /boarding pass/); + assert.match(text, /not in the pixels/); + assert.ok( + text.indexOf('em-dash-overuse') < text.indexOf('Direction-contract audit'), + 'detector findings come before the contract audit', + ); + assert.deepEqual(stop.emission.contractFiles, [file]); + // Included exactly once. + assert.equal(text.match(/Direction-contract audit/g).length, 1); + }); + + it('emits the audit even when the detector deep pass is clean', async () => { + const sid = 'contract-clean-detector'; + const file = write('index.html', CONTRACT_HTML); + const det = fakeDetector([]); + await touch(file, sid, det); + + const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); + assert.equal(stop.audit.emitted, true); + const out = JSON.parse(stop.stdout); + assert.equal(out.hookSpecificOutput.hookEventName, 'Stop'); + assert.match(out.hookSpecificOutput.additionalContext, /Direction-contract audit/); + assert.doesNotMatch(out.hookSpecificOutput.additionalContext, /findings requiring review/); + }); + + it('adds no audit section when the HTML has no contract comment', async () => { + const sid = 'no-contract'; + const file = write('index.html', 'plain'); + const det = fakeDetector([finding('em-dash-overuse', 3)]); + await touch(file, sid, det); + + const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); + const text = JSON.parse(stop.stdout).hookSpecificOutput.additionalContext; + assert.match(text, /em-dash-overuse/); + assert.doesNotMatch(text, /Direction-contract audit/); + assert.equal(stop.audit.contractAudits, undefined); + assert.equal(stop.emission.contractFiles, undefined); + }); + + it('a second Stop never repeats the audit, even with fresh detector findings', async () => { + const sid = 'contract-once'; + const file = write('index.html', CONTRACT_HTML); + await touch(file, sid, fakeDetector([])); + + const first = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: fakeDetector([]) }); + assert.match(first.stdout, /Direction-contract audit/); + + // No new findings: fully silent. + const second = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: fakeDetector([]) }); + assert.equal(second.stdout, ''); + assert.equal(second.audit.skipped, 'stop-clean'); + + // New findings ride a fresh emission, but the audit does not come back. + const third = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: fakeDetector([finding('side-tab', 7)]) }); + const text = JSON.parse(third.stdout).hookSpecificOutput.additionalContext; + assert.match(text, /side-tab/); + assert.doesNotMatch(text, /Direction-contract audit/); + }); + + it('ignores contract-looking comments in non-HTML touched files', async () => { + const sid = 'contract-non-html'; + const file = write('src/Card.tsx', '{/* stub */}\n// \nexport default 1;\n'); + const det = fakeDetector([finding('em-dash-overuse', 3)]); + await touch(file, sid, det); + + const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det }); + const text = JSON.parse(stop.stdout).hookSpecificOutput.additionalContext; + assert.match(text, /em-dash-overuse/); + assert.doesNotMatch(text, /Direction-contract audit/); + }); + + it('stays silent for a malformed (unclosed) contract comment on a clean pass', async () => { + const sid = 'contract-malformed'; + const file = write('index.html', '\n`; + const file = write('index.html', huge); + await touch(file, sid, fakeDetector([])); + + const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: fakeDetector([]) }); + const text = JSON.parse(stop.stdout).hookSpecificOutput.additionalContext; + assert.match(text, /Direction-contract audit/); + assert.doesNotMatch(text, /TAIL-MARKER/); + }); +});