diff --git a/docs/loadouts.md b/docs/loadouts.md index 3fec04d3b..ac140af83 100644 --- a/docs/loadouts.md +++ b/docs/loadouts.md @@ -26,7 +26,7 @@ bin/gstack-modules render --host claude \ `render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`. -`bun run gen:skill-docs --loadout ` and `--modules ` apply the same filter to an ordinary generation run. A non-`full` render adds `` to each SKILL.md header. +`bun run gen:skill-docs --loadout --out-dir ` and `--modules --out-dir ` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `` to each SKILL.md header. ## Building an eval arm diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5208d68bd..353f367b4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5159,7 +5159,7 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul ### `MODULE_SPEC_INVALID: …` -**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists. +**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists. ### `MODULE_UNKNOWN: "" is not a module` diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 97dfae1a0..322be1b8e 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -24,7 +24,7 @@ import type { Host, TemplateContext } from './resolvers/types'; import { HOST_PATHS } from './resolvers/types'; import { RESOLVERS } from './resolvers/index'; import { applySectionGuards, rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections'; -import { excludedSkillDirs, selectModules } from './modules'; +import { ModuleError, excludedSkillDirs, selectModules } from './modules'; import type { ModuleFilter } from './resolvers/types'; import { insertRuntimePreludes } from './resolvers/runtime-root'; import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist'; @@ -150,6 +150,9 @@ function parseGenerationArgs(args: string[]): GenerationOptions { const badDisabled = disabledSkills.find(n => !/^\/?[a-z0-9-]+$/.test(n)); if (badDisabled) throw new Error(`--disabled-skills takes skill names (got ${JSON.stringify(badDisabled)})`); const modules = selectModules({ loadout: value('--loadout'), modules: value('--modules') }); + if (modules && outDir === undefined) { + throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs --out-dir; the committed tree is always the full render`, 'bin/gstack-modules render --host --modules --out-dir '); + } const installRoot = value('--install-root'); if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) { throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`); diff --git a/test/module-loadouts.test.ts b/test/module-loadouts.test.ts index a1e0f54c8..cb29038da 100644 --- a/test/module-loadouts.test.ts +++ b/test/module-loadouts.test.ts @@ -156,6 +156,7 @@ describe('render filter units', () => { describe('gstack-modules render', () => { const spec = 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design,-preamble:writing-style,-overlay:claude,-skill:design-shotgun'; const readLock = (dir: string) => JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as LoadoutLock; + const ensureArm = async (dir: string) => { if (!fs.existsSync(path.join(dir, LOCK_FILE))) await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: dir }); }; test('claude: one arm excludes one module of each kind and locks every file', async () => { const out = path.join(tmp, 'claude-arm'); @@ -194,6 +195,7 @@ describe('gstack-modules render', () => { test('a dangling reference is refused and the earlier render survives', async () => { const out = path.join(tmp, 'claude-arm'); + await ensureArm(out); const before = readLock(out); const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out }); await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' }); @@ -204,6 +206,8 @@ describe('gstack-modules render', () => { test('a later render replaces an earlier one; a foreign directory is never replaced', async () => { const out = path.join(tmp, 'claude-arm'); + await ensureArm(out); + expect(readLock(out).loadout).toBe('adhoc'); const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:design-shotgun' }), outDir: out }); expect(readLock(out).spec_sha256).toBe(lock.spec_sha256); expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(true); @@ -266,6 +270,14 @@ describe('bin/gstack-modules', () => { expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') }); }); + test('gen:skill-docs refuses an exclusion render into the committed tree', () => { + const before = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout; + const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun'], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain('MODULE_SPEC_INVALID'); + expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before); + }); + test('render prints one RENDERED line and writes the lock', () => { const out = path.join(tmp, 'cli-render'); const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out);