enhance(#2229): add three-way claim disposition (admit/refute/abstain) to /gsd-explore research pass (#2543)

Closes #2229.

Each claim surfaced by /gsd-explore's research pass is dispositioned admit, refute, or abstain, with abstentions routed to a visible ledger instead of being smoothed into confident prose. Refute and abstain are separated by whether the disagreeing source is authoritative for that claim; a strong prior is never authoritative alone.

Two guards ride with it: conflict-abstention, and a tier floor that presents a would-be admit as an abstain when the researcher's resolved tier is the budget tier or cannot be determined.

To make that floor enforceable, resolve-model now emits the effective tier (--pick tier). It was already computed above the resolve_model_ids omit gate but was unreachable from a workflow, which left the floor inert on every non-Claude install - the model id is blank under omit and runtime-substituted where a tier map exists, and the profile defaults to balanced. The tier signal mirrors every resolution step that can change which tier runs, including the model_policy preset, and reports unknown rather than guessing. Output is additive; model, profile and effort are unchanged.

Two residuals are disclosed in the workflow rather than papered over: a raw-model-id model_overrides pin reports unknown and is floored (fails closed), and a model_profile_overrides entry repointing a tier at another tier's model can under-report (fails open, and predates this change).

Admin merge used only to satisfy the missing secondary reviewer on a single-maintainer PR. No CI failure and no conflict were bypassed: 38 checks green, remote runner 32255/32255 on both Node lanes.
This commit is contained in:
Rezolv
2026-08-09 23:05:41 -04:00
committed by GitHub
parent 693f12ad56
commit 2dbee3ebdd
12 changed files with 1079 additions and 25 deletions

View File

@@ -33,7 +33,7 @@ import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelResolverMod = require('./model-resolver.cjs');
const { resolveModelInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
const { resolveModelInternal, resolveTierInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import agentCommandRouterMod = require('./agent-command-router.cjs');
const { AGENT_FAILURE_CLASSES } = agentCommandRouterMod;
@@ -516,10 +516,20 @@ function cmdResolveModel(cwd: string, agentType: string | undefined, raw: boolea
const model = resolveModelInternal(cwd, agentType!);
const effort = resolveEffortInternal(cwd, agentType!);
const agentModels = (MODEL_PROFILES as Record<string, unknown>)[agentType!];
// Own-property guard: agentType is an unvalidated CLI positional, so a
// prototype-chain value ("toString", "constructor") would otherwise return
// an inherited truthy member from this plain object and misreport a
// genuinely unknown agent as known (unknown_agent dropped from the result).
const agentModelsMap = MODEL_PROFILES as Record<string, unknown>;
const agentModels = Object.hasOwn(agentModelsMap, agentType!) ? agentModelsMap[agentType!] : undefined;
// #2229: `tier` is additive — existing keys and their values are untouched, so
// every `--pick model` / `--pick profile` / `--raw` consumer is unaffected. It
// exists because the model id is deliberately blank under resolve_model_ids:"omit",
// which leaves a tier-sensitive guard with nothing to read.
const tier = resolveTierInternal(cwd, agentType!);
const result = agentModels
? { model, profile, effort }
: { model, profile, effort, unknown_agent: true };
? { model, profile, effort, tier }
: { model, profile, effort, tier, unknown_agent: true };
output(result, raw, model);
}
@@ -594,7 +604,12 @@ function cmdResolveExecution(cwd: string, agentType: string | undefined, raw: bo
const fastModeSupported = RUNTIMES_WITH_FAST_MODE.has(runtime);
const agentModels = (MODEL_PROFILES as Record<string, unknown>)[agentType!];
// Own-property guard: agentType is an unvalidated CLI positional, so a
// prototype-chain value ("toString", "constructor") would otherwise return
// an inherited truthy member from this plain object and misreport a
// genuinely unknown agent as known (unknown_agent dropped from the result).
const agentModelsMap = MODEL_PROFILES as Record<string, unknown>;
const agentModels = Object.hasOwn(agentModelsMap, agentType!) ? agentModelsMap[agentType!] : undefined;
const result: Record<string, unknown> = {
model,
profile,

View File

@@ -313,6 +313,133 @@ function resolveModelPolicy(policy: Record<string, unknown> | null | undefined,
return budgetEntry.model;
}
/**
* #2229 — the profile/phase-type tier for (config, agentType).
*
* Extracted verbatim from resolveModelInternal's step 2 so the same expression can
* answer "which tier did GSD resolve?" without also resolving a model id. The
* extraction is behaviour-preserving by construction: resolveModelInternal calls
* straight back into it.
*
* Returns null when the agent has no catalog entry and the profile is not `inherit`.
*/
function computeProfileTier(config: Record<string, unknown>, agentType: string): string | null {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const profile = String(config['model_profile'] || 'balanced').toLowerCase();
// Own-property guard: agentType is an unvalidated CLI positional (the
// `resolve-model <agent-type>` argument is never checked against a known
// agent list), so a prototype-chain agentType ("toString", "constructor")
// would otherwise return an inherited member from this plain object
// instead of undefined — verified reachable purely via the CLI.
const modelProfilesMap = MODEL_PROFILES as unknown as Record<string, Record<string, string>>;
const agentModels = Object.hasOwn(modelProfilesMap, agentType) ? modelProfilesMap[agentType] : undefined;
const phaseType = (AGENT_TO_PHASE_TYPE)[agentType];
const configModels = config['models'] as Record<string, string> | null | undefined;
const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
? configModels[phaseType]
: undefined;
return (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
? phaseTypeTier
: (profile === 'inherit'
? 'inherit'
: (agentModels
// Own-property guard: `profile` is a config-supplied string
// (config['model_profile'], lower-cased); an already-lowercase
// prototype-chain key ("constructor", "__proto__") would otherwise
// return an inherited non-string member instead of falling back to
// 'balanced' (verified: profile:"constructor"/"__proto__" leaked a
// function/object through both the tier and model resolution paths).
? ((Object.hasOwn(agentModels, profile) ? agentModels[profile] : undefined) || agentModels['balanced'])
: null));
}
/**
* #2229 — the effective model TIER for (config, agentType), as a signal a workflow can
* read: `gsd_run query resolve-model <agent> --pick tier`.
*
* Why this is not just "look at the resolved model": on every runtime the installer
* configures with `resolve_model_ids: "omit"` — which is every non-Claude runtime, see
* docs/CONFIGURATION.md — resolveModelInternal deliberately returns '' below. A guard
* keyed on the model id therefore cannot tell a budget-tier run from a top-tier one
* there, while the tier itself is computed ABOVE that early-return and stays knowable.
*
* Honesty contract — this never guesses, because a guard that reports a wrong tier is
* worse than one that reports none:
* - a per-agent `model_overrides` pin naming a known alias (or a full Claude id that
* maps to one) reports that alias;
* - a pin that maps to nothing reports 'unknown' — a raw model id carries no tier;
* - `model_profile: inherit` reports 'inherit' — the session model is not ours to name;
* - an agent with no catalog entry reports 'unknown'.
*
* Callers must treat 'unknown' and 'inherit' as "cannot tell", never as "adequate".
*/
function resolveTierFromConfig(config: Record<string, unknown>, agentType: string): string {
const rawOverrides = config['model_overrides'];
const modelOverrides = (rawOverrides && typeof rawOverrides === 'object' && !Array.isArray(rawOverrides))
? rawOverrides as Record<string, string>
: null;
// Own-property guard: agentType is a caller-supplied string (the
// `resolve-model <agent-type>` CLI positional is not validated against a
// known agent list); a prototype-chain agentType ("toString",
// "constructor") against ANY model_overrides object — even `{}` — would
// otherwise return an inherited member instead of undefined.
const override = (modelOverrides && Object.hasOwn(modelOverrides, agentType))
? modelOverrides[agentType]
: undefined;
if (override && typeof override === 'string') {
if (CLAUDE_AGENT_ALIASES.has(override)) return override;
// Own-property guard: this indexes a plain object with a config-supplied
// string, so a prototype-chain key ("toString", "constructor", "valueOf")
// would otherwise return an inherited member instead of undefined — and a
// function-valued tier is dropped entirely by JSON.stringify, silently
// removing the key a guard depends on.
const alias = Object.hasOwn(CLAUDE_POLICY_ID_TO_ALIAS, override)
? CLAUDE_POLICY_ID_TO_ALIAS[override]
: undefined;
if (typeof alias === 'string' && alias) return alias;
return 'unknown';
}
const profileTier = computeProfileTier(config, agentType);
// #3282 — mirror resolveModelInternal's step 2.5 (model_policy preset). The
// profile tier alone under-reports: model_policy can dispatch a DIFFERENT
// tier than the profile implies (e.g. a `balanced` profile's "sonnet" tier
// combined with `model_policy: {budget: 'low'}` actually spawns "haiku"),
// and reporting the profile tier there is exactly the under-report this
// fixes — a haiku-tier run must never be reported as "sonnet". Skipped
// under the same condition resolveModelInternal skips it (no tier, or
// "inherit" — the session model is not ours to name).
if (profileTier && profileTier !== 'inherit') {
const mergedPolicy = config['model_policy']
? { ...(config['model_policy'] as Record<string, unknown>), runtime: (config['runtime'] as string | null | undefined) || 'claude' }
: null;
const policyModel = resolveModelPolicy(mergedPolicy, profileTier);
if (policyModel) {
// Map the policy-resolved id back to a tier alias with the same
// own-property-guarded lookups used above. If it maps, that alias IS
// the tier that actually runs — report it (the fix). If it does not
// map — including every non-Claude runtime, where resolveModelInternal
// returns the policy model verbatim with no tier meaning — the model
// carries no tier we can name; report 'unknown' rather than falling
// back to the profile tier, which would silently reintroduce the
// under-report this block exists to close.
const aliasForId = Object.hasOwn(CLAUDE_POLICY_ID_TO_ALIAS, policyModel)
? CLAUDE_POLICY_ID_TO_ALIAS[policyModel]
: undefined;
if (typeof aliasForId === 'string' && aliasForId) return aliasForId;
if (CLAUDE_AGENT_ALIASES.has(policyModel)) return policyModel;
return 'unknown';
}
}
return profileTier || 'unknown';
}
function resolveTierInternal(cwd: string, agentType: string): string {
return resolveTierFromConfig(loadConfig(cwd), agentType);
}
function resolveModelInternal(cwd: string, agentType: string): string {
const config = loadConfig(cwd);
@@ -320,27 +447,30 @@ function resolveModelInternal(cwd: string, agentType: string): string {
// the claude runtime, mirroring the model_policy path #1144; non-Claude
// runtimes and non-Claude values pass through verbatim).
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
const override = modelOverrides?.[agentType];
// Own-property guard (see resolveTierFromConfig above): without it, an
// agentType of "toString" against `model_overrides: {}` returned the
// inherited Function.prototype.toString as the resolved "model" — verified
// reachable purely via the CLI, no override value needed.
const override = (modelOverrides && Object.hasOwn(modelOverrides, agentType))
? modelOverrides[agentType]
: undefined;
if (override) {
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
if (mapped !== null) return mapped;
// Unmappable Claude ID — fall through to tier resolution (matches model_policy).
}
// 2. Compute the tier
// 2. Compute the tier (#2229: shared with resolveTierFromConfig so the tier a
// workflow reads and the tier a model is resolved from can never diverge).
// eslint-disable-next-line @typescript-eslint/no-base-to-string
const profile = String(config['model_profile'] || 'balanced').toLowerCase();
const agentModels = (MODEL_PROFILES as unknown as Record<string, Record<string, string>>)[agentType];
const phaseType = (AGENT_TO_PHASE_TYPE)[agentType];
const configModels = config['models'] as Record<string, string> | null | undefined;
const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
? configModels[phaseType]
: undefined;
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
? phaseTypeTier
: (profile === 'inherit'
? 'inherit'
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
// Own-property guard (see computeProfileTier above): without it, agentType
// "toString" returned Function.prototype.toString as `agentModels`
// (truthy), which skipped the "unknown agent" fallback below and made
// resolveModelInternal return undefined instead of a tier-derived string.
const modelProfilesMapForModel = MODEL_PROFILES as unknown as Record<string, Record<string, string>>;
const agentModels = Object.hasOwn(modelProfilesMapForModel, agentType) ? modelProfilesMapForModel[agentType] : undefined;
const tier = computeProfileTier(config, agentType);
// 2.5. model_policy preset (#49, #1133)
const configRuntime = config['runtime'] as string | null | undefined;
@@ -356,8 +486,10 @@ function resolveModelInternal(cwd: string, agentType: string): string {
if (!onClaude) return policyModel;
// Claude Code's Agent tool takes tier aliases (opus/sonnet/haiku/fable),
// not full model IDs — map the policy-resolved ID back to an alias (#1133).
const aliasForId = CLAUDE_POLICY_ID_TO_ALIAS[policyModel];
if (aliasForId) return aliasForId;
const aliasForId = Object.hasOwn(CLAUDE_POLICY_ID_TO_ALIAS, policyModel)
? CLAUDE_POLICY_ID_TO_ALIAS[policyModel]
: undefined;
if (typeof aliasForId === 'string' && aliasForId) return aliasForId;
// The policy value may already be a bare Claude agent alias (e.g. "fable").
if (CLAUDE_AGENT_ALIASES.has(policyModel)) return policyModel;
// No Claude alias for this ID (e.g. a pinned minor version like
@@ -460,7 +592,13 @@ function resolveModelForTier(cwd: string, agentType: string, attempt?: number):
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0;
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
const override = modelOverrides?.[agentType];
// Own-property guard (see resolveTierFromConfig above): without it, an
// agentType of "toString" against `model_overrides: {}` returned the
// inherited Function.prototype.toString as the resolved "model" — verified
// reachable purely via the CLI, no override value needed.
const override = (modelOverrides && Object.hasOwn(modelOverrides, agentType))
? modelOverrides[agentType]
: undefined;
if (override) {
const mapped = mapClaudeOverrideForRuntime(override, config['runtime'] as string | null | undefined, agentType);
if (mapped !== null) return mapped;
@@ -781,6 +919,8 @@ export = {
CLAUDE_AGENT_ALIASES,
resolveModelPolicy,
resolveModelInternal,
resolveTierInternal,
resolveTierFromConfig,
_resetModelPolicyWarningCacheForTests,
_resetModelOverrideWarningCacheForTests,
_setInstallRuntimeMarkerForTests,