diff --git a/picker/styles/design-context.css b/picker/styles/design-context.css index 711b0d176..c8ac676d5 100644 --- a/picker/styles/design-context.css +++ b/picker/styles/design-context.css @@ -65,6 +65,10 @@ body.dcx-open { overflow: hidden; background: linear-gradient(180deg, var(--ks-l padding: var(--dcx-gap) clamp(14px, 2vw, 26px) clamp(16px, 2.2vw, 28px); } +/* The author display above would defeat the hidden attribute the shell ships + with, leaving an invisible 100svh box under the questionnaire. */ +.dcx-shell[hidden] { display: none; } + .dcx-grid { position: relative; flex: 1 1 0; diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index 8887f9b32..2c111c192 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -47,6 +47,7 @@ * * Usage: * node scripts/concept-seed.mjs --scope direction --mode persuade + * node scripts/concept-seed.mjs --scope direction --mode persuade --seed-declined="" * node scripts/concept-seed.mjs --scope surface --mode operate --from * node scripts/concept-seed.mjs --scope surface --mode operate --grain flow * node scripts/concept-seed.mjs --scope direction --candidate-count 6 @@ -55,6 +56,16 @@ * node scripts/concept-seed.mjs --chosen --kind challenger --from --scope direction * node scripts/concept-seed.mjs --kind assigned --from --scope direction * + * --seed-declined records that the user was offered the document --seed + * questionnaire on a no-DESIGN.md project and skipped it. The flag carries + * evidence: its value is the user's verbatim skip answer, quoted from the + * conversation. Without it, a --scope direction roll on a project with no + * DESIGN.md refuses to deal and prints the pause directive instead; a bare + * or empty flag refuses the same way (a real live session self-passed the + * boolean form without asking, which is why the flag demands the quote). + * Fabricating or paraphrasing the quote is a contract violation, not a + * shortcut. + * * --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 @@ -87,6 +98,10 @@ * IMPECCABLE_CATALOG_DIR — directory holding the four catalog JSON files. * IMPECCABLE_API_URL — roll API base (default https://impeccable.style/api). * IMPECCABLE_NO_TELEMETRY — disables the choice ping (DO_NOT_TRACK also honored). + * IMPECCABLE_SEED_DECLINED — set to 1 to bypass the seed pause without a + * quote; the unattended escape for eval and CI harnesses that + * legitimately roll direction on a no-DESIGN.md workspace. Never for + * attended sessions; the bypass is logged to stderr so it stays auditable. */ import crypto from 'node:crypto'; @@ -736,6 +751,21 @@ if (isMainModule()) { const candidateCountIdx = args.indexOf('--candidate-count'); const chosenIdx = args.indexOf('--chosen'); const kindIdx = args.indexOf('--kind'); + // --seed-declined carries evidence: the user's verbatim skip answer, in + // either --seed-declined="" or --seed-declined form. A + // bare or empty flag does not count; a live session self-passed the + // boolean form without asking the user, so the flag demands the quote. + let seedDeclinedAnswer = null; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--seed-declined') { + const next = args[i + 1]; + seedDeclinedAnswer = next !== undefined && !next.startsWith('--') ? next : ''; + } else if (args[i].startsWith('--seed-declined=')) { + seedDeclinedAnswer = args[i].slice('--seed-declined='.length); + } + } + const seedDeclinedByEnv = process.env.IMPECCABLE_SEED_DECLINED === '1'; + const seedDeclined = Boolean(seedDeclinedAnswer && seedDeclinedAnswer.trim()) || seedDeclinedByEnv; try { if (chosenIdx !== -1 || kindIdx !== -1) { // Choice ping: always exits 0, telemetry must never fail a design flow. @@ -776,7 +806,8 @@ if (isMainModule()) { // rolled directions with no PRODUCT.md, so nothing grounded the fusion). // The --chosen branch above stays ungated; telemetry never blocks. const { loadContext } = await import('./context.mjs'); - if (!loadContext(process.cwd()).hasProduct) { + const ctx = loadContext(process.cwd()); + if (!ctx.hasProduct) { process.stdout.write([ 'NO_PRODUCT_MD: the dice stay in the cup until product truth exists.', 'Complete the init ask round and write PRODUCT.md first (reference/init.md), then re-run this exact command.', @@ -784,6 +815,31 @@ if (isMainModule()) { ].join(' ') + '\n'); process.exit(1); } + // Mechanical seed-pause gate: prose alone did not keep a model from + // rolling a direction before offering the seed questionnaire (a real + // session read a stale reference file and dealt straight after init), + // and a boolean flag did not either (the next session self-passed it + // without asking). A direction roll invents the visual world, so on a + // project with no DESIGN.md the user gets the choice first; a seed + // DESIGN.md counts as present, so a post-questionnaire re-entry never + // re-asks. The flag now carries the user's verbatim skip answer. + const scopeArg = scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface'; + if (scopeArg === 'direction' && !ctx.hasDesign && !seedDeclined) { + process.stdout.write([ + 'NO_DESIGN_MD: the dice stay in the cup until the user answers the seed question.', + 'First action: create a tracked todo "Ask user: document --seed or skip" with the harness todo tool and start no other todo until it is answered; with no todo tool, state this gate to the user in chat before anything else.', + 'Then ask one question: recommend `document --seed`, the guided interview plus browser questionnaire (reference/document.md, seed mode), because a world built from the user\'s own choices beats one assigned to them; offer the skip in the same breath.', + 'User accepts: run document seed mode; its seed DESIGN.md reads as an established world, so no direction roll happens.', + 'User skips: re-run this exact command with --seed-declined="" quoting the user\'s actual words from this conversation.', + 'The original build request is never a skip answer, a bare or empty flag refuses again, and a fabricated or paraphrased quote is a contract violation.', + ].join(' ') + '\n'); + process.exit(1); + } + if (scopeArg === 'direction' && !ctx.hasDesign && seedDeclinedByEnv && !(seedDeclinedAnswer && seedDeclinedAnswer.trim())) { + // Auditable trace for the unattended escape; stderr keeps the seed + // output clean for the agent. + process.stderr.write('seed pause bypassed via IMPECCABLE_SEED_DECLINED (unattended harness escape)\n'); + } process.stdout.write(await renderConceptSeed({ scope: scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface', key: fromIdx !== -1 diff --git a/tests/concept-seed.test.mjs b/tests/concept-seed.test.mjs index 40a225557..0a3aeffdf 100644 --- a/tests/concept-seed.test.mjs +++ b/tests/concept-seed.test.mjs @@ -532,7 +532,9 @@ describe('init gate', () => { const gateRun = (cwd) => spawnSync(process.execPath, [SCRIPT, '--scope', 'direction', '--from', 'gate-test'], { cwd, encoding: 'utf-8', - env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_CONTEXT_DIR: '' }, + // Blank the escape hatch so a developer shell carrying it never turns + // the refusal assertions green for the wrong reason. + env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_CONTEXT_DIR: '', IMPECCABLE_SEED_DECLINED: '' }, }); it('refuses to deal when no PRODUCT.md exists and routes to init', () => { @@ -547,11 +549,75 @@ describe('init gate', () => { it('deals normally once PRODUCT.md exists', () => { const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-product-')); writeFileSync(path.join(dir, 'PRODUCT.md'), '# Test Product\n\n## Register\n\nbrand\n'); + // DESIGN.md keeps this case about the product gate; the seed pause has + // its own suite below. + writeFileSync(path.join(dir, 'DESIGN.md'), '# Design\n\nEstablished world.\n'); const result = gateRun(dir); assert.equal(result.status, 0); assert.doesNotMatch(result.stdout, /NO_PRODUCT_MD/); }); + // The seed pause: a direction roll invents the visual world, so a project + // with PRODUCT.md but no DESIGN.md gets the questionnaire offer before the + // deal. The refusal is mechanical because prose alone did not stop a real + // session from rolling straight after init. + const seedGateDir = (design = null) => { + const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-seedgate-')); + writeFileSync(path.join(dir, 'PRODUCT.md'), '# Test Product\n\n## Platform\n\nweb\n'); + if (design !== null) writeFileSync(path.join(dir, 'DESIGN.md'), design); + return dir; + }; + + it('refuses a direction roll with no DESIGN.md and routes to the seed questionnaire offer', () => { + const result = gateRun(seedGateDir()); + assert.equal(result.status, 1); + assert.match(result.stdout, /NO_DESIGN_MD/); + assert.match(result.stdout, /document --seed/); + assert.match(result.stdout, /--seed-declined/); + assert.doesNotMatch(result.stdout, /ASSIGNED INDEX/); + }); + + it('deals a direction once --seed-declined records the user skip', () => { + const dir = seedGateDir(); + const result = spawnSync(process.execPath, [SCRIPT, '--scope', 'direction', '--from', 'gate-test', '--seed-declined'], { + cwd: dir, + encoding: 'utf-8', + env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_CONTEXT_DIR: '' }, + }); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /NO_DESIGN_MD/); + assert.match(result.stdout, /ASSIGNED INDEX/); + }); + + it('honors IMPECCABLE_SEED_DECLINED=1 for harnesses that cannot thread the flag', () => { + const dir = seedGateDir(); + const result = spawnSync(process.execPath, [SCRIPT, '--scope', 'direction', '--from', 'gate-test'], { + cwd: dir, + encoding: 'utf-8', + env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_CONTEXT_DIR: '', IMPECCABLE_SEED_DECLINED: '1' }, + }); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /NO_DESIGN_MD/); + }); + + it('treats a seed DESIGN.md as present so a post-questionnaire re-entry never re-asks', () => { + const seedDesign = '# Design\n\n\n'; + const result = gateRun(seedGateDir(seedDesign)); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /NO_DESIGN_MD/); + }); + + it('never seed-gates a surface roll', () => { + const dir = seedGateDir(); + const result = spawnSync(process.execPath, [SCRIPT, '--scope', 'surface', '--from', 'gate-test'], { + cwd: dir, + encoding: 'utf-8', + env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_CONTEXT_DIR: '' }, + }); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /NO_DESIGN_MD/); + }); + it('never gates the choice ping', () => { const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-ping-')); const result = spawnSync(process.execPath, [SCRIPT, '--chosen', 'assigned', '--from', 'gate-test'], { @@ -809,7 +875,7 @@ describe('API roll path', () => { const result = await new Promise((resolveRun, rejectRun) => { const child = spawn(NODE, [ '--import', pathToFileURL(preloadPath).href, - SCRIPT, '--scope', 'direction', '--mode', 'persuade', '--from', 'api-test', + SCRIPT, '--scope', 'direction', '--mode', 'persuade', '--from', 'api-test', '--seed-declined', ], { cwd: dir, env: {