mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 15:46:30 +03:00
Restore dice assignment, fusion, and the commitment counterweights
The ship40 concept pipeline had reversed the proven a-series mechanisms: the seed's roll decayed into a shortlist nomination that taste functions (model ranking, candidate floor, simulated user) then argmaxed into the safest card; the costume check returned as the Translation veto and carrier-removal test; and the 07-15 rewrite deleted the calibration, reflex-font lanes, color strategies, and commit-every-atom language that had held off the cream-editorial default since the alpha era. Five of six frozen craft directions converged on the same warm-paper family and both builders obeyed them. This lands the repair on top of the in-progress simplification: - new-work.md: the script assigns the build index again on both scopes; catalog challengers are fused (challenger supplies form and grammar, product supplies every fact, clarity wins conflicts) and weighed on the two proven axes only; attended runs present one fully committed direction with re-roll and an optional steer instead of a ranked lineup; the color-strategy picker, reflex-face list, saturated-look calibration, first-viewport thesis and memory test, commit-every-atom, scroll pacing, and prove-don't-claim return; the direction contract returns as five lean blocks audited by the separate-agent finish. - concept-seed.mjs: PROMOTED INDEX becomes ASSIGNED INDEX with build-assignment semantics; self re-roll only on named factual grounds. - craft-floor.md: hook-active sessions act on findings instead of re-auditing; the Refuse list is framed as category defaults the brief can earn; a closing commitment line keeps a ban list from being the last word before code. - codex.md / shape.md: contract references restored for flow coherence. Adopts the concurrent session's ceremony cuts, softened challenger instruction, seed SOURCE IDs and --candidate-count, detector-ownership fix, and the removal of the hook-side contract audit (the audit now belongs to the separate reviewer at finish). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a9540b5fe3
commit
36e3c05ca7
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* External concept seed: the dice half of new-work's coupled-direction and
|
||||
* External concept seed: the dice half of new-work's complete-direction and
|
||||
* established-world surface procedures.
|
||||
*
|
||||
* The model derives a grounded shortlist of candidate FORMS from the
|
||||
* audience's world and the subject's cultural home (see
|
||||
* Before this script runs, the model retrieves cultural material and derives
|
||||
* a grounded shortlist of complete candidate directions from it (see
|
||||
* reference/new-work.md). Left alone, it then always builds its #1 —
|
||||
* and a single model's resonance ranking is deterministic, so every run
|
||||
* in a category ships the same one or two concepts. Measured: 30/35
|
||||
@@ -13,19 +13,23 @@
|
||||
*
|
||||
* This script rolls them from outside, the same trick that made the
|
||||
* palette seed work:
|
||||
* - PROMOTED INDEX: which entry of the model's own resonance-ordered
|
||||
* shortlist must be taken seriously beside its favorites. The dice never
|
||||
* choose an ungrounded ingredient; they only refuse the argmax rut.
|
||||
* - ASSIGNED INDEX: which entry of the model's own resonance-ordered
|
||||
* shortlist gets built. The assignment is the dice: it never chooses an
|
||||
* ungrounded ingredient, it only refuses the argmax rut. Attended runs
|
||||
* present the assigned direction and offer re-roll instead of a ranked
|
||||
* lineup, because a lineup hands selection back to a taste function
|
||||
* (model or user) and taste functions pick the safest card.
|
||||
* - CHALLENGERS (6): outside forms from concept-ingredients.json, two from
|
||||
* each challenger tier (graphic system, instrument language, atmosphere
|
||||
* world), weighed
|
||||
* against the derived candidates on audience identification, product
|
||||
* clarity, system leverage, and use of the medium. They win only when they beat the
|
||||
* grounded list; measured behavior is that they lose to strong cultural
|
||||
* material and win over thin categories, which is the intended shape.
|
||||
* world), fused with the product first (challenger supplies form and
|
||||
* system grammar, product supplies every fact, clarity wins conflicts),
|
||||
* then weighed against the derived candidates on audience identification
|
||||
* and product clarity. They win only when they beat the grounded list;
|
||||
* measured behavior is that they lose to strong cultural material and
|
||||
* win over thin categories, which is the intended shape.
|
||||
* - RE-ROLL (--reroll <n>): round n of the same base key. The script
|
||||
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
|
||||
* fresh promoted index, challengers, and staging. One base key therefore
|
||||
* fresh assigned index, challengers, and staging. One base key therefore
|
||||
* reproduces the entire chain of rounds.
|
||||
* - RATINGS: the reviewer's approval ratings weight the challenger draw
|
||||
* (3-star doubles the odds, 1-star sits out); the approved pool itself
|
||||
@@ -34,6 +38,7 @@
|
||||
* Usage:
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade
|
||||
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
|
||||
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
|
||||
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
|
||||
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
|
||||
*
|
||||
@@ -43,7 +48,7 @@
|
||||
*
|
||||
* Challenger data resolves in order: a local catalog directory (the private
|
||||
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
|
||||
* API at impeccable.style, then a degraded promotion-only seed when both are
|
||||
* API at impeccable.style, then a degraded assignment-only seed when both are
|
||||
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
|
||||
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
|
||||
*
|
||||
@@ -70,7 +75,7 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Data resolution order: a local catalog (the private service repo, evals, and
|
||||
// tests point IMPECCABLE_CATALOG_DIR at one), then the roll API, then a
|
||||
// degraded promotion-only seed. The full catalog does not ship with the skill.
|
||||
// degraded assignment-only seed. The full catalog does not ship with the skill.
|
||||
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
|
||||
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
|
||||
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
|
||||
@@ -157,6 +162,7 @@ export async function pingChosen({ chosenId, key, scope, mode }) {
|
||||
export function renderChallenger(concept, index) {
|
||||
const system = concept.system.map(rule => ` - ${rule}`).join('\n');
|
||||
return ` ${index + 1}. ${concept.form}
|
||||
SOURCE ID: ${concept.id}
|
||||
CREATIVE SPARK: ${concept.spark}
|
||||
SYSTEM GRAMMAR:
|
||||
${system}
|
||||
@@ -166,6 +172,7 @@ ${system}
|
||||
export function renderStaging(composition, index = null) {
|
||||
const grammar = composition.grammar.map(rule => ` - ${rule}`).join('\n');
|
||||
return ` ${index == null ? '' : `${index + 1}. `}${composition.form}
|
||||
SOURCE ID: ${composition.id}
|
||||
SPARK: ${composition.spark}
|
||||
STAGING GRAMMAR:
|
||||
${grammar}
|
||||
@@ -309,6 +316,7 @@ export function renderConceptSeed({
|
||||
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
|
||||
reroll = 0,
|
||||
mode = null,
|
||||
candidateCount = 7,
|
||||
catalogDir = CATALOG_DIR,
|
||||
_resolvedData = undefined,
|
||||
} = {}) {
|
||||
@@ -321,15 +329,18 @@ export function renderConceptSeed({
|
||||
if (mode !== null && !SEED_MODES.has(mode)) {
|
||||
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
|
||||
}
|
||||
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
|
||||
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
|
||||
}
|
||||
const unit = (salt) => {
|
||||
const h = crypto.createHash('sha256').update(`${scope}:${salt}:${key}`).digest();
|
||||
return h.readUInt32BE(0) / 0xffffffff;
|
||||
};
|
||||
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
|
||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * 5); // 3..7
|
||||
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
|
||||
|
||||
// Local catalog first (private repo, evals, tests), then the roll API,
|
||||
// then a degraded promotion-only seed. The promoted index is pure local
|
||||
// then a degraded assignment-only seed. The assigned index is pure local
|
||||
// math, so even a fully offline run keeps the anti-argmax mechanism.
|
||||
let data = _resolvedData ?? null;
|
||||
if (_resolvedData === undefined) {
|
||||
@@ -357,6 +368,7 @@ export function renderConceptSeed({
|
||||
key,
|
||||
reroll,
|
||||
mode,
|
||||
candidateCount,
|
||||
catalogDir,
|
||||
_resolvedData: roll ? {
|
||||
source: 'api',
|
||||
@@ -371,68 +383,62 @@ export function renderConceptSeed({
|
||||
}
|
||||
|
||||
const promotedInstruction = scope === 'direction'
|
||||
? `After ordering the grounded coupled directions by product fit, promote
|
||||
candidate ${buildIndex} into the serious shortlist. Each candidate must join
|
||||
a durable visual system to a concrete expression for the requested first
|
||||
surface; select or revise that pair as one decision. It must survive the
|
||||
current task plus navigation, quiet and dense content, interaction and state,
|
||||
and a substantially different future surface.`
|
||||
? `After ordering the grounded directions by resonance, build candidate
|
||||
${buildIndex}. The assignment is the roll, not a suggestion: your top-ranked
|
||||
direction is what every run would ship, so the script decides which grounded
|
||||
direction gets built. Each direction joins a durable visual system to a
|
||||
concrete expression for the requested first surface, decided as one. It must
|
||||
survive the current task plus navigation, quiet and dense content,
|
||||
interaction and state, and a substantially different future surface. In an
|
||||
attended run, present the assigned direction fully committed and offer
|
||||
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
|
||||
on named factual grounds, when the assignment cannot carry the product's
|
||||
truth or task; taste is never grounds.`
|
||||
: `After ordering the task's grounded structural candidates by resonance,
|
||||
promote candidate ${buildIndex} into the serious shortlist. In an attended
|
||||
run, present it beside the strongest materially different candidates and
|
||||
let the user select or revise the surface concept. In a truly unattended
|
||||
run, use it when it survives audience identification, product clarity,
|
||||
system leverage, and use of the medium.`;
|
||||
build candidate ${buildIndex}. The assignment is the roll, not a suggestion.
|
||||
In an attended run, present the assigned structure and offer re-roll; never
|
||||
present a ranked lineup to choose from. Re-roll yourself only when the
|
||||
assignment fails audience identification or product clarity on named
|
||||
factual grounds.`;
|
||||
|
||||
const challengerInstruction = scope === 'direction'
|
||||
? `Translate each challenger's organizing logic into reusable identity grammar
|
||||
and a strong first-surface structure before judging it. Noticeable form is
|
||||
allowed when the product stays clear. Compare audience identification,
|
||||
product clarity, system leverage, and use of the medium.`
|
||||
: `A challenger wins only when it beats the grounded list on audience
|
||||
identification, product clarity, system leverage, and use of the medium. It may change task topology or
|
||||
? `Fuse each challenger before judging it: the challenger supplies the form
|
||||
and its system grammar, the product supplies every fact, and clarity wins
|
||||
conflicts. Weigh the fused result against the assigned direction on exactly
|
||||
two axes, audience identification and product clarity. Losing to strong
|
||||
grounded material is a valid outcome; beating a thin or tool-monoculture
|
||||
list is the point. A fused challenger that wins both axes becomes the build.`
|
||||
: `A challenger wins only when its fused result beats the grounded list on
|
||||
audience identification and product clarity. It may change task topology or
|
||||
interaction, but never the committed visual identity.`;
|
||||
|
||||
const authorityInstruction = scope === 'direction'
|
||||
? `PRODUCT.md and explicit incumbent brand commitments constrain every coupled
|
||||
direction. The seed never chooses exact colors, fonts, tokens, or a user
|
||||
preference, and it never permits the world and first surface to be selected
|
||||
independently.`
|
||||
? `PRODUCT.md and explicit incumbent brand commitments constrain every direction.
|
||||
The seed never chooses exact colors, fonts, tokens, or a user preference, and
|
||||
it never permits the world and first surface to be selected independently.`
|
||||
: `PRODUCT.md and DESIGN.md constrain every surface candidate's identity
|
||||
vocabulary; they do not cancel task-level composition. The seed never
|
||||
authorizes a new palette, type system, material world, or unfamiliar control
|
||||
behavior.`;
|
||||
|
||||
const richnessInstruction = `The CREATIVE SPARK is a visual world, artifact, or graphic tradition people
|
||||
would genuinely choose to enter, study, or explore, and whose palette,
|
||||
materials, type voice, and component grammar a designer could sketch on
|
||||
sight, not decorative art direction. The challengers are drawn two from each
|
||||
translation tier: graphic systems that map to interface almost directly,
|
||||
instrument or display languages that carry interaction physics, and material
|
||||
or performed worlds that need the largest translation step; judge
|
||||
each in its own register and pay that translation cost honestly. Translate
|
||||
its scale, material, spatial or compositional law, tension, rhythm, and
|
||||
memorable human experience into product structure. Inherit a movement's or
|
||||
artifact's rules, never just its name: grid, geometry, ornament logic,
|
||||
material behavior, and information structure become the interface's. Preserve
|
||||
the spark's imaginative distance: do not collapse a galaxy into a mission
|
||||
dashboard, a forest into a taxonomy app, or a performance into a control
|
||||
console. Translation is complete only when the source name and physical prop
|
||||
can disappear while a product-native relationship, state change, or proof
|
||||
remains. A carrier survives only when product evidence makes it functional;
|
||||
never name a candidate after the prompt merely to preserve the spark. Use
|
||||
Three.js, generative motion, film language, typography, craft,
|
||||
or another ambitious medium when it materially strengthens the task; keep
|
||||
semantic structure and graceful fallbacks fully capable.`;
|
||||
const richnessInstruction = `The CREATIVE SPARK is a complete visual system, not a theme or decorative
|
||||
reference. Translate every supplied system rule into the product: palette and
|
||||
material, type and composition, topology, controls and states, and adaptation.
|
||||
Keep the source's visible character, scale, rhythm, and interaction instead of
|
||||
reducing vivid grammar to generic nouns. When the source is already a credible
|
||||
interface language, commit to it across navigation, content, controls, and
|
||||
states. Otherwise keep a literal carrier only when it becomes functional.
|
||||
Ambitious motion, spatial media, or interaction is welcome when it strengthens
|
||||
the product without weakening semantics, performance, or fallback behavior.`;
|
||||
|
||||
if (!data) {
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''})
|
||||
PROMOTED INDEX: ${buildIndex}
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
|
||||
ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The promotion exists to refuse the model's ranking rut, not to outrank the
|
||||
user or the brief. Never expose promotion metadata in choice labels or order.
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
No challengers this run: the roll service was unreachable and no local
|
||||
catalog exists. Proceed with the grounded candidates alone; the promotion
|
||||
catalog exists. Proceed with the grounded candidates alone; the assignment
|
||||
above still applies at full strength.
|
||||
${authorityInstruction}
|
||||
A user- or brief-pinned decision beats the roll, always.
|
||||
@@ -461,11 +467,11 @@ habitual composition, but keep only structures that strengthen this product.\n`
|
||||
after resolution. The ping is anonymous (chosen id only) and is skipped
|
||||
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
|
||||
: '';
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}PROMOTED INDEX: ${buildIndex}
|
||||
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
|
||||
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
|
||||
${promotedInstruction}
|
||||
The promotion exists to refuse the model's ranking rut, not to outrank the
|
||||
user or the brief. Never expose promotion metadata in choice labels or order.
|
||||
The assignment exists to refuse the model's ranking rut, never to outrank
|
||||
the user or the brief. Never expose assignment metadata in user-facing labels.
|
||||
CHALLENGERS:
|
||||
${data.challengers.map(renderChallenger).join('\n')}
|
||||
${stagingBlock}${challengerInstruction}
|
||||
@@ -481,6 +487,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
const scopeIdx = args.indexOf('--scope');
|
||||
const rerollIdx = args.indexOf('--reroll');
|
||||
const modeIdx = args.indexOf('--mode');
|
||||
const candidateCountIdx = args.indexOf('--candidate-count');
|
||||
const chosenIdx = args.indexOf('--chosen');
|
||||
try {
|
||||
if (chosenIdx !== -1) {
|
||||
@@ -500,6 +507,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
||||
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
|
||||
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
|
||||
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
|
||||
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+4
-133
@@ -26,7 +26,6 @@
|
||||
* 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? }
|
||||
*
|
||||
@@ -1888,100 +1887,6 @@ 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 (THESIS / OWN-WORLD / STORY / FIRST VIEWPORT /
|
||||
// BAR-RAISER / 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;
|
||||
export const CONTRACT_EXTS = new Set(['.html', '.htm', '.astro', '.svelte', '.vue', '.jsx', '.tsx']);
|
||||
export const CONTRACT_REQUIRED_FIELDS = ['THESIS', 'OWN-WORLD', 'STORY', 'FIRST VIEWPORT', 'BAR-RAISER', 'FORM'];
|
||||
|
||||
/**
|
||||
* Extract the artifact's own direction-contract comment from the head of an
|
||||
* HTML or component file. Supports HTML-family comments and JSX block
|
||||
* comments so the contract works in the Astro/Svelte/Vue/React scaffolds the
|
||||
* skill actually builds. Returns the trimmed, length-capped body, or null
|
||||
* when the file carries no valid contract block.
|
||||
*/
|
||||
export function extractDirectionContract(content) {
|
||||
if (typeof content !== 'string' || !content) return null;
|
||||
const head = content.slice(0, CONTRACT_HEAD_CHARS);
|
||||
const candidates = [];
|
||||
for (const pattern of [/<!--([\s\S]*?)-->/g, /\{\/\*([\s\S]*?)\*\/\}/g]) {
|
||||
for (const match of head.matchAll(pattern)) {
|
||||
const index = match.index ?? 0;
|
||||
const linePrefix = head.slice(head.lastIndexOf('\n', index - 1) + 1, index).trim();
|
||||
if (linePrefix.startsWith('//')) continue;
|
||||
candidates.push({ index, body: match[1].trim() });
|
||||
}
|
||||
}
|
||||
candidates.sort((a, b) => a.index - b.index);
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.body || !/direction\s+contract|concept\s+contract/i.test(candidate.body.slice(0, CONTRACT_MARKER_CHARS))) continue;
|
||||
return candidate.body.slice(0, CONTRACT_MAX_CHARS);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function missingDirectionContractFields(contract) {
|
||||
const body = typeof contract === 'string' ? contract : '';
|
||||
return CONTRACT_REQUIRED_FIELDS.filter((field) => {
|
||||
const label = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
||||
return !new RegExp(`\\b${label}\\s*:`, 'i').test(body);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const missing = missingDirectionContractFields(contract);
|
||||
const integrity = missing.length > 0
|
||||
? `\n\nContract integrity defect: missing ${missing.join(', ')}. Repair the contract and the implementation together.`
|
||||
: '';
|
||||
return `${display} opens with this direction contract, written when the direction was decided:\n\n${contract}${integrity}`;
|
||||
});
|
||||
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.
|
||||
@@ -1990,10 +1895,7 @@ 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. 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():
|
||||
* per-edit hook already reported. Same result contract as runHook():
|
||||
* { exitCode, stdout, audit, emission? }
|
||||
*
|
||||
* Never throws; exits silent (and fast) when the session touched no UI
|
||||
@@ -2060,7 +1962,6 @@ 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;
|
||||
@@ -2082,22 +1983,6 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
|
||||
// Direction-contract audit: HTML and component artifacts, 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.
|
||||
const contractCapable = useHtmlEngine || CONTRACT_EXTS.has(ext);
|
||||
if (contractCapable) {
|
||||
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 {
|
||||
@@ -2116,41 +2001,27 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
|
||||
}
|
||||
audit.scannedFiles = scanned;
|
||||
|
||||
if (freshGroups.length === 0 && contractEntries.length === 0) {
|
||||
if (freshGroups.length === 0) {
|
||||
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
// Fresh findings and first-time contract audits earn the cache write;
|
||||
// both mark this batch as surfaced so the next Stop fire is silent
|
||||
// Fresh findings earn the cache write so the next Stop fire is silent
|
||||
// unless new issues appear.
|
||||
persistCache(projectCwd, cache);
|
||||
|
||||
// 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);
|
||||
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: payload(text, 'Stop', harness),
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
* `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. Touched
|
||||
* HTML artifacts opening with a direction-contract comment get a one-time
|
||||
* contract-audit section appended to the same emission.
|
||||
* surfaced, and emits once via the Stop additionalContext channel.
|
||||
*
|
||||
* 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.
|
||||
|
||||
Reference in New Issue
Block a user