diff --git a/skill/scripts/concept-seed.mjs b/skill/scripts/concept-seed.mjs index f1c31fb2a..1c595632e 100644 --- a/skill/scripts/concept-seed.mjs +++ b/skill/scripts/concept-seed.mjs @@ -511,6 +511,19 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur }); process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n'); } else { + // Mechanical init gate: prose alone does not keep a model from dealing + // before init, and fresh repos produced exactly that skip (the model + // 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) { + 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.', + 'Challengers fuse their form with facts from PRODUCT.md; without it every direction is ungrounded.', + ].join(' ') + '\n'); + process.exit(1); + } 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 f300aa482..ea564a446 100644 --- a/tests/concept-seed.test.mjs +++ b/tests/concept-seed.test.mjs @@ -1,6 +1,8 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -348,3 +350,39 @@ describe('concept seed scopes', () => { assert.equal(output.indexOf('CREATIVE SPARK:') < output.indexOf('SYSTEM GRAMMAR:'), true); }); }); + +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: '' }, + }); + + it('refuses to deal when no PRODUCT.md exists and routes to init', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'concept-seed-noproduct-')); + const result = gateRun(dir); + assert.equal(result.status, 1); + assert.match(result.stdout, /NO_PRODUCT_MD/); + assert.match(result.stdout, /init/); + assert.doesNotMatch(result.stdout, /ASSIGNED INDEX/); + }); + + 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'); + const result = gateRun(dir); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /NO_PRODUCT_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'], { + cwd: dir, + encoding: 'utf-8', + env: { ...process.env, IMPECCABLE_CATALOG_DIR: FIXTURE_DIR, IMPECCABLE_NO_TELEMETRY: '1' }, + }); + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /NO_PRODUCT_MD/); + }); +});