diff --git a/docs/ADDING_A_HOST.md b/docs/ADDING_A_HOST.md index 26b6fce8c..533c57489 100644 --- a/docs/ADDING_A_HOST.md +++ b/docs/ADDING_A_HOST.md @@ -60,6 +60,7 @@ That expands to the full `HostConfig` with these defaults: - `cliCommand: 'myhost'` (the name; binary for `command -v` detection) - `cliAliases: []` +- `defaultModel: 'claude'` (model overlay used when generation gets no explicit `--model`; codex overrides to `'gpt'`) - `globalRoot` / `localSkillRoot`: `.myhost/skills/gstack`, `hostSubdir`: `.myhost` - `usesEnvVars: true` (false only for Claude, which uses literal `~` paths) - `frontmatter`: allowlist keeping `name` + `description`, no description limit @@ -173,6 +174,7 @@ Key fields: The `validateHostConfig()` function in `scripts/host-config.ts` checks: - Name: lowercase alphanumeric with hyphens - CLI command: alphanumeric with hyphens/underscores +- `defaultModel`: must be a known model family from `scripts/models.ts` `ALL_MODEL_NAMES` - Paths: safe characters only (alphanumeric, `.`, `/`, `$`, `{}`, `~`, `-`, `_`) - No duplicate names, hostSubdirs, or globalRoots across configs diff --git a/hosts/codex.ts b/hosts/codex.ts index d6bcded54..b46e92224 100644 --- a/hosts/codex.ts +++ b/hosts/codex.ts @@ -4,6 +4,7 @@ const codex = defineHost({ name: 'codex', displayName: 'OpenAI Codex CLI', cliAliases: ['agents'], + defaultModel: 'gpt', localSkillRoot: '.agents/skills/gstack', hostSubdir: '.agents', diff --git a/hosts/define-host.ts b/hosts/define-host.ts index 2be6ba4c7..0ed190dd0 100644 --- a/hosts/define-host.ts +++ b/hosts/define-host.ts @@ -86,6 +86,7 @@ export function defineHost(overrides: HostOverrides): displayName, cliCommand = name, cliAliases = [], + defaultModel = 'claude', globalRoot = `.${name}/skills/gstack`, localSkillRoot = `.${name}/skills/gstack`, hostSubdir = `.${name}`, @@ -140,6 +141,7 @@ export function defineHost(overrides: HostOverrides): displayName, cliCommand, cliAliases, + defaultModel, globalRoot, localSkillRoot, hostSubdir, diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 9596466f6..6028ea4ea 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -92,12 +92,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. Without it, each host uses HostConfig.defaultModel. +// Host defaults are generation fallbacks, not claims that host === model. +// 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 MODEL_ARG_VAL: 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) { @@ -106,6 +107,10 @@ const MODEL_ARG_VAL: Model = (() => { return resolved; })(); +function generationModelForHost(host: Host): Model { + return MODEL_ARG_VAL ?? getHostConfig(host).defaultModel; +} + // ─── Catalog Mode (v1.45.0.0 T4) ──────────────────────────── // 'trim' (default): shorten frontmatter description to lead sentence and // move routing/voice prose into a "## When to invoke" body section. @@ -753,7 +758,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: generationModelForHost(host), interactive, explainLevel: EXPLAIN_LEVEL, }; } diff --git a/scripts/host-config.ts b/scripts/host-config.ts index ff64f863f..47e13fbef 100644 --- a/scripts/host-config.ts +++ b/scripts/host-config.ts @@ -14,6 +14,9 @@ * platform-detect, uninstall */ +import type { Model } from './models'; +import { validateModel } from './models'; + export interface HostConfig { /** Unique host identifier (e.g., 'opencode'). Must match filename in hosts/. */ name: string; @@ -24,6 +27,9 @@ export interface HostConfig { /** Alternative binary names (e.g., ['droid'] for factory). */ cliAliases?: string[]; + /** Model overlay used when generation does not receive an explicit --model. */ + defaultModel: Model; + // --- Path Configuration --- /** Global install path relative to $HOME (e.g., '.config/opencode/skills/gstack'). */ globalRoot: string; @@ -119,6 +125,10 @@ export function validateHostConfig(config: HostConfig, validResolverNames?: Read } } } + const modelError = validateModel(config.defaultModel); + if (modelError) { + errors.push(`defaultModel ${modelError}`); + } if (!PATH_REGEX.test(config.globalRoot)) { errors.push(`globalRoot '${config.globalRoot}' contains invalid characters`); } diff --git a/scripts/models.ts b/scripts/models.ts index 1819ae54d..e7bdf3d2f 100644 --- a/scripts/models.ts +++ b/scripts/models.ts @@ -2,13 +2,17 @@ * Model taxonomy — neutral module with no imports from hosts/ or resolvers/. * * Model families supported by model overlays in model-overlays/{family}.md. - * Host configs can reference these as `defaultModel` strings (validated at + * Host configs reference these as `defaultModel` strings (validated at * generation 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'. + * front multiple providers. The generator does NOT auto-detect the model from + * the host — users can pass --model explicitly, otherwise each host supplies + * its own generation default. Exception outside this module: ./setup detects + * the Codex model from ${CODEX_HOME:-~/.codex}/config.toml + * (scripts/resolve-codex-generation-model.ts) and passes it as an explicit + * --model. */ export const ALL_MODEL_NAMES = [ @@ -19,6 +23,7 @@ export const ALL_MODEL_NAMES = [ 'sonnet-5', 'gpt', 'gpt-5.4', + 'gpt-5.6-sol', 'gemini', 'o-series', ] as const; @@ -29,10 +34,11 @@ export type Model = (typeof ALL_MODEL_NAMES)[number]; * Resolve a model argument from CLI input to a known Model family. * * Precedence rules: - * 1. Exact match against ALL_MODEL_NAMES → return as-is. + * 1. Exact match against ALL_MODEL_NAMES → return as-is. This is the ONLY + * path that selects `gpt-5.6-sol` — Sol is intentionally exact-only. * 2. Family heuristics for common variants: * - `gpt-5.4-mini`, `gpt-5.4-turbo`, `gpt-5.4-*` → `gpt-5.4` - * - `gpt-*` (anything else GPT) → `gpt` + * - `gpt-*` (anything else GPT, including other 5.6 variants) → `gpt` * - `o3`, `o4`, `o4-mini`, `o1`, `o1-mini`, `o1-pro` → `o-series` * - `claude-*` (sonnet, opus, haiku, any version) → `claude` * - `gemini-*` (2.5-pro, flash, etc.) → `gemini` @@ -52,6 +58,10 @@ export function resolveModel(input: string): Model | null { } // Family heuristics + // Sol never reaches here — the exact match above already returned it. Do + // not add a Sol family pattern: Terra, Luna, future 5.6 variants, and + // suffixed model IDs must NOT inherit Sol's behavioral profile; they fall + // through to the generic `gpt` family below. if (/^gpt-5\.4(-|$)/.test(s)) return 'gpt-5.4'; if (/^gpt(-|$)/.test(s)) return 'gpt'; if (/^o[0-9]+(-|$)/.test(s)) return 'o-series'; diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index c1a60ee3f..1a00f6cdb 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -113,7 +113,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" @@ -579,23 +579,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/host-config.test.ts b/test/host-config.test.ts index b685bb89d..0a167f409 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -112,6 +112,7 @@ describe('validateHostConfig', () => { name: 'test-host', displayName: 'Test Host', cliCommand: 'testcli', + defaultModel: 'claude', globalRoot: '.test/skills/gstack', localSkillRoot: '.test/skills/gstack', hostSubdir: '.test', @@ -165,6 +166,12 @@ describe('validateHostConfig', () => { expect(validateHostConfig(c)).toEqual([]); }); + test('invalid defaultModel is caught', () => { + const c = makeValid(); + (c as any).defaultModel = 'llama-local'; + expect(validateHostConfig(c).some(e => e.includes('defaultModel'))).toBe(true); + }); + test('invalid globalRoot is caught', () => { const c = makeValid(); c.globalRoot = 'path with spaces'; @@ -470,6 +477,13 @@ describe('golden-file regression', () => { // ─── Individual host config correctness ───────────────────── describe('host config correctness', () => { + test('Codex defaults to generic GPT while all existing hosts retain Claude', () => { + expect(codex.defaultModel).toBe('gpt'); + for (const host of ALL_HOST_CONFIGS.filter(h => h.name !== 'codex')) { + expect(host.defaultModel).toBe('claude'); + } + }); + test('claude is the only host with real-dir-symlink strategy', () => { for (const config of ALL_HOST_CONFIGS) { if (config.name === 'claude') {