diff --git a/.agents/skills/impeccable/scripts/concept-seed.mjs b/.agents/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.agents/skills/impeccable/scripts/concept-seed.mjs +++ b/.agents/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.agents/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.agents/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.agents/skills/impeccable/scripts/lib/roll-selection.mjs b/.agents/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.agents/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.agents/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.claude/skills/impeccable/scripts/concept-seed.mjs b/.claude/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.claude/skills/impeccable/scripts/concept-seed.mjs +++ b/.claude/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.claude/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.claude/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.claude/skills/impeccable/scripts/lib/roll-selection.mjs b/.claude/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.claude/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.claude/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.cursor/skills/impeccable/scripts/concept-seed.mjs b/.cursor/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.cursor/skills/impeccable/scripts/concept-seed.mjs +++ b/.cursor/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.cursor/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.cursor/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs b/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.cursor/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.gemini/skills/impeccable/scripts/concept-seed.mjs b/.gemini/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.gemini/skills/impeccable/scripts/concept-seed.mjs +++ b/.gemini/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.gemini/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.gemini/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs b/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.gemini/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.github/skills/impeccable/scripts/concept-seed.mjs b/.github/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.github/skills/impeccable/scripts/concept-seed.mjs +++ b/.github/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.github/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.github/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.github/skills/impeccable/scripts/lib/roll-selection.mjs b/.github/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.github/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.github/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.grok/skills/impeccable/scripts/concept-seed.mjs b/.grok/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.grok/skills/impeccable/scripts/concept-seed.mjs +++ b/.grok/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.grok/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.grok/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.grok/skills/impeccable/scripts/lib/roll-selection.mjs b/.grok/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.grok/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.grok/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.kiro/skills/impeccable/scripts/concept-seed.mjs b/.kiro/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.kiro/skills/impeccable/scripts/concept-seed.mjs +++ b/.kiro/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.kiro/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.kiro/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs b/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.kiro/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.opencode/skills/impeccable/scripts/concept-seed.mjs b/.opencode/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.opencode/skills/impeccable/scripts/concept-seed.mjs +++ b/.opencode/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.opencode/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.opencode/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs b/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.opencode/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.pi/skills/impeccable/scripts/concept-seed.mjs b/.pi/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.pi/skills/impeccable/scripts/concept-seed.mjs +++ b/.pi/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.pi/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.pi/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.pi/skills/impeccable/scripts/lib/roll-selection.mjs b/.pi/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.pi/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.pi/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.qoder/skills/impeccable/scripts/concept-seed.mjs b/.qoder/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.qoder/skills/impeccable/scripts/concept-seed.mjs +++ b/.qoder/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.qoder/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.qoder/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs b/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.qoder/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.rovodev/skills/impeccable/scripts/concept-seed.mjs b/.rovodev/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.rovodev/skills/impeccable/scripts/concept-seed.mjs +++ b/.rovodev/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.rovodev/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs b/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.rovodev/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.trae-cn/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae-cn/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs b/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.trae-cn/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.trae/skills/impeccable/scripts/concept-seed.mjs b/.trae/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.trae/skills/impeccable/scripts/concept-seed.mjs +++ b/.trae/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.trae/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.trae/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.trae/skills/impeccable/scripts/lib/roll-selection.mjs b/.trae/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.trae/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.trae/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/.vibe/skills/impeccable/scripts/concept-seed.mjs b/.vibe/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/.vibe/skills/impeccable/scripts/concept-seed.mjs +++ b/.vibe/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/.vibe/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/.vibe/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs b/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/.vibe/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; } diff --git a/plugin/skills/impeccable/scripts/concept-seed.mjs b/plugin/skills/impeccable/scripts/concept-seed.mjs index feae61855..ce3486f46 100644 --- a/plugin/skills/impeccable/scripts/concept-seed.mjs +++ b/plugin/skills/impeccable/scripts/concept-seed.mjs @@ -38,16 +38,22 @@ * 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 surface --mode operate --grain flow * 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. + * --grain names how much of the product is in play: product, flow, view, or + * region. A docs site, an onboarding flow, a landing page and a data table are + * four different amounts of product and want different compositions. Grain is a + * preference: it deals matching compositions first and tops up from the rest of + * the register, and the rendered seed says how many actually matched so a + * borrowed structure is never mistaken for a supplied one. + * + * --platform names the delivery target (web, ios, android). Unlike grain this is + * a hard filter: a composition that needs hover or a pointer does not degrade on + * a phone, it stops working. --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, @@ -75,8 +81,10 @@ import { validateConceptCatalog, WELL_TIERS, } from './lib/concept-catalog.mjs'; -import { areasForSurface, readCompositionCatalog } from './lib/composition-catalog.mjs'; +import { readCompositionCatalog } from './lib/composition-catalog.mjs'; import { + COMPOSITION_GRAINS, + COMPOSITION_PLATFORMS, runSyncSelection, selectApprovedChallengers as selectApprovedChallengersCore, selectApprovedCompositions as selectApprovedCompositionsCore, @@ -134,10 +142,11 @@ function requireLocalConcepts() { return local; } -async function fetchRoll({ scope, key, mode, area, reroll }) { +async function fetchRoll({ scope, key, mode, grain, platform, reroll }) { const params = new URLSearchParams({ scope, key, reroll: String(reroll) }); if (mode) params.set('mode', mode); - if (area) params.set('area', area); + if (grain) params.set('grain', grain); + if (platform) params.set('platform', platform); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), apiBudgetMs()); try { @@ -218,9 +227,15 @@ function driveSelection(generator) { return runSyncSelection(generator, input => crypto.createHash('sha256').update(input).digest('hex')); } -export function selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, sourceCompositions = null, count = 3 }) { +export function dealCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = null, sourceCompositions = null, count = 3 }) { const compositions = sourceCompositions ?? requireLocalConcepts().compositions; - return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, area, compositions, count })); + return driveSelection(selectApprovedCompositionsCore({ scope, key, reroll, mode, grain, platform, compositions, count })); +} + +// Array-returning form, which is what every caller wanted before the match +// report existed. +export function selectApprovedCompositions(options) { + return dealCompositions(options).picks; } // Compatibility for callers that need a single smoke-test sample. @@ -246,7 +261,8 @@ export function renderConceptSeed({ key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'), reroll = 0, mode = null, - area = null, + grain = null, + platform = null, candidateCount = 7, catalogDir = CATALOG_DIR, _resolvedData = undefined, @@ -260,14 +276,13 @@ 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(', ')})`); - } + // Grain needs no mode: how much of the product is in play is independent of + // which register of work it is. + if (grain !== null && !COMPOSITION_GRAINS.includes(grain)) { + throw new Error(`concept-seed: --grain must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + if (platform !== null && !COMPOSITION_PLATFORMS.includes(platform)) { + throw new Error(`concept-seed: --platform must be one of ${COMPOSITION_PLATFORMS.join(', ')}`); } if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) { throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7'); @@ -299,17 +314,21 @@ export function renderConceptSeed({ approvedCount: approved.length, catalogCount, challengers: picks, - compositions: selectApprovedCompositions({ scope, key, reroll, mode, area, sourceCompositions: local.compositions }), + ...(() => { + const dealt = dealCompositions({ scope, key, reroll, mode, grain, platform, sourceCompositions: local.compositions }); + return { compositions: dealt.picks, compositionMatch: dealt.match }; + })(), }; } 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, area, reroll }).then(roll => renderConceptSeed({ + return fetchRoll({ scope, key, mode, grain, platform, reroll }).then(roll => renderConceptSeed({ scope, key, reroll, mode, - area, + grain, + platform, candidateCount, catalogDir, _resolvedData: roll ? { @@ -413,13 +432,32 @@ ${buildIndex} of your own grounded list; seed key ${key}. : Array.isArray(data.stagings) ? data.stagings : data.staging ? [data.staging] : []; + // The grain report. A top-up keeps the deal at three, which is right, but it + // must not read as three on-target inputs: a flow request answered entirely by + // view-grain compositions means the model has to derive the flow's own + // structure and borrow only their sequence law. Silence here would reproduce + // the exact failure this axis exists to fix. + const match = data.compositionMatch ?? null; + const grainNote = (() => { + if (!match?.grain) return ''; + if (match.grainAvailable === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, because the catalog holds no ${match.grain}-grain composition yet. Derive that structure yourself and borrow only their sequence and attention laws.`; + } + if (match.atGrain === 0) { + return `\nNONE of these sit at the requested ${match.grain} grain, though ${match.grainAvailable} exist; these were topped up from the rest of the register. Treat their structure as borrowed.`; + } + if (match.atGrain < compositions.length) { + return `\n${match.atGrain} of ${compositions.length} sit at the requested ${match.grain} grain; the rest were topped up from the register and their structure is borrowed.`; + } + return ''; + })(); const compositionBlock = compositions.length > 0 ? `\n${scope === 'direction' ? 'FIRST-SURFACE COMPOSITION INPUTS (identity-free; test them with shortlisted worlds and keep world plus composition one decision):' : 'COMPOSITION CHALLENGERS (identity-free; dress them in the committed visual identity before judging):'} ${compositions.map((composition, index) => renderComposition(composition, index)).join('\n')} Each one asks the same question of this build: what is the cleverest way to present, organize, or make interactive the problem in front of you? They carry structure only, never a palette, typeface, or material. Treat them as serious -rivals to your habitual layout, and keep only what makes this product clearer.\n` +rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n` : ''; const rerollBlock = reroll > 0 ? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded @@ -459,7 +497,8 @@ 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 grainIdx = args.indexOf('--grain'); + const platformIdx = args.indexOf('--platform'); const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); try { @@ -493,7 +532,8 @@ 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, + grain: grainIdx !== -1 ? args[grainIdx + 1] : null, + platform: platformIdx !== -1 ? args[platformIdx + 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 d0a6d4cc8..16378187e 100644 --- a/plugin/skills/impeccable/scripts/lib/composition-catalog.mjs +++ b/plugin/skills/impeccable/scripts/lib/composition-catalog.mjs @@ -3,9 +3,9 @@ import { readFileSync } from 'node:fs'; import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs'; // Defined in roll-selection.mjs for the same reason WELL_TIERS is: this file // reads the filesystem, and the roll API imports the taxonomy to validate its -// area parameter. Re-exported so existing importers keep working. -import { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS } from './roll-selection.mjs'; -export { areasForSurface, COMPOSITION_AREAS, ALL_COMPOSITION_AREAS }; +// grain and platform parameters. Re-exported so importers have one place to look. +import { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform } from './roll-selection.mjs'; +export { COMPOSITION_GRAINS, COMPOSITION_PLATFORMS, isGrain, isPlatform }; // Catalog B: compositions rather than styles. A composition organizes attention, // sequence, or manipulation on a surface and must survive being dressed in @@ -63,14 +63,24 @@ 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(', ')})` - ); + // Grain: how much of the product this composes. Optional, and absence means + // eligible at any grain, so nothing needs backfilling. + if (composition?.grain !== undefined && composition.grain !== null && !isGrain(composition.grain)) { + errors.push(`composition ${id} grain "${composition.grain}" must be one of ${COMPOSITION_GRAINS.join(', ')}`); + } + // Platforms this composition survives. Absence means all of them, so listing + // every platform is the same as omitting the field and is rejected in favour of + // leaving it out; an empty array would exclude the entry from every roll. + if (composition?.platforms !== undefined && composition.platforms !== null) { + const list = composition.platforms; + if (!Array.isArray(list) || list.length === 0) { + errors.push(`composition ${id} platforms must be a non-empty array, or omitted to allow every platform`); + } else if (list.some(entry => !isPlatform(entry))) { + errors.push(`composition ${id} platforms may only contain ${COMPOSITION_PLATFORMS.join(', ')}`); + } else if (new Set(list).size !== list.length) { + errors.push(`composition ${id} platforms must not repeat a platform`); + } else if (list.length === COMPOSITION_PLATFORMS.length) { + errors.push(`composition ${id} platforms lists every platform; omit the field instead`); } } if (!Array.isArray(composition?.tags) diff --git a/plugin/skills/impeccable/scripts/lib/roll-selection.mjs b/plugin/skills/impeccable/scripts/lib/roll-selection.mjs index 42964b207..e3c9efbb8 100644 --- a/plugin/skills/impeccable/scripts/lib/roll-selection.mjs +++ b/plugin/skills/impeccable/scripts/lib/roll-selection.mjs @@ -21,27 +21,44 @@ export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere']; -// 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. +// Grain: how much of the product a composition composes. Named grain rather than +// scope because scope already means direction-or-surface on every roll, and +// 'surface' is already a register value, so a scope of 'surface' would collide +// with both. // -// `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'], -}; +// This axis is framed by what the skill can be asked for, not by what the +// catalog happens to hold. A user asks for a docs site, an onboarding flow, a +// landing page, or a data table, and those are four different amounts of +// product. Register says what kind of work it is; grain says how much of it. +// Without grain, a request for a hero section can be dealt a whole-site +// navigation structure and nothing notices. +// +// Measured when this was added: 137 of 173 approved compositions were view +// grain, product grain was empty, and flow grain held one entry. That is why an +// onboarding request had nothing to draw. +export const COMPOSITION_GRAINS = [ + 'product', // a whole site or app: its information architecture + 'flow', // a sequence of views with one outcome: onboarding, checkout, setup + 'view', // one page or screen + 'region', // a section inside a view: a hero, a feature grid, a table +]; -export const ALL_COMPOSITION_AREAS = new Set(Object.values(COMPOSITION_AREAS).flat()); +// Delivery targets a composition can survive. Mirrors the skill's platform axis +// minus 'adaptive', which is a project-level value meaning both native targets +// rather than something a single composition is authored for. +// +// A composition that leans on hover, a pointer, or a wide viewport does not +// survive a phone, and nothing in the schema could say so before this. +export const COMPOSITION_PLATFORMS = ['web', 'ios', 'android']; -export function areasForSurface(surface) { - return COMPOSITION_AREAS[surface] ?? []; +// Both fields are optional and absence means eligible everywhere, so no entry +// has to be backfilled before this ships and no existing roll changes. +export function isGrain(value) { + return COMPOSITION_GRAINS.includes(value); +} + +export function isPlatform(value) { + return COMPOSITION_PLATFORMS.includes(value); } @@ -219,6 +236,10 @@ export function* selectApprovedChallengers({ scope, key, reroll = 0, minRating = return { approved, picks }; } +function emptyMatch(grain, platform, platformExcluded = 0) { + return { grain: grain ?? null, atGrain: grain ? 0 : null, grainAvailable: grain ? 0 : null, platform: platform ?? null, platformExcluded }; +} + /** * Three identity-free composition inputs from an explicit approved pool. * Drive with runSyncSelection or runAsyncSelection. @@ -236,12 +257,13 @@ 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 {string|null} [options.grain] how much of the product is in play + * @param {string|null} [options.platform] delivery target the result has to survive * @param {Array} options.compositions merged compositions with status, review, surface, familyId * @param {number} [options.count] - * @returns {Generator} + * @returns {Generator} */ -export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, area = null, compositions, count = 3 }) { +export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = null, grain = null, platform = 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 @@ -249,12 +271,28 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul let approved = compositions.filter(composition => composition.status === 'approved'); const broad = approved.filter(composition => composition.review?.breadth !== 'niche'); if (broad.length > 0) approved = broad; - if (approved.length === 0) return []; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; if (mode) { const matching = approved.filter(composition => composition.surface === mode); - if (matching.length === 0) return []; + if (matching.length === 0) return { picks: [], match: emptyMatch(grain, platform) }; approved = matching; } + // Platform is a hard filter, unlike grain. A composition that needs hover or a + // pointer does not degrade on a phone into something slightly worse; it stops + // working, so borrowing it would be a defect rather than a stretch. Absent + // platforms means it survives anywhere. + let platformExcluded = 0; + if (platform) { + const survives = approved.filter(composition => { + const only = composition.platforms; + return !Array.isArray(only) || only.length === 0 || only.includes(platform); + }); + platformExcluded = approved.length - survives.length; + // No fallback here either: dealing a hover-only composition to a phone build + // is worse than dealing nothing, and an empty deal is a visible gap. + approved = survives; + if (approved.length === 0) return { picks: [], match: emptyMatch(grain, platform, platformExcluded) }; + } const prior = new Set(); let picks = []; @@ -278,14 +316,18 @@ 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 + // Grain is a preference, not a filter: requesting an onboarding flow deals + // flow-grain compositions first and tops up from the rest of the register + // rather than dealing fewer than three. 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)] + // + // The top-up is why match is reported. Dealing three plausible view-grain + // compositions against a flow request, with no signal that none matched, is + // the same silent-plausibility failure this whole axis exists to fix: the + // model would improvise the flow structure while believing it was handed one. + const ordered = grain + ? [...ranked.filter(composition => composition.grain === grain), + ...ranked.filter(composition => composition.grain !== grain)] : ranked; const families = new Set(); @@ -303,5 +345,18 @@ export function* selectApprovedCompositions({ scope, key, reroll = 0, mode = nul } if (round < reroll) picks.forEach(composition => prior.add(composition.id)); } - return picks; + + const atGrain = grain ? picks.filter(composition => composition.grain === grain).length : null; + return { + picks, + match: { + grain: grain ?? null, + // How many of the dealt compositions actually sit at the requested grain. + // 0 with a grain requested means every pick is a borrowed structure. + atGrain, + grainAvailable: grain ? approved.filter(composition => composition.grain === grain).length : null, + platform: platform ?? null, + platformExcluded, + }, + }; }