diff --git a/hosts/claude.ts b/hosts/claude.ts index f805da040..6cf087991 100644 --- a/hosts/claude.ts +++ b/hosts/claude.ts @@ -10,6 +10,7 @@ const claude: HostConfig = { localSkillRoot: '.claude/skills/gstack', hostSubdir: '.claude', usesEnvVars: false, + defaultModel: 'claude', frontmatter: { mode: 'denylist', diff --git a/hosts/codex.ts b/hosts/codex.ts index 7dc80ea87..06a3a5287 100644 --- a/hosts/codex.ts +++ b/hosts/codex.ts @@ -10,6 +10,7 @@ const codex: HostConfig = { localSkillRoot: '.agents/skills/gstack', hostSubdir: '.agents', usesEnvVars: true, + defaultModel: 'gpt', frontmatter: { mode: 'allowlist', diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 71aa1a34c..4865d36e6 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -90,12 +90,13 @@ const HOST_ARG_VAL: HostArg = (() => { let HOST: Host = HOST_ARG_VAL === 'all' ? 'claude' : HOST_ARG_VAL; // ─── Model Overlay Selection ──────────────────────────────── -// --model is explicit. We do NOT auto-detect from host (host ≠ model). -// Default is 'claude'. Missing overlay file → empty string (graceful). +// --model is explicit and wins over host configuration. A host may declare a +// defaultModel; hosts without one retain the legacy Claude fallback. Missing +// overlay file → empty string (graceful). import { ALL_MODEL_NAMES, resolveModel, type Model } from './models'; const MODEL_ARG = process.argv.find(a => a.startsWith('--model')); -const MODEL_ARG_VAL: Model = (() => { - if (!MODEL_ARG) return 'claude'; +const EXPLICIT_MODEL: Model | null = (() => { + if (!MODEL_ARG) return null; const val = MODEL_ARG.includes('=') ? MODEL_ARG.split('=')[1] : process.argv[process.argv.indexOf(MODEL_ARG) + 1]; const resolved = resolveModel(val); if (!resolved) { @@ -104,6 +105,10 @@ const MODEL_ARG_VAL: Model = (() => { return resolved; })(); +function modelForHost(hostConfig: HostConfig): Model { + return EXPLICIT_MODEL ?? hostConfig.defaultModel ?? 'claude'; +} + // ─── Catalog Mode (v1.45.0.0 T4) ──────────────────────────── // 'trim' (default): shorten frontmatter description to lead sentence, // move routing/voice prose into a "## When to invoke" body section, and @@ -731,7 +736,7 @@ function buildContext( const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined; return { skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host], - preambleTier, model: MODEL_ARG_VAL, interactive, explainLevel: EXPLAIN_LEVEL, + preambleTier, model: modelForHost(getHostConfig(host)), interactive, explainLevel: EXPLAIN_LEVEL, }; } diff --git a/scripts/host-config.ts b/scripts/host-config.ts index c7a3ae9f3..0ed28781c 100644 --- a/scripts/host-config.ts +++ b/scripts/host-config.ts @@ -14,6 +14,8 @@ * platform-detect, uninstall */ +import { validateModel, type Model } from './models'; + export interface HostConfig { /** Unique host identifier (e.g., 'opencode'). Must match filename in hosts/. */ name: string; @@ -33,6 +35,8 @@ export interface HostConfig { hostSubdir: string; /** Whether preamble generates $GSTACK_ROOT env vars (true for non-Claude hosts). */ usesEnvVars: boolean; + /** Default model overlay when --model is omitted. Legacy fallback is Claude. */ + defaultModel?: Model; // --- Frontmatter Transformation --- frontmatter: { @@ -151,6 +155,10 @@ export function validateHostConfig(config: HostConfig, validResolverNames?: Read if (!['real-dir-symlink', 'symlink-generated'].includes(config.install.linkingStrategy)) { errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`); } + if (config.defaultModel) { + const modelError = validateModel(config.defaultModel); + if (modelError) errors.push(`defaultModel ${modelError}`); + } // Cross-check suppressedResolvers against the known resolver names (injected to avoid a // circular import on the resolver registry). A typo would otherwise silently no-op: the diff --git a/scripts/models.ts b/scripts/models.ts index b6d1d368d..8970bf9fa 100644 --- a/scripts/models.ts +++ b/scripts/models.ts @@ -3,12 +3,11 @@ * * Model families supported by model overlays in model-overlays/{family}.md. * Host configs can reference these as `defaultModel` strings (validated at - * generation time), but the model axis is independent of the host axis. + * config load time), but the model axis is independent of the host axis. * - * IMPORTANT: host ≠ model. Claude Code can run any Claude model (Opus, Sonnet, - * Haiku, future). Codex CLI runs GPT/o-series models. Cursor and OpenCode can - * front multiple providers. We do NOT auto-detect the model from the host — - * users pass --model explicitly. Default is 'claude'. + * IMPORTANT: host ≠ model. Host defaults are declarative fallbacks, not runtime + * model detection. An explicit --model always wins, and hosts without a + * configured default retain the legacy 'claude' fallback. */ export const ALL_MODEL_NAMES = [ diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index d99630c4b..c82f30dae 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -109,7 +109,7 @@ if [ -d ".agents/skills/gstack" ] && [ ! -L ".agents/skills/gstack" ]; then fi fi echo "VENDORED_GSTACK: $_VENDORED" -echo "MODEL_OVERLAY: claude" +echo "MODEL_OVERLAY: gpt" _CHECKPOINT_MODE=$($GSTACK_BIN/gstack-config get checkpoint_mode 2>/dev/null || echo "explicit") _CHECKPOINT_PUSH=$($GSTACK_BIN/gstack-config get checkpoint_push 2>/dev/null || echo "false") echo "CHECKPOINT_MODE: $_CHECKPOINT_MODE" @@ -569,23 +569,45 @@ At skill END before telemetry: ``` -## Model-Specific Behavioral Patch (claude) +## Model-Specific Behavioral Patch (gpt) -The following nudges are tuned for the claude model family. They are +The following nudges are tuned for the gpt model family. They are **subordinate** to skill workflow, STOP points, AskUserQuestion gates, plan-mode safety, and /ship review gates. If a nudge below conflicts with skill instructions, the skill wins. Treat these as preferences, not rules. -**Todo-list discipline.** When working through a multi-step plan, mark each task -complete individually as you finish it. Do not batch-complete at the end. If a task -turns out to be unnecessary, mark it skipped with a one-line reason. +**Completion bias.** Do not end your turn with a partial solution when the full +solution is reachable. If you encounter an error, debug it. If a test fails, fix it. +If something is ambiguous, make your best judgment and proceed — don't stop and ask +unless you're genuinely blocked. -**Think before heavy actions.** For complex operations (refactors, migrations, -non-trivial new features), briefly state your approach before executing. This lets -the user course-correct cheaply instead of mid-flight. +**Prefer doing over listing.** When you'd be tempted to write "you could also try X, +Y, or Z," try the best option yourself. Pick, execute, report results. -**Dedicated tools over Bash.** Prefer Read, Edit, Write, Glob, Grep over shell -equivalents (cat, sed, find, grep). The dedicated tools are cheaper and clearer. +**No preamble.** Skip "Great question!", "Let me help with that", and restating the +user's request. Start with the work. + +**AskUserQuestion is NOT preamble.** The "No preamble" and "Prefer doing over listing" +rules above do NOT apply to AskUserQuestion content. When you invoke AskUserQuestion, +the user is about to make a decision — they need context, not terseness. Always emit +the full format from the preamble's AskUserQuestion Format section: + +1. **Re-ground** (project + branch + task — 1-2 sentences). +2. **Simplify (ELI10)** — explain what's happening in plain English a 16-year-old could + follow. Concrete stakes, not abstract tradeoffs. Non-negotiable; this is NOT preamble. +3. **Recommend** — `RECOMMENDATION: Choose [X] because [one-line reason]` on its own + line. Never omit this line. Never collapse it into the options list. +4. **Options** — lettered `A) B) C)` with Completeness scores (coverage-differentiated) + or the "options differ in kind" note (kind-differentiated). + +If you find yourself about to present an AskUserQuestion without the Simplify/ELI10 +paragraph, without a RECOMMENDATION line, or by just listing options and asking "which +one?" — stop, back up, and emit the full format. The user will ask you to do it anyway, +so do it the first time. + +**Reminder: subordination applies.** When a skill workflow says STOP, stop. When the +skill asks via AskUserQuestion, that is the wait-for-user gate, not an ambiguity. +Completion bias does not override safety gates. ## Voice diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 2fb783ffd..eb4b429bd 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -1705,6 +1705,28 @@ describe('Codex generation (--host codex)', () => { } }); + test('--host codex defaults to the GPT model overlay', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8'); + expect(content).toContain('MODEL_OVERLAY: gpt'); + expect(content).not.toContain('MODEL_OVERLAY: claude'); + }); + + test('explicit --model overrides the Codex host default', () => { + try { + const result = Bun.spawnSync( + ['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex', '--model', 'claude'], + { cwd: ROOT, stdout: 'pipe', stderr: 'pipe' }, + ); + expect(result.exitCode).toBe(0); + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8'); + expect(content).toContain('MODEL_OVERLAY: claude'); + } finally { + Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], { + cwd: ROOT, stdout: 'pipe', stderr: 'pipe', + }); + } + }); + test('root gstack bundle has OpenAI metadata for Codex skill browsing', () => { const rootMetadata = path.join(ROOT, 'agents', 'openai.yaml'); expect(fs.existsSync(rootMetadata)).toBe(true); @@ -2249,6 +2271,20 @@ describe('--host all', () => { expect(output).toContain(`FRESH: ${hostConfig.hostSubdir}/skills/`); } }); + + test('--host all resolves defaults independently for each host', () => { + const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'all'], { + cwd: ROOT, stdout: 'pipe', stderr: 'pipe', + }); + expect(result.exitCode).toBe(0); + + const claudeSkill = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8'); + const codexSkill = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8'); + const factorySkill = fs.readFileSync(path.join(ROOT, '.factory', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8'); + expect(claudeSkill).toContain('MODEL_OVERLAY: claude'); + expect(codexSkill).toContain('MODEL_OVERLAY: gpt'); + expect(factorySkill).toContain('MODEL_OVERLAY: claude'); + }); }); // ─── Setup script validation ───────────────────────────────── diff --git a/test/host-config.test.ts b/test/host-config.test.ts index 1c939d7be..7fffe5d10 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -195,6 +195,18 @@ describe('validateHostConfig', () => { expect(validateHostConfig(c).some(e => e.includes('linkingStrategy'))).toBe(true); }); + test('valid defaultModel passes', () => { + const c = makeValid(); + c.defaultModel = 'gpt'; + expect(validateHostConfig(c)).toEqual([]); + }); + + test('invalid defaultModel is caught at runtime', () => { + const c = makeValid(); + (c as any).defaultModel = 'not-a-model'; + expect(validateHostConfig(c).some(e => e.includes('defaultModel'))).toBe(true); + }); + test('paths with $ and ~ are valid', () => { const c = makeValid(); c.globalRoot = '$HOME/.test/skills/gstack'; @@ -481,6 +493,11 @@ describe('host config correctness', () => { expect(codex.generation.metadataFormat).toBe('openai.yaml'); }); + test('Claude and Codex declare host-specific model defaults', () => { + expect(claude.defaultModel).toBe('claude'); + expect(codex.defaultModel).toBe('gpt'); + }); + test('codex has sidecar config', () => { expect(codex.sidecar).toBeDefined(); expect(codex.sidecar!.path).toBe('.agents/skills/gstack');