diff --git a/README.md b/README.md index ac4d1d8..9e3e4d6 100644 --- a/README.md +++ b/README.md @@ -117,17 +117,20 @@ Quick routing rule: Standard read-only review of your current work. ```text -$cc:review # review uncommitted changes (default: opus + xhigh effort) +$cc:review # review uncommitted changes (default: opus + high effort) $cc:review --base main # review branch vs main $cc:review --scope branch # explicitly compare branch tip to base $cc:review --background # run in background, check with $cc:status later $cc:review --model sonnet # switch to sonnet (defaults to high effort) -$cc:review --model opus --effort high # opus with a lighter effort +$cc:review --model fable # use Fable (defaults to high effort) +$cc:review --model opus --effort xhigh # explicitly raise opus effort ``` -**Flags:** `--base `, `--scope `, `--wait`, `--background`, `--model `, `--effort ` +**Flags:** `--base `, `--scope `, `--wait`, `--background`, `--model `, `--effort ` -**Defaults:** model `opus` (resolves to `claude-opus-4-7[1m]`, the 1M-context variant) with `xhigh` effort. If you pick `sonnet`, it resolves to `claude-sonnet-4-6[1m]` (also 1M context) and the default effort drops to `high`. `haiku` resolves to `claude-haiku-4-5` and has no effort setting. Pass `--model` and `--effort` to override. +**Defaults:** model `opus` with `high` effort. After trimming surrounding whitespace, the friendly aliases `fable`, `opus`, `sonnet`, and `haiku` are matched case-insensitively and canonicalized to lowercase; every friendly alias defaults to `high` effort. Every other `--model` value passes through unchanged for Claude Code to resolve, including full model IDs and provider-specific names, and receives no inferred effort. Pass `--model` and `--effort` to override. + +**Model discovery:** run `/model` in Claude Code to see the models and effort levels available to your current account and provider, then pass the selected alias or full ID to this plugin. The plugin intentionally does not maintain a static model catalog or force `[1m]`; Claude Code owns alias versions, managed restrictions, provider routing, and extended-context eligibility. Scope `auto` (the default) inspects `git status` and chooses between working-tree and branch automatically. @@ -171,8 +174,8 @@ $cc:rescue --model sonnet --effort medium investigate the flaky test | `--resume-last` | Alias for `--resume` | | `--fresh` | Force a new task (don't resume) | | `--write` | Allow file edits (default) | -| `--model ` | Claude model (`opus`, `sonnet`, `haiku`, or full ID; defaults to `opus`. The `opus` and `sonnet` aliases resolve to their 1M-context variants `claude-opus-4-7[1m]` and `claude-sonnet-4-6[1m]`.) | -| `--effort ` | Reasoning effort: `low`, `medium`, `high`, `xhigh`, `max` (default: `xhigh` for opus, `high` for sonnet, unset for haiku) | +| `--model ` | Any Claude Code model alias, full model ID, or provider-specific name; defaults to `opus`. Run `/model` in Claude Code to discover options available to your account and provider. | +| `--effort ` | Reasoning effort: `low`, `medium`, `high`, `xhigh`, `max` (default: `high` for every friendly model alias) | | `--prompt-file ` | Read task description from a file | **Resume behavior:** If you don't pass `--resume` or `--fresh`, rescue checks for a resumable Claude session and asks once whether to continue or start fresh. Your phrasing guides the recommendation — "continue the last run" → resume, "start over" → fresh. @@ -278,7 +281,7 @@ The review gate is an **optional turn-end hook**. When enabled, Codex runs a Cla | **Runtime** | Codex app-server + broker | Fresh `claude -p` subprocess per invocation | | **Review gate trigger** | End of Claude Code turn | End of Codex turn | | **Review gate target** | Reviews previous Claude response | Reviews previous Codex response | -| **Model flags** | Codex model names and effort controls | Claude model names and effort values (`low` / `medium` / `high` / `max`) | +| **Model flags** | Codex model names and effort controls | Claude model names and effort values (`low` / `medium` / `high` / `xhigh` / `max`) | ### Where This Goes Further diff --git a/internal-skills/cli-runtime/runtime.md b/internal-skills/cli-runtime/runtime.md index ccd205a..031c047 100644 --- a/internal-skills/cli-runtime/runtime.md +++ b/internal-skills/cli-runtime/runtime.md @@ -24,7 +24,8 @@ Command selection: Routing controls: - Treat `--model`, `--effort`, `--resume`, `--resume-last`, `--fresh`, `--prompt-file`, `--view-state`, `--owner-session-id`, and `--job-id` as routing controls, not task text. -- Leave `--model` and `--effort` unset unless the user explicitly asks for a specific model or effort. The companion command applies these defaults itself: model defaults to `opus`, effort defaults to `xhigh` for opus, `high` for sonnet, and is left unset for haiku. +- Leave `--model` and `--effort` unset unless the user explicitly asks for a specific model or effort. The companion command applies these defaults itself: model defaults to `opus`; fable, opus, sonnet, and haiku each default to `high` effort. +- Forward an explicit `--model` value unchanged to the companion. The companion trims surrounding whitespace, canonicalizes the friendly aliases `fable`, `opus`, `sonnet`, and `haiku` to lowercase, then forwards every other `--model` value unchanged to Claude Code. Claude Code owns alias resolution; `/model` is the authoritative picker for the current account and provider. - `--view-state on-success` means the user will see this companion result in the current turn, so the companion may mark it viewed on success. - `--view-state defer` means the parent is not waiting, so the companion must leave the result unread until the user explicitly checks it. - `--owner-session-id ` is an internal parent-session routing control. Preserve it when present so tracked jobs remain visible to the parent session's `$cc:status` / `$cc:result`. diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index af7ed83..f897f38 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -10,9 +10,9 @@ * * Adapted from codex-companion.mjs: * - Uses claude-cli.mjs instead of app-server/broker - * - MODEL_ALIASES: opus -> claude-opus-4-7[1m], sonnet -> claude-sonnet-4-6[1m], haiku -> claude-haiku-4-5 + * - Friendly model aliases canonicalize to lowercase; other names pass through * - Default model when --model is unset: opus - * - Default effort by model: opus -> xhigh, sonnet -> high, haiku -> unset + * - Default effort for friendly model aliases: high * - Claude CLI effort values: low, medium, high, xhigh, max * - Legacy effort aliases: none|minimal -> low * - Review gate matches upstream setup semantics: Stop hook runs when enabled @@ -42,7 +42,7 @@ import { runClaudeReview, runClaudeAdversarialReview, cancelClaudeProcess, - MODEL_ALIASES, + resolveModel, resolveEffort, resolveDefaultModel, resolveDefaultEffort, @@ -137,9 +137,9 @@ function printUsage() { [ "Usage:", " node scripts/claude-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", - " node scripts/claude-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ]", - " node scripts/claude-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [focus text]", - " node scripts/claude-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/claude-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ]", + " node scripts/claude-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [focus text]", + " node scripts/claude-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/claude-companion.mjs status [job-id] [--all] [--json]", " node scripts/claude-companion.mjs result [job-id] [--json]", " node scripts/claude-companion.mjs cancel [job-id] [--json]", @@ -179,17 +179,6 @@ function redactOutputReplacer(key, value) { // Normalization // --------------------------------------------------------------------------- -function normalizeRequestedModel(model) { - if (model == null) { - return null; - } - const normalized = String(model).trim(); - if (!normalized) { - return null; - } - return MODEL_ALIASES.get(normalized.toLowerCase()) ?? normalized; -} - function resolveReservedJobFile(workspaceRoot, jobId) { const safeJobId = sanitizeId(jobId, "job ID"); return path.join(resolveJobsDir(workspaceRoot), `${safeJobId}${JOB_RESERVATION_SUFFIX}`); @@ -1521,7 +1510,7 @@ async function handleReviewCommand(argv, config) { Boolean(options.background) ); - const requestedModel = normalizeRequestedModel(options.model); + const requestedModel = resolveModel(options.model); const resolvedModel = resolveDefaultModel(requestedModel); const resolvedEffort = resolveDefaultEffort(resolvedModel, options.effort); @@ -1622,7 +1611,7 @@ async function handleTask(argv) { const cwd = resolveCommandCwd(options); const workspaceRoot = resolveCommandWorkspace(options); - const requestedModel = normalizeRequestedModel(options.model); + const requestedModel = resolveModel(options.model); const model = resolveDefaultModel(requestedModel); const resolvedEffort = resolveDefaultEffort(model, options.effort); const effort = resolvedEffort ? resolveEffort(resolvedEffort) : null; diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index 6f76da1..e80c684 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -634,15 +634,9 @@ export function pruneStaleReviewMcpConfigs(options = {}) { } // --------------------------------------------------------------------------- -// Model & Effort Mapping +// Model & Effort Selection // --------------------------------------------------------------------------- -export const MODEL_ALIASES = new Map([ - ["opus", "claude-opus-4-7[1m]"], - ["sonnet", "claude-sonnet-4-6[1m]"], - ["haiku", "claude-haiku-4-5"], -]); - export const EFFORT_ALIASES = { none: "low", minimal: "low", @@ -652,13 +646,13 @@ export const VALID_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]); export const DEFAULT_MODEL = "opus"; +const FRIENDLY_ALIASES = new Set(["fable", "opus", "sonnet", "haiku"]); + export const DEFAULT_EFFORT_BY_MODEL = new Map([ - ["opus", "xhigh"], - ["claude-opus-4-7", "xhigh"], - ["claude-opus-4-7[1m]", "xhigh"], + ["fable", "high"], + ["opus", "high"], ["sonnet", "high"], - ["claude-sonnet-4-6", "high"], - ["claude-sonnet-4-6[1m]", "high"], + ["haiku", "high"], ]); export function resolveDefaultModel(model) { @@ -677,8 +671,11 @@ export function resolveDefaultEffort(model, effort) { } export function resolveModel(model) { - if (!model) return undefined; - return MODEL_ALIASES.get(model) ?? model; + if (model == null) return undefined; + const normalized = String(model).trim(); + if (!normalized) return undefined; + const canonical = normalized.toLowerCase(); + return FRIENDLY_ALIASES.has(canonical) ? canonical : normalized; } export function resolveEffort(effort) { diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index eb16dd0..d090b3e 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -1,6 +1,6 @@ --- name: adversarial-review -description: 'Run a design-challenging Claude Code review of local git changes in this repository. Args: --wait, --background, --base , --scope , --model , --effort , [focus text]. Defaults to opus + xhigh effort. Use only when the user wants stronger scrutiny than a normal review, such as explicit tradeoff challenge, risky-change review, or custom focus text.' +description: 'Run a design-challenging Claude Code review of local git changes in this repository. Args: --wait, --background, --base , --scope , --model , --effort , [focus text]. Defaults to opus + high effort. Use only when the user wants stronger scrutiny than a normal review, such as explicit tradeoff challenge, risky-change review, or custom focus text.' --- # Claude Code Adversarial Review @@ -16,7 +16,9 @@ Unlike `$cc:review`, this skill accepts custom focus text after the flags. The m Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: `node "/scripts/claude-companion.mjs" adversarial-review ...` -Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort `, plus optional focus text after the flags (defaults: model=opus, effort=xhigh; sonnet defaults to high; haiku has no effort) +Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort `, plus optional focus text after the flags (defaults: model=opus; fable, opus, sonnet, and haiku each default to high effort) + +Forward `--model` unchanged to the companion. The companion trims surrounding whitespace, canonicalizes the friendly aliases `fable`, `opus`, `sonnet`, and `haiku` to lowercase, then forwards every other `--model` value unchanged to Claude Code. Claude Code owns alias resolution; `/model` is the authoritative picker for the current account and provider. Raw slash-command arguments: `$ARGUMENTS` diff --git a/skills/rescue/SKILL.md b/skills/rescue/SKILL.md index b446f4f..f83fc08 100644 --- a/skills/rescue/SKILL.md +++ b/skills/rescue/SKILL.md @@ -1,6 +1,6 @@ --- name: rescue -description: 'Delegate a substantial diagnosis, implementation, or follow-up task to Claude Code through the tracked-job runtime. Args: --background, --wait, --resume, --resume-last, --fresh, --write, --model , --effort , --prompt-file , [task text]. Defaults to opus + xhigh effort. Use when Claude should investigate or change things, not when the user only wants review findings.' +description: 'Delegate a substantial diagnosis, implementation, or follow-up task to Claude Code through the tracked-job runtime. Args: --background, --wait, --resume, --resume-last, --fresh, --write, --model , --effort , --prompt-file , [task text]. Defaults to opus + high effort. Use when Claude should investigate or change things, not when the user only wants review findings.' --- # Claude Code Rescue @@ -24,6 +24,10 @@ Raw slash-command arguments: Supported arguments: `--background`, `--wait`, `--resume`, `--resume-last`, `--fresh`, `--write`, `--model `, `--effort `, `--prompt-file `, plus free-text task text +Companion defaults: model=opus; fable, opus, sonnet, and haiku each default to high effort. + +Forward `--model` unchanged to the companion. The companion trims surrounding whitespace, canonicalizes the friendly aliases `fable`, `opus`, `sonnet`, and `haiku` to lowercase, then forwards every other `--model` value unchanged to Claude Code. Claude Code owns alias resolution; `/model` is the authoritative picker for the current account and provider. + Main-thread routing rules: - If the user explicitly invoked `$cc:rescue` or `Claude Code Rescue`, do not keep the work in the main Codex thread. Delegate it. - If the user did not supply a task, ask what Claude Code should investigate or fix. diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index fa0ee17..efac1b1 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -1,6 +1,6 @@ --- name: review -description: 'Run a standard Claude Code review of local git changes in this repository. Args: --wait, --background, --base , --scope , --model , --effort . Defaults to opus + xhigh effort. Use as the default path for ordinary code-review requests when the user did not explicitly ask for stronger adversarial scrutiny or for Claude to own the implementation work.' +description: 'Run a standard Claude Code review of local git changes in this repository. Args: --wait, --background, --base , --scope , --model , --effort . Defaults to opus + high effort. Use as the default path for ordinary code-review requests when the user did not explicitly ask for stronger adversarial scrutiny or for Claude to own the implementation work.' --- # Claude Code Review @@ -16,7 +16,9 @@ If the overall request is "you review it too, also ask Claude to review in the b Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: `node "/scripts/claude-companion.mjs" review ...` -Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort ` (defaults: model=opus, effort=xhigh; sonnet defaults to high; haiku has no effort) +Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort ` (defaults: model=opus; fable, opus, sonnet, and haiku each default to high effort) + +Forward `--model` unchanged to the companion. The companion trims surrounding whitespace, canonicalizes the friendly aliases `fable`, `opus`, `sonnet`, and `haiku` to lowercase, then forwards every other `--model` value unchanged to Claude Code. Claude Code owns alias resolution; `/model` is the authoritative picker for the current account and provider. Raw slash-command arguments: `$ARGUMENTS` diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index 58d73a8..a3cd9aa 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -14,7 +14,6 @@ import { resolveDefaultEffort, resolveClaudeBin, buildArgs, - MODEL_ALIASES, EFFORT_ALIASES, VALID_EFFORTS, DEFAULT_MODEL, @@ -428,16 +427,24 @@ describe("validateTurnCompletion", () => { // =========================================================================== describe("resolveModel", () => { - it("maps 'sonnet' to the 1M variant 'claude-sonnet-4-6[1m]'", () => { - assert.equal(resolveModel("sonnet"), "claude-sonnet-4-6[1m]"); - }); - - it("maps 'haiku' to 'claude-haiku-4-5'", () => { - assert.equal(resolveModel("haiku"), "claude-haiku-4-5"); + it("canonicalizes friendly model aliases case-insensitively", () => { + for (const alias of ["fable", "opus", "sonnet", "haiku"]) { + assert.equal(resolveModel(alias), alias); + assert.equal(resolveModel(alias.toUpperCase()), alias); + assert.equal(resolveModel(`${alias[0].toUpperCase()}${alias.slice(1)}`), alias); + } }); - it("passes through unknown model names", () => { - assert.equal(resolveModel("claude-3-opus-20240229"), "claude-3-opus-20240229"); + it("passes other aliases, full IDs, and provider-specific names through unchanged", () => { + for (const alias of ["default", "best", "opusplan", "opus[1m]", "sonnet[1m]"]) { + assert.equal(resolveModel(alias), alias); + } + assert.equal(resolveModel("claude-fable-5"), "claude-fable-5"); + assert.equal(resolveModel("CLAUDE-FABLE-5"), "CLAUDE-FABLE-5"); + assert.equal( + resolveModel("Us.Anthropic.Claude-Opus-Custom"), + "Us.Anthropic.Claude-Opus-Custom" + ); }); it("returns undefined for null/undefined input", () => { @@ -445,19 +452,14 @@ describe("resolveModel", () => { assert.equal(resolveModel(undefined), undefined); }); - it("passes through empty string", () => { + it("returns undefined for blank input", () => { assert.equal(resolveModel(""), undefined); + assert.equal(resolveModel(" "), undefined); }); - it("maps 'opus' to the 1M variant 'claude-opus-4-7[1m]'", () => { - assert.equal(resolveModel("opus"), "claude-opus-4-7[1m]"); - }); - - it("MODEL_ALIASES map has expected entries", () => { - assert.equal(MODEL_ALIASES.size, 3); - assert.ok(MODEL_ALIASES.has("opus")); - assert.ok(MODEL_ALIASES.has("sonnet")); - assert.ok(MODEL_ALIASES.has("haiku")); + it("trims surrounding whitespace before canonicalizing friendly aliases", () => { + assert.equal(resolveModel(" OpUs "), "opus"); + assert.equal(resolveModel(" claude-fable-5 "), "claude-fable-5"); }); }); @@ -475,6 +477,7 @@ describe("resolveDefaultModel", () => { it("passes through an explicit model", () => { assert.equal(resolveDefaultModel("sonnet"), "sonnet"); + assert.equal(resolveDefaultModel("fable"), "fable"); assert.equal(resolveDefaultModel("haiku"), "haiku"); assert.equal(resolveDefaultModel("claude-opus-4-7"), "claude-opus-4-7"); }); @@ -485,25 +488,17 @@ describe("resolveDefaultModel", () => { }); describe("resolveDefaultEffort", () => { - it("defaults to xhigh for opus alias and resolved id (including 1M variant)", () => { - assert.equal(resolveDefaultEffort("opus", null), "xhigh"); - assert.equal(resolveDefaultEffort("claude-opus-4-7", null), "xhigh"); - assert.equal(resolveDefaultEffort("claude-opus-4-7[1m]", null), "xhigh"); - assert.equal(resolveDefaultEffort("OPUS", undefined), "xhigh"); - }); - - it("defaults to high for sonnet alias and resolved id (including 1M variant)", () => { - assert.equal(resolveDefaultEffort("sonnet", null), "high"); - assert.equal(resolveDefaultEffort("claude-sonnet-4-6", null), "high"); - assert.equal(resolveDefaultEffort("claude-sonnet-4-6[1m]", null), "high"); - }); - - it("returns undefined for haiku (no effort default)", () => { - assert.equal(resolveDefaultEffort("haiku", null), undefined); - assert.equal(resolveDefaultEffort("claude-haiku-4-5", undefined), undefined); + it("defaults to high for every friendly model alias", () => { + for (const alias of ["fable", "opus", "sonnet", "haiku"]) { + assert.equal(resolveDefaultEffort(alias, null), "high"); + assert.equal(resolveDefaultEffort(alias.toUpperCase(), undefined), "high"); + } }); - it("returns undefined for unknown model when effort not provided", () => { + it("does not infer effort for full or provider-specific model names", () => { + assert.equal(resolveDefaultEffort("claude-opus-4-7[1m]", null), undefined); + assert.equal(resolveDefaultEffort("claude-sonnet-4-6", null), undefined); + assert.equal(resolveDefaultEffort("us.anthropic.claude-opus-custom", null), undefined); assert.equal(resolveDefaultEffort("some-future-model", null), undefined); }); @@ -515,18 +510,15 @@ describe("resolveDefaultEffort", () => { }); it("treats blank effort as missing", () => { - assert.equal(resolveDefaultEffort("opus", ""), "xhigh"); - assert.equal(resolveDefaultEffort("opus", " "), "xhigh"); + assert.equal(resolveDefaultEffort("opus", ""), "high"); + assert.equal(resolveDefaultEffort("opus", " "), "high"); }); it("DEFAULT_EFFORT_BY_MODEL contains the expected entries", () => { - assert.equal(DEFAULT_EFFORT_BY_MODEL.get("opus"), "xhigh"); - assert.equal(DEFAULT_EFFORT_BY_MODEL.get("claude-opus-4-7"), "xhigh"); - assert.equal(DEFAULT_EFFORT_BY_MODEL.get("claude-opus-4-7[1m]"), "xhigh"); - assert.equal(DEFAULT_EFFORT_BY_MODEL.get("sonnet"), "high"); - assert.equal(DEFAULT_EFFORT_BY_MODEL.get("claude-sonnet-4-6"), "high"); - assert.equal(DEFAULT_EFFORT_BY_MODEL.get("claude-sonnet-4-6[1m]"), "high"); - assert.equal(DEFAULT_EFFORT_BY_MODEL.has("haiku"), false); + for (const alias of ["fable", "opus", "sonnet", "haiku"]) { + assert.equal(DEFAULT_EFFORT_BY_MODEL.get(alias), "high"); + } + assert.equal(DEFAULT_EFFORT_BY_MODEL.size, 4); }); }); @@ -728,11 +720,18 @@ describe("buildArgs", () => { assert.ok(!args.includes("--no-session-persistence")); }); - it("includes --model with resolved model alias", () => { + it("includes the model alias unchanged", () => { const args = buildArgs("p", { model: "sonnet" }); const idx = args.indexOf("--model"); assert.ok(idx >= 0); - assert.equal(args[idx + 1], "claude-sonnet-4-6[1m]"); + assert.equal(args[idx + 1], "sonnet"); + }); + + it("includes the native fable alias unchanged", () => { + const args = buildArgs("p", { model: "fable" }); + const idx = args.indexOf("--model"); + assert.ok(idx >= 0); + assert.equal(args[idx + 1], "fable"); }); it("includes --effort with resolved effort", () => { diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index b2da566..a0a1fde 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -1741,7 +1741,7 @@ describe("Codex direct-skill E2E", () => { const claudeInvocations = readClaudeInvocations(testEnv.claudeLogFile); assert.ok( claudeInvocations.some( - (entry) => entry.args.includes("--model") && entry.args.includes("claude-haiku-4-5") + (entry) => entry.args.includes("--model") && entry.args.includes("haiku") ), "installed plugin review should forward the requested model alias to Claude without running setup first" ); @@ -1792,7 +1792,7 @@ describe("Codex direct-skill E2E", () => { assert.match(finalMessage, /Claude Code Review/); const claudeInvocations = readClaudeInvocations(testEnv.claudeLogFile); assert.ok( - claudeInvocations.some((entry) => entry.args.includes("--model") && entry.args.includes("claude-haiku-4-5")), + claudeInvocations.some((entry) => entry.args.includes("--model") && entry.args.includes("haiku")), "review e2e should forward the requested model alias to Claude" ); } finally { diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 3f09d55..0840578 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -942,7 +942,7 @@ describe("claude-companion integration", () => { const args = JSON.parse(fs.readFileSync(argsFile, "utf8")); assert.equal(args[0], "-p"); assert.ok(args.includes("--model")); - assert.equal(args[args.indexOf("--model") + 1], "claude-haiku-4-5"); + assert.equal(args[args.indexOf("--model") + 1], "haiku"); assert.ok(args.includes("--effort")); assert.equal(args[args.indexOf("--effort") + 1], "high"); assert.ok(args.includes("--permission-mode")); @@ -954,6 +954,103 @@ describe("claude-companion integration", () => { } }); + it("defaults every friendly model alias to high effort", () => { + const testEnv = createTestEnvironment(); + + try { + for (const alias of ["fable", "opus", "sonnet", "haiku"]) { + const argsFile = path.join(testEnv.rootDir, `${alias}-default-effort-args.json`); + const requestedAlias = alias.toUpperCase(); + runCompanion( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--model", + requestedAlias, + "--quiet-progress", + `${alias} default effort delay=20`, + ], + { + env: { + ...testEnv.env, + CLAUDE_ARGS_FILE: argsFile, + }, + } + ); + + const args = JSON.parse(fs.readFileSync(argsFile, "utf8")); + assert.equal(args[args.indexOf("--model") + 1], alias); + assert.equal(args[args.indexOf("--effort") + 1], "high"); + } + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("canonicalizes native Fable with high default effort through task and review flows", () => { + const testEnv = createTestEnvironment(); + + try { + const taskArgsFile = path.join(testEnv.rootDir, "fable-task-args.json"); + runCompanion( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--model", + "FaBlE", + "--quiet-progress", + "fable task delay=20", + ], + { + env: { + ...testEnv.env, + CLAUDE_ARGS_FILE: taskArgsFile, + }, + } + ); + + const taskArgs = JSON.parse(fs.readFileSync(taskArgsFile, "utf8")); + assert.equal(taskArgs[taskArgs.indexOf("--model") + 1], "fable"); + assert.equal(taskArgs[taskArgs.indexOf("--effort") + 1], "high"); + + setupGitWorkspace(testEnv.workspaceDir); + seedWorkingTreeDiff(testEnv.workspaceDir); + + for (const [command, focusText] of [ + ["review", []], + ["adversarial-review", ["focus on model routing"]], + ]) { + const invocationFile = path.join(testEnv.rootDir, `fable-${command}-invocation.json`); + runCompanion( + [ + command, + "--cwd", + testEnv.workspaceDir, + "--scope", + "working-tree", + "--model", + "FaBlE", + ...focusText, + ], + { + env: { + ...testEnv.env, + CLAUDE_INVOCATION_FILE: invocationFile, + }, + } + ); + + const invocation = JSON.parse(fs.readFileSync(invocationFile, "utf8")); + assert.equal(invocation.args[invocation.args.indexOf("--model") + 1], "fable"); + assert.equal(invocation.args[invocation.args.indexOf("--effort") + 1], "high"); + } + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("uses --resume to continue the latest session and keeps --fresh from injecting a resume id", async () => { const testEnv = createTestEnvironment(); const sessionEnv = { @@ -1054,7 +1151,7 @@ describe("claude-companion integration", () => { ); assert.equal( reviewInvocation.args[reviewInvocation.args.indexOf("--model") + 1], - "claude-haiku-4-5" + "haiku" ); assert.match(reviewInvocation.prompt, /working tree diff/i); assert.match(reviewResult.stdout, /Claude Code Review/); @@ -1148,7 +1245,7 @@ describe("claude-companion integration", () => { const invocation = JSON.parse(fs.readFileSync(invocationFile, "utf8")); assert.equal( invocation.args[invocation.args.indexOf("--model") + 1], - "claude-haiku-4-5" + "haiku" ); assert.match(invocation.prompt, /focus on command injection/i); assert.match(result.stdout, /Adversarial Review/); diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index 032b4ea..1362e14 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -16,6 +16,70 @@ function read(relativePath) { return fs.readFileSync(path.join(PROJECT_ROOT, relativePath), "utf8"); } +test("public model contracts document native Fable support and alias effort policy", () => { + const contracts = [ + "README.md", + "skills/review/SKILL.md", + "skills/adversarial-review/SKILL.md", + "skills/rescue/SKILL.md", + "internal-skills/cli-runtime/runtime.md", + ]; + + for (const contractPath of contracts) { + const contract = read(contractPath); + assert.match(contract, /fable/i, `${contractPath} must document Fable`); + for (const alias of ["fable", "opus", "sonnet", "haiku"]) { + assert.match( + contract, + new RegExp(`${alias}[^\\n]*high|high[^\\n]*${alias}`, "i"), + `${contractPath} must document ${alias}'s high default effort` + ); + } + } +}); + +test("model contracts delegate discovery and alias resolution to Claude Code", () => { + const contracts = [ + "README.md", + "skills/review/SKILL.md", + "skills/adversarial-review/SKILL.md", + "skills/rescue/SKILL.md", + "internal-skills/cli-runtime/runtime.md", + ]; + + for (const contractPath of contracts) { + const contract = read(contractPath); + assert.match(contract, /\/model/i, `${contractPath} must point to Claude Code model discovery`); + assert.match( + contract, + /friendly aliases?[^\n]*lowercase/i, + `${contractPath} must document friendly alias canonicalization` + ); + assert.match( + contract, + /every other `--model` value[^\n]*unchanged/i, + `${contractPath} must preserve non-friendly model values` + ); + } + + for (const contractPath of contracts.filter((contractPath) => contractPath !== "README.md")) { + const contract = read(contractPath); + assert.match( + contract, + /Forward (?:an explicit )?`--model`(?: value)? unchanged to the companion/i, + `${contractPath} must preserve the user's model value until the companion boundary` + ); + } + + const implementation = [ + read("scripts/claude-companion.mjs"), + read("scripts/lib/claude-cli.mjs"), + ].join("\n"); + assert.doesNotMatch(implementation, /MODEL_ALIASES/); + assert.doesNotMatch(implementation, /claude-(?:opus|sonnet|haiku)-\d/); + assert.doesNotMatch(implementation, /\[1m\]/); +}); + test("built-in child commands preserve the workspace for reserved job ids", () => { const skills = [ ["rescue", "skills/rescue/SKILL.md"],