From 6be04d3fd3476b953f84c5cb6408940267c6cdde Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:46:05 +0000 Subject: [PATCH] Sync generated provider output --- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- .../impeccable/scripts/concept-seed.mjs | 38 ++++++++++++++---- .../scripts/lib/composition-catalog.mjs | 33 ++++++++++++++++ .../scripts/lib/concept-catalog.mjs | 28 +++++++++++++ .../impeccable/scripts/lib/roll-selection.mjs | 39 +++++++++++++++++-- 60 files changed, 1890 insertions(+), 180 deletions(-) diff --git a/.agents/skills/impeccable/scripts/concept-seed.mjs b/.agents/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.agents/skills/impeccable/scripts/concept-seed.mjs +++ b/.agents/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.agents/skills/impeccable/scripts/lib/composition-catalog.mjs b/.agents/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.agents/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.agents/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.agents/skills/impeccable/scripts/lib/concept-catalog.mjs b/.agents/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.agents/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.agents/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.agents/skills/impeccable/scripts/lib/roll-selection.mjs b/.agents/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.agents/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.agents/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.claude/skills/impeccable/scripts/concept-seed.mjs b/.claude/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.claude/skills/impeccable/scripts/concept-seed.mjs +++ b/.claude/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.claude/skills/impeccable/scripts/lib/composition-catalog.mjs b/.claude/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.claude/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.claude/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.claude/skills/impeccable/scripts/lib/concept-catalog.mjs b/.claude/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.claude/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.claude/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.claude/skills/impeccable/scripts/lib/roll-selection.mjs b/.claude/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.claude/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.claude/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.cursor/skills/impeccable/scripts/concept-seed.mjs b/.cursor/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.cursor/skills/impeccable/scripts/concept-seed.mjs +++ b/.cursor/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.cursor/skills/impeccable/scripts/lib/composition-catalog.mjs b/.cursor/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.cursor/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.cursor/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.cursor/skills/impeccable/scripts/lib/concept-catalog.mjs b/.cursor/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.cursor/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.cursor/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs b/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.gemini/skills/impeccable/scripts/concept-seed.mjs b/.gemini/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.gemini/skills/impeccable/scripts/concept-seed.mjs +++ b/.gemini/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.gemini/skills/impeccable/scripts/lib/composition-catalog.mjs b/.gemini/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.gemini/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.gemini/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.gemini/skills/impeccable/scripts/lib/concept-catalog.mjs b/.gemini/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.gemini/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.gemini/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs b/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.github/skills/impeccable/scripts/concept-seed.mjs b/.github/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.github/skills/impeccable/scripts/concept-seed.mjs +++ b/.github/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.github/skills/impeccable/scripts/lib/composition-catalog.mjs b/.github/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.github/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.github/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.github/skills/impeccable/scripts/lib/concept-catalog.mjs b/.github/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.github/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.github/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.github/skills/impeccable/scripts/lib/roll-selection.mjs b/.github/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.github/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.github/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.grok/skills/impeccable/scripts/concept-seed.mjs b/.grok/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.grok/skills/impeccable/scripts/concept-seed.mjs +++ b/.grok/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.grok/skills/impeccable/scripts/lib/composition-catalog.mjs b/.grok/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.grok/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.grok/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.grok/skills/impeccable/scripts/lib/concept-catalog.mjs b/.grok/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.grok/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.grok/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.grok/skills/impeccable/scripts/lib/roll-selection.mjs b/.grok/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.grok/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.grok/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.kiro/skills/impeccable/scripts/concept-seed.mjs b/.kiro/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.kiro/skills/impeccable/scripts/concept-seed.mjs +++ b/.kiro/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.kiro/skills/impeccable/scripts/lib/composition-catalog.mjs b/.kiro/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.kiro/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.kiro/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.kiro/skills/impeccable/scripts/lib/concept-catalog.mjs b/.kiro/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.kiro/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.kiro/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs b/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.opencode/skills/impeccable/scripts/concept-seed.mjs b/.opencode/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.opencode/skills/impeccable/scripts/concept-seed.mjs +++ b/.opencode/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.opencode/skills/impeccable/scripts/lib/composition-catalog.mjs b/.opencode/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.opencode/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.opencode/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.opencode/skills/impeccable/scripts/lib/concept-catalog.mjs b/.opencode/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.opencode/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.opencode/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs b/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.pi/skills/impeccable/scripts/concept-seed.mjs b/.pi/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.pi/skills/impeccable/scripts/concept-seed.mjs +++ b/.pi/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.pi/skills/impeccable/scripts/lib/composition-catalog.mjs b/.pi/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.pi/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.pi/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.pi/skills/impeccable/scripts/lib/concept-catalog.mjs b/.pi/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.pi/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.pi/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.pi/skills/impeccable/scripts/lib/roll-selection.mjs b/.pi/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.pi/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.pi/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.qoder/skills/impeccable/scripts/concept-seed.mjs b/.qoder/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.qoder/skills/impeccable/scripts/concept-seed.mjs +++ b/.qoder/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.qoder/skills/impeccable/scripts/lib/composition-catalog.mjs b/.qoder/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.qoder/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.qoder/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.qoder/skills/impeccable/scripts/lib/concept-catalog.mjs b/.qoder/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.qoder/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.qoder/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs b/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.rovodev/skills/impeccable/scripts/concept-seed.mjs b/.rovodev/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.rovodev/skills/impeccable/scripts/concept-seed.mjs +++ b/.rovodev/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.rovodev/skills/impeccable/scripts/lib/composition-catalog.mjs b/.rovodev/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.rovodev/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.rovodev/skills/impeccable/scripts/lib/concept-catalog.mjs b/.rovodev/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.rovodev/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs b/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.trae-cn/skills/impeccable/scripts/lib/composition-catalog.mjs b/.trae-cn/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.trae-cn/skills/impeccable/scripts/lib/concept-catalog.mjs b/.trae-cn/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs b/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.trae/skills/impeccable/scripts/concept-seed.mjs b/.trae/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.trae/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.trae/skills/impeccable/scripts/lib/composition-catalog.mjs b/.trae/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.trae/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.trae/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.trae/skills/impeccable/scripts/lib/concept-catalog.mjs b/.trae/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.trae/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.trae/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.trae/skills/impeccable/scripts/lib/roll-selection.mjs b/.trae/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.trae/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.trae/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/.vibe/skills/impeccable/scripts/concept-seed.mjs b/.vibe/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/.vibe/skills/impeccable/scripts/concept-seed.mjs +++ b/.vibe/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/.vibe/skills/impeccable/scripts/lib/composition-catalog.mjs b/.vibe/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/.vibe/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.vibe/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/.vibe/skills/impeccable/scripts/lib/concept-catalog.mjs b/.vibe/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/.vibe/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/.vibe/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs b/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); } diff --git a/plugin/skills/impeccable/scripts/concept-seed.mjs b/plugin/skills/impeccable/scripts/concept-seed.mjs index 6efb7d643..feae61855 100644 --- a/plugin/skills/impeccable/scripts/concept-seed.mjs +++ b/plugin/skills/impeccable/scripts/concept-seed.mjs @@ -38,10 +38,17 @@ * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade * node scripts/concept-seed.mjs --scope surface --mode operate --from + * node scripts/concept-seed.mjs --scope surface --mode operate --area onboarding-and-setup * node scripts/concept-seed.mjs --scope direction --candidate-count 6 * node scripts/concept-seed.mjs --scope direction --mode persuade --from --reroll 1 * node scripts/concept-seed.mjs --chosen --from --scope direction * + * --area names the area of concern inside that mode (an onboarding flow and a + * settings page are both operate, and want different compositions). It needs + * --mode, prefers compositions in that area, and tops up from the rest of the + * surface rather than dealing fewer. --mode also gates which worlds are + * eligible, for worlds whose reviewer marked them as carrying only some modes. + * * --mode names the requested surface's mode (persuade, operate, read, * experience) so the appended compositions match its register of work; omitted, * they roll from the full approved pool. @@ -68,7 +75,7 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; import { runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, @@ -127,9 +134,10 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, reroll }) { +async function fetchRoll({ scope, key, mode, area, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); + if (area) params.set('area', area); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -210,9 +218,9 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, sourceCompositions = null, count = 3 }) { +export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); } // Compatibility for callers that need a single smoke-test sample. @@ -220,9 +228,9 @@ export function selectApprovedComposition(options) { return selectApprovedCompositions({ ...options, count: 1 })[0] ?? null; } -export function selectApprovedChallengers({ scope, key, reroll = 0, sourceConcepts = null }) { +export function selectApprovedChallengers({ scope, key, reroll = 0, mode = null, sourceConcepts = null }) { const source = sourceConcepts ?? requireLocalConcepts().concepts; - const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, concepts: source })); + const { approved, picks } = driveSelection(selectApprovedChallengersCore({ scope, key, reroll, mode, concepts: source })); return { approved, picks, @@ -238,6 +246,7 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, + area = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -251,6 +260,15 @@ export function renderConceptSeed({ if (mode !== null && !SEED_MODES.has(mode)) { throw new Error('concept-seed: --mode must be persuade, operate, read, or experience'); } + if (area !== null) { + if (mode === null) { + throw new Error('concept-seed: --area needs --mode, because areas are scoped to a surface'); + } + const allowed = areasForSurface(mode); + if (!allowed.includes(area)) { + throw new Error(`concept-seed: --area must be one of the ${mode} areas (${allowed.join(', ')})`); + } + } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); } @@ -272,6 +290,7 @@ export function renderConceptSeed({ scope, key, reroll, + mode, sourceConcepts: local.concepts, }); data = { @@ -280,16 +299,17 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, sourceCompositions: local.compositions }), + compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), }; } else { // Keep local renders synchronous for prepared eval sessions and tests; // installed skills without a bundled catalog resolve through the API. - return fetchRoll({ scope, key, mode, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, area, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, + area, candidateCount, catalogDir, _resolvedData: roll ? { @@ -439,6 +459,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 areaIdx = args.indexOf('--area'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -472,6 +493,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, + area: areaIdx !== -1 ? args[areaIdx + 1] : null, candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7, })); } diff --git a/plugin/skills/impeccable/scripts/lib/composition-catalog.mjs b/plugin/skills/impeccable/scripts/lib/composition-catalog.mjs index 349cb4913..fc4d76fd4 100644 --- a/plugin/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/plugin/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -19,6 +19,29 @@ export const COMPOSITION_GRAMMAR_PREFIXES = [ // composition are different species, and read/experience surfaces get their own. export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']); +// Areas of concern, one level below surface. Surface alone is too coarse to deal +// against: "operate" spans onboarding, settings, dashboards and editors, so a +// build designing an onboarding flow could legitimately draw a settings +// composition and the input would read as noise. Areas name the problem the +// composition is about, not how it is built, which is what familyId already does. +// +// `area` is optional. An entry without one is eligible for any request in its +// surface, so nothing has to be backfilled before this ships, and a request for +// an area with a thin pool tops up from the rest of the surface rather than +// dealing fewer. +export const COMPOSITION_AREAS = { + persuade: ['landing-hero', 'feature-argument', 'pricing-and-plans', 'proof-and-testimony', 'campaign-and-launch'], + operate: ['onboarding-and-setup', 'dashboard-and-overview', 'records-and-tables', 'editor-and-canvas', 'settings-and-account', 'empty-and-failure'], + read: ['long-form-article', 'reference-and-docs', 'index-and-archive', 'search-and-results'], + experience: ['gallery-and-collection', 'player-and-timeline', 'space-and-map', 'play-and-toy'], +}; + +export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); + +export function areasForSurface(surface) { + return COMPOSITION_AREAS[surface] ?? []; +} + export function compositionContentHash(composition) { const payload = [ composition?.form ?? '', @@ -57,6 +80,16 @@ export function validateCompositionEntry(composition, { existingForms = new Map( if (!COMPOSITION_SURFACES.has(composition?.surface)) { errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`); } + // Optional, but an area from the wrong surface is a mistake rather than a + // looser tag: it would make the entry unreachable by every real request. + if (composition?.area !== undefined && composition.area !== null) { + const allowed = areasForSurface(composition.surface); + if (!allowed.includes(composition.area)) { + errors.push( + `composition ${id} area "${composition.area}" is not one of the ${composition.surface} areas (${allowed.join(', ')})` + ); + } + } if (!Array.isArray(composition?.tags) || composition.tags.length !== 3 || composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) { diff --git a/plugin/skills/impeccable/scripts/lib/concept-catalog.mjs b/plugin/skills/impeccable/scripts/lib/concept-catalog.mjs index 42c828c74..fc5b66a46 100644 --- a/plugin/skills/impeccable/scripts/lib/concept-catalog.mjs +++ b/plugin/skills/impeccable/scripts/lib/concept-catalog.mjs @@ -24,6 +24,13 @@ export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']); // validateConceptCatalog needs it. export { WELL_TIERS }; +// Reviewer axes that gate the challenger draw without touching approval. +export const CONCEPT_BREADTHS = new Set(['general', 'niche']); +// The registers of work a roll can be asked for. Kept here beside the review +// validation that uses it; roll-selection.mjs filters on it and the seeder +// validates the --mode flag against the same four. +export const SEED_MODES = new Set(['persuade', 'operate', 'read', 'experience']); + const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i; export const SYSTEM_PREFIXES = [ 'Palette/material:', @@ -289,6 +296,27 @@ export function validateConceptCatalog(catalog, reviewData, { errors.push(`review ${id} rating only applies to approved concepts`); } } + // Breadth: a world too narrow to serve an arbitrary build keeps its approval + // and leaves the challenger pool. Selection has honoured this for a while but + // nothing validated it, so a typo would silently read as "general". + if (review?.breadth !== undefined && !CONCEPT_BREADTHS.has(review.breadth)) { + errors.push(`review ${id} breadth must be one of ${[...CONCEPT_BREADTHS].join(', ')}`); + } + // Mode eligibility: which registers of work this world can carry. Absent + // means all of them, which is why it needs no backfill. Listing every mode + // is the same as omitting it, and an empty list would deal nothing, so both + // are rejected in favour of leaving the field out. + if (review?.allowedModes !== undefined) { + if (!Array.isArray(review.allowedModes) || review.allowedModes.length === 0) { + errors.push(`review ${id} allowedModes must be a non-empty array, or omitted to allow every mode`); + } else if (review.allowedModes.some(mode => !SEED_MODES.has(mode))) { + errors.push(`review ${id} allowedModes may only contain ${[...SEED_MODES].join(', ')}`); + } else if (new Set(review.allowedModes).size !== review.allowedModes.length) { + errors.push(`review ${id} allowedModes must not repeat a mode`); + } else if (review.allowedModes.length === SEED_MODES.size) { + errors.push(`review ${id} allowedModes lists every mode; omit the field instead`); + } + } } const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier])); diff --git a/plugin/skills/impeccable/scripts/lib/roll-selection.mjs b/plugin/skills/impeccable/scripts/lib/roll-selection.mjs index f579029e4..019a1330a 100644 --- a/plugin/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/plugin/skills/impeccable/scripts/lib/roll-selection.mjs @@ -94,7 +94,15 @@ function compositionTickets(pool) { * @param {Array} options.concepts merged concepts with status, review, wellTier, familyId * @returns {Generator} */ -export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, concepts }) { +// A world with no allowedModes is eligible everywhere, which is what keeps this +// additive: nothing has to be backfilled for the filter to be safe. +function modeAllows(concept, mode) { + const allowed = concept.review?.allowedModes; + if (!Array.isArray(allowed) || allowed.length === 0) return true; + return allowed.includes(mode); +} + +export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = null, mode = null, concepts }) { const approved = concepts.filter(concept => concept.status === 'approved'); // Direction chooses a durable identity, so it draws worlds; surface designs // one page inside a committed identity, so it draws compositions. Duals serve @@ -122,6 +130,18 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = if (rated.length > 0) approvedByTier.set(tier, rated); } } + // Mode eligibility, per tier and skipped where it would empty a tier. Worlds + // used to be drawn with no mode awareness at all, so a build asking for an app + // UI could get six worlds that only make sense on a landing page. A world is an + // identity and identities transfer further than compositions do, so this is a + // ceiling the reviewer sets rather than a category assignment: eligible + // everywhere until someone says otherwise. + if (mode) { + for (const [tier, pool] of approvedByTier) { + const eligible = pool.filter(concept => modeAllows(concept, mode)); + if (eligible.length > 0) approvedByTier.set(tier, eligible); + } + } for (const [tier, pool] of approvedByTier) { const matching = pool.filter(concept => wanted.has(concept.strength)); if (matching.length > 0) approvedByTier.set(tier, matching); @@ -192,11 +212,12 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = * @param {string} options.key * @param {number} [options.reroll] * @param {string|null} [options.mode] surface register to stay inside + * @param {string|null} [options.area] area of concern to prefer within that surface * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { // Compositions honour the same breadth gate as worlds: one too specific to serve // an arbitrary build stays approved for direct briefs and leaves the // challenger pool. Falls back to the full approved set rather than returning @@ -233,16 +254,26 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul entry => `${entry.composition.id}#${entry.ticket}` )).map(entry => entry.composition); + // Area is a preference, not a filter. Requesting an onboarding flow should + // deal onboarding compositions first and top up from the rest of the surface + // rather than deal fewer than three, because the per-area pools are small and + // an unset area is eligible everywhere. A stable partition of an already + // deterministic ranking is still deterministic. + const ordered = area + ? [...ranked.filter(composition => composition.area === area), + ...ranked.filter(composition => composition.area !== area)] + : ranked; + const families = new Set(); picks = []; - for (const composition of ranked) { + for (const composition of ordered) { const family = composition.familyId ?? composition.id; if (families.has(family)) continue; picks.push(composition); families.add(family); if (picks.length >= count) break; } - for (const composition of ranked) { + for (const composition of ordered) { if (picks.length >= count) break; if (!picks.some(pick => pick.id === composition.id)) picks.push(composition); }