feat(#443): unified cross-provider effort controls + fast-mode-aware routing (#463)

* test(#443): RED unified effort + fast_mode + resolve-execution

All 68 tests failing as expected — no implementation yet.
Covers: effort cascade (tier defaults, overrides, invalid fallthrough),
fast_mode cascade (boolean-only, tier defaults), resolveEffortForTier
escalation, renderEffortForRuntime clamping, resolve-execution CLI,
config schema new keys, QA hostile-input matrix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#443): unified cross-provider effort + fast_mode knobs and resolve-execution query

Adds config-driven effort control (universal ladder: minimal<low<medium<high<xhigh<max)
and fast_mode propagation knobs, with per-runtime rendering that clamps the unique
tail values (max=Anthropic-only clamps to xhigh on Codex; minimal=Codex-only clamps
to low on Claude).

Key changes:
- config-schema.manifest.json: add effort.default, fast_mode.enabled as validKeys;
  add 4 dynamicKeyPatterns for effort.routing_tier_defaults, effort.agent_overrides,
  fast_mode.routing_tier_defaults, fast_mode.agent_overrides; fix stale _comment
- config-defaults.manifest.json: add effort and fast_mode blocks with tier defaults
- model-catalog.cjs: add EFFORT_RENDERING map, renderEffortForRuntime(), RUNTIMES_WITH_FAST_MODE
- model-profiles.cjs: re-export new catalog exports
- core.cjs: add resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier,
  VALID_EFFORTS, EFFORT_SET, nextEffort; pass effort/fast_mode through loadConfig
- commands.cjs: replace reasoning_effort in cmdResolveModel with unified effort;
  add cmdResolveExecution (superset command with effort_rendered, effort_param,
  effort_propagation, fast_mode, fast_mode_supported)
- gsd-tools.cjs: add resolve-execution case with --effort/--fast-mode/--attempt flags
- tests/feat-443: 69 tests covering cascade, rendering, escalation, CLI, schema, QA matrix
- tests/commands.test.cjs: convert 3 reasoning_effort assertions to unified effort
- docs/CONFIGURATION.md: document effort + fast_mode + resolve-execution sections
- settings-advanced.md: list new effort/fast_mode keys in confirmation table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#443): remove dead catalog effort lane; unify codex effort through renderEffortForRuntime

- Remove resolveReasoningEffortInternal (catalog-driven effort function) from
  core.cjs and its export; remove from commands.cjs destructure import
- Convert tests/issue-2517-runtime-aware-profiles.test.cjs: all 11 effort
  assertions now use resolveEffortInternal + renderEffortForRuntime; Claude
  effort is first-class (output_config.effort); unknown runtimes assert param===null
- Convert tests/feat-3023-model-phase-types.test.cjs: replace the entire
  resolveReasoningEffortInternal describe with unified effort assertions;
  effort derives from AGENT_DEFAULT_TIERS routing tier, not phase-type tier

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(#443): ADR for unified cross-provider effort + fast-mode routing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(#443): architecture-level QA invariants + test-strategy doc

Add 48-test integration suite (feat-443-effort-fast-mode.integration.test.cjs)
covering 8 architectural invariants: cross-provider validity (never emit a value
the real API would 400 on), param/channel contract stability, resolve-execution
JSON contract (all 8 keys + correct types), totality across the full 33-agent
registry, fast-mode honesty (claude always fast_mode_supported=false), precedence
first-valid-wins matrix for both effort and fast_mode cascades, dynamic-routing
composition (effort escalation independent of model tier), and config-set round-trip
for all new effort/* and fast_mode/* key namespaces. Append test-strategy section
with invariant rationale and E2E gap documentation to docs/TESTING-SUITES.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#443): add failing install-wiring tests for effort per-runtime injection (RED)

TDD RED: 10 failing tests covering:
- Claude .md gets effort: injected per tier (planner=xhigh, mapper=low, executor=high)
- Gemini .md does NOT get effort: (already passing — Gemini-safe)
- Codex .toml gets model_reasoning_effort via unified resolver
- Config-driven: effort.agent_overrides drives both Claude .md and Codex .toml
- Source purity: agents/*.md have no effort: key (already passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#443): wire effort per-runtime at install (Claude .md frontmatter + Codex .toml unified)

- Import AGENT_DEFAULT_TIERS and renderEffortForRuntime from model-catalog.cjs
- Add readGsdEffectiveEffortConfig(targetDir): reads merged effort config from
  .planning/config.json (per-project wins) + ~/.gsd/defaults.json (global fallback),
  same probe pattern as readGsdRuntimeProfileResolver
- Add resolveInstallTimeEffort(effortCfg, agentName): pure function matching
  resolveEffortInternal() precedence (agent_overrides > routing_tier_defaults > default > 'high')
  without loadConfig side-effects (no sub-repo detection, no migration writes)
- Claude agent copy loop: inject `effort: <value>` into frontmatter ONLY for
  runtime === 'claude'; all other .md runtimes (Gemini, Qwen, Hermes, etc.) stay
  effort-free (Gemini-safe source contract preserved in agents/*.md)
- generateCodexAgentToml: add effortCfg param; emit model_reasoning_effort from
  unified resolver (replaces old catalog entry.reasoning_effort); Codex clamps
  max → xhigh via renderEffortForRuntime('codex', ...)
- installCodexConfig: pass readGsdEffectiveEffortConfig(targetDir) to
  generateCodexAgentToml so per-project config wins for Codex .toml too
- Update failing tests to GREEN: 12/12 pass; all 17 install tests pass;
  2847/2848 unit tests pass (1 pre-existing failure: policy-shell-pinning)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#443): source install effort defaults from manifest (kill drift) + guard test

Replace hardcoded _GSD_EFFORT_MANIFEST_TIER_DEFAULTS and the 'high' fallback in
resolveInstallTimeEffort with values read from config-defaults.manifest.json at
module init, using the same __dirname-relative path install.js already uses for
all shared manifests. Add feat-443-effort-defaults-drift.test.cjs to assert
equality between install.js's runtime constants and the manifest on every CI run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#443): reconcile Codex TOML tests with unified effort design

The #443 unified effort resolver makes generateCodexAgentToml always emit
model_reasoning_effort (driven by resolveInstallTimeEffort, not model_profile_overrides).
The test 'generated TOML omits reasoning_effort when runtime has none' had an
obsolete premise — model_profile_overrides.reasoning_effort:'' no longer suppresses
unified effort. Convert it to assert the new invariant: Codex TOML always carries a
valid model_reasoning_effort from the agent's routing tier (xhigh for gsd-planner,
a heavy-tier agent), while model_profile_overrides model override is still respected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#443): make install.js effort resolution lazy (no load-time side effects breaking launcher-parity)

Replace module-load-time IIFE + hard throw (config-defaults.manifest.json read)
and top-level require of model-catalog.cjs with a lazy _getGsdEffortCatalog()
getter that initialises on first call from resolveInstallTimeEffort /
generateCodexAgentToml / Claude .md effort injection.  Requiring install.js in
unrelated test contexts (e.g. runtime-launcher-parity) no longer triggers
manifest IO or throws, eliminating the load-time side effect that changed
subprocess exit codes / stderr on the bench.

Drift-guard exports (_GSD_EFFORT_MANIFEST_TIER_DEFAULTS / _GSD_EFFORT_MANIFEST_DEFAULT)
preserved as lazy getter properties on module.exports so feat-443-effort-defaults-drift
still validates them without forcing eager load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#443): isolate install-wiring test HOME to stop \$HOME/.claude pollution breaking launcher-parity

runGlobalInstall() now redirects HOME to a per-call isolated tmpdir in addition
to the existing runtime-specific env-var redirects (CLAUDE_CONFIG_DIR,
GEMINI_CONFIG_DIR, CODEX_HOME). This ensures install.js code that uses
os.homedir() directly — including the ~/.cache/gsd update-check deletion,
~/.gsd/defaults.json reads, and any HOME-relative npm subprocess writes —
never touches the real \$HOME during the test.

Without the HOME isolation the install test (which is new to this branch and
is now picked up by Docker's raw \`tests/*.test.cjs\` glob) could write or
delete files under the real \$HOME, causing runtime-launcher-parity test (D)
to fail: (D) asserts a loud non-zero exit when \$RUNTIME_DIR/gsd-tools.cjs is
absent and gsd-tools is not on PATH, but the launcher's \$HOME/.claude fallback
arm succeeds if \$HOME/.claude/get-shit-done/bin/gsd-tools.cjs exists.

Also sets GSD_SKIP_STALE_SDK_CHECK=1 to suppress the \`npm ls -g\` subprocess
that the global installer spawns — irrelevant to effort-wiring assertions,
slow, and potentially writes to ~/.npm cache.

All 12 feat-443 install-wiring assertions preserved. Drift-guard 5/5. Unit
suite 2848/2850 (pre-existing policy-shell-pinning.test.cjs failure on next).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(#443): add changeset fragment for effort + fast-mode routing

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#443): set GSD_TEST_MODE before requiring install.js in drift-guard test to prevent HOME leak

Without GSD_TEST_MODE=1, require('bin/install.js') runs the module's main
install block (guarded by !GSD_TEST_MODE), performing a real global Claude
install into $HOME/.claude/. On CI ubuntu where node is on standard PATH,
the launcher's $HOME/.claude fallback arm then finds gsd-tools.cjs, causing
runtime-launcher-parity test (D) to exit zero when it must exit non-zero.

Root cause: feat-443-effort-defaults-drift.test.cjs (unit suite) runs
alphabetically before runtime-launcher-parity.test.cjs in the same node
--test invocation. Each runs in a separate worker process but shares the
same HOME. The drift test's install leaks gsd-tools.cjs into that HOME,
then the launcher test's bash subprocess finds it via the $HOME/.claude arm.

Fix: add process.env.GSD_TEST_MODE = '1' at the top of the drift-guard
test, before the require(installPath) call. This matches the pattern used
by feat-443-effort-fast-mode.test.cjs and feat-443-effort-install-wiring
.install.test.cjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#443): deterministic resolve-execution arg parsing + validate install-time effort (Codex adversarial findings)

Finding 1: resolve-execution --effort low gsd-planner misrouted 'low' as the agent.
Replace find(non-dash) with a proper flag-consuming loop that collects a single
positional; validate missing/extra positionals and malformed --attempt values.

Finding 2: resolveInstallTimeEffort returned unvalidated effort strings (e.g. "ultra")
verbatim. Each precedence layer now checks GSD_EFFORT_SET (imported once from
core.cjs) before accepting a value, mirroring resolveEffortInternal exactly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#443): newline-agnostic effort frontmatter injection (Windows CRLF) + CRLF-safe assertions

Extracts injectEffortFrontmatter(content, effortValue) pure helper that detects
EOL (LF vs CRLF) from the opening '---' line and inserts 'effort: <value>'
before the closing '---' delimiter using the same EOL as the surrounding
frontmatter. Regex now uses /^---\r?\n([\s\S]*?)^---\r?$/m instead of the
LF-only /^(---\n[\s\S]*?)(---)(\n|$)/ that silently skipped CRLF files on
Windows (git core.autocrlf=true checkout).

Also adds 7 unit tests covering LF, CRLF, idempotency, no-frontmatter, and
complex frontmatter cases. Exports injectEffortFrontmatter from module.exports.

Fixes 6 CI failures in tests/feat-443-effort-install-wiring.install.test.cjs
on windows-latest runners (lines 138, 145, 152, 261, 345, 356).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-29 10:32:58 -04:00
committed by GitHub
parent 199251c0f6
commit 5ca646f015
20 changed files with 3354 additions and 211 deletions

View File

@@ -538,6 +538,81 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
break;
}
case 'resolve-execution': {
// Deterministic flag parsing: consume --flag <value> pairs first,
// then the AGENT is the single remaining positional.
// Supports both orderings: <agent> --flag val AND --flag val <agent>.
// Also supports --flag=value form (same convention as --cwd= above).
const execArgs = args.slice(1);
let effortOverride;
let fastModeOverride;
let attempt;
const positionals = [];
for (let i = 0; i < execArgs.length; i++) {
const a = execArgs[i];
// --effort=<val> form
if (a.startsWith('--effort=')) {
effortOverride = a.slice('--effort='.length);
continue;
}
// --fast-mode=<val> form
if (a.startsWith('--fast-mode=')) {
const v = a.slice('--fast-mode='.length);
fastModeOverride = v === 'true' ? true : v === 'false' ? false : undefined;
continue;
}
// --attempt=<val> form
if (a.startsWith('--attempt=')) {
const v = a.slice('--attempt='.length);
const n = parseInt(v, 10);
if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
attempt = n;
continue;
}
// --effort <val>
if (a === '--effort') {
const val = execArgs[i + 1];
if (val === undefined || val.startsWith('--')) error('Missing value for --effort', ERROR_REASON.USAGE);
effortOverride = val;
i++;
continue;
}
// --fast-mode <val>
if (a === '--fast-mode') {
const val = execArgs[i + 1];
if (val === undefined || val.startsWith('--')) error('Missing value for --fast-mode', ERROR_REASON.USAGE);
fastModeOverride = val === 'true' ? true : val === 'false' ? false : undefined;
i++;
continue;
}
// --attempt <val>
if (a === '--attempt') {
const val = execArgs[i + 1];
if (val === undefined || val.startsWith('--')) error('Missing value for --attempt', ERROR_REASON.USAGE);
const n = parseInt(val, 10);
if (!Number.isInteger(n) || n < 0) error('--attempt requires a non-negative integer', ERROR_REASON.USAGE);
attempt = n;
i++;
continue;
}
// --raw is handled by top-level arg processing; skip it here
if (a === '--raw') continue;
// Unknown flag
if (a.startsWith('-')) error(`Unknown flag for resolve-execution: ${a}`, ERROR_REASON.USAGE);
// Positional
positionals.push(a);
}
if (positionals.length === 0) error('agent-type required', ERROR_REASON.USAGE);
if (positionals.length > 1) error(`resolve-execution requires exactly one agent-type argument; got: ${positionals.join(', ')}`, ERROR_REASON.USAGE);
const agentTypeArg = positionals[0];
commands.cmdResolveExecution(cwd, agentTypeArg, raw, {
effortOverride,
fastModeOverride,
attempt,
});
break;
}
case 'find-phase': {
// Phase 6 (#3575): dispatch via SDK executeForCjs when available.
// SDK handler: findPhase in sdk/src/query/phase.ts.

View File

@@ -4,7 +4,8 @@
const fs = require('fs');
const path = require('path');
const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveReasoningEffortInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs');
const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs');
const { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } = require('./model-catalog.cjs');
const { planningDir, planningPaths } = require('./planning-workspace.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { MODEL_PROFILES } = require('./model-profiles.cjs');
@@ -241,16 +242,71 @@ function cmdResolveModel(cwd, agentType, raw) {
const config = loadConfig(cwd);
const profile = config.model_profile || 'balanced';
const model = resolveModelInternal(cwd, agentType);
const reasoningEffort = resolveReasoningEffortInternal(cwd, agentType);
const effort = resolveEffortInternal(cwd, agentType);
const agentModels = MODEL_PROFILES[agentType];
const result = agentModels
? { model, profile }
: { model, profile, unknown_agent: true };
if (reasoningEffort) result.reasoning_effort = reasoningEffort;
? { model, profile, effort }
: { model, profile, effort, unknown_agent: true };
output(result, raw, model);
}
/**
* #443 — Superset execution query: model + unified effort + fast_mode.
*
* Emits JSON:
* { model, profile, effort, effort_rendered, effort_param, effort_propagation,
* fast_mode, fast_mode_supported, [unknown_agent] }
*
* Flags: --effort <level>, --fast-mode <true|false>, --attempt <n>
*
* @param {string} cwd
* @param {string} agentType
* @param {boolean} raw
* @param {{ effortOverride?: string, fastModeOverride?: boolean, attempt?: number }} [opts]
*/
function cmdResolveExecution(cwd, agentType, raw, opts) {
if (!agentType) {
error('agent-type required');
}
opts = opts || {};
const config = loadConfig(cwd);
const profile = config.model_profile || 'balanced';
const model = resolveModelInternal(cwd, agentType);
const effortOpts = {};
if (typeof opts.effortOverride === 'string') effortOpts.override = opts.effortOverride;
const fastModeOpts = {};
if (typeof opts.fastModeOverride === 'boolean') fastModeOpts.override = opts.fastModeOverride;
const effort = (opts.attempt !== undefined && opts.attempt !== null)
? resolveEffortForTier(cwd, agentType, opts.attempt)
: resolveEffortInternal(cwd, agentType, effortOpts);
const fastMode = resolveFastModeInternal(cwd, agentType, fastModeOpts);
const runtime = config.runtime || 'claude';
const rendered = renderEffortForRuntime(runtime, effort);
const fastModeSupported = RUNTIMES_WITH_FAST_MODE.has(runtime);
const agentModels = MODEL_PROFILES[agentType];
const result = {
model,
profile,
effort,
effort_rendered: rendered.value,
effort_param: rendered.param,
effort_propagation: rendered.channel,
fast_mode: fastMode,
fast_mode_supported: fastModeSupported,
};
if (!agentModels) result.unknown_agent = true;
output(result, raw, effort);
}
function cmdCommit(cwd, message, files, raw, amend, noVerify) {
if (!message && !amend) {
error('commit message required');
@@ -1117,6 +1173,7 @@ module.exports = {
cmdVerifyPathExists,
cmdHistoryDigest,
cmdResolveModel,
cmdResolveExecution,
cmdCommit,
cmdCommitToSubrepo,
cmdSummaryExtract,

View File

@@ -7,7 +7,7 @@ const os = require('os');
const path = require('path');
const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = require('./model-profiles.cjs');
const { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT } = require('./model-catalog.cjs');
const { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } = require('./model-catalog.cjs');
const {
resolveWorktreeContext,
parseWorktreePorcelain: parseWorktreePorcelainPolicy,
@@ -410,7 +410,7 @@ function loadConfig(cwd, options = {}) {
// Dynamic-pattern top-level containers (e.g. review, model_profile_overrides)
...DYNAMIC_KEY_PATTERNS.map(p => p.topLevel),
// Internal keys loadConfig reads but config-set doesn't expose
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path',
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
// Deprecated keys (still accepted for migration, not in config-set)
// 'branching_strategy' is kept here as a safety net: it is migrated to
// git.branching_strategy above (#3523), but on the first read of a root
@@ -514,6 +514,10 @@ function loadConfig(cwd, options = {}) {
// unknown runtime/tier names at load time, not silently (review finding #10).
runtime: parsed.runtime || null,
model_profile_overrides: parsed.model_profile_overrides || null,
// #443 — effort/fast_mode: pass through from config.json; resolvers handle
// defaults + tier lookups internally.
effort: parsed.effort || null,
fast_mode: parsed.fast_mode || null,
agent_skills: parsed.agent_skills || {},
manager: parsed.manager || {},
response_language: get('response_language') || null,
@@ -558,6 +562,8 @@ function loadConfig(cwd, options = {}) {
model_overrides: globalDefaults.model_overrides || null,
models: globalDefaults.models || null,
dynamic_routing: globalDefaults.dynamic_routing || null,
effort: globalDefaults.effort || null,
fast_mode: globalDefaults.fast_mode || null,
agent_skills: globalDefaults.agent_skills || {},
response_language: globalDefaults.response_language || null,
};
@@ -1248,9 +1254,9 @@ function _resetRuntimeWarningCacheForTests() {
/**
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
*
* Single source of truth shared by core.cjs (resolveModelInternal /
* resolveReasoningEffortInternal) and bin/install.js (Codex/OpenCode TOML emit
* paths). Always merges built-in defaults with user overrides at the field
* Single source of truth shared by core.cjs (resolveModelInternal)
* and bin/install.js (Codex/OpenCode TOML emit paths). Always merges
* built-in defaults with user overrides at the field
* level so partial overrides keep the unspecified fields:
*
* `{ codex: { opus: "gpt-5-pro" } }` keeps reasoning_effort: 'xhigh'
@@ -1287,7 +1293,7 @@ function resolveTierEntry({ runtime, tier, overrides }) {
}
/**
* Convenience wrapper used by resolveModelInternal / resolveReasoningEffortInternal.
* Convenience wrapper used by resolveModelInternal.
* Pulls runtime + overrides out of a loaded config and delegates to resolveTierEntry.
*/
function _resolveRuntimeTier(config, tier) {
@@ -1475,66 +1481,209 @@ function resolveModelForTier(cwd, agentType, attempt) {
return alias;
}
// ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
//
// Universal effort ladder (ordered):
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
const EFFORT_SET = new Set(VALID_EFFORTS);
/**
* #2517 — Resolve runtime-specific reasoning_effort for an agent.
* Returns null unless:
* - `runtime` is explicitly set in config,
* - the runtime supports reasoning_effort (currently: codex),
* - profile is not 'inherit',
* - the resolved tier entry has a `reasoning_effort` value.
*
* Never returns a value for Claude — keeps reasoning_effort out of Claude spawn paths.
* Walk one step up the effort ladder from `e`. Returns the next level, or
* the same level if already at the top.
*/
function resolveReasoningEffortInternal(cwd, agentType) {
function nextEffort(e) {
const i = VALID_EFFORTS.indexOf(e);
if (i < 0) return null;
return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)];
}
/**
* #443 — Resolve a universal effort string for (cwd, agentType).
*
* Precedence (first valid wins; invalid/wrong-type values are IGNORED and fall
* through — mirrors the VALID_TIERS gate pattern in resolveModelInternal):
* 1. opts.override (if in EFFORT_SET)
* 2. config.effort.agent_overrides[agentType] (if valid)
* 3. config.effort.routing_tier_defaults[ AGENT_DEFAULT_TIERS[agentType] ] (agent known + valid)
* 4. config.effort.default (if valid)
* 5. 'high' (Anthropic Opus 4.8 universal default)
*
* Handles: config.effort missing; effort.* non-object/malformed; unknown
* agentType skips step 3; numeric/boolean garbage ignored.
*
* @param {string} cwd - Project directory.
* @param {string} agentType - Agent name.
* @param {{ override?: string }} [opts]
* @returns {string} A valid effort string.
*/
function resolveEffortInternal(cwd, agentType, opts) {
// Step 1: invocation override
if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) {
return opts.override;
}
const config = loadConfig(cwd);
if (!config.runtime) return null;
// Strict allowlist: reasoning_effort only propagates for runtimes whose
// install path actually accepts it. Adding a new runtime here is the only
// way to enable effort propagation — overrides cannot bypass the gate.
// Without this, a typo in `runtime` (e.g. `"codx"`) plus a user override
// for that typo would leak `xhigh` into a Claude or unknown install
// (review finding #3).
if (!RUNTIMES_WITH_REASONING_EFFORT.has(config.runtime)) return null;
// Per-agent override means user supplied a fully-qualified ID; reasoning_effort
// for that case must be set via per-agent mechanism, not tier inference.
if (config.model_overrides?.[agentType]) return null;
const effortCfg = (config.effort && typeof config.effort === 'object' && !Array.isArray(config.effort))
? config.effort
: null;
const profile = String(config.model_profile || 'balanced').toLowerCase();
const agentModels = MODEL_PROFILES[agentType];
if (!agentModels) return null;
// Step 2: agent_overrides
if (effortCfg) {
const ao = effortCfg.agent_overrides;
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
const v = ao[agentType];
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
}
}
// #3023 (CR Major): mirror the phase-type tier lookup from
// resolveModelInternal. Without this, `model` and `reasoning_effort`
// derive from different tier sources on Codex when models.<phase_type>
// overrides the profile.
//
// #3030 CR follow-up: do NOT short-circuit on profile === 'inherit'
// before reading the phase-type tier. A config like
// { model_profile: 'inherit', models: { execution: 'opus' } }
// must produce the opus runtime effort, not null. Compute tier from
// phase-type first; only fall back to profile when there's no valid
// phase-type override; only return null when the resolved tier is
// 'inherit' or unknown.
const phaseType = AGENT_TO_PHASE_TYPE[agentType];
const phaseTypeTier = (phaseType && config.models && typeof config.models === 'object')
? config.models[phaseType]
: undefined;
// Explicit phase-type 'inherit' is the user opting out of tier-based
// effort for this phase — return null instead of falling through to
// profile (which would silently emit the profile's effort and
// contradict the user's choice).
if (phaseTypeTier === 'inherit') return null;
const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku']);
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
? phaseTypeTier
: (profile === 'inherit'
? 'inherit'
: (agentModels[profile] || agentModels['balanced']));
// 'inherit' (from profile fallback) yields no runtime effort.
if (!tier || tier === 'inherit') return null;
// Step 3: routing_tier_defaults by agent's default tier.
// Manifest tier defaults are only used when there is NO effort config block at all
// (effortCfg === null). When the user explicitly sets an effort block, we respect
// their explicit routing_tier_defaults (if set) and fall through to effort.default
// if they didn't set them. This prevents the manifest tier defaults from silently
// overriding a user's `effort: { default: "medium" }`.
const agentTier = AGENT_DEFAULT_TIERS[agentType];
if (agentTier) {
if (effortCfg && effortCfg.routing_tier_defaults &&
typeof effortCfg.routing_tier_defaults === 'object' &&
!Array.isArray(effortCfg.routing_tier_defaults)) {
// User provided routing_tier_defaults — honor them
const v = effortCfg.routing_tier_defaults[agentTier];
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
} else if (!effortCfg) {
// No effort config at all — use manifest tier defaults
const manifestDefaults = CANONICAL_CONFIG_DEFAULTS.effort?.routing_tier_defaults;
if (manifestDefaults && typeof manifestDefaults === 'object') {
const v = manifestDefaults[agentTier];
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
}
}
// else: effortCfg exists but no routing_tier_defaults — fall through to effort.default
}
const entry = _resolveRuntimeTier(config, tier);
return entry?.reasoning_effort || null;
// Step 4: effort.default
if (effortCfg) {
const d = effortCfg.default;
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
}
// Step 5: hardcoded default
return 'high';
}
/**
* #443 — Resolve fast_mode boolean for (cwd, agentType).
*
* Accepts ONLY real booleans at each level. Strings like "true" are NOT accepted
* and fall through.
*
* Precedence:
* 1. opts.override (typeof boolean)
* 2. config.fast_mode.agent_overrides[agentType] (boolean)
* 3. config.fast_mode.routing_tier_defaults[ AGENT_DEFAULT_TIERS[agentType] ] (agent known + boolean)
* 4. config.fast_mode.enabled (boolean)
* 5. false
*
* @param {string} cwd
* @param {string} agentType
* @param {{ override?: boolean }} [opts]
* @returns {boolean}
*/
function resolveFastModeInternal(cwd, agentType, opts) {
// Step 1: invocation override
if (opts && typeof opts.override === 'boolean') {
return opts.override;
}
const config = loadConfig(cwd);
const fmCfg = (config.fast_mode && typeof config.fast_mode === 'object' && !Array.isArray(config.fast_mode))
? config.fast_mode
: null;
// Step 2: agent_overrides
if (fmCfg) {
const ao = fmCfg.agent_overrides;
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
const v = ao[agentType];
if (typeof v === 'boolean') return v;
}
}
// Step 3: routing_tier_defaults by agent's default tier.
// Manifest tier defaults are only used when there is no fast_mode config block at all
// (fmCfg === null). When the user explicitly set a fast_mode block (even with just
// `enabled`), manifest routing_tier_defaults do not fire — we fall through to enabled (step 4).
// This ensures `fast_mode: { enabled: true }` works intuitively without the user having
// to also spell out all three tier defaults.
const agentTier = AGENT_DEFAULT_TIERS[agentType];
if (agentTier) {
if (fmCfg && fmCfg.routing_tier_defaults &&
typeof fmCfg.routing_tier_defaults === 'object' &&
!Array.isArray(fmCfg.routing_tier_defaults)) {
// User provided routing_tier_defaults — honor them
const v = fmCfg.routing_tier_defaults[agentTier];
if (typeof v === 'boolean') return v;
} else if (!fmCfg) {
// No fast_mode config at all — use manifest defaults for tier
const manifestDefaults = CANONICAL_CONFIG_DEFAULTS.fast_mode?.routing_tier_defaults;
if (manifestDefaults && typeof manifestDefaults === 'object') {
const v = manifestDefaults[agentTier];
if (typeof v === 'boolean') return v;
}
}
// else: fmCfg exists but no routing_tier_defaults — fall through to enabled
}
// Step 4: fast_mode.enabled
if (fmCfg && typeof fmCfg.enabled === 'boolean') {
return fmCfg.enabled;
}
// Step 5: hardcoded default
return false;
}
/**
* #443 — Resolve effort for a dynamic-routing attempt (with escalation).
*
* MUST NOT modify resolveModelForTier behavior.
* base = resolveEffortInternal(cwd, agentType).
* If config.dynamic_routing missing/enabled!==true OR escalate_on_failure===false
* -> return base (attempt ignored).
* Else: effectiveAttempt = min(max(0, attempt), max_escalations).
* Walk nextEffort effectiveAttempt times from base, clamp at 'max'.
*
* @param {string} cwd
* @param {string} agentType
* @param {number} [attempt=0]
* @returns {string}
*/
function resolveEffortForTier(cwd, agentType, attempt) {
const base = resolveEffortInternal(cwd, agentType);
const config = loadConfig(cwd);
const dr = config.dynamic_routing;
if (!dr || typeof dr !== 'object' || dr.enabled !== true) {
return base;
}
if (dr.escalate_on_failure === false) {
return base;
}
const maxEscalations = Number.isInteger(dr.max_escalations) && dr.max_escalations >= 0
? dr.max_escalations
: 1;
const attemptN = Number.isInteger(attempt) && attempt > 0 ? attempt : 0;
const effectiveAttempt = Math.min(attemptN, maxEscalations);
let current = base;
for (let i = 0; i < effectiveAttempt; i++) {
const next = nextEffort(current);
if (!next || next === current) break; // already at max
current = next;
}
return current;
}
// ─── Summary body helpers ─────────────────────────────────────────────────
@@ -1913,9 +2062,15 @@ module.exports = {
getRoadmapPhaseInternal,
resolveModelInternal,
resolveModelForTier,
resolveReasoningEffortInternal,
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,
VALID_EFFORTS,
EFFORT_SET,
nextEffort,
RUNTIME_PROFILE_MAP,
RUNTIMES_WITH_REASONING_EFFORT,
RUNTIMES_WITH_FAST_MODE,
KNOWN_RUNTIMES,
RUNTIME_OVERRIDE_TIERS,
resolveTierEntry,

View File

@@ -118,6 +118,79 @@ function getAgentToModelMapForProfile(normalizedProfile) {
return out;
}
// ─── Effort rendering ────────────────────────────────────────────────────────
//
// Universal effort ladder: minimal < low < medium < high < xhigh < max
//
// Each runtime supports a subset. The unique tails must be clamped when emitting
// to a runtime that does not support them:
// - 'max' is Anthropic-only: Codex does not support it -> clamp to 'xhigh'
// - 'minimal' is Codex-only: Claude does not support it -> clamp to 'low'
//
// Rendering maps the universal effort string to the runtime's native parameter.
const EFFORT_RENDERING = {
// Claude Code subagent effort: output_config.effort frontmatter key /
// CLAUDE_CODE_EFFORT_LEVEL env. Supports: low, medium, high, xhigh, max.
// Does NOT support 'minimal' (Codex-only) -> clamp to 'low'.
claude: {
param: 'output_config.effort',
channel: 'frontmatter',
supported: new Set(['low', 'medium', 'high', 'xhigh', 'max']),
clamp(level) {
if (level === 'minimal') return 'low';
return level;
},
},
// Codex Responses API reasoning.effort. Supports: minimal, low, medium, high, xhigh.
// Does NOT support 'max' (Anthropic-only) -> clamp to 'xhigh'.
codex: {
param: 'model_reasoning_effort',
channel: 'api',
supported: new Set(['minimal', 'low', 'medium', 'high', 'xhigh']),
clamp(level) {
if (level === 'max') return 'xhigh';
return level;
},
},
};
/**
* Render a universal effort string for a specific runtime.
*
* Returns { value (clamped), param, channel } where:
* - value: the clamped effort string safe to pass to the runtime
* - param: the native parameter name (e.g. 'output_config.effort')
* - channel: how the value is propagated ('frontmatter', 'api', null)
*
* Unknown runtimes return { value: universalEffort, param: null, channel: null }
* so callers can always read .value safely.
*/
function renderEffortForRuntime(runtime, universalEffort) {
const spec = EFFORT_RENDERING[runtime];
if (!spec) {
return { value: universalEffort, param: null, channel: null };
}
return {
value: spec.clamp(universalEffort),
param: spec.param,
channel: spec.channel,
};
}
// ─── Fast mode propagation ───────────────────────────────────────────────────
//
// RUNTIMES_WITH_FAST_MODE is the set of runtimes where fast_mode=true can be
// propagated to a SPAWNED SUBAGENT via a native mechanism.
//
// Claude Code has NO per-subagent fast-mode mechanism — /fast is a session-level
// toggle only. Emitting a `fast_mode: true` frontmatter key on a Claude subagent
// would be a SILENT NO-OP, which is why 'claude' is deliberately excluded here.
//
// Only API-direct runtimes ('api') accept a speed:"fast" field in the request.
// Codex and other runtimes do not expose per-call fast_mode either.
const RUNTIMES_WITH_FAST_MODE = new Set(['api']);
module.exports = {
catalog,
MODEL_PROFILES,
@@ -133,4 +206,7 @@ module.exports = {
nextTier,
formatAgentToModelMapAsTable,
getAgentToModelMapForProfile,
EFFORT_RENDERING,
renderEffortForRuntime,
RUNTIMES_WITH_FAST_MODE,
};

View File

@@ -10,6 +10,9 @@ const {
nextTier,
formatAgentToModelMapAsTable,
getAgentToModelMapForProfile,
EFFORT_RENDERING,
renderEffortForRuntime,
RUNTIMES_WITH_FAST_MODE,
} = require('./model-catalog.cjs');
module.exports = {
@@ -22,4 +25,7 @@ module.exports = {
nextTier,
formatAgentToModelMapAsTable,
getAgentToModelMapForProfile,
EFFORT_RENDERING,
renderEffortForRuntime,
RUNTIMES_WITH_FAST_MODE,
};

View File

@@ -71,5 +71,23 @@
"graphify": {
"auto_update": false
},
"agent_skills": {}
"agent_skills": {},
"effort": {
"default": "high",
"routing_tier_defaults": {
"light": "low",
"standard": "high",
"heavy": "xhigh"
},
"agent_overrides": {}
},
"fast_mode": {
"enabled": false,
"routing_tier_defaults": {
"light": true,
"standard": false,
"heavy": false
},
"agent_overrides": {}
}
}

View File

@@ -1,5 +1,5 @@
{
"_comment": "Canonical schema manifest for valid config key paths. validKeys is the union of CJS config-schema.cjs and SDK query/config-schema.ts (they are enforced set-equal by tests/config-schema-sdk-parity.test.cjs). runtimeStateKeys mirrors RUNTIME_STATE_KEYS. dynamicKeyPatterns mirrors DYNAMIC_KEY_PATTERNS from SDK (which includes the canonical 'source' regex strings). The .test function is reconstructed at runtime from new RegExp(source).",
"_comment": "Canonical schema manifest for valid config key paths. This manifest is the single CJS source of truth for valid config keys; dynamicKeyPatterns source strings are recompiled to RegExp at runtime by config-schema.cjs. runtimeStateKeys mirrors RUNTIME_STATE_KEYS.",
"validKeys": [
"mode",
"granularity",
@@ -96,7 +96,9 @@
"claude_md_path",
"claude_md_assembly.mode",
"runtime",
"resolve_model_ids"
"resolve_model_ids",
"effort.default",
"fast_mode.enabled"
],
"runtimeStateKeys": [
"workflow._auto_chain_active"
@@ -142,6 +144,26 @@
"source": "^model_overrides\\.[a-zA-Z0-9_-]+$",
"description": "model_overrides.<agent-id>"
},
{
"topLevel": "effort",
"source": "^effort\\.routing_tier_defaults\\.(light|standard|heavy)$",
"description": "effort.routing_tier_defaults.<light|standard|heavy>"
},
{
"topLevel": "effort",
"source": "^effort\\.agent_overrides\\.[a-zA-Z0-9_-]+$",
"description": "effort.agent_overrides.<agent-id>"
},
{
"topLevel": "fast_mode",
"source": "^fast_mode\\.routing_tier_defaults\\.(light|standard|heavy)$",
"description": "fast_mode.routing_tier_defaults.<light|standard|heavy>"
},
{
"topLevel": "fast_mode",
"source": "^fast_mode\\.agent_overrides\\.[a-zA-Z0-9_-]+$",
"description": "fast_mode.agent_overrides.<agent-id>"
},
{
"topLevel": "review",
"source": "^review\\.max_prompt_tokens_per_reviewer\\.[a-zA-Z0-9_-]+$",

View File

@@ -584,6 +584,16 @@ Display:
| model_profile_overrides.<runtime>.opus | {model/built-in/null} |
| model_profile_overrides.<runtime>.sonnet | {model/built-in/null} |
| model_profile_overrides.<runtime>.haiku | {model/built-in/null} |
| effort.default | {low/medium/high/xhigh/max} |
| effort.routing_tier_defaults.light | {low/medium/high/xhigh/max} |
| effort.routing_tier_defaults.standard | {low/medium/high/xhigh/max} |
| effort.routing_tier_defaults.heavy | {low/medium/high/xhigh/max} |
| effort.agent_overrides.<agent-id> | {low/medium/high/xhigh/max} |
| fast_mode.enabled | {true/false} |
| fast_mode.routing_tier_defaults.light | {true/false} |
| fast_mode.routing_tier_defaults.standard | {true/false} |
| fast_mode.routing_tier_defaults.heavy | {true/false} |
| fast_mode.agent_overrides.<agent-id> | {true/false} |
These settings apply to future /gsd:plan-phase, /gsd:execute-phase, /gsd:discuss-phase,
and /gsd:ship runs.