diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index 93daeb8e..56248471 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No } } - await parseAndCreateFeatures(projectPath, contentForParsing, events); + await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService); logger.debug('========== generateFeaturesFromSpec() completed =========='); } diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 0827313f..b7a474d2 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/ import { getFeaturesDir } from '@automaker/platform'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { getNotificationService } from '../../services/notification-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { resolvePhaseModel } from '@automaker/model-resolver'; const logger = createLogger('SpecRegeneration'); export async function parseAndCreateFeatures( projectPath: string, content: string, - events: EventEmitter + events: EventEmitter, + settingsService?: SettingsService ): Promise { logger.info('========== parseAndCreateFeatures() started =========='); logger.info(`Content length: ${content.length} chars`); @@ -23,6 +26,37 @@ export async function parseAndCreateFeatures( logger.info(content); logger.info('========== END CONTENT =========='); + // Load default model and planning settings from settingsService + let defaultModel: string | undefined; + let defaultPlanningMode: string = 'skip'; + let defaultRequirePlanApproval = false; + + if (settingsService) { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const projectSettings = await settingsService.getProjectSettings(projectPath); + + const defaultModelEntry = + projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel; + if (defaultModelEntry) { + const resolved = resolvePhaseModel(defaultModelEntry); + defaultModel = resolved.model; + } + + defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip'; + defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false; + + logger.info( + `[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}` + ); + } catch (settingsError) { + logger.warn( + '[parseAndCreateFeatures] Failed to load settings, using defaults:', + settingsError + ); + } + } + try { // Extract JSON from response using shared utility logger.info('Extracting JSON from response using extractJsonWithArray...'); @@ -61,7 +95,7 @@ export async function parseAndCreateFeatures( const featureDir = path.join(featuresDir, feature.id); await secureFs.mkdir(featureDir, { recursive: true }); - const featureData = { + const featureData: Record = { id: feature.id, category: feature.category || 'Uncategorized', title: feature.title, @@ -70,12 +104,20 @@ export async function parseAndCreateFeatures( priority: feature.priority || 2, complexity: feature.complexity || 'moderate', dependencies: feature.dependencies || [], - planningMode: 'skip', - requirePlanApproval: false, + planningMode: defaultPlanningMode, + requirePlanApproval: + defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite' + ? false + : defaultRequirePlanApproval, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; + // Apply default model if available from settings + if (defaultModel) { + featureData.model = defaultModel; + } + // Use atomic write with backup support for crash protection await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, { backupCount: DEFAULT_BACKUP_COUNT, diff --git a/apps/server/src/services/codex-model-cache-service.ts b/apps/server/src/services/codex-model-cache-service.ts index 7e171428..0c905403 100644 --- a/apps/server/src/services/codex-model-cache-service.ts +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -193,7 +193,11 @@ export class CodexModelCacheService { * Infer tier from model ID */ private inferTier(modelId: string): 'premium' | 'standard' | 'basic' { - if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) { + if ( + modelId.includes('max') || + modelId.includes('gpt-5.2-codex') || + modelId.includes('gpt-5.3-codex') + ) { return 'premium'; } if (modelId.includes('mini')) { diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 83ac0dd6..ba6c38b2 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -833,18 +833,11 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { ) ) : current.agentModelBySession, - // Sanitize currentWorktreeByProject: only restore entries where path is null - // (main branch). Non-null paths point to worktree directories that may have - // been deleted while the app was closed. Restoring a stale path causes the - // board to render an invalid worktree selection, triggering a crash loop - // (error boundary reloads → restores same bad path → crash again). - // The use-worktrees validation effect will re-discover valid worktrees - // from the server once they load. - currentWorktreeByProject: Object.fromEntries( - Object.entries(sanitizeWorktreeByProject(settings.currentWorktreeByProject)).filter( - ([, worktree]) => worktree.path === null - ) - ), + // Restore all valid worktree selections (both main branch and feature worktrees). + // The validation effect in use-worktrees.ts handles deleted worktrees gracefully + // by resetting to main branch when the worktree list loads and the cached + // worktree no longer exists. + currentWorktreeByProject: sanitizeWorktreeByProject(settings.currentWorktreeByProject), // UI State worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false, lastProjectDir: settings.lastProjectDir ?? '', diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 82d5568a..29bcc8b5 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -864,8 +864,8 @@ export async function refreshSettingsFromServer(): Promise { ) ) : currentAppState.agentModelBySession, - // Sanitize: only restore entries with path === null (main branch). - // Non-null paths may reference deleted worktrees, causing crash loops. + // Restore all valid worktree selections (both main branch and feature worktrees). + // The validation effect in use-worktrees.ts handles deleted worktrees gracefully. currentWorktreeByProject: sanitizeWorktreeByProject( serverSettings.currentWorktreeByProject ?? currentAppState.currentWorktreeByProject ), diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 703460ea..6bf808ed 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -2512,9 +2512,33 @@ export const useAppStore = create()((set, get) => ({ setSpecCreatingForProject: (projectPath) => set({ specCreatingForProject: projectPath }), isSpecCreatingForProject: (projectPath) => get().specCreatingForProject === projectPath, - setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }), - setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }), - setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }), + setDefaultPlanningMode: async (mode) => { + set({ defaultPlanningMode: mode }); + try { + const httpApi = getHttpApiClient(); + await httpApi.settings.updateGlobal({ defaultPlanningMode: mode }); + } catch (error) { + logger.error('Failed to sync defaultPlanningMode:', error); + } + }, + setDefaultRequirePlanApproval: async (require) => { + set({ defaultRequirePlanApproval: require }); + try { + const httpApi = getHttpApiClient(); + await httpApi.settings.updateGlobal({ defaultRequirePlanApproval: require }); + } catch (error) { + logger.error('Failed to sync defaultRequirePlanApproval:', error); + } + }, + setDefaultFeatureModel: async (entry) => { + set({ defaultFeatureModel: entry }); + try { + const httpApi = getHttpApiClient(); + await httpApi.settings.updateGlobal({ defaultFeatureModel: entry }); + } catch (error) { + logger.error('Failed to sync defaultFeatureModel:', error); + } + }, setDefaultThinkingLevel: async (level) => { const currentModel = get().defaultFeatureModel; @@ -2523,20 +2547,30 @@ export const useAppStore = create()((set, get) => ({ // Also update defaultFeatureModel's thinkingLevel if compatible if (availableLevels.includes(level)) { + const updatedFeatureModel = { ...currentModel, thinkingLevel: level }; set({ defaultThinkingLevel: level, - defaultFeatureModel: { ...currentModel, thinkingLevel: level }, + defaultFeatureModel: updatedFeatureModel, }); + // Sync to server - include defaultFeatureModel since thinkingLevel is embedded there too + try { + const httpApi = getHttpApiClient(); + await httpApi.settings.updateGlobal({ + defaultThinkingLevel: level, + defaultFeatureModel: updatedFeatureModel, + }); + } catch (error) { + logger.error('Failed to sync defaultThinkingLevel:', error); + } } else { set({ defaultThinkingLevel: level }); - } - - // Sync to server - try { - const httpApi = getHttpApiClient(); - await httpApi.settings.updateGlobal({ defaultThinkingLevel: level }); - } catch (error) { - logger.error('Failed to sync defaultThinkingLevel:', error); + // Sync to server + try { + const httpApi = getHttpApiClient(); + await httpApi.settings.updateGlobal({ defaultThinkingLevel: level }); + } catch (error) { + logger.error('Failed to sync defaultThinkingLevel:', error); + } } },