Merge branch 'next' into kimi-runtime-support
This commit is contained in:
@@ -547,7 +547,7 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
const phaseInfo = findPhaseInternal(cwd, phaseNum) as Record<string, unknown> | null;
|
||||
if (phaseInfo) {
|
||||
branchName = (config['phase_branch_template'] as string)
|
||||
.replace('{phase}', phaseInfo['phase_number'] as string)
|
||||
.replace('{phase}', normalizePhaseName(phaseInfo['phase_number']))
|
||||
.replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Configuration Module — single source of truth for config loading,
|
||||
* legacy-key normalization, defaults merge, and explicit on-disk migration.
|
||||
* Configuration Module — legacy-key normalization, defaults merge, and explicit
|
||||
* on-disk migration. Pure normalization primitives consumed by config-loader.cjs
|
||||
* and config-schema.cjs. `loadConfig` was extracted to config-loader.cjs per
|
||||
* ADR-857 phase 2e (#885) and removed from this module per #893.
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/configuration.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
@@ -132,11 +134,6 @@ interface NormalizeLegacyKeysResult {
|
||||
normalizations: Normalization[];
|
||||
}
|
||||
|
||||
interface LoadConfigOptions {
|
||||
workstream?: string;
|
||||
onNormalizations?: (normalizations: Normalization[]) => void;
|
||||
}
|
||||
|
||||
interface MigrateOnDiskResult {
|
||||
migrated: boolean;
|
||||
normalizations: Normalization[];
|
||||
@@ -197,38 +194,6 @@ function mergeDefaults(parsed: Record<string, unknown>): Record<string, unknown>
|
||||
return deepMergeConfig(defaults, parsed);
|
||||
}
|
||||
|
||||
function loadConfig(cwd: string, options?: LoadConfigOptions): Record<string, unknown> {
|
||||
const configPath = join(planningDir(cwd, options?.workstream), 'config.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf-8');
|
||||
}
|
||||
catch {
|
||||
// File missing — return defaults
|
||||
return mergeDefaults({});
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return mergeDefaults({});
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`Config at ${configPath} must be a JSON object`);
|
||||
}
|
||||
const { parsed: normalized, normalizations } = normalizeLegacyKeys(parsed as Record<string, unknown>);
|
||||
if (options?.onNormalizations && normalizations.length > 0) {
|
||||
options.onNormalizations(normalizations);
|
||||
}
|
||||
return mergeDefaults(normalized);
|
||||
}
|
||||
|
||||
function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult {
|
||||
const configPath = join(planningDir(cwd, workstream), 'config.json');
|
||||
let raw: string;
|
||||
@@ -277,7 +242,6 @@ function migrateOnDisk(cwd: string, workstream?: string): MigrateOnDiskResult {
|
||||
}
|
||||
|
||||
export {
|
||||
loadConfig,
|
||||
normalizeLegacyKeys,
|
||||
mergeDefaults,
|
||||
migrateOnDisk,
|
||||
|
||||
457
src/core.cts
457
src/core.cts
@@ -20,8 +20,8 @@ import roadmapParserModule = require('./roadmap-parser.cjs');
|
||||
const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import modelProfiles = require('./model-profiles.cjs');
|
||||
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
|
||||
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } from './model-catalog.cjs';
|
||||
const { MODEL_PROFILES, VALID_PHASE_TYPES: _VALID_PHASE_TYPES } = modelProfiles;
|
||||
import { RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, KNOWN_PROVIDERS, MODEL_ALIAS_MAP } from './model-catalog.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import worktreeSafety = require('./worktree-safety.cjs');
|
||||
const {
|
||||
@@ -62,9 +62,6 @@ const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocat
|
||||
import { findProjectRoot } from './project-root.cjs';
|
||||
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
||||
|
||||
// ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─
|
||||
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cjs';
|
||||
|
||||
// ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ─────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configLoaderModule = require('./config-loader.cjs');
|
||||
@@ -77,6 +74,25 @@ const {
|
||||
RUNTIME_OVERRIDE_TIERS,
|
||||
} = configLoaderModule;
|
||||
|
||||
// ─── Model Resolver Module (extracted from core, ADR-857 phase 2f / #888) ────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import modelResolverModule = require('./model-resolver.cjs');
|
||||
const {
|
||||
resolveTierEntry,
|
||||
resolveModelPolicy,
|
||||
resolveModelInternal,
|
||||
VALID_GRANULARITIES,
|
||||
resolveGranularityInternal,
|
||||
assertValidGranularityOverride,
|
||||
resolveModelForTier,
|
||||
VALID_EFFORTS,
|
||||
EFFORT_SET,
|
||||
nextEffort,
|
||||
resolveEffortInternal,
|
||||
resolveFastModeInternal,
|
||||
resolveEffortForTier,
|
||||
} = modelResolverModule;
|
||||
|
||||
// ─── Path helpers ────────────────────────────────────────────────────────────
|
||||
// toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877).
|
||||
// The destructured bindings above (from coreUtilsModule) make them available to
|
||||
@@ -258,431 +274,12 @@ function checkAgentsInstalled(runtime?: string): AgentsInstalledResult {
|
||||
// The destructured bindings above (from configLoaderModule) make them available to
|
||||
// core-internal callers; _resetRuntimeWarningCacheForTests is re-exported for back-compat.
|
||||
|
||||
interface TierEntryResolved {
|
||||
model: string;
|
||||
reasoning_effort?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ResolveTierEntryOpts {
|
||||
runtime: string | null | undefined;
|
||||
tier: string | null | undefined;
|
||||
overrides: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
|
||||
*/
|
||||
function resolveTierEntry({ runtime, tier, overrides }: ResolveTierEntryOpts): TierEntryResolved | null {
|
||||
if (!runtime || !tier) return null;
|
||||
|
||||
const runtimeMap = RUNTIME_PROFILE_MAP as unknown as Record<string, Record<string, Record<string, unknown>>>;
|
||||
const builtin = runtimeMap[runtime]?.[tier] || null;
|
||||
const overridesMap = overrides as Record<string, Record<string, unknown>> | null | undefined;
|
||||
const userRaw = overridesMap?.[runtime]?.[tier];
|
||||
|
||||
let userEntry: Record<string, unknown> | null = null;
|
||||
if (userRaw) {
|
||||
userEntry = typeof userRaw === 'string' ? { model: userRaw } : (userRaw as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (!builtin && !userEntry) return null;
|
||||
return { ...(builtin || {}), ...(userEntry || {}) } as TierEntryResolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper used by resolveModelInternal.
|
||||
*/
|
||||
function _resolveRuntimeTier(config: Record<string, unknown>, tier: string): TierEntryResolved | null {
|
||||
return resolveTierEntry({
|
||||
runtime: config['runtime'] as string | null | undefined,
|
||||
tier,
|
||||
overrides: config['model_profile_overrides'] as Record<string, unknown> | null | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* #49 — Provider-neutral model policy preset resolution.
|
||||
*/
|
||||
function resolveModelPolicy(policy: Record<string, unknown> | null | undefined, tier: string | null | undefined): string | null {
|
||||
if (!policy || typeof policy !== 'object') return null;
|
||||
if (!tier) return null;
|
||||
|
||||
const runtime = policy['runtime'];
|
||||
const rtOverrides = policy['runtime_tiers'];
|
||||
if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') {
|
||||
const rtOverridesMap = rtOverrides as Record<string, unknown>;
|
||||
if (Object.hasOwn(rtOverridesMap, runtime)) {
|
||||
const runtimeEntry = rtOverridesMap[runtime];
|
||||
if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) {
|
||||
const raw = (runtimeEntry as Record<string, unknown>)[tier];
|
||||
if (raw != null) {
|
||||
const entry = typeof raw === 'string' ? { model: raw } : (raw as Record<string, unknown>);
|
||||
if (entry && entry['model']) return entry['model'] as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provider = policy['provider'];
|
||||
if (!provider || typeof provider !== 'string') return null;
|
||||
|
||||
if (provider === 'generic' || provider === 'custom') {
|
||||
const TIER_TO_POLICY_KEY: Record<string, string> = { opus: 'high', sonnet: 'medium', haiku: 'low' };
|
||||
const policyKey = TIER_TO_POLICY_KEY[tier];
|
||||
if (!policyKey) return null;
|
||||
const v = policy[policyKey];
|
||||
return (v && typeof v === 'string') ? v : null;
|
||||
}
|
||||
|
||||
const presetsMap = PROVIDER_PRESETS as Record<string, Record<string, Record<string, { model: string } | null>>>;
|
||||
if (!Object.hasOwn(presetsMap, provider)) return null;
|
||||
const presetForProvider = presetsMap[provider];
|
||||
if (!presetForProvider || typeof presetForProvider !== 'object') return null;
|
||||
|
||||
if (!Object.hasOwn(presetForProvider, tier)) return null;
|
||||
const tierPresets = presetForProvider[tier];
|
||||
if (!tierPresets || typeof tierPresets !== 'object') return null;
|
||||
|
||||
const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium';
|
||||
if (!Object.hasOwn(tierPresets, budget)) return null;
|
||||
const budgetEntry = tierPresets[budget];
|
||||
if (!budgetEntry || !budgetEntry.model) return null;
|
||||
|
||||
return budgetEntry.model;
|
||||
}
|
||||
|
||||
function resolveModelInternal(cwd: string, agentType: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
// 1. Per-agent override
|
||||
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
||||
const override = modelOverrides?.[agentType];
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
// 2. Compute the tier
|
||||
// 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 VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
|
||||
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
||||
? phaseTypeTier
|
||||
: (profile === 'inherit'
|
||||
? 'inherit'
|
||||
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
|
||||
|
||||
// 2.5. model_policy preset (#49)
|
||||
const configRuntime = config['runtime'] as string | null | undefined;
|
||||
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
||||
const mergedPolicy = config['model_policy']
|
||||
? { ...(config['model_policy'] as Record<string, unknown>), runtime: configRuntime }
|
||||
: null;
|
||||
const policyModel = resolveModelPolicy(mergedPolicy, tier);
|
||||
if (policyModel) return policyModel;
|
||||
}
|
||||
|
||||
// 3. Runtime-aware resolution (#2517)
|
||||
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
||||
const entry = _resolveRuntimeTier(config, tier);
|
||||
if (entry?.model) return entry.model;
|
||||
}
|
||||
|
||||
// 4. resolve_model_ids: "omit"
|
||||
if (config['resolve_model_ids'] === 'omit') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 5. Profile lookup (Claude-native default).
|
||||
if (!agentModels) {
|
||||
return profile === 'quality' ? 'opus'
|
||||
: profile === 'budget' ? 'haiku'
|
||||
: profile === 'inherit' ? 'inherit'
|
||||
: 'sonnet';
|
||||
}
|
||||
if (tier === 'inherit') return 'inherit';
|
||||
const alias = tier;
|
||||
|
||||
if (config['resolve_model_ids']) {
|
||||
return (MODEL_ALIAS_MAP as Record<string, string>)[alias!] || alias!;
|
||||
}
|
||||
|
||||
return alias!;
|
||||
}
|
||||
|
||||
const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
|
||||
|
||||
/**
|
||||
* Resolve the planning granularity for a phase type (#68).
|
||||
*/
|
||||
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
|
||||
if (override !== undefined && override !== null && override !== '') {
|
||||
if (VALID_GRANULARITIES.has(override)) {
|
||||
return override;
|
||||
}
|
||||
}
|
||||
const config = loadConfig(cwd);
|
||||
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
|
||||
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
|
||||
? configGranularities[phaseType]
|
||||
: undefined;
|
||||
if (perPhase && VALID_GRANULARITIES.has(perPhase)) {
|
||||
return perPhase;
|
||||
}
|
||||
if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') {
|
||||
return config['granularity'] as string;
|
||||
}
|
||||
const planning = config['planning'] as Record<string, unknown> | null | undefined;
|
||||
const planningGran = planning && planning['granularity'];
|
||||
if (planningGran !== undefined && planningGran !== null && planningGran !== '') {
|
||||
return planningGran as string;
|
||||
}
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
|
||||
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
|
||||
*/
|
||||
function assertValidGranularityOverride(
|
||||
override: string | null | undefined,
|
||||
fail: (msg: string) => never,
|
||||
): void {
|
||||
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
|
||||
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3024 — Resolve a model for a specific dynamic-routing attempt.
|
||||
*/
|
||||
function resolveModelForTier(cwd: string, agentType: string, attempt?: number): string {
|
||||
const config = loadConfig(cwd);
|
||||
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];
|
||||
if (override) return override;
|
||||
|
||||
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
||||
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const tierModels = dr['tier_models'] as Record<string, string> | null | undefined;
|
||||
if (!tierModels || typeof tierModels !== 'object') {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
|
||||
if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
||||
? (dr['max_escalations'] as number)
|
||||
: 1;
|
||||
const escalationEnabled = dr['escalate_on_failure'] !== false;
|
||||
const effectiveAttempt = escalationEnabled
|
||||
? Math.min(attemptN, maxEscalations)
|
||||
: 0;
|
||||
|
||||
let tier = defaultTier;
|
||||
for (let i = 0; i < effectiveAttempt; i += 1) {
|
||||
const next = (nextTier)(tier);
|
||||
if (!next || next === tier) break;
|
||||
tier = next;
|
||||
}
|
||||
|
||||
const alias = tierModels[tier];
|
||||
if (typeof alias !== 'string' || alias.length === 0) {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
return alias;
|
||||
}
|
||||
|
||||
// ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
|
||||
|
||||
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
||||
const EFFORT_SET = new Set(VALID_EFFORTS);
|
||||
|
||||
/**
|
||||
* Walk one step up the effort ladder from `e`.
|
||||
*/
|
||||
function nextEffort(e: string): string | null {
|
||||
const i = VALID_EFFORTS.indexOf(e);
|
||||
if (i < 0) return null;
|
||||
return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)];
|
||||
}
|
||||
|
||||
interface EffortOpts {
|
||||
override?: string;
|
||||
}
|
||||
|
||||
interface FastModeOpts {
|
||||
override?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* #443 — Resolve a universal effort string for (cwd, agentType).
|
||||
*/
|
||||
function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts): string {
|
||||
// Step 1: invocation override
|
||||
if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) {
|
||||
return opts.override;
|
||||
}
|
||||
|
||||
const config = loadConfig(cwd);
|
||||
const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort']))
|
||||
? (config['effort'] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
// Step 2: agent_overrides
|
||||
if (effortCfg) {
|
||||
const ao = effortCfg['agent_overrides'];
|
||||
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
||||
const v = (ao as Record<string, unknown>)[agentType];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
}
|
||||
} else {
|
||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||
const mao = canonicalEffort && typeof canonicalEffort === 'object'
|
||||
? (canonicalEffort as Record<string, unknown>)['agent_overrides']
|
||||
: undefined;
|
||||
if (mao && typeof mao === 'object' && !Array.isArray(mao)) {
|
||||
const v = (mao as Record<string, unknown>)[agentType];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: routing_tier_defaults by agent's default tier.
|
||||
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'])) {
|
||||
const v = (effortCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
} else if (!effortCfg) {
|
||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||
const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
|
||||
? (canonicalEffort as Record<string, unknown>)['routing_tier_defaults']
|
||||
: undefined;
|
||||
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
||||
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: effort.default
|
||||
if (effortCfg) {
|
||||
const d = effortCfg['default'];
|
||||
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
||||
} else {
|
||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||
const d = canonicalEffort && typeof canonicalEffort === 'object'
|
||||
? (canonicalEffort as Record<string, unknown>)['default']
|
||||
: undefined;
|
||||
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
||||
}
|
||||
|
||||
// Step 5: hardcoded default
|
||||
return 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* #443 — Resolve fast_mode boolean for (cwd, agentType).
|
||||
*/
|
||||
function resolveFastModeInternal(cwd: string, agentType: string, opts?: FastModeOpts): boolean {
|
||||
// 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'] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
// Step 2: agent_overrides
|
||||
if (fmCfg) {
|
||||
const ao = fmCfg['agent_overrides'];
|
||||
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
||||
const v = (ao as Record<string, unknown>)[agentType];
|
||||
if (typeof v === 'boolean') return v;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: routing_tier_defaults by agent's default tier.
|
||||
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'])) {
|
||||
const v = (fmCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'boolean') return v;
|
||||
} else if (!fmCfg) {
|
||||
const canonicalFm = (CANONICAL_CONFIG_DEFAULTS)['fast_mode'];
|
||||
const manifestDefaults = canonicalFm && typeof canonicalFm === 'object'
|
||||
? (canonicalFm as Record<string, unknown>)['routing_tier_defaults']
|
||||
: undefined;
|
||||
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
||||
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'boolean') return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
*/
|
||||
function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): string {
|
||||
const base = resolveEffortInternal(cwd, agentType);
|
||||
|
||||
const config = loadConfig(cwd);
|
||||
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
||||
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'] as number) >= 0
|
||||
? (dr['max_escalations'] as number)
|
||||
: 1;
|
||||
|
||||
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 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;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
// resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES,
|
||||
// resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier,
|
||||
// VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal,
|
||||
// resolveEffortForTier — all moved to model-resolver.cjs (ADR-857 phase 2f / #888).
|
||||
// The destructured bindings above (from modelResolverModule) make them available to
|
||||
// core-internal callers; core.cjs re-exports all 13 symbols for back-compat.
|
||||
|
||||
// ─── Summary body helpers / Misc utilities / Phase file helpers ───────────────
|
||||
// extractOneLinerFromBody, pathExistsInternal, generateSlugInternal,
|
||||
|
||||
@@ -260,7 +260,7 @@ function cmdInitExecutePhase(
|
||||
config.branching_strategy === 'phase' && phaseInfo
|
||||
? (config.phase_branch_template as string)
|
||||
.replace('{project}', (config.project_code as string) || '')
|
||||
.replace('{phase}', phaseInfo['phase_number'] as string)
|
||||
.replace('{phase}', normalizePhaseName(phaseInfo['phase_number']))
|
||||
.replace('{slug}', (phaseInfo['phase_slug'] as string) || 'phase')
|
||||
: config.branching_strategy === 'milestone'
|
||||
? (config.milestone_branch_template as string)
|
||||
|
||||
476
src/model-resolver.cts
Normal file
476
src/model-resolver.cts
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Model Resolver — Model and effort resolution policy
|
||||
*
|
||||
* ADR-857 rollout phase 2f: extracted from core.cts (issue #888).
|
||||
* Owns model and effort resolution policy: resolves the model, runtime tier,
|
||||
* planning granularity, reasoning effort, and fast-mode for a given agent by
|
||||
* reading project config and resolving against the model profiles and catalog.
|
||||
* Behaviour is preserved byte-for-behaviour from the prior location; only
|
||||
* the module boundary moved. core.cjs re-exports the resolvers for back-compat.
|
||||
*
|
||||
* New imports should pull resolvers from model-resolver.cjs directly.
|
||||
*
|
||||
* Dependencies (leaf modules only — no core.cjs):
|
||||
* - node:fs / node:path (stdlib, not currently needed — included for future use)
|
||||
* - ./config-loader.cjs (loadConfig)
|
||||
* - ./configuration.cjs (CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
|
||||
* - ./model-profiles.cjs (MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier)
|
||||
* - ./model-catalog.cjs (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS)
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configLoaderModule = require('./config-loader.cjs');
|
||||
const { loadConfig } = configLoaderModule;
|
||||
|
||||
// ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─
|
||||
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS } from './configuration.cjs';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import modelProfiles = require('./model-profiles.cjs');
|
||||
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
|
||||
|
||||
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS } from './model-catalog.cjs';
|
||||
|
||||
// ─── Model alias resolution ───────────────────────────────────────────────────
|
||||
|
||||
interface TierEntryResolved {
|
||||
model: string;
|
||||
reasoning_effort?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ResolveTierEntryOpts {
|
||||
runtime: string | null | undefined;
|
||||
tier: string | null | undefined;
|
||||
overrides: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
|
||||
*/
|
||||
function resolveTierEntry({ runtime, tier, overrides }: ResolveTierEntryOpts): TierEntryResolved | null {
|
||||
if (!runtime || !tier) return null;
|
||||
|
||||
const runtimeMap = RUNTIME_PROFILE_MAP as unknown as Record<string, Record<string, Record<string, unknown>>>;
|
||||
const builtin = runtimeMap[runtime]?.[tier] || null;
|
||||
const overridesMap = overrides as Record<string, Record<string, unknown>> | null | undefined;
|
||||
const userRaw = overridesMap?.[runtime]?.[tier];
|
||||
|
||||
let userEntry: Record<string, unknown> | null = null;
|
||||
if (userRaw) {
|
||||
userEntry = typeof userRaw === 'string' ? { model: userRaw } : (userRaw as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (!builtin && !userEntry) return null;
|
||||
return { ...(builtin || {}), ...(userEntry || {}) } as TierEntryResolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper used by resolveModelInternal.
|
||||
*/
|
||||
function _resolveRuntimeTier(config: Record<string, unknown>, tier: string): TierEntryResolved | null {
|
||||
return resolveTierEntry({
|
||||
runtime: config['runtime'] as string | null | undefined,
|
||||
tier,
|
||||
overrides: config['model_profile_overrides'] as Record<string, unknown> | null | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* #49 — Provider-neutral model policy preset resolution.
|
||||
*/
|
||||
function resolveModelPolicy(policy: Record<string, unknown> | null | undefined, tier: string | null | undefined): string | null {
|
||||
if (!policy || typeof policy !== 'object') return null;
|
||||
if (!tier) return null;
|
||||
|
||||
const runtime = policy['runtime'];
|
||||
const rtOverrides = policy['runtime_tiers'];
|
||||
if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') {
|
||||
const rtOverridesMap = rtOverrides as Record<string, unknown>;
|
||||
if (Object.hasOwn(rtOverridesMap, runtime)) {
|
||||
const runtimeEntry = rtOverridesMap[runtime];
|
||||
if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) {
|
||||
const raw = (runtimeEntry as Record<string, unknown>)[tier];
|
||||
if (raw != null) {
|
||||
const entry = typeof raw === 'string' ? { model: raw } : (raw as Record<string, unknown>);
|
||||
if (entry && entry['model']) return entry['model'] as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provider = policy['provider'];
|
||||
if (!provider || typeof provider !== 'string') return null;
|
||||
|
||||
if (provider === 'generic' || provider === 'custom') {
|
||||
const TIER_TO_POLICY_KEY: Record<string, string> = { opus: 'high', sonnet: 'medium', haiku: 'low' };
|
||||
const policyKey = TIER_TO_POLICY_KEY[tier];
|
||||
if (!policyKey) return null;
|
||||
const v = policy[policyKey];
|
||||
return (v && typeof v === 'string') ? v : null;
|
||||
}
|
||||
|
||||
const presetsMap = PROVIDER_PRESETS as Record<string, Record<string, Record<string, { model: string } | null>>>;
|
||||
if (!Object.hasOwn(presetsMap, provider)) return null;
|
||||
const presetForProvider = presetsMap[provider];
|
||||
if (!presetForProvider || typeof presetForProvider !== 'object') return null;
|
||||
|
||||
if (!Object.hasOwn(presetForProvider, tier)) return null;
|
||||
const tierPresets = presetForProvider[tier];
|
||||
if (!tierPresets || typeof tierPresets !== 'object') return null;
|
||||
|
||||
const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium';
|
||||
if (!Object.hasOwn(tierPresets, budget)) return null;
|
||||
const budgetEntry = tierPresets[budget];
|
||||
if (!budgetEntry || !budgetEntry.model) return null;
|
||||
|
||||
return budgetEntry.model;
|
||||
}
|
||||
|
||||
function resolveModelInternal(cwd: string, agentType: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
|
||||
// 1. Per-agent override
|
||||
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
||||
const override = modelOverrides?.[agentType];
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
// 2. Compute the tier
|
||||
// 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 VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
|
||||
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
||||
? phaseTypeTier
|
||||
: (profile === 'inherit'
|
||||
? 'inherit'
|
||||
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
|
||||
|
||||
// 2.5. model_policy preset (#49)
|
||||
const configRuntime = config['runtime'] as string | null | undefined;
|
||||
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
||||
const mergedPolicy = config['model_policy']
|
||||
? { ...(config['model_policy'] as Record<string, unknown>), runtime: configRuntime }
|
||||
: null;
|
||||
const policyModel = resolveModelPolicy(mergedPolicy, tier);
|
||||
if (policyModel) return policyModel;
|
||||
}
|
||||
|
||||
// 3. Runtime-aware resolution (#2517)
|
||||
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
||||
const entry = _resolveRuntimeTier(config, tier);
|
||||
if (entry?.model) return entry.model;
|
||||
}
|
||||
|
||||
// 4. resolve_model_ids: "omit"
|
||||
if (config['resolve_model_ids'] === 'omit') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 5. Profile lookup (Claude-native default).
|
||||
if (!agentModels) {
|
||||
return profile === 'quality' ? 'opus'
|
||||
: profile === 'budget' ? 'haiku'
|
||||
: profile === 'inherit' ? 'inherit'
|
||||
: 'sonnet';
|
||||
}
|
||||
if (tier === 'inherit') return 'inherit';
|
||||
const alias = tier;
|
||||
|
||||
if (config['resolve_model_ids']) {
|
||||
return (MODEL_ALIAS_MAP as Record<string, string>)[alias!] || alias!;
|
||||
}
|
||||
|
||||
return alias!;
|
||||
}
|
||||
|
||||
const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
|
||||
|
||||
/**
|
||||
* Resolve the planning granularity for a phase type (#68).
|
||||
*/
|
||||
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
|
||||
if (override !== undefined && override !== null && override !== '') {
|
||||
if (VALID_GRANULARITIES.has(override)) {
|
||||
return override;
|
||||
}
|
||||
}
|
||||
const config = loadConfig(cwd);
|
||||
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
|
||||
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
|
||||
? configGranularities[phaseType]
|
||||
: undefined;
|
||||
if (perPhase && VALID_GRANULARITIES.has(perPhase)) {
|
||||
return perPhase;
|
||||
}
|
||||
if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') {
|
||||
return config['granularity'] as string;
|
||||
}
|
||||
const planning = config['planning'] as Record<string, unknown> | null | undefined;
|
||||
const planningGran = planning && planning['granularity'];
|
||||
if (planningGran !== undefined && planningGran !== null && planningGran !== '') {
|
||||
return planningGran as string;
|
||||
}
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
|
||||
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
|
||||
*/
|
||||
function assertValidGranularityOverride(
|
||||
override: string | null | undefined,
|
||||
fail: (msg: string) => never,
|
||||
): void {
|
||||
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
|
||||
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3024 — Resolve a model for a specific dynamic-routing attempt.
|
||||
*/
|
||||
function resolveModelForTier(cwd: string, agentType: string, attempt?: number): string {
|
||||
const config = loadConfig(cwd);
|
||||
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];
|
||||
if (override) return override;
|
||||
|
||||
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
||||
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const tierModels = dr['tier_models'] as Record<string, string> | null | undefined;
|
||||
if (!tierModels || typeof tierModels !== 'object') {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
|
||||
if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
|
||||
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
||||
? (dr['max_escalations'] as number)
|
||||
: 1;
|
||||
const escalationEnabled = dr['escalate_on_failure'] !== false;
|
||||
const effectiveAttempt = escalationEnabled
|
||||
? Math.min(attemptN, maxEscalations)
|
||||
: 0;
|
||||
|
||||
let tier = defaultTier;
|
||||
for (let i = 0; i < effectiveAttempt; i += 1) {
|
||||
const next = (nextTier)(tier);
|
||||
if (!next || next === tier) break;
|
||||
tier = next;
|
||||
}
|
||||
|
||||
const alias = tierModels[tier];
|
||||
if (typeof alias !== 'string' || alias.length === 0) {
|
||||
return resolveModelInternal(cwd, agentType);
|
||||
}
|
||||
return alias;
|
||||
}
|
||||
|
||||
// ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
|
||||
|
||||
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
||||
const EFFORT_SET = new Set(VALID_EFFORTS);
|
||||
|
||||
/**
|
||||
* Walk one step up the effort ladder from `e`.
|
||||
*/
|
||||
function nextEffort(e: string): string | null {
|
||||
const i = VALID_EFFORTS.indexOf(e);
|
||||
if (i < 0) return null;
|
||||
return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)];
|
||||
}
|
||||
|
||||
interface EffortOpts {
|
||||
override?: string;
|
||||
}
|
||||
|
||||
interface FastModeOpts {
|
||||
override?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* #443 — Resolve a universal effort string for (cwd, agentType).
|
||||
*/
|
||||
function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts): string {
|
||||
// Step 1: invocation override
|
||||
if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) {
|
||||
return opts.override;
|
||||
}
|
||||
|
||||
const config = loadConfig(cwd);
|
||||
const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort']))
|
||||
? (config['effort'] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
// Step 2: agent_overrides
|
||||
if (effortCfg) {
|
||||
const ao = effortCfg['agent_overrides'];
|
||||
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
||||
const v = (ao as Record<string, unknown>)[agentType];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
}
|
||||
} else {
|
||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||
const mao = canonicalEffort && typeof canonicalEffort === 'object'
|
||||
? (canonicalEffort as Record<string, unknown>)['agent_overrides']
|
||||
: undefined;
|
||||
if (mao && typeof mao === 'object' && !Array.isArray(mao)) {
|
||||
const v = (mao as Record<string, unknown>)[agentType];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: routing_tier_defaults by agent's default tier.
|
||||
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'])) {
|
||||
const v = (effortCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
} else if (!effortCfg) {
|
||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||
const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
|
||||
? (canonicalEffort as Record<string, unknown>)['routing_tier_defaults']
|
||||
: undefined;
|
||||
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
||||
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: effort.default
|
||||
if (effortCfg) {
|
||||
const d = effortCfg['default'];
|
||||
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
||||
} else {
|
||||
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
||||
const d = canonicalEffort && typeof canonicalEffort === 'object'
|
||||
? (canonicalEffort as Record<string, unknown>)['default']
|
||||
: undefined;
|
||||
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
||||
}
|
||||
|
||||
// Step 5: hardcoded default
|
||||
return 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* #443 — Resolve fast_mode boolean for (cwd, agentType).
|
||||
*/
|
||||
function resolveFastModeInternal(cwd: string, agentType: string, opts?: FastModeOpts): boolean {
|
||||
// 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'] as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
// Step 2: agent_overrides
|
||||
if (fmCfg) {
|
||||
const ao = fmCfg['agent_overrides'];
|
||||
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
||||
const v = (ao as Record<string, unknown>)[agentType];
|
||||
if (typeof v === 'boolean') return v;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: routing_tier_defaults by agent's default tier.
|
||||
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'])) {
|
||||
const v = (fmCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'boolean') return v;
|
||||
} else if (!fmCfg) {
|
||||
const canonicalFm = (CANONICAL_CONFIG_DEFAULTS)['fast_mode'];
|
||||
const manifestDefaults = canonicalFm && typeof canonicalFm === 'object'
|
||||
? (canonicalFm as Record<string, unknown>)['routing_tier_defaults']
|
||||
: undefined;
|
||||
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
||||
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
||||
if (typeof v === 'boolean') return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
*/
|
||||
function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): string {
|
||||
const base = resolveEffortInternal(cwd, agentType);
|
||||
|
||||
const config = loadConfig(cwd);
|
||||
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
||||
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'] as number) >= 0
|
||||
? (dr['max_escalations'] as number)
|
||||
: 1;
|
||||
|
||||
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 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;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export = {
|
||||
resolveTierEntry,
|
||||
resolveModelPolicy,
|
||||
resolveModelInternal,
|
||||
VALID_GRANULARITIES,
|
||||
resolveGranularityInternal,
|
||||
assertValidGranularityOverride,
|
||||
resolveModelForTier,
|
||||
VALID_EFFORTS,
|
||||
EFFORT_SET,
|
||||
nextEffort,
|
||||
resolveEffortInternal,
|
||||
resolveFastModeInternal,
|
||||
resolveEffortForTier,
|
||||
};
|
||||
@@ -1073,6 +1073,36 @@ function syncStateFrontmatter(content: string, cwd: string | undefined): string
|
||||
derivedFm['status'] = existingFm['status'];
|
||||
}
|
||||
|
||||
// Bug #905: preserve scalar fields that buildStateFrontmatter can only derive
|
||||
// from body annotations (Current Phase:, Current Plan:, etc.). When those
|
||||
// annotations are absent — e.g. after an agent or tool rewrites the body —
|
||||
// buildStateFrontmatter returns no value for those keys. Mirror the same
|
||||
// fallback pattern used in cmdStateJson so the existing frontmatter values
|
||||
// survive every writeStateMd call.
|
||||
if (!derivedFm['stopped_at'] && existingFm['stopped_at']) {
|
||||
derivedFm['stopped_at'] = existingFm['stopped_at'];
|
||||
}
|
||||
if (!derivedFm['paused_at'] && existingFm['paused_at']) {
|
||||
derivedFm['paused_at'] = existingFm['paused_at'];
|
||||
}
|
||||
if (!derivedFm['current_phase'] && existingFm['current_phase']) {
|
||||
derivedFm['current_phase'] = existingFm['current_phase'];
|
||||
}
|
||||
if (!derivedFm['current_phase_name'] && existingFm['current_phase_name']) {
|
||||
derivedFm['current_phase_name'] = existingFm['current_phase_name'];
|
||||
}
|
||||
if (!derivedFm['current_plan'] && existingFm['current_plan']) {
|
||||
derivedFm['current_plan'] = existingFm['current_plan'];
|
||||
}
|
||||
// progress is a sub-object: fall back to existing only when the body+disk
|
||||
// scan produced NO progress block at all. When buildStateFrontmatter did
|
||||
// derive a progress block (even a lower one), that derived value wins — the
|
||||
// shouldPreserveExistingProgress cross-milestone logic is applied later in
|
||||
// cmdStateJson on the read path where it is appropriate.
|
||||
if (!derivedFm['progress'] && existingFm['progress']) {
|
||||
derivedFm['progress'] = normalizeProgressNumbers(existingFm['progress']);
|
||||
}
|
||||
|
||||
const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter);
|
||||
return `---\n${yamlStr}\n---\n\n${body}`;
|
||||
}
|
||||
@@ -1265,6 +1295,17 @@ function cmdStateJson(cwd: string, raw: boolean): void {
|
||||
if (built['status'] === 'unknown' && existingFm && existingFm['status'] && existingFm['status'] !== 'unknown') {
|
||||
built['status'] = existingFm['status'];
|
||||
}
|
||||
// Bug #905: preserve scalar fields when body annotations are absent.
|
||||
// Mirrors the same fallback pattern applied in syncStateFrontmatter.
|
||||
if (existingFm && !built['current_phase'] && existingFm['current_phase']) {
|
||||
built['current_phase'] = existingFm['current_phase'];
|
||||
}
|
||||
if (existingFm && !built['current_phase_name'] && existingFm['current_phase_name']) {
|
||||
built['current_phase_name'] = existingFm['current_phase_name'];
|
||||
}
|
||||
if (existingFm && !built['current_plan'] && existingFm['current_plan']) {
|
||||
built['current_plan'] = existingFm['current_plan'];
|
||||
}
|
||||
// Preserve curated cross-milestone aggregates when local disk scanning sees
|
||||
// only a narrower realized subset (#3242 Bug A). Stale lower counters still
|
||||
// rebuild from disk because they do not exceed the derived scan.
|
||||
|
||||
@@ -100,6 +100,15 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV
|
||||
roadmapPhases.add(m[1]);
|
||||
for (const variant of phaseVariants(m[1])) roadmapPhaseVariants.add(variant);
|
||||
}
|
||||
// Also matches checklist-style entries (checked or unchecked):
|
||||
// - [x] **Phase 01: name** - [X] **Phase 2-01: name** - [ ] **Phase 3: name**
|
||||
// This is a supported ROADMAP format (parallel to buildNotStartedPhaseVariants).
|
||||
const checklistPattern = /-\s*\[[ xX]\]\s*\*{0,2}Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let cm: RegExpExecArray | null;
|
||||
while ((cm = checklistPattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhases.add(cm[1]);
|
||||
for (const variant of phaseVariants(cm[1])) roadmapPhaseVariants.add(variant);
|
||||
}
|
||||
return { roadmapPhases, roadmapPhaseVariants };
|
||||
}
|
||||
|
||||
|
||||
@@ -641,21 +641,8 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
|
||||
const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd);
|
||||
|
||||
const roadmapPhases = new Set<string>();
|
||||
const phasePattern =
|
||||
/#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhases.add(m[1]);
|
||||
}
|
||||
|
||||
const fullRoadmapPhases = new Set<string>();
|
||||
const fullPhasePattern =
|
||||
/#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let fm: RegExpExecArray | null;
|
||||
while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) {
|
||||
fullRoadmapPhases.add(fm[1]);
|
||||
}
|
||||
const { roadmapPhases } = buildRoadmapPhaseVariants(roadmapContent);
|
||||
const { roadmapPhaseVariants: fullRoadmapPhaseVariants } = buildRoadmapPhaseVariants(roadmapContentRaw);
|
||||
|
||||
const diskPhases = collectDiskPhases(planBase);
|
||||
|
||||
@@ -666,13 +653,8 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void {
|
||||
}
|
||||
|
||||
for (const p of diskPhases) {
|
||||
const normalized = normalizePhaseName(p);
|
||||
const unpadded = String(parseInt(p, 10));
|
||||
if (
|
||||
!fullRoadmapPhases.has(p) &&
|
||||
!fullRoadmapPhases.has(normalized) &&
|
||||
!fullRoadmapPhases.has(unpadded)
|
||||
) {
|
||||
const variants = phaseVariants(p);
|
||||
if (![...variants].some((v) => fullRoadmapPhaseVariants.has(v))) {
|
||||
warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user